├── LICENSE.md ├── README.md ├── composer.json └── src ├── Action.php ├── CreateAction.php ├── IndexAction.php ├── LinkAction.php ├── UnlinkAction.php ├── UnlinkAllAction.php ├── UrlRule.php └── ViewAction.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | The yii2-nested-rest extention for the Yii framework is free software. It is released under the terms of the following BSD License. 4 | 5 | Copyright © 2016, Salem Ouerdani (https://github.com/tunecino) All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 8 | 9 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 10 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 11 | Neither the name of Yii Software LLC nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yii2-nested-rest 2 | 3 | [![Packagist Version](https://img.shields.io/packagist/v/tunecino/yii2-nested-rest.svg?style=flat-square)](https://packagist.org/packages/tunecino/yii2-nested-rest) 4 | [![Total Downloads](https://img.shields.io/packagist/dt/tunecino/yii2-nested-rest.svg?style=flat-square)](https://packagist.org/packages/tunecino/yii2-nested-rest) 5 | 6 | Adds nested resources routing support along with related actions and relationship handlers to the [Yii RESTful API framework](http://www.yiiframework.com/doc-2.0/guide-rest-quick-start.html). 7 | 8 | ## How It Works 9 | 10 | This extension doesn't replace any of the built-in REST components. It is about a collection of helper actions and a custom `UrlRule` class designed to be used along with the default one: 11 | 12 | ```php 13 | 'rules' => [ 14 | [ 15 | // Yii defaults REST UrlRule class 16 | 'class' => 'yii\rest\UrlRule', 17 | 'controller' => ['team','player','skill'], 18 | ], 19 | [ 20 | // The custom UrlRule class 21 | 'class' => 'tunecino\nestedrest\UrlRule', 22 | 'modelClass' => 'app\models\Team', 23 | 'relations' => ['players'], 24 | ], 25 | [ 26 | 'class' => 'tunecino\nestedrest\UrlRule', 27 | 'modelClass' => 'app\models\Player', 28 | 'relations' => ['team','skills'], 29 | ], 30 | ] 31 | ``` 32 | 33 | To explain how it works, lets better go through an example: 34 | 35 | If within the previous configurations we expect `team` and `player` to share a _one-to-many_ relationship while `player` and `skill` shares a _many-to-many_ relation within a junction table and having an extra column called `level` in that junction table then this extension may help achieving the following HTTP requests: 36 | 37 | ```bash 38 | # get the players 2, 3 and 4 from team 1 39 | GET /teams/1/players/2,3,4 40 | 41 | # list all skills of player 5 42 | GET /players/5/skills 43 | 44 | # put the players 5 and 6 in team 1 45 | PUT /teams/1/players/5,6 46 | 47 | # create a new player and put him in team 1 48 | POST /teams/1/players 49 | {name: 'Didier Drogba', position: 'FC'} 50 | 51 | # create a new skill called 'dribble' and assign it to player 9 52 | # with a related level of 10 ('level' should be stored in the junction table) 53 | POST /players/9/skills 54 | {name: 'dribble', level: 10} 55 | 56 | # update the 'level' attribute in the junction table related to player 9 and skill 2 57 | PUT /players/9/skills/2 58 | {level: 11} 59 | 60 | # unlink skill 3 and player 2 61 | DELETE /players/2/skills/3 62 | 63 | # get all players out of team 2 64 | DELETE /teams/2/players 65 | ``` 66 | 67 | ## Installation 68 | 69 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 70 | 71 | Either run 72 | 73 | ```bash 74 | $ composer require tunecino/yii2-nested-rest 75 | ``` 76 | 77 | or add 78 | 79 | ``` 80 | "tunecino/yii2-nested-rest": "*" 81 | ``` 82 | 83 | to the `require` section of your `composer.json` file. 84 | 85 | ## Configuration 86 | 87 | By default, all the properties used by the custom UrlRule class in this extension will be used to generate multiple instances of the built-in [yii\rest\UrlRule](http://www.yiiframework.com/doc-2.0/yii-rest-urlrule.html) so basically both classes are sharing similar configurations. 88 | 89 | Those are all the possible configurations that may be set to the UrlManager in the app config file: 90 | 91 | ```php 92 | 'rules' => [ 93 | [ 94 | /** 95 | * the custom UrlRule class 96 | */ 97 | 'class' => 'tunecino\nestedrest\UrlRule', /* required */ 98 | /** 99 | * the model class name 100 | */ 101 | 'modelClass' => 'app\models\Player', /* required */ 102 | /** 103 | * relations names to be nested with this model 104 | * they should be already defined in the model's Active Record class. 105 | * check the below section for more about advanced configurations. 106 | */ 107 | 'relations' => ['team','skills'], /* required */ 108 | /** 109 | * used to generate the 'prefix'. 110 | * default: the model name pluralized 111 | */ 112 | 'resourceName' => 'players', /* optional */ 113 | /** 114 | * also used with 'prefix'. is the expected foreign key. 115 | * default: $model_name . '_id' 116 | */ 117 | 'linkAttribute' => 'player_id', /* optional */ 118 | /** 119 | * building related rules using 'controller => ['teams' => 'v1/team']' 120 | * instead of 'controller => ['team']' 121 | */ 122 | 'modulePrefix' => 'v1', /* optional */ 123 | /** 124 | * the default list of tokens that should be replaced for each pattern. 125 | */ 126 | 'tokens' => [ /* optional */ 127 | '{id}' => '', 128 | '{IDs}' => '', 129 | ], 130 | /** 131 | * The Regular Expressions Syntax used to parse the id of the main resource from url. 132 | * For example, in the following final rule, $linkAttributePattern is default to that `\d+` to parse $brand_id value: 133 | * 134 | * GET,HEAD v1/brands//items/ 135 | * 136 | * While that works fine with digital IDs, in a system using a different format, like uuid for example, 137 | * you may use $linkAttributePattern to define different patterns. Something like this maybe: 138 | * 139 | * [ 140 | * // Nested Rules Brand 141 | * 'class' => 'tunecino\nestedrest\UrlRule', 142 | * 'modelClass' => 'app\modules\v1\models\Brand', 143 | * 'modulePrefix' => 'v1', 144 | * 'resourceName' => 'v1/brands', 145 | * 'relations' => ['items'], 146 | * 'tokens' => [ 147 | * '{id}' => '', 148 | * '{IDs}' => '', 149 | * ], 150 | * 'linkAttributePattern' => '[a-f0-9]{8}\\-[a-f0-9]{4}\\-4[a-f0-9]{3}\\-(8|9|a|b)[a-f0-9]{3}\\-[a-f0-9]{12}', 151 | * ], 152 | */ 153 | 'linkAttributePattern' => '\d+', /* optional */ 154 | /** 155 | * the default list of patterns. they may all be overridden here 156 | * or just edited within $only, $except and $extraPatterns properties 157 | */ 158 | 'patterns' => [ /* optional */ 159 | 'GET,HEAD {IDs}' => 'nested-view', 160 | 'GET,HEAD' => 'nested-index', 161 | 'POST' => 'nested-create', 162 | 'PUT {IDs}' => 'nested-link', 163 | 'DELETE {IDs}' => 'nested-unlink', 164 | 'DELETE' => 'nested-unlink-all', 165 | '{id}' => 'options', 166 | '' => 'options', 167 | ], 168 | /** 169 | * list of acceptable actions. 170 | */ 171 | 'only' => [], /* optional */ 172 | /** 173 | * actions that should be excluded. 174 | */ 175 | 'except' => [], /* optional */ 176 | /** 177 | * supporting extra actions in addition to those listed in $patterns. 178 | */ 179 | 'extraPatterns' => [] /* optional */ 180 | ], 181 | ] 182 | ``` 183 | 184 | As you may notice; by default; `$patterns` is pointing to 6 new actions different from the basic CRUD actions attached to the [ActiveController](http://www.yiiframework.com/doc-2.0/yii-rest-activecontroller.html) class. Those are the helper actions included in this extension and you will need to manually declare them whenever needed inside your controllers or inside a `BaseController` from which all others should extend. Also note that by default we are expecting an [OptionsAction](http://www.yiiframework.com/doc-2.0/yii-rest-optionsaction.html) attached to the related controller. That should be the case for any controller extending [ActiveController](http://www.yiiframework.com/doc-2.0/yii-rest-activecontroller.html) or its child controllers. Otherwise, you should also implement `\yii\rest\OptionsAction`. 185 | 186 | The following is an example of a full implementation within the [controller::actions()](http://www.yiiframework.com/doc-2.0/yii-rest-activecontroller.html#actions%28%29-detail) function: 187 | 188 | ```php 189 | public function actions() 190 | { 191 | $actions = parent::actions(); 192 | 193 | $actions['nested-index'] = [ 194 | 'class' => 'tunecino\nestedrest\IndexAction', /* required */ 195 | 'modelClass' => $this->modelClass, /* required */ 196 | 'checkAccess' => [$this, 'checkAccess'], /* optional */ 197 | ]; 198 | 199 | $actions['nested-view'] = [ 200 | 'class' => 'tunecino\nestedrest\ViewAction', /* required */ 201 | 'modelClass' => $this->modelClass, /* required */ 202 | 'checkAccess' => [$this, 'checkAccess'], /* optional */ 203 | ]; 204 | 205 | $actions['nested-create'] = [ 206 | 'class' => 'tunecino\nestedrest\CreateAction', /* required */ 207 | 'modelClass' => $this->modelClass, /* required */ 208 | 'checkAccess' => [$this, 'checkAccess'], /* optional */ 209 | /** 210 | * the scenario to be assigned to the new model before it is validated and saved. 211 | */ 212 | 'scenario' => 'default', /* optional */ 213 | /** 214 | * the scenario to be assigned to the model class responsible 215 | * of handling the data stored in the juction table. 216 | */ 217 | 'viaScenario' => 'default', /* optional */ 218 | /** 219 | * expect junction table related data to be wrapped in a sub object key in the body request. 220 | * In the example we gave above we would need to do : 221 | * POST {name: 'dribble', related: {level: 10}} 222 | * instead of {name: 'dribble', level: 10} 223 | */ 224 | 'viaWrapper' => 'related' /* optional */ 225 | ]; 226 | 227 | $actions['nested-link'] = [ 228 | 'class' => 'tunecino\nestedrest\LinkAction', /* required */ 229 | 'modelClass' => $this->modelClass, /* required */ 230 | 'checkAccess' => [$this, 'checkAccess'], /* optional */ 231 | /** 232 | * the scenario to be assigned to the model class responsible 233 | * of handling the data stored in the juction table. 234 | */ 235 | 'viaScenario' => 'default', /* optional */ 236 | ]; 237 | 238 | $actions['nested-unlink'] = [ 239 | 'class' => 'tunecino\nestedrest\UnlinkAction', /* required */ 240 | 'modelClass' => $this->modelClass, /* required */ 241 | 'checkAccess' => [$this, 'checkAccess'], /* optional */ 242 | ]; 243 | 244 | $actions['nested-unlink-all'] = [ 245 | 'class' => 'tunecino\nestedrest\UnlinkAllAction', /* required */ 246 | 'modelClass' => $this->modelClass, /* required */ 247 | 'checkAccess' => [$this, 'checkAccess'], /* optional */ 248 | ]; 249 | 250 | return $actions; 251 | } 252 | ``` 253 | 254 | ## What you need to know 255 | 256 | **_1._** This doesn't support composite keys. In fact one of my main concerns when building this extension was to figure out a clean alternative to not have to build resources for composite keys related models like the ones mapping a junction table. check the example provided in section **_8._** for more details. 257 | 258 | **_2._** When defining relation names in the config file they should match the method names implemented inside your model _(see [Declaring Relations](http://www.yiiframework.com/doc-2.0/guide-db-active-record.html#declaring-relations) section in the Yii guide for more details)_. 259 | This extension will do the check and will throw an _InvalidConfigException_ if they don't match but for performance reasons _(check [this](http://www.yiiframework.com/doc-2.0/guide-runtime-routing.html#performance-consideration))_ and because it make no sense to keep doing the same verification with each request when you already did correctly set a list of relations, this extension won't do that DB schema parsing anymore when the application is in _production_ mode. in other words verification is made only when`YII_DEBUG` is true. 260 | 261 | **_3._** By default, when you specify a relation 'abc' in the `$relation` property, its related name expected to be used in the URL endpoint should be 'abcs' (pluralized) while its controller is expected to be `AbcController`. This can be changed by configuring the `$relation` property to explicitly specify how to map the relation name used in endpoint URLs to its related controller ID. 262 | For example, if we had a relation defined inside the `Team` model class within a `getJuniorCoaches()` method we can do the following: 263 | 264 | ```php 265 | // GET /players/1/junior-coaches => should route to 'JuniorCoachController' 266 | 'relations' => ['players','juniorCoaches'] // how it works by default 267 | 268 | // GET /players/1/junior-coaches => should route to 'JuniorCoachesController' 269 | 'relations' => [ 270 | 'players', 271 | 'juniorCoaches' => 'junior-coaches' // different controller name 272 | ] 273 | 274 | // GET /players/1/juniors => should route to 'JuniorCoachesController' 275 | 'relations' => [ 276 | 'players', 277 | 'juniorCoaches' => ['juniors' => 'junior-coaches'] // different endpoint name and different controller name 278 | ] 279 | ``` 280 | 281 | **_4._** When it comes to linking _many-to-many_ relations with extra columns in a junction table it is highly recommended to use [via()](http://www.yiiframework.com/doc-2.0/yii-db-activerelationtrait.html#via%28%29-detail) instead of [viaTable()](http://www.yiiframework.com/doc-2.0/yii-db-activequery.html#viaTable%28%29-detail) so the intermediate class can be used by this extension to validate related attributes instead of using [link()](http://www.yiiframework.com/doc-2.0/yii-db-baseactiverecord.html#link%28%29-detail) and saving data without performing the appropriate validations. Refer to the [Relations via a Junction Table](http://www.yiiframework.com/doc-2.0/guide-db-active-record.html#junction-table) section in the Yii guide for more details. 282 | 283 | **_5._** When you do: 284 | 285 | ```bash 286 | POST /players/9/skills 287 | {name: 'dribble', level: 10} 288 | ``` 289 | 290 | and the 'name' attribute is supposed to be loaded and saved along with the new created model while 'level' should be added in a related junction table. Then you should know this: 291 | 292 | - If relation between both models is defined within [via()](http://www.yiiframework.com/doc-2.0/yii-db-activerelationtrait.html#via%28%29-detail) , `Yii::$app->request->bodyParams` will be populated to to both models using the [load()]() method: 293 | 294 | ```php 295 | $model->load($bodyParams); 296 | $viaModel->load($bodyParams); 297 | /* Scenarios can also be assigned to both models. when attaching actions. see configuration section */ 298 | ``` 299 | 300 | - If relation is defined within [viaTable()](http://www.yiiframework.com/doc-2.0/yii-db-activequery.html#viaTable%28%29-detail) instead the script will try to do some guessing. 301 | 302 | So when unexpected results happens or when attribute names are similar in model class and junction related class, it would be recommended to set the `viaWrapper` property. See the 'nested-create' action in the [configuration](#configuration) section for more details. 303 | 304 | **_6._** When unlinking data, if the relation type between both models is _many_to_many_ related row in the junction table will be removed. Otherwise the concerned foreign key attribute will be set to NULL in its related column in database. 305 | 306 | **_7._** When a successful linking or unlinking request happens, a `204` response should be expected while a `304` response should tell that no change has been made like when asking to link two already linked models. 307 | When you try to link 2 models sharing a `many_to_many` relationship and both models are already linked no extra row will be added to related junction table: If the `bodyRequest` is empty you'll get a `304` response otherwise the `bodyRequest` content will be used to update the extra attributes found in the junction table and you'll get a `204` headers response. 308 | 309 | **_8._** When performing any HTTP request; lets say as example `GET /players/9/skills/2`; The custom `UrlRule` will redirect it by default to the route `skill/nested-view` _(or other depending on your patterns)_ with those 4 extra attributes added to `Yii::$app->request->queryParams`: 310 | 311 | ```php 312 | relativeClass = 'app/models/player'; // the class name of the relative model 313 | relationName = 'skills'; // the one you did set in rules configuration. 314 | linkAttribute = 'player_id'; // the foreign key attribute name. 315 | player_id = 9; // the foreign key attribute and its value 316 | ``` 317 | 318 | Those may be useful when building your own actions or doing extra things like for example if we add the following inside `app/models/skill` : 319 | 320 | ```php 321 | // junction table related method. usually auto generated by gii. 322 | public function getSkillHasPlayers() 323 | { 324 | return $this->hasMany(SkillHasPlayer::className(), ['skill_id' => 'id']); 325 | } 326 | 327 | protected function getSharedData() 328 | { 329 | $params = Yii::$app->request->queryParams; 330 | $player_id = empty($params['player_id']) ? null : $params['player_id']; 331 | 332 | return ($player_id) ? $this->getSkillHasPlayers() 333 | ->where(['player_id' => $player_id ]) 334 | ->select('level') 335 | ->one() : null; 336 | } 337 | 338 | public function fields() 339 | { 340 | $fields = parent::fields(); 341 | 342 | if (!empty(Yii::$app->request->queryParams['player_id'])) { 343 | $fields['_shared'] = 'sharedData'; 344 | } 345 | 346 | return $fields; 347 | } 348 | ``` 349 | 350 | a request like `GET /players/9/skills` or `GET /players/9/skills/2` will also output the related data between both models that is stored in the related junction table: 351 | 352 | ```bash 353 | GET /players/9/skills/2 354 | # outputs: 355 | { 356 | "id": 2, 357 | "name": "dribble", 358 | "_shared": { 359 | "level": 11 360 | } 361 | } 362 | ``` 363 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tunecino/yii2-nested-rest", 3 | "type": "yii2-extension", 4 | "description": "Adds nested resources routing support along with related actions and relationship handlers to the Yii RESTful API framework", 5 | "keywords": [ 6 | "yii2", 7 | "rest", 8 | "nested", 9 | "nested routes", 10 | "nested resources", 11 | "rest relationships" 12 | ], 13 | "license": "BSD-3-Clause", 14 | "support": { 15 | "issues": "https://github.com/tunecino/yii2-nested-rest/issues", 16 | "forum": "http://www.yiiframework.com/forum/", 17 | "wiki": "https://github.com/tunecino/yii2-nested-rest/wiki", 18 | "source": "https://github.com/tunecino/yii2-nested-rest" 19 | }, 20 | "authors": [ 21 | { 22 | "name": "Salem Ouerdani", 23 | "email": "tunecino@gmail.com" 24 | } 25 | ], 26 | "require": { 27 | "yiisoft/yii2": "~2.0.13" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "tunecino\\nestedrest\\": "src" 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/Action.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class Action extends \yii\rest\Action 21 | { 22 | /** 23 | * @var string class name of the related model. 24 | * This should be provided by the UrlClass within queryParams. 25 | */ 26 | protected $relativeClass; 27 | /** 28 | * @var string name of the resource. used to generating the related 'prefix'. 29 | * This should be provided by the UrlClass within queryParams. 30 | */ 31 | protected $relationName; 32 | /** 33 | * @var string name of the attribute name used as a foreign key in the related model. also used to build the 'prefix'. 34 | * This should be provided by the UrlClass within queryParams. 35 | */ 36 | protected $linkAttribute; 37 | /** 38 | * @var primary key value of the linkAttribute. 39 | * This should be provided by the UrlClass within queryParams. 40 | * @see linkAttribute 41 | */ 42 | protected $relative_id; 43 | 44 | /** 45 | * @inheritdoc 46 | */ 47 | public function init() 48 | { 49 | parent::init(); 50 | $params = Yii::$app->request->queryParams; 51 | 52 | if ($this->expectedParams($params) === false) { 53 | throw new InvalidConfigException("unexpected configurations."); 54 | } 55 | 56 | $this->relativeClass = $params['relativeClass']; 57 | $this->relationName = $params['relationName']; 58 | $this->linkAttribute = $params['linkAttribute']; 59 | $this->relative_id = $params[$this->linkAttribute]; 60 | } 61 | 62 | /** 63 | * Checks if the expected params that should be provided by the custom UrlClass are not missing. 64 | * @return Bolean. 65 | */ 66 | protected function expectedParams($params) 67 | { 68 | $expected = ['relativeClass', 'relationName', 'linkAttribute']; 69 | foreach ($expected as $attr) { 70 | if (isset($params[$attr]) === false || ($attr === 'linkAttribute' && isset($params[$params[$attr]]) === false)) { 71 | return false; 72 | } 73 | } 74 | return true; 75 | } 76 | 77 | /** 78 | * Finds the related model. 79 | * @return \yii\db\ActiveRecordInterface. 80 | * @throws NotFoundHttpException if not found. 81 | */ 82 | public function getRelativeModel() 83 | { 84 | $relativeClass = $this->relativeClass; 85 | $relModel = $relativeClass::findOne($this->relative_id); 86 | 87 | if ($relModel === null) { 88 | throw new NotFoundHttpException(StringHelper::basename($relativeClass) . " '$this->relative_id' not found."); 89 | } 90 | 91 | if ($this->checkAccess) { 92 | call_user_func($this->checkAccess, $this->id, $relModel); 93 | } 94 | 95 | return $relModel; 96 | } 97 | 98 | /** 99 | * Finds the model or the list of models corresponding 100 | * to the specified primary keys values within the relative model retreived by [[getRelativeModel()]]. 101 | * @param string $IDs should hold the list of IDs related to the models to be loaded. 102 | * it must be a string of the primary keys values separated by commas. 103 | * @return \yii\db\ActiveRecordInterface 104 | * @throws NotFoundHttpException if not found or not related. 105 | */ 106 | public function findCurrentModels($IDs) 107 | { 108 | $modelClass = $this->modelClass; 109 | $pk = $modelClass::primaryKey()[0]; 110 | $ids = preg_split('/\s*,\s*/', $IDs, -1, PREG_SPLIT_NO_EMPTY); 111 | $getter = 'get' . $this->relationName; 112 | 113 | $relModel = $this->getRelativeModel(); 114 | $q = $relModel->$getter()->andWhere([$pk => $ids]); 115 | 116 | $ci = count($ids); 117 | $model = $ci > 1 ? $q->all() : $q->one(); 118 | 119 | if ($model === null || (is_array($model) && count($model) !== $ci)) { 120 | throw new NotFoundHttpException("Not found or unrelated objects."); 121 | } 122 | 123 | return $model; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/CreateAction.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class CreateAction extends Action 22 | { 23 | /** 24 | * @var string the scenario to be assigned to the new model before it is validated and saved. 25 | */ 26 | public $scenario = Model::SCENARIO_DEFAULT; 27 | /** 28 | * @var string the scenario to be assigned to the model representing 29 | * the junction table related data before it is validated and saved. 30 | */ 31 | public $viaScenario = Model::SCENARIO_DEFAULT; 32 | /** 33 | * @var string the wrapper name of the junction table related data 34 | * that should be expected in BodyParams. 35 | */ 36 | public $viaWrapper = null; 37 | 38 | /** 39 | * Creates a new model and link it to the relative model. 40 | * @return \yii\db\ActiveRecordInterface the model newly created 41 | * @throws ServerErrorHttpException if there is any error when creating the model 42 | */ 43 | public function run() 44 | { 45 | if ($this->checkAccess) { 46 | call_user_func($this->checkAccess, $this->id); 47 | } 48 | 49 | $relModel = $this->getRelativeModel(); 50 | $relType = $relModel->getRelation($this->relationName); 51 | 52 | $isManyToMany = ($relType->multiple === true && $relType->via !== null); 53 | $isManyToMany_viaClass = ($relType->multiple === true && is_array($relType->via)); 54 | 55 | $model = new $this->modelClass(['scenario' => $this->scenario]); 56 | 57 | $bodyParams = Yii::$app->getRequest()->getBodyParams(); 58 | $viaData = $this->viaWrapper ? ArrayHelper::remove($bodyParams, $this->viaWrapper) : $bodyParams; 59 | 60 | // special case: when poor many-to-many configs -> try to manually guess junction's extraColumns 61 | if (!empty($viaData) && $isManyToMany && $isManyToMany_viaClass === false && $this->viaWrapper === null) { 62 | $model_attributes = $model->safeAttributes(); 63 | $junction = []; 64 | foreach ($viaData as $key => $value) { 65 | if (!in_array($key, $model_attributes)) { 66 | $junction[$key] = $value; 67 | } 68 | } 69 | $viaData = $junction; 70 | } 71 | 72 | $model->load($bodyParams, ''); 73 | 74 | if ($model->save() === false && !$model->hasErrors()) { 75 | throw new ServerErrorHttpException('Failed to update the object for unknown reason.'); 76 | } else if ($model->hasErrors()) { 77 | return $model; 78 | } 79 | 80 | $id = implode(',', array_values($model->getPrimaryKey(true))); 81 | 82 | if ($isManyToMany) { 83 | $extraColumns = $viaData === null ? [] : $viaData; 84 | 85 | if ($isManyToMany_viaClass) { 86 | $viaRelation = $relType->via[1]; 87 | $viaClass = $viaRelation->modelClass; 88 | 89 | $viaModel = new $viaClass; 90 | $viaModel->scenario = $this->viaScenario; 91 | 92 | if ($this->checkAccess) { 93 | call_user_func($this->checkAccess, $this->id, $viaModel); 94 | } 95 | 96 | $modelClass = $this->modelClass; 97 | $pk = $modelClass::primaryKey()[0]; 98 | 99 | $attributes = array_merge([ 100 | $this->linkAttribute => $this->relative_id, 101 | $relType->link[$pk] => $id 102 | ], $extraColumns); 103 | 104 | $viaModel->load($attributes, ''); 105 | 106 | if ($viaModel->save() === false && !$viaModel->hasErrors()) { 107 | throw new ServerErrorHttpException('Failed to update the object for unknown reason.'); 108 | } else if ($viaModel->hasErrors()) { 109 | return $viaModel; 110 | } 111 | } else { 112 | $relModel->link($this->relationName, $model, $extraColumns); 113 | } 114 | } else { 115 | $relModel->link($this->relationName, $model); 116 | } 117 | 118 | $response = Yii::$app->getResponse(); 119 | $response->setStatusCode(201); 120 | $response->getHeaders()->set('Location', Url::to('', true) . '/' . $id); 121 | 122 | return $model; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/IndexAction.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class IndexAction extends Action 17 | { 18 | /** 19 | * Prepares the data provider that should return the requested 20 | * collection of the models within its related model. 21 | * @return ActiveDataProvider 22 | */ 23 | public function run() 24 | { 25 | if ($this->checkAccess) { 26 | call_user_func($this->checkAccess, $this->id); 27 | } 28 | 29 | $relModel = $this->getRelativeModel(); 30 | $getter = 'get' . $this->relationName; 31 | 32 | return new ActiveDataProvider([ 33 | 'query' => $relModel->$getter(), 34 | ]); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/LinkAction.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class LinkAction extends Action 21 | { 22 | /** 23 | * @var string the scenario to be assigned to the model representing 24 | * the junction table related data before it is validated and saved. 25 | */ 26 | public $viaScenario = \yii\base\Model::SCENARIO_DEFAULT; 27 | 28 | /** 29 | * Links two or more models or updates the related data stored in a junction table. 30 | * A '204' response should be set to headers if any change has been made. 31 | * A '304' response should be set to headers if no change is made. 32 | * @param string $IDs should hold the list of IDs related to the models to be linken with the relative one. 33 | * it must be a string of the primary keys values separated by commas. 34 | * @throws NotFoundHttpException if model doesn't exist. 35 | * @throws ServerErrorHttpException if there is any error when linking the models 36 | * @throws ServerErrorHttpException if relation is many_to_many + both models are linked + no via() class provided + extraColumns provided via bodyParams. 37 | */ 38 | public function run($IDs) 39 | { 40 | $relModel = $this->getRelativeModel(); 41 | 42 | $modelClass = $this->modelClass; 43 | $pk = $modelClass::primaryKey()[0]; 44 | $ids = preg_split('/\s*,\s*/', $IDs, -1, PREG_SPLIT_NO_EMPTY); 45 | $bodyParams = Yii::$app->request->bodyParams; 46 | $getter = 'get' . $this->relationName; 47 | 48 | $relType = $relModel->getRelation($this->relationName); 49 | 50 | $isManyToMany = ($relType->multiple === true && $relType->via !== null); 51 | $isManyToMany_viaClass = ($relType->multiple === true && is_array($relType->via)); 52 | 53 | $to_link = []; 54 | foreach ($ids as $pk_value) { 55 | $linked = $relModel->$getter()->andWhere([$pk => $pk_value])->exists(); 56 | 57 | if ($linked === false) { 58 | $exist = $modelClass::find()->andWhere([$pk => $pk_value])->exists(); 59 | if ($exist === false) { 60 | throw new NotFoundHttpException(StringHelper::basename($modelClass) . " '$pk_value' not found."); 61 | } 62 | $to_link[] = $isManyToMany_viaClass ? $pk_value : $this->findModel($pk_value); 63 | } 64 | } 65 | 66 | if ($isManyToMany_viaClass) { 67 | // many_to_many relation and via class is set 68 | $viaRelation = $relType->via[1]; 69 | $viaClass = $viaRelation->modelClass; 70 | 71 | if (count($to_link) === 0 && count($bodyParams)===0) { 72 | Yii::$app->getResponse()->setStatusCode(304); 73 | } else { 74 | foreach ($ids as $pk_value) { 75 | if (in_array($pk_value, $to_link)) { 76 | $viaModel = new $viaClass; 77 | $viaModel->scenario = $this->viaScenario; 78 | 79 | if ($this->checkAccess) { 80 | call_user_func($this->checkAccess, $this->id, $viaModel); 81 | } 82 | 83 | $attributes = array_merge([ 84 | $this->linkAttribute => $this->relative_id, 85 | $relType->link[$pk] => $pk_value 86 | ],$bodyParams); 87 | 88 | $viaModel->load($attributes, ''); 89 | } else { 90 | // already linked -> update data in junction table. 91 | $viaModel = $viaClass::findOne([ 92 | $this->linkAttribute => $this->relative_id, 93 | $relType->link[$pk] => $pk_value 94 | ]); 95 | 96 | if ($this->checkAccess) { 97 | call_user_func($this->checkAccess, $this->id, $viaModel); 98 | } 99 | 100 | $viaModel->scenario = $this->viaScenario; 101 | $viaModel->load($bodyParams, ''); 102 | } 103 | 104 | if ($viaModel->save() === false && !$viaModel->hasErrors()) { 105 | throw new ServerErrorHttpException('Failed to update the object for unknown reason.'); 106 | } else if ($viaModel->hasErrors()) { 107 | return $viaModel; 108 | } 109 | } 110 | Yii::$app->getResponse()->setStatusCode(204); 111 | } 112 | } 113 | 114 | else { 115 | // could be many_to_many viaTable (no class) or whatever else relation 116 | $extraColumns = $isManyToMany ? $bodyParams : []; 117 | 118 | // junction table update is expected. inserting 2nd record won't be valid solution. 119 | if (count($to_link) === 0 && count($extraColumns) > 0) { 120 | throw new ServerErrorHttpException('objects already linked.'); 121 | } else if (count($to_link) === 0) { 122 | Yii::$app->getResponse()->setStatusCode(304); 123 | } else { 124 | foreach ($to_link as $model) { 125 | if ($this->checkAccess) { 126 | call_user_func($this->checkAccess, $this->id, $model); 127 | } 128 | $relModel->link($this->relationName, $model, $extraColumns); 129 | } 130 | Yii::$app->getResponse()->setStatusCode(204); 131 | } 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/UnlinkAction.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class UnlinkAction extends Action 20 | { 21 | /** 22 | * Unlinks two or more models by provided primary key or a list of primary keys. 23 | * If the relation type is a many_to_many. related row in the junction table will be deleted. 24 | * Otherwise related foreign key will be simply set to NULL. 25 | * A '204' response should be set to headers if any change has been made. 26 | * @param string $IDs should hold the list of IDs related to the models to be unlinken from the relative one. 27 | * it must be a string of the primary keys values separated by commas. 28 | * @throws BadRequestHttpException if any of the models are not linked. 29 | * @throws InvalidCallException if the models cannot be unlinked 30 | */ 31 | public function run($IDs) 32 | { 33 | $relModel = $this->getRelativeModel(); 34 | 35 | $modelClass = $this->modelClass; 36 | $pk = $modelClass::primaryKey()[0]; 37 | $getter = 'get' . $this->relationName; 38 | $ids = preg_split('/\s*,\s*/', $IDs, -1, PREG_SPLIT_NO_EMPTY); 39 | 40 | $to_unlink = []; 41 | foreach ($ids as $pk_value) { 42 | $linked = $relModel->$getter()->andWhere([$pk => $pk_value])->exists(); 43 | if ($linked === true) { 44 | $to_unlink[] = $this->findModel($pk_value); 45 | } else { 46 | throw new BadRequestHttpException(StringHelper::basename($modelClass) . " '$pk_value' not linked to ".StringHelper::basename($this->relativeClass)." '$this->relative_id'."); 47 | } 48 | } 49 | 50 | $relType = $relModel->getRelation($this->relationName); 51 | $delete = ($relType->multiple === true && $relType->via !== null); 52 | 53 | foreach ($to_unlink as $model) { 54 | if ($this->checkAccess) { 55 | call_user_func($this->checkAccess, $this->id, $model); 56 | } 57 | $relModel->unlink($this->relationName, $model, $delete); 58 | } 59 | Yii::$app->getResponse()->setStatusCode(204); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/UnlinkAllAction.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class UnlinkAllAction extends Action 18 | { 19 | /** 20 | * Unlinks all the related models. 21 | * If the relation type is a many_to_many. related row in the junction table will be deleted. 22 | * Otherwise related foreign key will be simply set to NULL. 23 | * A '204' response should be set to headers if any change has been made. 24 | * @throws InvalidCallException if the models cannot be unlinked 25 | */ 26 | public function run() 27 | { 28 | $relModel = $this->getRelativeModel(); 29 | 30 | $relType = $relModel->getRelation($this->relationName); 31 | $delete = ($relType->multiple === true && $relType->via !== null); 32 | 33 | $relModel->unlinkAll($this->relationName, $delete); 34 | 35 | Yii::$app->getResponse()->setStatusCode(204); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/UrlRule.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class UrlRule extends BaseObject implements UrlRuleInterface 22 | { 23 | /** 24 | * @var string class name of the model which will be used to generate related rules. 25 | * The model class must implement [[ActiveRecordInterface]]. 26 | * This property must be set. 27 | */ 28 | public $modelClass; 29 | /** 30 | * @var array list of relation names as defined in model class. 31 | * This property must be set. 32 | */ 33 | public $relations = []; 34 | /** 35 | * @var string name of the resource. used to generating the related 'prefix'. 36 | */ 37 | public $resourceName; 38 | /** 39 | * @var string name of the attribute name used as a foreign key in the related model. also used to build the 'prefix'. 40 | */ 41 | public $linkAttribute; 42 | /** 43 | * @var string name of the module to use as a prefix when generating the list of the related controllers. 44 | */ 45 | public $modulePrefix; 46 | /** 47 | * @var array list of tokens that should be replaced for each pattern. The keys are the token names, 48 | * and the values are the corresponding replacements. 49 | * @see patterns 50 | */ 51 | public $tokens = [ 52 | '{id}' => '', 53 | '{IDs}' => '', 54 | ]; 55 | /** 56 | * @var string The Regular Expressions Syntax used to parse the id of the main resource from url. 57 | * For example, in the following final rule, $linkAttributePattern is default to that `\d+` to parse $brand_id value: 58 | * 59 | * GET,HEAD v1/brands//items/ 60 | * 61 | * While that works fine with digital IDs, in a system using a different format, like uuid for example, 62 | * you may use $linkAttributePattern to define different patterns. Something like this maybe: 63 | * 64 | * [ 65 | * // Nested Rules Brand 66 | * 'class' => 'tunecino\nestedrest\UrlRule', 67 | * 'modelClass' => 'app\modules\v1\models\Brand', 68 | * 'modulePrefix' => 'v1', 69 | * 'resourceName' => 'v1/brands', 70 | * 'relations' => ['items'], 71 | * 'tokens' => [ 72 | * '{id}' => '', 73 | * '{IDs}' => '', 74 | * ], 75 | * 'linkAttributePattern' => '[a-f0-9]{8}\\-[a-f0-9]{4}\\-4[a-f0-9]{3}\\-(8|9|a|b)[a-f0-9]{3}\\-[a-f0-9]{12}', 76 | * ], 77 | */ 78 | public $linkAttributePattern = '\d+'; 79 | /** 80 | * @var array list of possible patterns and the corresponding actions for creating the URL rules. 81 | * The keys are the patterns and the values are the corresponding actions. 82 | * The format of patterns is `Verbs Pattern`, where `Verbs` stands for a list of HTTP verbs separated 83 | * by comma (without space). If `Verbs` is not specified, it means all verbs are allowed. 84 | * `Pattern` is optional. It will be prefixed with [[prefix]]/[[controller]]/, 85 | * and tokens in it will be replaced by [[tokens]]. 86 | */ 87 | public $patterns = [ 88 | 'GET,HEAD {IDs}' => 'nested-view', 89 | 'GET,HEAD' => 'nested-index', 90 | 'POST' => 'nested-create', 91 | 'PUT {IDs}' => 'nested-link', 92 | 'DELETE {IDs}' => 'nested-unlink', 93 | 'DELETE' => 'nested-unlink-all', 94 | '{id}' => 'options', 95 | '' => 'options', 96 | ]; 97 | /** 98 | * @var array list of acceptable actions. If not empty, only the actions within this array 99 | * will have the corresponding URL rules created. 100 | * @see patterns 101 | */ 102 | public $only = []; 103 | /** 104 | * @var array list of actions that should be excluded. Any action found in this array 105 | * will NOT have its URL rules created. 106 | * @see patterns 107 | */ 108 | public $except = []; 109 | /** 110 | * @var array patterns for supporting extra actions in addition to those listed in [[patterns]]. 111 | * The keys are the patterns and the values are the corresponding action IDs. 112 | * These extra patterns will take precedence over [[patterns]]. 113 | * @see patterns 114 | */ 115 | public $extraPatterns = []; 116 | /** 117 | * @var array the default configuration for creating each collection of URL rules related to a model relation. 118 | */ 119 | private $config = [ 120 | 'class' => 'yii\rest\UrlRule' 121 | ]; 122 | 123 | private $_rulesFactory; 124 | /** 125 | * @var bool whether to automatically pluralize the URL names for controllers. 126 | * If true, a controller ID will appear in plural form in URLs. For example, `user` controller 127 | * will appear as `users` in URLs. 128 | * @see controller 129 | */ 130 | public $pluralize = true; 131 | 132 | /** 133 | * Returns the UrlRule instance used to generate related rules to each model. 134 | * @return UrlRuleInterface[] 135 | * @see config 136 | */ 137 | protected function getRulesFactory() 138 | { 139 | return $this->_rulesFactory; 140 | } 141 | 142 | /** 143 | * Sets the UrlRule instance used to generate related rules to each model. 144 | * @param $config 145 | * @see config 146 | */ 147 | protected function setRulesFactory($config) 148 | { 149 | $this->_rulesFactory = Yii::createObject($config); 150 | } 151 | 152 | /** 153 | * @inheritdoc 154 | */ 155 | public function init() 156 | { 157 | parent::init(); 158 | if (empty($this->modelClass)) { 159 | throw new InvalidConfigException('"modelClass" must be set.'); 160 | } 161 | 162 | if (empty($this->relations)) { 163 | throw new InvalidConfigException('"relations" must be set.'); 164 | } 165 | 166 | $this->config['patterns'] = $this->patterns; 167 | $this->config['tokens'] = $this->tokens; 168 | if (!empty($this->only)) { 169 | $this->config['only'] = $this->only; 170 | } 171 | if (!empty($this->except)) { 172 | $this->config['except'] = $this->except; 173 | } 174 | if (!empty($this->extraPatterns)) { 175 | $this->config['extraPatterns'] = $this->extraPatterns; 176 | } 177 | } 178 | 179 | /** 180 | * @inheritdoc 181 | */ 182 | public function createUrl($manager, $route, $params) 183 | { 184 | if ($this->rulesFactory) { 185 | unset($params['relativeClass'], $params['relationName'], $params['linkAttribute']); 186 | return $this->rulesFactory->createUrl($manager, $route, $params); 187 | } 188 | return false; 189 | } 190 | 191 | /** 192 | * @inheritdoc 193 | */ 194 | public function parseRequest($manager, $request) 195 | { 196 | $modelName = Inflector::camel2id(StringHelper::basename($this->modelClass)); 197 | 198 | if (isset($this->resourceName)) { 199 | $resourceName = $this->resourceName; 200 | } else { 201 | $resourceName = $this->pluralize ? Inflector::pluralize($modelName) : $modelName; 202 | } 203 | 204 | $link_attribute = isset($this->linkAttribute) ? $this->linkAttribute : $modelName . '_id'; 205 | $this->config['prefix'] = $resourceName . '/<' . $link_attribute . ':' . $this->linkAttributePattern . '>'; 206 | 207 | foreach ($this->relations as $key => $value) { 208 | if (is_int($key)) { 209 | $relation = $value; 210 | $urlName = $this->pluralize ? Inflector::camel2id(Inflector::pluralize($relation)) : Inflector::camel2id($relation); 211 | $controller = Inflector::camel2id(Inflector::singularize($relation)); 212 | } else { 213 | $relation = $key; 214 | if (is_array($value)) { 215 | list($urlName, $controller) = [key($value), current($value)]; 216 | } else { 217 | $urlName = $this->pluralize ? Inflector::camel2id(Inflector::pluralize($relation)) : Inflector::camel2id($relation); 218 | $controller = $value; 219 | } 220 | } 221 | 222 | if (YII_DEBUG) { 223 | (new $this->modelClass)->getRelation($relation); 224 | } 225 | 226 | $modulePrefix = isset($this->modulePrefix) ? $this->modulePrefix . '/' : ''; 227 | $this->config['controller'][$urlName] = $modulePrefix . $controller; 228 | 229 | $this->setRulesFactory($this->config); 230 | $routeObj = $this->rulesFactory->parseRequest($manager, $request); 231 | 232 | if ($routeObj) { 233 | $routeObj[1]['relativeClass'] = $this->modelClass; 234 | $routeObj[1]['relationName'] = $relation; 235 | $routeObj[1]['linkAttribute'] = $link_attribute; 236 | return $routeObj; 237 | } 238 | } 239 | 240 | return false; 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/ViewAction.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class ViewAction extends Action 19 | { 20 | /** 21 | * Displays a model or a list of provided models. 22 | * @param string $IDs should hold the list of IDs related to the models to be loaded. 23 | * it must be a string of the primary keys values separated by commas. 24 | * @return \yii\db\ActiveRecordInterface the model(s) being displayed 25 | */ 26 | public function run($IDs) 27 | { 28 | $model = $this->findCurrentModels($IDs); 29 | if ($this->checkAccess) { 30 | call_user_func($this->checkAccess, $this->id, $model); 31 | } 32 | 33 | return $model; 34 | } 35 | } 36 | --------------------------------------------------------------------------------