├── .gitignore ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src ├── Controller.php ├── Inflector.php ├── JsonApiParser.php ├── JsonApiResponseFormatter.php ├── LinksInterface.php ├── Pagination.php ├── ResourceIdentifierInterface.php ├── ResourceInterface.php ├── ResourceTrait.php ├── Serializer.php ├── UrlRule.php └── actions │ ├── Action.php │ ├── CreateAction.php │ ├── DeleteAction.php │ ├── DeleteRelationshipAction.php │ ├── IndexAction.php │ ├── UpdateAction.php │ ├── UpdateRelationshipAction.php │ ├── ViewAction.php │ └── ViewRelatedAction.php └── tests ├── JsonApiParserTest.php ├── JsonApiResponseFormatterTest.php ├── SerializerTest.php ├── TestCase.php ├── actions ├── CreateActionTest.php ├── DeleteActionTest.php ├── DeleteRelationshipActionTest.php ├── IndexActionTest.php ├── UpdateActionTest.php ├── UpdateRelationshipActionTest.php └── ViewRelatedActionTest.php ├── bootstrap.php └── data ├── ActiveQuery.php └── ResourceModel.php /.gitignore: -------------------------------------------------------------------------------- 1 | # phpstorm project files 2 | .idea 3 | 4 | # netbeans project files 5 | nbproject 6 | 7 | # zend studio for eclipse project files 8 | .buildpath 9 | .project 10 | .settings 11 | 12 | # windows thumbnail cache 13 | Thumbs.db 14 | 15 | # composer vendor dir 16 | /vendor 17 | 18 | # composer itself is not needed 19 | composer.phar 20 | composer.lock 21 | 22 | # Mac DS_Store Files 23 | .DS_Store 24 | 25 | # phpunit itself is not needed 26 | phpunit.phar 27 | # local phpunit config 28 | /phpunit.xml 29 | 30 | # local tests configuration 31 | /tests/data/config.local.php 32 | 33 | /tests/runtime 34 | /tests/docker 35 | /tests/dockerids 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Implementation of JSON API specification for the Yii framework 4 | ================================================================== 5 | [![Latest Stable Version](https://poser.pugx.org/tuyakhov/yii2-json-api/v/stable.png)](https://packagist.org/packages/tuyakhov/yii2-json-api) 6 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/tuyakhov/yii2-json-api/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/tuyakhov/yii2-json-api/?branch=master) [![Build Status](https://scrutinizer-ci.com/g/tuyakhov/yii2-json-api/badges/build.png?b=master)](https://scrutinizer-ci.com/g/tuyakhov/yii2-json-api/build-status/master) 7 | [![Total Downloads](https://poser.pugx.org/tuyakhov/yii2-json-api/downloads.png)](https://packagist.org/packages/tuyakhov/yii2-json-api) 8 | 9 | Installation 10 | ------------ 11 | 12 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 13 | 14 | Either run 15 | 16 | ``` 17 | php composer.phar require --prefer-dist tuyakhov/yii2-json-api "*" 18 | ``` 19 | 20 | or add 21 | 22 | ``` 23 | "tuyakhov/yii2-json-api": "*" 24 | ``` 25 | 26 | to the require section of your `composer.json` file. 27 | 28 | Data Serializing and Content Negotiation: 29 | ------------------------------------------- 30 | Controller: 31 | ```php 32 | class Controller extends \yii\rest\Controller 33 | { 34 | public $serializer = 'tuyakhov\jsonapi\Serializer'; 35 | 36 | public function behaviors() 37 | { 38 | return ArrayHelper::merge(parent::behaviors(), [ 39 | 'contentNegotiator' => [ 40 | 'class' => ContentNegotiator::className(), 41 | 'formats' => [ 42 | 'application/vnd.api+json' => Response::FORMAT_JSON, 43 | ], 44 | ] 45 | ]); 46 | } 47 | } 48 | ``` 49 | By default, the value of `type` is automatically pluralized. 50 | You can change this behavior by setting `tuyakhov\jsonapi\Serializer::$pluralize` property: 51 | ```php 52 | class Controller extends \yii\rest\Controller 53 | { 54 | public $serializer = [ 55 | 'class' => 'tuyakhov\jsonapi\Serializer', 56 | 'pluralize' => false, // makes {"type": "user"}, instead of {"type": "users"} 57 | ]; 58 | } 59 | ``` 60 | Defining models: 61 | 1) Let's define `User` model and declare an `articles` relation 62 | ```php 63 | use tuyakhov\jsonapi\ResourceTrait; 64 | use tuyakhov\jsonapi\ResourceInterface; 65 | 66 | class User extends ActiveRecord implements ResourceInterface 67 | { 68 | use ResourceTrait; 69 | 70 | public function getArticles() 71 | { 72 | return $this->hasMany(Article::className(), ['author_id' => 'id']); 73 | } 74 | } 75 | ``` 76 | 2) Now we need to define `Article` model 77 | ```php 78 | use tuyakhov\jsonapi\ResourceTrait; 79 | use tuyakhov\jsonapi\ResourceInterface; 80 | 81 | class Article extends ActiveRecord implements ResourceInterface 82 | { 83 | use ResourceTrait; 84 | } 85 | ``` 86 | 3) As the result `User` model will be serialized into the proper json api resource object: 87 | ```javascript 88 | { 89 | "data": { 90 | "type": "users", 91 | "id": "1", 92 | "attributes": { 93 | // ... this user's attributes 94 | }, 95 | "relationships": { 96 | "articles": { 97 | // ... this user's articles 98 | } 99 | } 100 | } 101 | } 102 | ``` 103 | Controlling JSON API output 104 | ------------------------------ 105 | The JSON response is generated by the `tuyakhov\jsonapi\JsonApiResponseFormatter` class which will 106 | use the `yii\helpers\Json` helper internally. This formatter can be configured with different options like 107 | for example the `$prettyPrint` option, which is useful on development for 108 | better readable responses, or `$encodeOptions` to control the output 109 | of the JSON encoding. 110 | 111 | The formatter can be configured in the `yii\web\Response::formatters` property of the `response` application 112 | component in the application configuration like the following: 113 | 114 | ```php 115 | 'response' => [ 116 | // ... 117 | 'formatters' => [ 118 | \yii\web\Response::FORMAT_JSON => [ 119 | 'class' => 'tuyakhov\jsonapi\JsonApiResponseFormatter', 120 | 'prettyPrint' => YII_DEBUG, // use "pretty" output in debug mode 121 | 'encodeOptions' => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, 122 | ], 123 | ], 124 | ], 125 | ``` 126 | Links 127 | --------------------------- 128 | Your resource classes may support HATEOAS by implementing the `LinksInterface`. 129 | The interface contains `getLinks()` method which should return a list of links. 130 | Typically, you should return at least the `self` link representing the URL to the resource object itself. 131 | In order to appear the links in relationships `getLinks()` method should return `self` link. 132 | Based on this link each relationship will generate `self` and `related` links. 133 | By default it happens by appending a relationship name at the end of the `self` link of the primary model, 134 | you can simply change that behavior by overwriting `getRelationshipLinks()` method. 135 | For example, 136 | ```php 137 | class User extends ActiveRecord implements ResourceInterface, LinksInterface 138 | { 139 | use ResourceTrait; 140 | 141 | public function getLinks() 142 | { 143 | return [ 144 | Link::REL_SELF => Url::to(['user/view', 'id' => $this->id], true), 145 | ]; 146 | } 147 | } 148 | ``` 149 | As the result: 150 | ```javascript 151 | { 152 | "data": { 153 | "type": "users", 154 | "id": "1", 155 | // ... this user's attributes 156 | "relationships": { 157 | "articles": { 158 | // ... article's data 159 | "links": { 160 | "self": {"href": "http://yourdomain.com/users/1/relationships/articles"}, 161 | "related": {"href": "http://yourdomain.com/users/1/articles"} 162 | } 163 | } 164 | } 165 | "links": { 166 | "self": {"href": "http://yourdomain.com/users/1"} 167 | } 168 | } 169 | } 170 | ``` 171 | Pagination 172 | --------------------------- 173 | The `page` query parameter family is reserved for pagination. 174 | This library implements a page-based strategy and allows the usage of query parameters such as `page[number]` and `page[size]` 175 | Example: `http://yourdomain.com/users?page[number]=3&page[size]=10` 176 | 177 | Enabling JSON API Input 178 | --------------------------- 179 | To let the API accept input data in JSON API format, configure the [[yii\web\Request::$parsers|parsers]] property of the request application component to use the [[tuyakhov\jsonapi\JsonApiParser]] for JSON input 180 | ```php 181 | 'request' => [ 182 | 'parsers' => [ 183 | 'application/vnd.api+json' => 'tuyakhov\jsonapi\JsonApiParser', 184 | ] 185 | ] 186 | ``` 187 | By default it parses a HTTP request body so that you can populate model attributes with user inputs. 188 | For example the request body: 189 | ```javascript 190 | { 191 | "data": { 192 | "type": "users", 193 | "id": "1", 194 | "attributes": { 195 | "first-name": "Bob", 196 | "last-name": "Homster" 197 | } 198 | } 199 | } 200 | ``` 201 | Will be resolved into the following array: 202 | ```php 203 | // var_dump($_POST); 204 | [ 205 | "User" => [ 206 | "first_name" => "Bob", 207 | "last_name" => "Homster" 208 | ] 209 | ] 210 | ``` 211 | So you can access request body by calling `\Yii::$app->request->post()` and simply populate the model with input data: 212 | ```php 213 | $model = new User(); 214 | $model->load(\Yii::$app->request->post()); 215 | ``` 216 | By default type `users` will be converted into `User` (singular, camelCase) which corresponds to the model's `formName()` method (which you may override). 217 | You can override the `JsonApiParser::formNameCallback` property which refers to a callback that converts 'type' member to form name. 218 | Also you could change the default behavior for conversion of member names to variable names ('first-name' converts into 'first_name') by setting `JsonApiParser::memberNameCallback` property. 219 | 220 | Examples 221 | -------- 222 | Controller: 223 | ```php 224 | class UserController extends \yii\rest\Controller 225 | { 226 | public $serializer = 'tuyakhov\jsonapi\Serializer'; 227 | 228 | /** 229 | * @inheritdoc 230 | */ 231 | public function behaviors() 232 | { 233 | return ArrayHelper::merge(parent::behaviors(), [ 234 | 'contentNegotiator' => [ 235 | 'class' => ContentNegotiator::className(), 236 | 'formats' => [ 237 | 'application/vnd.api+json' => Response::FORMAT_JSON, 238 | ], 239 | ] 240 | ]); 241 | } 242 | 243 | /** 244 | * @inheritdoc 245 | */ 246 | public function actions() 247 | { 248 | return [ 249 | 'create' => [ 250 | 'class' => 'tuyakhov\jsonapi\actions\CreateAction', 251 | 'modelClass' => ExampleModel::className() 252 | ], 253 | 'update' => [ 254 | 'class' => 'tuyakhov\jsonapi\actions\UpdateAction', 255 | 'modelClass' => ExampleModel::className() 256 | ], 257 | 'view' => [ 258 | 'class' => 'tuyakhov\jsonapi\actions\ViewAction', 259 | 'modelClass' => ExampleModel::className(), 260 | ], 261 | 'delete' => [ 262 | 'class' => 'tuyakhov\jsonapi\actions\DeleteAction', 263 | 'modelClass' => ExampleModel::className(), 264 | ], 265 | 'view-related' => [ 266 | 'class' => 'tuyakhov\jsonapi\actions\ViewRelatedAction', 267 | 'modelClass' => ExampleModel::className() 268 | ], 269 | 'update-relationship' => [ 270 | 'class' => 'tuyakhov\jsonapi\actions\UpdateRelationshipAction', 271 | 'modelClass' => ExampleModel::className() 272 | ], 273 | 'delete-relationship' => [ 274 | 'class' => 'tuyakhov\jsonapi\actions\DeleteRelationshipAction', 275 | 'modelClass' => ExampleModel::className() 276 | ], 277 | 'options' => [ 278 | 'class' => 'yii\rest\OptionsAction', 279 | ], 280 | ]; 281 | } 282 | } 283 | 284 | ``` 285 | 286 | Model: 287 | ```php 288 | class User extends ActiveRecord implements LinksInterface, ResourceInterface 289 | { 290 | use ResourceTrait; 291 | 292 | public function getLinks() 293 | { 294 | $reflect = new \ReflectionClass($this); 295 | $controller = Inflector::camel2id($reflect->getShortName()); 296 | return [ 297 | Link::REL_SELF => Url::to(["$controller/view", 'id' => $this->getId()], true) 298 | ]; 299 | } 300 | } 301 | ``` 302 | 303 | Configuration file `config/main.php`: 304 | ```php 305 | return [ 306 | // ... 307 | 'components' => [ 308 | 'request' => [ 309 | 'parsers' => [ 310 | 'application/vnd.api+json' => 'tuyakhov\jsonapi\JsonApiParser', 311 | ] 312 | ], 313 | 'response' => [ 314 | 'format' => \yii\web\Response::FORMAT_JSON, 315 | 'formatters' => [ 316 | \yii\web\Response::FORMAT_JSON => 'tuyakhov\jsonapi\JsonApiResponseFormatter' 317 | ] 318 | ], 319 | 'urlManager' => [ 320 | 'rules' => [ 321 | [ 322 | 'class' => 'tuyakhov\jsonapi\UrlRule', 323 | 'controller' => ['user'], 324 | ], 325 | 326 | ] 327 | ] 328 | // ... 329 | ] 330 | // ... 331 | ] 332 | ``` 333 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tuyakhov/yii2-json-api", 3 | "type": "yii2-extension", 4 | "description": "Implementation of JSON API specification for the Yii framework", 5 | "keywords": ["yii2", "json api", "api", "json", "rest", "json-api"], 6 | "homepage": "https://github.com/tuyakhov/yii2-json-api", 7 | "license": "MIT", 8 | "support": { 9 | "issues": "https://github.com/tuyakhov/yii2-json-api/issues", 10 | "source": "https://github.com/tuyakhov/yii2-json-api" 11 | }, 12 | "authors": [ 13 | { 14 | "name": "Anton Tuyakhov", 15 | "email": "atuyakhov@gmail.com", 16 | "role": "Developer" 17 | } 18 | ], 19 | "require": { 20 | "yiisoft/yii2": "^2.0.13" 21 | }, 22 | "require-dev": { 23 | "phpunit/phpunit": "5.5.*" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "tuyakhov\\jsonapi\\": "src/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "tuyakhov\\jsonapi\\tests\\": "tests/" 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | ./tests 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Controller.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi; 7 | 8 | 9 | use yii\filters\ContentNegotiator; 10 | use yii\helpers\ArrayHelper; 11 | use yii\web\Response; 12 | 13 | class Controller extends \yii\rest\Controller 14 | { 15 | public $serializer = 'tuyakhov\jsonapi\Serializer'; 16 | 17 | /** 18 | * @inheritdoc 19 | */ 20 | public function behaviors() 21 | { 22 | return ArrayHelper::merge(parent::behaviors(), [ 23 | 'contentNegotiator' => [ 24 | 'class' => ContentNegotiator::className(), 25 | 'formats' => [ 26 | 'application/vnd.api+json' => Response::FORMAT_JSON, 27 | ], 28 | ] 29 | ]); 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /src/Inflector.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi; 7 | 8 | use yii\helpers\BaseInflector; 9 | 10 | class Inflector extends BaseInflector 11 | { 12 | /** 13 | * Format member names according to recommendations for JSON API implementations. 14 | * For example, both 'firstName' and 'first_name' will be converted to 'first-name'. 15 | * @link http://jsonapi.org/format/#document-member-names 16 | * @param $var string 17 | * @return string 18 | */ 19 | public static function var2member($var) 20 | { 21 | return self::camel2id(self::variablize($var)); 22 | } 23 | 24 | /** 25 | * Converts member names to variable names 26 | * All special characters will be replaced by underscore 27 | * For example, 'first-name' will be converted to 'first_name' 28 | * @param $member string 29 | * @return mixed 30 | */ 31 | public static function member2var($member) 32 | { 33 | return str_replace(' ', '_', preg_replace('/[^A-Za-z0-9\.]+/', ' ', $member)); 34 | } 35 | 36 | /** 37 | * Converts 'type' member to form name 38 | * Will be converted to singular form. 39 | * For example, 'articles' will be converted to 'Article' 40 | * @param $type string 'type' member of the document 41 | * @return string 42 | */ 43 | public static function type2form($type) 44 | { 45 | return self::id2camel(self::singularize($type)); 46 | } 47 | } -------------------------------------------------------------------------------- /src/JsonApiParser.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi; 7 | 8 | use yii\helpers\ArrayHelper; 9 | use yii\web\BadRequestHttpException; 10 | use \yii\web\JsonParser; 11 | 12 | class JsonApiParser extends JsonParser 13 | { 14 | /** 15 | * Converts 'type' member to form name 16 | * If not set, type will be converted to singular form. 17 | * For example, 'articles' will be converted to 'Article' 18 | * @var callable 19 | */ 20 | public $formNameCallback = ['tuyakhov\jsonapi\Inflector', 'type2form']; 21 | 22 | /** 23 | * Converts member names to variable names 24 | * If not set, all special characters will be replaced by underscore 25 | * For example, 'first-name' will be converted to 'first_name' 26 | * @var callable 27 | */ 28 | public $memberNameCallback = ['tuyakhov\jsonapi\Inflector', 'member2var']; 29 | 30 | /** 31 | * Parse resource object into the input data to populates the model 32 | * @inheritdoc 33 | */ 34 | public function parse($rawBody, $contentType) 35 | { 36 | $array = parent::parse($rawBody, $contentType); 37 | if (!empty($array) && !ArrayHelper::keyExists('data', $array)) { 38 | if ($this->throwException) { 39 | throw new BadRequestHttpException('The request MUST include a single resource object as primary data.'); 40 | } 41 | return []; 42 | } 43 | $data = ArrayHelper::getValue($array, 'data', []); 44 | if (empty($data)) { 45 | return []; 46 | } 47 | if (ArrayHelper::isAssociative($data)) { 48 | $result = $this->parseResource($data); 49 | 50 | $relObjects = ArrayHelper::getValue($data, 'relationships', []); 51 | $result['relationships'] = $this->parseRelationships($relObjects); 52 | } else { 53 | foreach ($data as $object) { 54 | $resource = $this->parseResource($object); 55 | foreach (array_keys($resource) as $key) { 56 | $result[$key][] = $resource[$key]; 57 | } 58 | } 59 | } 60 | 61 | return isset($result) ? $result : $array; 62 | } 63 | 64 | /** 65 | * @param $type 'type' member of the document 66 | * @return string form name 67 | */ 68 | protected function typeToFormName($type) 69 | { 70 | return call_user_func($this->formNameCallback, $type); 71 | } 72 | 73 | /** 74 | * @param array $memberNames 75 | * @return array variable names 76 | */ 77 | protected function parseMemberNames(array $memberNames = []) 78 | { 79 | return array_map($this->memberNameCallback, $memberNames); 80 | } 81 | 82 | /** 83 | * @param $item 84 | * @return array 85 | * @throws BadRequestHttpException 86 | */ 87 | protected function parseResource($item) 88 | { 89 | if (!$type = ArrayHelper::getValue($item, 'type')) { 90 | if ($this->throwException) { 91 | throw new BadRequestHttpException('The resource object MUST contain at least a type member'); 92 | } 93 | return []; 94 | } 95 | $formName = $this->typeToFormName($type); 96 | 97 | $attributes = ArrayHelper::getValue($item, 'attributes', []); 98 | $attributes = array_combine($this->parseMemberNames(array_keys($attributes)), array_values($attributes)); 99 | 100 | if ($id = ArrayHelper::getValue($item, 'id')) { 101 | $attributes['id'] = $id; 102 | } 103 | 104 | return [$formName => $attributes]; 105 | } 106 | 107 | /** 108 | * @param array $relObjects 109 | * @return array 110 | */ 111 | protected function parseRelationships(array $relObjects = []) 112 | { 113 | $relationships = []; 114 | foreach ($relObjects as $name => $relationship) { 115 | if (!ArrayHelper::keyExists('data', $relationship)) { 116 | continue; 117 | } 118 | $relData = ArrayHelper::getValue($relationship, 'data', []); 119 | if (!ArrayHelper::isIndexed($relData)) { 120 | $relData = [$relData]; 121 | } 122 | $relationships[$name] = []; 123 | foreach ($relData as $identifier) { 124 | if (isset($identifier['type']) && isset($identifier['id'])) { 125 | $formName = $this->typeToFormName($identifier['type']); 126 | $relationships[$name][$formName][] = ['id' => $identifier['id']]; 127 | } 128 | } 129 | } 130 | return $relationships; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/JsonApiResponseFormatter.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi; 7 | 8 | use yii\base\Component; 9 | use yii\helpers\ArrayHelper; 10 | use yii\helpers\Json; 11 | use yii\helpers\Url; 12 | use yii\web\ErrorHandler; 13 | use yii\web\Link; 14 | use yii\web\Response; 15 | use yii\web\ResponseFormatterInterface; 16 | 17 | class JsonApiResponseFormatter extends Component implements ResponseFormatterInterface 18 | { 19 | /** 20 | * Mapping between the error handler component and JSON API error object 21 | * @see ErrorHandler::convertExceptionToArray() 22 | */ 23 | const ERROR_EXCEPTION_MAPPING = [ 24 | 'title' => 'name', 25 | 'detail' => 'message', 26 | 'code' => 'code', 27 | 'status' => 'status' 28 | ]; 29 | /** 30 | * An error object MAY have the following members 31 | * @link http://jsonapi.org/format/#error-objects 32 | */ 33 | const ERROR_ALLOWED_MEMBERS = [ 34 | 'id', 'links', 'status', 'code', 'title', 'detail', 'source', 'meta' 35 | ]; 36 | /** 37 | * @var integer the encoding options passed to [[Json::encode()]]. For more details please refer to 38 | * . 39 | * Default is `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE`. 40 | */ 41 | public $encodeOptions = 320; 42 | /** 43 | * @var bool whether to format the output in a readable "pretty" format. This can be useful for debugging purpose. 44 | * If this is true, `JSON_PRETTY_PRINT` will be added to [[encodeOptions]]. 45 | * Defaults to `false`. 46 | */ 47 | public $prettyPrint = false; 48 | 49 | /** 50 | * Formats response data in JSON format. 51 | * @link http://jsonapi.org/format/upcoming/#document-structure 52 | * @param Response $response 53 | */ 54 | public function format($response) 55 | { 56 | $response->getHeaders()->set('Content-Type', 'application/vnd.api+json; charset=UTF-8'); 57 | $options = $this->encodeOptions; 58 | if ($this->prettyPrint) { 59 | $options |= JSON_PRETTY_PRINT; 60 | } 61 | 62 | $apiDocument = $response->data; 63 | 64 | if (!$response->isEmpty && empty($apiDocument)) { 65 | $apiDocument = ['data' => $response->data]; 66 | if (\Yii::$app->controller) { 67 | $apiDocument['links'] = Link::serialize([ 68 | Link::REL_SELF => Url::current([], true) 69 | ]); 70 | } 71 | } 72 | 73 | if ($response->isClientError || $response->isServerError) { 74 | if (ArrayHelper::isAssociative($response->data)) { 75 | $response->data = [$response->data]; 76 | } 77 | $formattedErrors = []; 78 | foreach ($response->data as $error) { 79 | $formattedError = array_intersect_key($error, array_flip(static::ERROR_ALLOWED_MEMBERS)); 80 | foreach (static::ERROR_EXCEPTION_MAPPING as $member => $key) { 81 | if (isset($error[$key])) { 82 | $formattedError[$member] = (string) $error[$key]; 83 | } 84 | } 85 | if (!empty($formattedError)) { 86 | $formattedErrors[] = $formattedError; 87 | } 88 | } 89 | $apiDocument = ['errors' => $formattedErrors]; 90 | } 91 | if ($apiDocument !== null) { 92 | $response->content = Json::encode($apiDocument, $options); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/LinksInterface.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi; 7 | 8 | use \yii\web\Linkable; 9 | 10 | interface LinksInterface extends Linkable 11 | { 12 | public function getRelationshipLinks($name); 13 | } -------------------------------------------------------------------------------- /src/Pagination.php: -------------------------------------------------------------------------------- 1 | params === null) { 28 | $request = Yii::$app->getRequest(); 29 | $params = $request instanceof Request ? $request->getQueryParam('page') : []; 30 | if (!is_array($params)) { 31 | $params = []; 32 | } 33 | $this->params = $params; 34 | } 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /src/ResourceIdentifierInterface.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi; 7 | 8 | /** 9 | * Interface for a “resource identifier object” that identifies an individual resource. 10 | */ 11 | interface ResourceIdentifierInterface 12 | { 13 | /** 14 | * The "id" member of a resource object. 15 | * @return string an ID that in pair with type uniquely identifies the resource. 16 | */ 17 | public function getId(); 18 | 19 | /** 20 | * The "type" member of a resource object. 21 | * @return string a type that identifies the resource. 22 | */ 23 | public function getType(); 24 | } 25 | -------------------------------------------------------------------------------- /src/ResourceInterface.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi; 7 | 8 | /** 9 | * Interface for a “resource object” that represent an individual resource. 10 | */ 11 | interface ResourceInterface extends ResourceIdentifierInterface 12 | { 13 | /** 14 | * The "attributes" member of the resource object representing some of the resource’s data. 15 | * @param array $fields specific fields that a client has requested. 16 | * @return array an array of attributes that represent information about the resource object in which it’s defined. 17 | */ 18 | public function getResourceAttributes(array $fields = []); 19 | 20 | /** 21 | * The "relationships" member of the resource object describing relationships between the resource and other JSON API resources. 22 | * @param array $linked specific resource linkage that a client has requested. 23 | * @return ResourceIdentifierInterface[] represent references from the resource object in which it’s defined to other resource objects. 24 | */ 25 | public function getResourceRelationships(array $linked = []); 26 | 27 | public function setResourceRelationship($name, $relationship); 28 | 29 | } -------------------------------------------------------------------------------- /src/ResourceTrait.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi; 7 | 8 | use yii\base\Arrayable; 9 | use yii\db\ActiveRecordInterface; 10 | use yii\web\Link; 11 | use yii\web\Linkable; 12 | 13 | trait ResourceTrait 14 | { 15 | /** 16 | * @var bool a flag that enables/disables deleting of the model that contains the foreign key when setting relationships 17 | * By default the model's foreign key will be set `null` and saved. 18 | */ 19 | protected $allowDeletingResources = false; 20 | 21 | /** 22 | * @return string 23 | */ 24 | public function getId() 25 | { 26 | return (string) ($this instanceof ActiveRecordInterface ? $this->getPrimaryKey() : null); 27 | } 28 | 29 | /** 30 | * @return string 31 | */ 32 | public function getType() 33 | { 34 | $reflect = new \ReflectionClass($this); 35 | $className = $reflect->getShortName(); 36 | return Inflector::camel2id($className); 37 | } 38 | 39 | /** 40 | * @param array $fields 41 | * @return array 42 | */ 43 | public function getResourceAttributes(array $fields = []) 44 | { 45 | $attributes = []; 46 | if ($this instanceof Arrayable) { 47 | $fieldDefinitions = $this->fields(); 48 | } else { 49 | $vars = array_keys(\Yii::getObjectVars($this)); 50 | $fieldDefinitions = array_combine($vars, $vars); 51 | } 52 | 53 | foreach ($this->resolveFields($fieldDefinitions, $fields) as $name => $definition) { 54 | $attributes[$name] = is_string($definition) ? $this->$definition : call_user_func($definition, $this, $name); 55 | } 56 | return $attributes; 57 | } 58 | 59 | /** 60 | * @param array $linked 61 | * @return array 62 | */ 63 | public function getResourceRelationships(array $linked = []) 64 | { 65 | $fields = []; 66 | if ($this instanceof Arrayable) { 67 | $fields = $this->extraFields(); 68 | } 69 | $resolvedFields = $this->resolveFields($fields); 70 | $keys = array_keys($resolvedFields); 71 | 72 | $relationships = array_fill_keys($keys, null); 73 | $linkedFields = array_intersect($keys, $linked); 74 | 75 | foreach ($linkedFields as $name) { 76 | $definition = $resolvedFields[$name]; 77 | $relationships[$name] = is_string($definition) ? $this->$definition : call_user_func($definition, $this, $name); 78 | } 79 | 80 | return $relationships; 81 | } 82 | 83 | /** 84 | * @param string $name the case sensitive name of the relationship. 85 | * @param array|ActiveRecordInterface $relationship 86 | */ 87 | public function setResourceRelationship($name, $relationship) 88 | { 89 | /** @var $this ActiveRecordInterface */ 90 | if (!$this instanceof ActiveRecordInterface) { 91 | return; 92 | } 93 | if (!is_array($relationship)) { 94 | $relationship = [$relationship]; 95 | } 96 | $this->unlinkAll($name, $this->allowDeletingResources); 97 | foreach ($relationship as $key => $value) { 98 | if ($value instanceof ActiveRecordInterface) { 99 | $this->link($name, $value); 100 | } 101 | } 102 | } 103 | 104 | /** 105 | * @param string $name the case sensitive name of the relationship. 106 | * @return array 107 | */ 108 | public function getRelationshipLinks($name) 109 | { 110 | if (!$this instanceof Linkable) { 111 | return []; 112 | } 113 | $primaryLinks = $this->getLinks(); 114 | if (!array_key_exists(Link::REL_SELF, $primaryLinks)) { 115 | return []; 116 | } 117 | $resourceLink = is_string($primaryLinks[Link::REL_SELF]) ? rtrim($primaryLinks[Link::REL_SELF], '/') : null; 118 | if (!$resourceLink) { 119 | return []; 120 | } 121 | return [ 122 | Link::REL_SELF => "{$resourceLink}/relationships/{$name}", 123 | 'related' => "{$resourceLink}/{$name}", 124 | ]; 125 | } 126 | 127 | /** 128 | * @param array $fields 129 | * @param array $fieldSet 130 | * @return array 131 | */ 132 | protected function resolveFields(array $fields, array $fieldSet = []) 133 | { 134 | $result = []; 135 | 136 | foreach ($fields as $field => $definition) { 137 | if (is_int($field)) { 138 | $field = $definition; 139 | } 140 | $field = Inflector::camel2id(Inflector::variablize($field), '_'); 141 | if (empty($fieldSet) || in_array($field, $fieldSet, true)) { 142 | $result[$field] = $definition; 143 | } 144 | } 145 | 146 | return $result; 147 | } 148 | 149 | /** 150 | * @param $value boolean 151 | */ 152 | public function setAllowDeletingResources($value) 153 | { 154 | $this->allowDeletingResources = $value; 155 | } 156 | 157 | /** 158 | * @return bool 159 | */ 160 | public function getAllowDeletingResources() 161 | { 162 | return $this->allowDeletingResources; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/Serializer.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi; 7 | 8 | use yii\base\Component; 9 | use yii\base\InvalidValueException; 10 | use yii\base\Model; 11 | use yii\data\DataProviderInterface; 12 | use yii\data\Pagination; 13 | use yii\web\Link; 14 | use yii\web\Linkable; 15 | use yii\web\Request; 16 | use yii\web\Response; 17 | 18 | class Serializer extends Component 19 | { 20 | /** 21 | * @var string the name of the query parameter containing the information about which fields should be returned 22 | * for a [[Model]] object. If the parameter is not provided or empty, the default set of fields as defined 23 | * by [[Model::fields()]] will be returned. 24 | */ 25 | public $fieldsParam = 'fields'; 26 | /** 27 | * @var string the name of the query parameter containing the information about which fields should be returned 28 | * in addition to those listed in [[fieldsParam]] for a resource object. 29 | */ 30 | public $expandParam = 'include'; 31 | /** 32 | * @var string the name of the envelope (e.g. `_links`) for returning the links objects. 33 | * It takes effect only, if `collectionEnvelope` is set. 34 | * @since 2.0.4 35 | */ 36 | public $linksEnvelope = 'links'; 37 | /** 38 | * @var string the name of the envelope (e.g. `_meta`) for returning the pagination object. 39 | * It takes effect only, if `collectionEnvelope` is set. 40 | * @since 2.0.4 41 | */ 42 | public $metaEnvelope = 'meta'; 43 | /** 44 | * @var Request the current request. If not set, the `request` application component will be used. 45 | */ 46 | public $request; 47 | /** 48 | * @var Response the response to be sent. If not set, the `response` application component will be used. 49 | */ 50 | public $response; 51 | /** 52 | * @var bool whether to automatically pluralize the `type` of resource. 53 | */ 54 | public $pluralize = true; 55 | 56 | /** 57 | * Prepares the member name that should be returned. 58 | * If not set, all member names will be converted to recommended format. 59 | * For example, both 'firstName' and 'first_name' will be converted to 'first-name'. 60 | * @var callable 61 | */ 62 | public $prepareMemberName = ['tuyakhov\jsonapi\Inflector', 'var2member']; 63 | 64 | /** 65 | * Converts a member name to an attribute name. 66 | * @var callable 67 | */ 68 | public $formatMemberName = ['tuyakhov\jsonapi\Inflector', 'member2var']; 69 | 70 | 71 | /** 72 | * @inheritdoc 73 | */ 74 | public function init() 75 | { 76 | if ($this->request === null) { 77 | $this->request = \Yii::$app->getRequest(); 78 | } 79 | if ($this->response === null) { 80 | $this->response = \Yii::$app->getResponse(); 81 | } 82 | } 83 | 84 | /** 85 | * Serializes the given data into a format that can be easily turned into other formats. 86 | * This method mainly converts the objects of recognized types into array representation. 87 | * It will not do conversion for unknown object types or non-object data. 88 | * @param mixed $data the data to be serialized. 89 | * @return mixed the converted data. 90 | */ 91 | public function serialize($data) 92 | { 93 | if ($data instanceof Model && $data->hasErrors()) { 94 | return $this->serializeModelErrors($data); 95 | } elseif ($data instanceof ResourceInterface) { 96 | return $this->serializeResource($data); 97 | } elseif ($data instanceof DataProviderInterface) { 98 | return $this->serializeDataProvider($data); 99 | } else { 100 | return $data; 101 | } 102 | } 103 | 104 | /** 105 | * @param array $included 106 | * @param ResourceInterface $model 107 | * @return array 108 | */ 109 | protected function serializeModel(ResourceInterface $model, array $included = []) 110 | { 111 | $fields = $this->getRequestedFields(); 112 | $type = $this->pluralize ? Inflector::pluralize($model->getType()) : $model->getType(); 113 | $fields = isset($fields[$type]) ? $fields[$type] : []; 114 | 115 | $topLevel = array_map(function($item) { 116 | if (($pos = strrpos($item, '.')) !== false) { 117 | return substr($item, 0, $pos); 118 | } 119 | return $item; 120 | }, $included); 121 | 122 | $attributes = $model->getResourceAttributes($fields); 123 | $attributes = array_combine($this->prepareMemberNames(array_keys($attributes)), array_values($attributes)); 124 | 125 | $data = array_merge($this->serializeIdentifier($model), [ 126 | 'attributes' => $attributes, 127 | ]); 128 | 129 | $relationships = $model->getResourceRelationships($topLevel); 130 | if (!empty($relationships)) { 131 | foreach ($relationships as $name => $items) { 132 | $relationship = []; 133 | if (is_array($items)) { 134 | foreach ($items as $item) { 135 | if ($item instanceof ResourceIdentifierInterface) { 136 | $relationship[] = $this->serializeIdentifier($item); 137 | } 138 | } 139 | } elseif ($items instanceof ResourceIdentifierInterface) { 140 | $relationship = $this->serializeIdentifier($items); 141 | } 142 | $memberName = $this->prepareMemberNames([$name]); 143 | $memberName = reset($memberName); 144 | if (!empty($relationship)) { 145 | $data['relationships'][$memberName]['data'] = $relationship; 146 | } 147 | if ($model instanceof LinksInterface) { 148 | $links = $model->getRelationshipLinks($memberName); 149 | if (!empty($links)) { 150 | $data['relationships'][$memberName]['links'] = Link::serialize($links); 151 | } 152 | } 153 | } 154 | } 155 | 156 | if ($model instanceof Linkable) { 157 | $data['links'] = Link::serialize($model->getLinks()); 158 | } 159 | 160 | return $data; 161 | } 162 | 163 | /** 164 | * @param ResourceInterface $resource 165 | * @return array 166 | */ 167 | protected function serializeResource(ResourceInterface $resource) 168 | { 169 | if ($this->request->getIsHead()) { 170 | return null; 171 | } else { 172 | $included = $this->getIncluded(); 173 | $data = [ 174 | 'data' => $this->serializeModel($resource, $included) 175 | ]; 176 | 177 | $relatedResources = $this->serializeIncluded($resource, $included); 178 | if (!empty($relatedResources)) { 179 | $data['included'] = $relatedResources; 180 | } 181 | 182 | return $data; 183 | } 184 | } 185 | 186 | /** 187 | * Serialize resource identifier object and make type juggling 188 | * @link http://jsonapi.org/format/#document-resource-object-identification 189 | * @param ResourceIdentifierInterface $identifier 190 | * @return array 191 | */ 192 | protected function serializeIdentifier(ResourceIdentifierInterface $identifier) 193 | { 194 | $result = []; 195 | foreach (['id', 'type'] as $key) { 196 | $getter = 'get' . ucfirst($key); 197 | $value = $identifier->$getter(); 198 | if ($value === null || is_array($value) || (is_object($value) && !method_exists($value, '__toString'))) { 199 | throw new InvalidValueException("The value {$key} of resource object " . get_class($identifier) . ' MUST be a string.'); 200 | } 201 | if ($key === 'type' && $this->pluralize) { 202 | $value = Inflector::pluralize($value); 203 | } 204 | $result[$key] = (string) $value; 205 | } 206 | return $result; 207 | } 208 | 209 | /** 210 | * @param ResourceInterface|array $resources 211 | * @param array $included 212 | * @param true $assoc 213 | * @return array 214 | */ 215 | protected function serializeIncluded($resources, array $included = [], $assoc = false) 216 | { 217 | $resources = is_array($resources) ? $resources : [$resources]; 218 | $data = []; 219 | 220 | $inclusion = []; 221 | foreach ($included as $path) { 222 | if (($pos = strrpos($path, '.')) === false) { 223 | $inclusion[$path] = []; 224 | continue; 225 | } 226 | $name = substr($path, $pos + 1); 227 | $key = substr($path, 0, $pos); 228 | $inclusion[$key][] = $name; 229 | } 230 | 231 | foreach ($resources as $resource) { 232 | if (!$resource instanceof ResourceInterface) { 233 | continue; 234 | } 235 | $relationships = $resource->getResourceRelationships(array_keys($inclusion)); 236 | foreach ($relationships as $name => $relationship) { 237 | if ($relationship === null) { 238 | continue; 239 | } 240 | if (!is_array($relationship)) { 241 | $relationship = [$relationship]; 242 | } 243 | foreach ($relationship as $model) { 244 | if (!$model instanceof ResourceInterface) { 245 | continue; 246 | } 247 | $uniqueKey = $model->getType() . '/' . $model->getId(); 248 | if (!isset($data[$uniqueKey])) { 249 | $data[$uniqueKey] = $this->serializeModel($model, $inclusion[$name]); 250 | } 251 | if (!empty($inclusion[$name])) { 252 | $data = array_merge($data, $this->serializeIncluded($model, $inclusion[$name], true)); 253 | } 254 | } 255 | } 256 | } 257 | 258 | return $assoc ? $data : array_values($data); 259 | } 260 | 261 | /** 262 | * Serializes a data provider. 263 | * @param DataProviderInterface $dataProvider 264 | * @return null|array the array representation of the data provider. 265 | */ 266 | protected function serializeDataProvider($dataProvider) 267 | { 268 | if ($this->request->getIsHead()) { 269 | return null; 270 | } else { 271 | $models = $dataProvider->getModels(); 272 | $data = []; 273 | 274 | $included = $this->getIncluded(); 275 | foreach ($models as $model) { 276 | if ($model instanceof ResourceInterface) { 277 | $data[] = $this->serializeModel($model, $included); 278 | } 279 | } 280 | 281 | $result = ['data' => $data]; 282 | 283 | $relatedResources = $this->serializeIncluded($models, $included); 284 | if (!empty($relatedResources)) { 285 | $result['included'] = $relatedResources; 286 | } 287 | 288 | if (($pagination = $dataProvider->getPagination()) !== false) { 289 | return array_merge($result, $this->serializePagination($pagination)); 290 | } 291 | 292 | return $result; 293 | } 294 | } 295 | 296 | /** 297 | * Serializes a pagination into an array. 298 | * @param Pagination $pagination 299 | * @return array the array representation of the pagination 300 | * @see addPaginationHeaders() 301 | */ 302 | protected function serializePagination($pagination) 303 | { 304 | return [ 305 | $this->linksEnvelope => Link::serialize($pagination->getLinks(true)), 306 | $this->metaEnvelope => [ 307 | 'total-count' => $pagination->totalCount, 308 | 'page-count' => $pagination->getPageCount(), 309 | 'current-page' => $pagination->getPage() + 1, 310 | 'per-page' => $pagination->getPageSize(), 311 | ], 312 | ]; 313 | } 314 | 315 | /** 316 | * Serializes the validation errors in a model. 317 | * @param Model $model 318 | * @return array the array representation of the errors 319 | */ 320 | protected function serializeModelErrors($model) 321 | { 322 | $this->response->setStatusCode(422, 'Data Validation Failed.'); 323 | $result = []; 324 | foreach ($model->getFirstErrors() as $name => $message) { 325 | $memberName = call_user_func($this->prepareMemberName, $name); 326 | $result[] = [ 327 | 'source' => ['pointer' => "/data/attributes/{$memberName}"], 328 | 'detail' => $message, 329 | 'status' => '422' 330 | ]; 331 | } 332 | 333 | return $result; 334 | } 335 | 336 | /** 337 | * @return array 338 | */ 339 | protected function getRequestedFields() 340 | { 341 | $fields = $this->request->get($this->fieldsParam); 342 | 343 | if (!is_array($fields)) { 344 | $fields = []; 345 | } 346 | foreach ($fields as $key => $field) { 347 | $fields[$key] = array_map($this->formatMemberName, preg_split('/\s*,\s*/', $field, -1, PREG_SPLIT_NO_EMPTY)); 348 | } 349 | return $fields; 350 | } 351 | 352 | /** 353 | * @return array|null 354 | */ 355 | protected function getIncluded() 356 | { 357 | $include = $this->request->get($this->expandParam); 358 | return is_string($include) ? array_map($this->formatMemberName, preg_split('/\s*,\s*/', $include, -1, PREG_SPLIT_NO_EMPTY)) : []; 359 | } 360 | 361 | 362 | /** 363 | * Format member names according to recommendations for JSON API implementations 364 | * @link http://jsonapi.org/format/#document-member-names 365 | * @param array $memberNames 366 | * @return array 367 | */ 368 | protected function prepareMemberNames(array $memberNames = []) 369 | { 370 | return array_map($this->prepareMemberName, $memberNames); 371 | } 372 | } 373 | -------------------------------------------------------------------------------- /src/UrlRule.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi; 7 | 8 | 9 | /** 10 | * UrlRule is provided to simplify the creation of URL rules for JSON API support. 11 | * @package tuyakhov\jsonapi 12 | */ 13 | class UrlRule extends \yii\rest\UrlRule 14 | { 15 | /** 16 | * @inheritdoc 17 | */ 18 | public function init() 19 | { 20 | $this->tokens = array_merge($this->tokens, array_merge([ 21 | '{relationship}' => '' 22 | ])); 23 | $this->patterns = array_merge($this->patterns, [ 24 | 'DELETE {id}/relationships/{relationship}' => 'delete-relationship', 25 | 'POST,PATCH {id}/relationships/{relationship}' => 'update-relationship', 26 | 'GET {id}/{relationship}' => 'view-related', 27 | '{id}/{relationship}' => 'options' 28 | ]); 29 | parent::init(); 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /src/actions/Action.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\actions; 7 | 8 | use tuyakhov\jsonapi\ResourceInterface; 9 | use tuyakhov\jsonapi\ResourceTrait; 10 | use yii\db\ActiveRecordInterface; 11 | use yii\db\BaseActiveRecord; 12 | use yii\helpers\ArrayHelper; 13 | 14 | class Action extends \yii\rest\Action 15 | { 16 | /** 17 | * Links the relationships with primary model. 18 | * @var callable 19 | */ 20 | public $linkRelationships; 21 | 22 | /** 23 | * @var bool Weather allow to do a full replacement of a to-many relationship 24 | */ 25 | public $allowFullReplacement = true; 26 | 27 | /** 28 | * @var bool Weather allow to delete the underlying resource if a relationship is deleted (as a garbage collection measure) 29 | */ 30 | public $enableResourceDeleting = false; 31 | 32 | /** 33 | * Links the relationships with primary model. 34 | * @param $model ActiveRecordInterface 35 | * @param array $data relationship links 36 | */ 37 | protected function linkRelationships($model, array $data = []) 38 | { 39 | if ($this->linkRelationships !== null) { 40 | call_user_func($this->linkRelationships, $this, $model, $data); 41 | return; 42 | } 43 | 44 | if (!$model instanceof ResourceInterface) { 45 | return; 46 | } 47 | 48 | foreach ($data as $name => $relationship) { 49 | if (!$related = $model->getRelation($name, false)) { 50 | continue; 51 | } 52 | /** @var BaseActiveRecord $relatedClass */ 53 | $relatedClass = new $related->modelClass; 54 | $relationships = ArrayHelper::keyExists($relatedClass->formName(), $relationship) ? $relationship[$relatedClass->formName()] : []; 55 | 56 | $ids = []; 57 | foreach ($relationships as $index => $relObject) { 58 | if (!isset($relObject['id'])) { 59 | continue; 60 | } 61 | $ids[] = $relObject['id']; 62 | } 63 | 64 | if ($related->multiple && !$this->allowFullReplacement) { 65 | continue; 66 | } 67 | $records = $relatedClass::find()->andWhere(['in', $relatedClass::primaryKey(), $ids])->all(); 68 | 69 | /** @see ResourceTrait::$allowDeletingResources */ 70 | if (method_exists($model, 'setAllowDeletingResources')) { 71 | $model->setAllowDeletingResources($this->enableResourceDeleting); 72 | } 73 | 74 | $model->setResourceRelationship($name, $records); 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /src/actions/CreateAction.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\actions; 7 | 8 | use Yii; 9 | use yii\base\Model; 10 | use yii\helpers\Url; 11 | use yii\web\ServerErrorHttpException; 12 | 13 | class CreateAction extends Action 14 | { 15 | /** 16 | * @var string the scenario to be assigned to the new model before it is validated and saved. 17 | */ 18 | public $scenario = Model::SCENARIO_DEFAULT; 19 | 20 | /** 21 | * @var string the name of the view action. This property is need to create the URL when the model is successfully created. 22 | */ 23 | public $viewAction = 'view'; 24 | 25 | /** 26 | * Links the relationships with primary model. 27 | * @var callable 28 | */ 29 | public $linkRelationships; 30 | 31 | /** 32 | * Creates a new resource. 33 | * @return \yii\db\ActiveRecordInterface the model newly created 34 | * @throws ServerErrorHttpException if there is any error when creating the model 35 | */ 36 | public function run() 37 | { 38 | if ($this->checkAccess) { 39 | call_user_func($this->checkAccess, $this->id); 40 | } 41 | 42 | /* @var $model \yii\db\ActiveRecord */ 43 | $model = new $this->modelClass([ 44 | 'scenario' => $this->scenario, 45 | ]); 46 | 47 | $request = Yii::$app->getRequest(); 48 | $model->load($request->getBodyParams()); 49 | if ($model->save()) { 50 | $this->linkRelationships($model, $request->getBodyParam('relationships', [])); 51 | $response = Yii::$app->getResponse(); 52 | $response->setStatusCode(201); 53 | $id = implode(',', array_values($model->getPrimaryKey(true))); 54 | $response->getHeaders()->set('Location', Url::toRoute([$this->viewAction, 'id' => $id], true)); 55 | } elseif (!$model->hasErrors()) { 56 | throw new ServerErrorHttpException('Failed to create the object for unknown reason.'); 57 | } 58 | 59 | return $model; 60 | } 61 | } -------------------------------------------------------------------------------- /src/actions/DeleteAction.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\actions; 7 | 8 | use Yii; 9 | use yii\db\BaseActiveRecord; 10 | use yii\web\NotFoundHttpException; 11 | use yii\web\ServerErrorHttpException; 12 | 13 | /** 14 | * Implements the API endpoint for deleting resources. 15 | * @link http://jsonapi.org/format/#crud-deleting 16 | */ 17 | class DeleteAction extends Action 18 | { 19 | /** 20 | * Deletes a resource. 21 | * @param mixed $id id of the resource to be deleted. 22 | * @throws NotFoundHttpException 23 | * @throws ServerErrorHttpException on failure. 24 | */ 25 | public function run($id) 26 | { 27 | /** @var BaseActiveRecord $model */ 28 | $model = $this->findModel($id); 29 | 30 | if ($this->checkAccess) { 31 | call_user_func($this->checkAccess, $this->id, $model); 32 | } 33 | 34 | if ($model->delete() === false) { 35 | throw new ServerErrorHttpException('Failed to delete the resource for unknown reason.'); 36 | } 37 | 38 | Yii::$app->getResponse()->setStatusCode(204); 39 | } 40 | } -------------------------------------------------------------------------------- /src/actions/DeleteRelationshipAction.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\actions; 7 | 8 | use Yii; 9 | use yii\data\ActiveDataProvider; 10 | use yii\db\ActiveRecordInterface; 11 | use yii\db\ActiveRelationTrait; 12 | use yii\db\BaseActiveRecord; 13 | use yii\helpers\ArrayHelper; 14 | use yii\web\ForbiddenHttpException; 15 | use yii\web\NotFoundHttpException; 16 | 17 | /** 18 | * Deletes the specified members from a relationship 19 | * @link http://jsonapi.org/format/#crud-updating-relationships 20 | */ 21 | class DeleteRelationshipAction extends Action 22 | { 23 | /** 24 | * Removes the relationships from primary model. 25 | * @var callable 26 | */ 27 | public $unlinkRelationships; 28 | 29 | /** 30 | * @param string $id an ID of the primary resource 31 | * @param string $name a name of the related resource 32 | * @return ActiveDataProvider 33 | * @throws ForbiddenHttpException 34 | * @throws NotFoundHttpException 35 | * @throws \yii\base\InvalidConfigException 36 | */ 37 | public function run($id, $name) 38 | { 39 | /** @var BaseActiveRecord $model */ 40 | $model = $this->findModel($id); 41 | 42 | if (!$related = $model->getRelation($name, false)) { 43 | throw new NotFoundHttpException('Relationship does not exist'); 44 | } 45 | 46 | if (!$related->multiple) { 47 | throw new ForbiddenHttpException('Unsupported request to update relationship'); 48 | } 49 | 50 | if ($this->checkAccess) { 51 | call_user_func($this->checkAccess, $this->id, $model, $name); 52 | } 53 | 54 | $this->unlinkRelationships($model, [$name => Yii::$app->getRequest()->getBodyParams()]); 55 | 56 | 57 | return new ActiveDataProvider([ 58 | 'query' => $related 59 | ]); 60 | } 61 | 62 | /** 63 | * Removes the relationships from primary model. 64 | * @param $model ActiveRecordInterface 65 | * @param array $data relationship links 66 | */ 67 | protected function unlinkRelationships($model, array $data = []) 68 | { 69 | if ($this->unlinkRelationships !== null) { 70 | call_user_func($this->unlinkRelationships, $this, $model, $data); 71 | return; 72 | } 73 | 74 | foreach ($data as $name => $relationship) { 75 | /** @var $related ActiveRelationTrait */ 76 | if (!$related = $model->getRelation($name, false)) { 77 | continue; 78 | } 79 | /** @var BaseActiveRecord $relatedClass */ 80 | $relatedClass = new $related->modelClass; 81 | $relationships = ArrayHelper::keyExists($relatedClass->formName(), $relationship) ? $relationship[$relatedClass->formName()] : []; 82 | 83 | $ids = []; 84 | foreach ($relationships as $index => $relObject) { 85 | if (!isset($relObject['id'])) { 86 | continue; 87 | } 88 | $ids[] = $relObject['id']; 89 | } 90 | 91 | $records = $relatedClass::find()->andWhere(['in', $relatedClass::primaryKey(), $ids])->all(); 92 | foreach ($records as $record) { 93 | $model->unlink($name, $record, $this->enableResourceDeleting); 94 | } 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /src/actions/IndexAction.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\actions; 7 | 8 | 9 | use tuyakhov\jsonapi\Inflector; 10 | use tuyakhov\jsonapi\Pagination; 11 | use yii\data\ActiveDataProvider; 12 | use yii\data\DataFilter; 13 | use Yii; 14 | 15 | class IndexAction extends Action 16 | { 17 | /** 18 | * @var callable a PHP callable that will be called to prepare a data provider that 19 | * should return a collection of the models. If not set, [[prepareDataProvider()]] will be used instead. 20 | * The signature of the callable should be: 21 | * 22 | * ```php 23 | * function (IndexAction $action) { 24 | * // $action is the action object currently running 25 | * } 26 | * ``` 27 | * 28 | * The callable should return an instance of [[ActiveDataProvider]]. 29 | * 30 | * If [[dataFilter]] is set the result of [[DataFilter::build()]] will be passed to the callable as a second parameter. 31 | * In this case the signature of the callable should be the following: 32 | * 33 | * ```php 34 | * function (IndexAction $action, mixed $filter) { 35 | * // $action is the action object currently running 36 | * // $filter the built filter condition 37 | * } 38 | * ``` 39 | */ 40 | public $prepareDataProvider; 41 | /** 42 | * @var DataFilter|null data filter to be used for the search filter composition. 43 | * You must setup this field explicitly in order to enable filter processing. 44 | * For example: 45 | * 46 | * ```php 47 | * [ 48 | * 'class' => 'yii\data\ActiveDataFilter', 49 | * 'searchModel' => function () { 50 | * return (new \yii\base\DynamicModel(['id' => null, 'name' => null, 'price' => null])) 51 | * ->addRule('id', 'integer') 52 | * ->addRule('name', 'trim') 53 | * ->addRule('name', 'string') 54 | * ->addRule('price', 'number'); 55 | * }, 56 | * ] 57 | * ``` 58 | * 59 | * @see DataFilter 60 | * 61 | * @since 2.0.13 62 | */ 63 | public $dataFilter; 64 | 65 | 66 | /** 67 | * @return ActiveDataProvider 68 | * @throws \yii\base\InvalidConfigException 69 | */ 70 | public function run() 71 | { 72 | if ($this->checkAccess) { 73 | call_user_func($this->checkAccess, $this->id); 74 | } 75 | 76 | return $this->prepareDataProvider(); 77 | } 78 | 79 | /** 80 | * Prepares the data provider that should return the requested collection of the models. 81 | * @return mixed|null|object|DataFilter|ActiveDataProvider 82 | * @throws \yii\base\InvalidConfigException 83 | */ 84 | protected function prepareDataProvider() 85 | { 86 | $filter = $this->getFilter(); 87 | 88 | if ($this->prepareDataProvider !== null) { 89 | return call_user_func($this->prepareDataProvider, $this, $filter); 90 | } 91 | 92 | /* @var $modelClass \yii\db\BaseActiveRecord */ 93 | $modelClass = $this->modelClass; 94 | 95 | $query = $modelClass::find(); 96 | if (!empty($filter)) { 97 | $query->andWhere($filter); 98 | } 99 | 100 | return Yii::createObject([ 101 | 'class' => ActiveDataProvider::className(), 102 | 'query' => $query, 103 | 'pagination' => [ 104 | 'class' => Pagination::className(), 105 | ], 106 | 'sort' => [ 107 | 'enableMultiSort' => true 108 | ] 109 | ]); 110 | } 111 | 112 | protected function getFilter() 113 | { 114 | if ($this->dataFilter === null) { 115 | return null; 116 | } 117 | $requestParams = Yii::$app->getRequest()->getQueryParam('filter', []); 118 | $attributeMap = []; 119 | foreach ($requestParams as $attribute => $value) { 120 | $attributeMap[$attribute] = Inflector::camel2id(Inflector::variablize($attribute), '_'); 121 | if (is_string($value) && strpos($value, ',') !== false) { 122 | $requestParams[$attribute] = ['in' => explode(',', $value)]; 123 | } 124 | } 125 | $config = array_merge(['attributeMap' => $attributeMap], $this->dataFilter); 126 | /** @var DataFilter $dataFilter */ 127 | $dataFilter = Yii::createObject($config); 128 | if ($dataFilter->load(['filter' => $requestParams])) { 129 | return $dataFilter->build(); 130 | } 131 | return null; 132 | } 133 | } -------------------------------------------------------------------------------- /src/actions/UpdateAction.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\actions; 7 | 8 | use yii\base\Model; 9 | use yii\db\ActiveRecord; 10 | use Yii; 11 | use yii\web\ServerErrorHttpException; 12 | 13 | class UpdateAction extends Action 14 | { 15 | /** 16 | * @var string the scenario to be assigned to the model before it is validated and updated. 17 | */ 18 | public $scenario = Model::SCENARIO_DEFAULT; 19 | 20 | /** 21 | * Updates an existing resource. 22 | * @param string $id the primary key of the model. 23 | * @return \yii\db\ActiveRecordInterface the model being updated 24 | * @throws ServerErrorHttpException if there is any error when updating the model 25 | */ 26 | public function run($id) 27 | { 28 | /* @var $model ActiveRecord */ 29 | $model = $this->findModel($id); 30 | 31 | if ($this->checkAccess) { 32 | call_user_func($this->checkAccess, $this->id, $model); 33 | } 34 | 35 | $request = Yii::$app->getRequest(); 36 | $model->scenario = $this->scenario; 37 | $model->load($request->getBodyParams()); 38 | if ($model->save() === false && !$model->hasErrors()) { 39 | throw new ServerErrorHttpException('Failed to update the object for unknown reason.'); 40 | } 41 | 42 | $this->linkRelationships($model, $request->getBodyParam('relationships', [])); 43 | 44 | return $model; 45 | } 46 | } -------------------------------------------------------------------------------- /src/actions/UpdateRelationshipAction.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\actions; 7 | 8 | use yii\data\ActiveDataProvider; 9 | use yii\db\BaseActiveRecord; 10 | use yii\web\BadRequestHttpException; 11 | use yii\web\NotFoundHttpException; 12 | use Yii; 13 | 14 | /** 15 | * UpdateRelationshipAction implements the API endpoint for updating relationships. 16 | * @link http://jsonapi.org/format/#crud-updating-relationships 17 | */ 18 | class UpdateRelationshipAction extends Action 19 | { 20 | /** 21 | * Update of relationships independently. 22 | * @param string $id an ID of the primary resource 23 | * @param string $name a name of the related resource 24 | * @return ActiveDataProvider|BaseActiveRecord 25 | * @throws BadRequestHttpException 26 | * @throws NotFoundHttpException 27 | */ 28 | public function run($id, $name) 29 | { 30 | /** @var BaseActiveRecord $model */ 31 | $model = $this->findModel($id); 32 | 33 | if (!$related = $model->getRelation($name, false)) { 34 | throw new NotFoundHttpException('Relationship does not exist'); 35 | } 36 | 37 | if ($this->checkAccess) { 38 | call_user_func($this->checkAccess, $this->id, $model, $name); 39 | } 40 | 41 | $this->linkRelationships($model, [$name => Yii::$app->getRequest()->getBodyParams()]); 42 | 43 | if ($related->multiple) { 44 | return new ActiveDataProvider([ 45 | 'query' => $related 46 | ]); 47 | } else { 48 | return $related->one(); 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/actions/ViewAction.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\actions; 7 | 8 | 9 | class ViewAction extends \yii\rest\ViewAction 10 | { 11 | 12 | } -------------------------------------------------------------------------------- /src/actions/ViewRelatedAction.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\actions; 7 | 8 | 9 | use tuyakhov\jsonapi\Pagination; 10 | use tuyakhov\jsonapi\ResourceInterface; 11 | use yii\data\ActiveDataProvider; 12 | use yii\db\ActiveQuery; 13 | use yii\web\BadRequestHttpException; 14 | use yii\web\NotFoundHttpException; 15 | 16 | class ViewRelatedAction extends Action 17 | { 18 | /** 19 | * Prepares the data provider that should return the requested collection of the models. 20 | * @var callable 21 | */ 22 | public $prepareDataProvider; 23 | 24 | /** 25 | * @param $id 26 | * @param $name 27 | * @return ActiveDataProvider|\yii\db\ActiveRecordInterface 28 | * @throws BadRequestHttpException 29 | * @throws NotFoundHttpException 30 | */ 31 | public function run($id, $name) 32 | { 33 | $model = $this->findModel($id); 34 | 35 | if (!$model instanceof ResourceInterface) { 36 | throw new BadRequestHttpException('Impossible to fetch related resource'); 37 | } 38 | 39 | /** @var ActiveQuery $related */ 40 | if (!$related = $model->getRelation($name, false)) { 41 | throw new NotFoundHttpException('Resource does not exist'); 42 | } 43 | 44 | if ($this->checkAccess) { 45 | call_user_func($this->checkAccess, $this->id, $model, $name); 46 | } 47 | 48 | if ($this->prepareDataProvider !== null) { 49 | return call_user_func($this->prepareDataProvider, $this, $related, $name); 50 | } 51 | 52 | if ($related->multiple) { 53 | return new ActiveDataProvider([ 54 | 'query' => $related, 55 | 'pagination' => [ 56 | 'class' => Pagination::className(), 57 | ], 58 | ]); 59 | } else { 60 | return $related->one(); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/JsonApiParserTest.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\tests; 7 | 8 | 9 | use tuyakhov\jsonapi\JsonApiParser; 10 | use yii\helpers\Json; 11 | use yii\web\BadRequestHttpException; 12 | 13 | class JsonApiParserTest extends TestCase 14 | { 15 | public function testEmptyBody() 16 | { 17 | $parser = new JsonApiParser(); 18 | $body = ''; 19 | $this->assertEquals([], $parser->parse($body, '')); 20 | } 21 | 22 | public function testMissingData() 23 | { 24 | $parser = new JsonApiParser(); 25 | $this->expectException(BadRequestHttpException::class); 26 | $body = Json::encode(['incorrect-member']); 27 | $parser->parse($body, ''); 28 | } 29 | 30 | public function testSingleResource() 31 | { 32 | $parser = new JsonApiParser(); 33 | $body = Json::encode([ 34 | 'data' => [ 35 | 'type' => 'resource-models', 36 | 'attributes' => [ 37 | 'field1' => 'test', 38 | 'field2' => 2, 39 | 'first-name' => 'Bob' 40 | ], 41 | 'relationships' => [ 42 | 'author' => [ 43 | 'data' => [ 44 | 'id' => '321', 45 | 'type' => 'resource-models' 46 | ] 47 | ], 48 | 'client' => [ 49 | 'data' => [ 50 | ['id' => '321', 'type' => 'resource-models'], 51 | ['id' => '123', 'type' => 'resource-models'] 52 | ] 53 | ] 54 | ] 55 | ] 56 | ]); 57 | $this->assertEquals([ 58 | 'ResourceModel' => [ 59 | 'field1' => 'test', 60 | 'field2' => 2, 61 | 'first_name' => 'Bob', 62 | ], 63 | 'relationships' => [ 64 | 'author' => [ 65 | 'ResourceModel' => [ 66 | ['id' => '321'] 67 | ] 68 | ], 69 | 'client' => [ 70 | 'ResourceModel' => [ 71 | ['id' => '321'], 72 | ['id' => '123'] 73 | ] 74 | ] 75 | ] 76 | ], $parser->parse($body, '')); 77 | } 78 | 79 | public function testMultiple() 80 | { 81 | $parser = new JsonApiParser(); 82 | $resourceActual = [ 83 | 'type' => 'resource-models', 84 | 'id' => 12, 85 | 'attributes' => [ 86 | 'field1' => 'test', 87 | 'field2' => 2, 88 | 'first-name' => 'Bob' 89 | ], 90 | ]; 91 | $resourceExpected = [ 92 | 'id' => 12, 93 | 'field1' => 'test', 94 | 'field2' => 2, 95 | 'first_name' => 'Bob', 96 | ]; 97 | $body = Json::encode([ 98 | 'data' => [ 99 | $resourceActual, 100 | $resourceActual 101 | ] 102 | ]); 103 | $this->assertEquals([ 104 | 'ResourceModel' => [ 105 | $resourceExpected, 106 | $resourceExpected 107 | ], 108 | ], $parser->parse($body, '')); 109 | } 110 | } -------------------------------------------------------------------------------- /tests/JsonApiResponseFormatterTest.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | namespace tuyakhov\jsonapi\tests; 6 | 7 | 8 | use tuyakhov\jsonapi\JsonApiResponseFormatter; 9 | use tuyakhov\jsonapi\Serializer; 10 | use tuyakhov\jsonapi\tests\data\ResourceModel; 11 | use yii\base\Controller; 12 | use yii\helpers\Json; 13 | use yii\web\Response; 14 | use yii\web\ServerErrorHttpException; 15 | 16 | class JsonApiResponseFormatterTest extends TestCase 17 | { 18 | public function testFormatException() 19 | { 20 | $formatter = new JsonApiResponseFormatter(); 21 | $exception = new ServerErrorHttpException('Server error'); 22 | $response = new Response(); 23 | $response->setStatusCode($exception->statusCode); 24 | $response->data = [ 25 | 'name' => $exception->getName(), 26 | 'message' => $exception->getMessage(), 27 | 'code' => $exception->getCode(), 28 | 'status' => $exception->statusCode 29 | ]; 30 | $formatter->format($response); 31 | $this->assertJson($response->content); 32 | $this->assertSame(Json::encode([ 33 | 'errors' => [ 34 | [ 35 | 'code' => '0', 36 | 'status' => '500', 37 | 'title' => Response::$httpStatuses[500], 38 | 'detail' => 'Server error', 39 | ] 40 | ] 41 | ]), $response->content); 42 | } 43 | 44 | public function testFormModelError() 45 | { 46 | $formatter = new JsonApiResponseFormatter(); 47 | $exception = new ServerErrorHttpException('Server error'); 48 | $response = new Response(); 49 | $response->setStatusCode($exception->statusCode); 50 | $serializer = new Serializer(); 51 | $model = new ResourceModel(); 52 | $model->addError('field1', 'Error'); 53 | $model->addError('field2', 'Test Error'); 54 | $response->data = $serializer->serialize($model); 55 | $formatter->format($response); 56 | $this->assertJson($response->content); 57 | $this->assertSame(Json::encode([ 58 | 'errors' => [ 59 | [ 60 | 'source' => ['pointer' => "/data/attributes/field1"], 61 | 'detail' => 'Error', 62 | 'status' => '422' 63 | ], 64 | [ 65 | 'source' => ['pointer' => "/data/attributes/field2"], 66 | 'detail' => 'Test Error', 67 | 'status' => '422' 68 | ] 69 | ] 70 | ]), $response->content); 71 | } 72 | 73 | public function testSuccessModel() 74 | { 75 | $formatter = new JsonApiResponseFormatter(); 76 | $response = new Response(); 77 | $serializer = new Serializer(); 78 | $model = new ResourceModel(); 79 | $response->data = $serializer->serialize($model); 80 | $response->setStatusCode(200); 81 | $formatter->format($response); 82 | $this->assertJson($response->content); 83 | $this->assertSame(Json::encode([ 84 | 'data' => [ 85 | 'id' => '123', 86 | 'type' => 'resource-models', 87 | 'attributes' => [ 88 | 'field1' => 'test', 89 | 'field2' => 2, 90 | ], 91 | 'links' => [ 92 | 'self' => [ 93 | 'href' => 'http://example.com/resource/123' 94 | ] 95 | ] 96 | ] 97 | ]), $response->content); 98 | } 99 | 100 | public function testEmptyData() 101 | { 102 | $formatter = new JsonApiResponseFormatter(); 103 | $response = new Response(); 104 | $response->setStatusCode('200'); 105 | $serializer = new Serializer(); 106 | $response->data = $serializer->serialize(null); 107 | $formatter->format($response); 108 | $this->assertJson($response->content); 109 | $this->assertSame(Json::encode([ 110 | 'data' => null 111 | ]), $response->content); 112 | \Yii::$app->controller = new Controller('test', \Yii::$app); 113 | $formatter->format($response); 114 | $this->assertJson($response->content); 115 | $this->assertSame(Json::encode([ 116 | 'data' => null, 117 | 'links' => [ 118 | 'self' => ['href' => '/index.php?r=test'] 119 | ] 120 | ]), $response->content); 121 | $response->clear(); 122 | $response->setStatusCode(201); 123 | $formatter->format($response); 124 | $this->assertNull($response->content); 125 | } 126 | } -------------------------------------------------------------------------------- /tests/SerializerTest.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | namespace tuyakhov\jsonapi\tests; 6 | 7 | use tuyakhov\jsonapi\tests\data\ResourceModel; 8 | use tuyakhov\jsonapi\Serializer; 9 | use yii\base\InvalidValueException; 10 | use yii\data\ArrayDataProvider; 11 | 12 | class SerializerTest extends TestCase 13 | { 14 | protected function setUp() 15 | { 16 | parent::setUp(); 17 | ResourceModel::$fields = ['field1', 'field2']; 18 | ResourceModel::$extraFields = []; 19 | } 20 | 21 | // https://github.com/tuyakhov/yii2-json-api/pull/9 22 | public function testSerializeIdentifier() 23 | { 24 | ResourceModel::$id = []; 25 | $model = new ResourceModel(); 26 | $serializer = new Serializer(); 27 | $this->expectException(InvalidValueException::class); 28 | $serializer->serialize($model); 29 | } 30 | 31 | public function testSerializeModelErrors() 32 | { 33 | $serializer = new Serializer(); 34 | $model = new ResourceModel(); 35 | $model->addError('field1', 'Test error'); 36 | $model->addError('field2', 'Multiple error 1'); 37 | $model->addError('field2', 'Multiple error 2'); 38 | $model->addError('first_name', 'Member name check'); 39 | $this->assertEquals([ 40 | [ 41 | 'source' => ['pointer' => "/data/attributes/field1"], 42 | 'detail' => 'Test error', 43 | 'status' => '422' 44 | ], 45 | [ 46 | 'source' => ['pointer' => "/data/attributes/field2"], 47 | 'detail' => 'Multiple error 1', 48 | 'status' => '422' 49 | ], 50 | [ 51 | 'source' => ['pointer' => "/data/attributes/first-name"], 52 | 'detail' => 'Member name check', 53 | 'status' => '422' 54 | ] 55 | ], $serializer->serialize($model)); 56 | } 57 | 58 | public function testSerializeModelData() 59 | { 60 | $serializer = new Serializer(); 61 | ResourceModel::$id = 123; 62 | $model = new ResourceModel(); 63 | $this->assertSame([ 64 | 'data' => [ 65 | 'id' => '123', 66 | 'type' => 'resource-models', 67 | 'attributes' => [ 68 | 'field1' => 'test', 69 | 'field2' => 2, 70 | ], 71 | 'links' => [ 72 | 'self' => ['href' => 'http://example.com/resource/123'] 73 | ] 74 | ] 75 | ], $serializer->serialize($model)); 76 | 77 | ResourceModel::$fields = ['first_name']; 78 | ResourceModel::$extraFields = ['extraField1']; 79 | $model->extraField1 = new ResourceModel(); 80 | $relationship = [ 81 | 'data' => ['id' => '123', 'type' => 'resource-models'], 82 | 'links' => [ 83 | 'self' => ['href' => 'http://example.com/resource/123/relationships/extra-field1'], 84 | 'related' => ['href' => 'http://example.com/resource/123/extra-field1'], 85 | ] 86 | ]; 87 | $resource = [ 88 | 'id' => '123', 89 | 'type' => 'resource-models', 90 | 'attributes' => [ 91 | 'first-name' => 'Bob', 92 | ], 93 | 'relationships' => [ 94 | 'extra-field1' => [ 95 | 'links' => $relationship['links'] 96 | ] 97 | ], 98 | 'links' => [ 99 | 'self' => ['href' => 'http://example.com/resource/123'] 100 | ] 101 | ]; 102 | $expected = [ 103 | 'data' => $resource 104 | ]; 105 | $this->assertSame($expected, $serializer->serialize($model)); 106 | $_POST[$serializer->request->methodParam] = 'POST'; 107 | \Yii::$app->request->setQueryParams(['include' => 'extra-field1']); 108 | $expected['included'][] = $resource; 109 | $expected['data']['relationships']['extra-field1'] = $relationship; 110 | $this->assertSame($expected, $serializer->serialize($model)); 111 | } 112 | 113 | public function testExpand() 114 | { 115 | $serializer = new Serializer(); 116 | $compoundModel = $includedModel = [ 117 | 'id' => '123', 118 | 'type' => 'resource-models', 119 | 'attributes' => [ 120 | 'field1' => 'test', 121 | 'field2' => 2, 122 | ], 123 | 'relationships' => [ 124 | 'extra-field1' => [ 125 | 'data' => ['id' => '123', 'type' => 'resource-models'], 126 | 'links' => [ 127 | 'self' => ['href' => 'http://example.com/resource/123/relationships/extra-field1'], 128 | 'related' => ['href' => 'http://example.com/resource/123/extra-field1'], 129 | ] 130 | ] 131 | ] 132 | ]; 133 | unset($includedModel['relationships']['extra-field1']['data']); 134 | $compoundModel['links'] = $includedModel['links'] = [ 135 | 'self' => ['href' => 'http://example.com/resource/123'] 136 | ]; 137 | $model = new ResourceModel(); 138 | ResourceModel::$fields = ['field1', 'field2']; 139 | ResourceModel::$extraFields = ['extraField1']; 140 | $model->extraField1 = new ResourceModel(); 141 | 142 | \Yii::$app->request->setQueryParams(['include' => 'extra-field1']); 143 | $this->assertSame([ 144 | 'data' => $compoundModel, 145 | 'included' => [ 146 | $includedModel 147 | ] 148 | ], $serializer->serialize($model)); 149 | 150 | \Yii::$app->request->setQueryParams(['include' => 'extra-field1,extra-field2']); 151 | $this->assertSame([ 152 | 'data' => $compoundModel, 153 | 'included' => [ 154 | $includedModel 155 | ] 156 | ], $serializer->serialize($model)); 157 | 158 | \Yii::$app->request->setQueryParams(['include' => 'field1,extra-field2']); 159 | $compoundModel['relationships'] = [ 160 | 'extra-field1' => [ 161 | 'links' => [ 162 | 'self' => ['href' => 'http://example.com/resource/123/relationships/extra-field1'], 163 | 'related' => ['href' => 'http://example.com/resource/123/extra-field1'], 164 | ] 165 | ] 166 | ]; 167 | $this->assertSame([ 168 | 'data' => $compoundModel 169 | ], $serializer->serialize($model)); 170 | } 171 | 172 | public function testNestedRelationships() 173 | { 174 | ResourceModel::$fields = ['field1']; 175 | ResourceModel::$extraFields = ['extraField1']; 176 | $resource = new ResourceModel(); 177 | $relationship = new ResourceModel(); 178 | $subRelationship = new ResourceModel(); 179 | $subRelationship->setId(321); 180 | $relationship->extraField1 = $subRelationship; 181 | $resource->extraField1 = $relationship; 182 | $compoundDocument = [ 183 | 'data' => [ 184 | 'id' => '123', 185 | 'type' => 'resource-models', 186 | 'attributes' => ['field1' => 'test'], 187 | 'relationships' => [ 188 | 'extra-field1' => [ 189 | 'data' => ['id' => '123', 'type' => 'resource-models'], 190 | 'links' => [ 191 | 'self' => ['href' => 'http://example.com/resource/123/relationships/extra-field1'], 192 | 'related' => ['href' => 'http://example.com/resource/123/extra-field1'], 193 | ] 194 | ], 195 | ], 196 | 'links' => ['self' => ['href' => 'http://example.com/resource/123']], 197 | ], 198 | 'included' => [ 199 | [ 200 | 'id' => '123', 201 | 'type' => 'resource-models', 202 | 'attributes' => ['field1' => 'test'], 203 | 'relationships' => [ 204 | 'extra-field1' => [ 205 | 'data' => ['id' => '321', 'type' => 'resource-models'], 206 | 'links' => [ 207 | 'self' => ['href' => 'http://example.com/resource/123/relationships/extra-field1'], 208 | 'related' => ['href' => 'http://example.com/resource/123/extra-field1'], 209 | ] 210 | ], 211 | ], 212 | 'links' => ['self' => ['href' => 'http://example.com/resource/123']], 213 | ], 214 | [ 215 | 'id' => '321', 216 | 'type' => 'resource-models', 217 | 'attributes' => ['field1' => 'test'], 218 | 'relationships' => [ 219 | 'extra-field1' => [ 220 | 'links' => [ 221 | 'self' => ['href' => 'http://example.com/resource/321/relationships/extra-field1'], 222 | 'related' => ['href' => 'http://example.com/resource/321/extra-field1'], 223 | ] 224 | ], 225 | ], 226 | 'links' => ['self' => ['href' => 'http://example.com/resource/321']], 227 | ] 228 | ] 229 | ]; 230 | 231 | $serializer = new Serializer(); 232 | \Yii::$app->request->setQueryParams(['include' => 'extra-field1.extra-field1']); 233 | $this->assertSame($compoundDocument, $serializer->serialize($resource)); 234 | } 235 | 236 | public function testIncludedDuplicates() 237 | { 238 | $serializer = new Serializer(); 239 | 240 | $compoundModel = $includedModel = [ 241 | 'id' => '123', 242 | 'type' => 'resource-models', 243 | 'attributes' => [ 244 | 'field1' => 'test', 245 | 'field2' => 2, 246 | ], 247 | 'relationships' => [ 248 | 'extra-field1' => [ 249 | 'data' => ['id' => '123', 'type' => 'resource-models'], 250 | 'links' => [ 251 | 'self' => ['href' => 'http://example.com/resource/123/relationships/extra-field1'], 252 | 'related' => ['href' => 'http://example.com/resource/123/extra-field1'], 253 | ] 254 | ], 255 | 'extra-field2' => [ 256 | 'data' => ['id' => '123', 'type' => 'resource-models'], 257 | 'links' => [ 258 | 'self' => ['href' => 'http://example.com/resource/123/relationships/extra-field2'], 259 | 'related' => ['href' => 'http://example.com/resource/123/extra-field2'], 260 | ] 261 | ] 262 | ] 263 | ]; 264 | unset($includedModel['relationships']['extra-field1']['data']); 265 | unset($includedModel['relationships']['extra-field2']['data']); 266 | $compoundModel['links'] = $includedModel['links'] = [ 267 | 'self' => ['href' => 'http://example.com/resource/123'] 268 | ]; 269 | 270 | $model = new ResourceModel(); 271 | ResourceModel::$fields = ['field1', 'field2']; 272 | ResourceModel::$extraFields = ['extraField1', 'extraField2']; 273 | $relationship = new ResourceModel(); 274 | $relationship->extraField1 = new ResourceModel(); 275 | $model->extraField2 = $relationship; 276 | $model->extraField1 = new ResourceModel(); 277 | 278 | \Yii::$app->request->setQueryParams(['include' => 'extra-field1,extra-field2.extra-field1']); 279 | $this->assertSame([ 280 | 'data' => $compoundModel, 281 | 'included' => [ 282 | $includedModel 283 | ] 284 | ], $serializer->serialize($model)); 285 | $this->assertSame([ 286 | 'data' => [$compoundModel], 287 | 'included' => [ 288 | $includedModel 289 | ], 290 | 'links' => [ 291 | 'self' => ['href' => '/index.php?r=&include=extra-field1%2Cextra-field2.extra-field1&page=1'] 292 | ], 293 | 'meta' => [ 294 | 'total-count' => 1, 295 | 'page-count' => 1, 296 | 'current-page' => 1, 297 | 'per-page' => 20 298 | ] 299 | ], $serializer->serialize(new ArrayDataProvider([ 300 | 'allModels' => [$model], 301 | 'pagination' => [ 302 | 'route' => '/', 303 | ], 304 | ]))); 305 | } 306 | 307 | public function dataProviderSerializeDataProvider() 308 | { 309 | $bob = new ResourceModel(); 310 | $bob->username = 'Bob'; 311 | $bob->extraField1 = new ResourceModel(); 312 | $expectedBob = ['id' => '123', 'type' => 'resource-models', 313 | 'attributes' => ['username' => 'Bob'], 314 | 'links' => ['self' => ['href' => 'http://example.com/resource/123']], 315 | 'relationships' => ['extra-field1' => [ 316 | 'links' => [ 317 | 'related' => ['href' => 'http://example.com/resource/123/extra-field1'], 318 | 'self' => ['href' => 'http://example.com/resource/123/relationships/extra-field1'] 319 | ] 320 | ]]]; 321 | $tom = new ResourceModel(); 322 | $tom->username = 'Tom'; 323 | $tom->extraField1 = new ResourceModel(); 324 | $expectedTom = [ 325 | 'id' => '123', 'type' => 'resource-models', 326 | 'attributes' => ['username' => 'Tom'], 327 | 'links' => ['self' => ['href' => 'http://example.com/resource/123']], 328 | 'relationships' => ['extra-field1' => [ 329 | 'links' => [ 330 | 'related' => ['href' => 'http://example.com/resource/123/extra-field1'], 331 | 'self' => ['href' => 'http://example.com/resource/123/relationships/extra-field1'] 332 | ] 333 | ]]]; 334 | return [ 335 | [ 336 | new ArrayDataProvider([ 337 | 'allModels' => [ 338 | $bob, 339 | $tom 340 | ], 341 | 'pagination' => [ 342 | 'route' => '/', 343 | ], 344 | ]), 345 | [ 346 | 'data' => [ 347 | $expectedBob, 348 | $expectedTom 349 | ], 350 | 'meta' => [ 351 | 'total-count' => 2, 352 | 'page-count' => 1, 353 | 'current-page' => 1, 354 | 'per-page' => 20 355 | ], 356 | 'links' => [ 357 | 'self' => ['href' => '/index.php?r=&page=1'] 358 | ] 359 | ] 360 | ], 361 | [ 362 | new ArrayDataProvider([ 363 | 'allModels' => [ 364 | $bob, 365 | $tom 366 | ], 367 | 'pagination' => [ 368 | 'route' => '/', 369 | 'pageSize' => 1, 370 | 'page' => 0 371 | ], 372 | ]), 373 | [ 374 | 'data' => [ 375 | $expectedBob 376 | ], 377 | 'meta' => [ 378 | 'total-count' => 2, 379 | 'page-count' => 2, 380 | 'current-page' => 1, 381 | 'per-page' => 1 382 | ], 383 | 'links' => [ 384 | 'self' => ['href' => '/index.php?r=&page=1&per-page=1'], 385 | 'next' => ['href' => '/index.php?r=&page=2&per-page=1'], 386 | 'last' => ['href' => '/index.php?r=&page=2&per-page=1'] 387 | ] 388 | ] 389 | ], 390 | [ 391 | new ArrayDataProvider([ 392 | 'allModels' => [ 393 | $bob, 394 | $tom 395 | ], 396 | 'pagination' => [ 397 | 'route' => '/', 398 | 'pageSize' => 1, 399 | 'page' => 1 400 | ], 401 | ]), 402 | [ 403 | 'data' => [ 404 | $expectedTom 405 | ], 406 | 'meta' => [ 407 | 'total-count' => 2, 408 | 'page-count' => 2, 409 | 'current-page' => 2, 410 | 'per-page' => 1 411 | ], 412 | 'links' => [ 413 | 'self' => ['href' => '/index.php?r=&page=2&per-page=1'], 414 | 'first' => ['href' => '/index.php?r=&page=1&per-page=1'], 415 | 'prev' => ['href' => '/index.php?r=&page=1&per-page=1'] 416 | ] 417 | ] 418 | ], 419 | [ 420 | new ArrayDataProvider([ 421 | 'allModels' => [ 422 | $bob, 423 | $tom 424 | ], 425 | 'pagination' => false, 426 | ]), 427 | [ 428 | 'data' => [ 429 | $expectedBob, 430 | $expectedTom 431 | ] 432 | ] 433 | ], 434 | ]; 435 | } 436 | /** 437 | * @dataProvider dataProviderSerializeDataProvider 438 | * 439 | * @param \yii\data\DataProviderInterface $dataProvider 440 | * @param array $expectedResult 441 | */ 442 | public function testSerializeDataProvider($dataProvider, $expectedResult) 443 | { 444 | $serializer = new Serializer(); 445 | ResourceModel::$extraFields = ['extraField1']; 446 | ResourceModel::$fields = ['username']; 447 | $this->assertEquals($expectedResult, $serializer->serialize($dataProvider)); 448 | } 449 | 450 | public function testFieldSets() 451 | { 452 | $serializer = new Serializer(); 453 | ResourceModel::$id = 123; 454 | ResourceModel::$fields = ['field1', 'first_name', 'field2']; 455 | $model = new ResourceModel(); 456 | $expectedModel = [ 457 | 'data' => [ 458 | 'id' => '123', 459 | 'type' => 'resource-models', 460 | 'attributes' => [ 461 | 'first-name' => 'Bob', 462 | ], 463 | 'links' => [ 464 | 'self' => ['href' => 'http://example.com/resource/123'] 465 | ] 466 | ] 467 | ]; 468 | \Yii::$app->request->setQueryParams(['fields' => ['resource-models' => 'first-name']]); 469 | $this->assertSame($expectedModel, $serializer->serialize($model)); 470 | $serializer->pluralize = false; 471 | \Yii::$app->request->setQueryParams(['fields' => ['resource-model' => 'first-name']]); 472 | $expectedModel['data']['type'] = 'resource-model'; 473 | $this->assertSame($expectedModel, $serializer->serialize($model)); 474 | } 475 | 476 | public function testTypeInflection() 477 | { 478 | $serializer = new Serializer(); 479 | $serializer->pluralize = false; 480 | $model = new ResourceModel(); 481 | ResourceModel::$fields = []; 482 | $this->assertSame([ 483 | 'data' => [ 484 | 'id' => '123', 485 | 'type' => 'resource-model', 486 | 'attributes' => [], 487 | 'links' => [ 488 | 'self' => ['href' => 'http://example.com/resource/123'] 489 | ] 490 | ] 491 | ], $serializer->serialize($model)); 492 | } 493 | } 494 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | namespace tuyakhov\jsonapi\tests; 6 | 7 | use \yii\helpers\ArrayHelper; 8 | use yii\web\Response; 9 | 10 | class TestCase extends \PHPUnit_Framework_TestCase 11 | { 12 | protected function setUp() 13 | { 14 | parent::setUp(); 15 | $this->mockApplication(); 16 | } 17 | 18 | protected function tearDown() 19 | { 20 | $this->destroyApplication(); 21 | } 22 | /** 23 | * Populates Yii::$app with a new application 24 | * The application will be destroyed on tearDown() automatically. 25 | * @param array $config The application configuration, if needed 26 | * @param string $appClass name of the application class to create 27 | */ 28 | protected function mockApplication($config = [], $appClass = '\yii\web\Application') 29 | { 30 | new $appClass(ArrayHelper::merge([ 31 | 'id' => 'testapp', 32 | 'basePath' => __DIR__, 33 | 'components' => [ 34 | 'request' => [ 35 | 'parsers' => [ 36 | 'application/vnd.api+json' => '\tuyakhov\jsonapi\JsonApiParser' 37 | ], 38 | 'cookieValidationKey' => 'wefJDF8sfdsfSDefwqdxj9oq', 39 | 'scriptFile' => __DIR__ .'/index.php', 40 | 'scriptUrl' => '/index.php', 41 | ], 42 | 'response' => [ 43 | 'format' => Response::FORMAT_JSON, 44 | 'formatters' => [ 45 | Response::FORMAT_JSON => 'tuyakhov\jsonapi\JsonApiResponseFormatter' 46 | ] 47 | ] 48 | ], 49 | 'vendorPath' => $this->getVendorPath(), 50 | ], $config)); 51 | } 52 | /** 53 | * @return string vendor path 54 | */ 55 | protected function getVendorPath() 56 | { 57 | return dirname(dirname(__DIR__)) . '/vendor'; 58 | } 59 | /** 60 | * Destroys application in Yii::$app by setting it to null. 61 | */ 62 | protected function destroyApplication() 63 | { 64 | \Yii::$app = null; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/actions/CreateActionTest.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\tests\actions; 7 | 8 | 9 | use tuyakhov\jsonapi\actions\CreateAction; 10 | use tuyakhov\jsonapi\tests\data\ActiveQuery; 11 | use tuyakhov\jsonapi\tests\data\ResourceModel; 12 | use tuyakhov\jsonapi\tests\TestCase; 13 | use yii\base\Controller; 14 | 15 | class CreateActionTest extends TestCase 16 | { 17 | public function testSuccess() 18 | { 19 | ResourceModel::$extraFields = ['extraField1']; 20 | ResourceModel::$related = [ 21 | 'extraField1' => new ActiveQuery(ResourceModel::className()) 22 | ]; 23 | 24 | \Yii::$app->controller = new Controller('test', \Yii::$app); 25 | $action = new CreateAction('test', \Yii::$app->controller, [ 26 | 'modelClass' => ResourceModel::className(), 27 | ]); 28 | 29 | ResourceModel::$id = 124; 30 | ActiveQuery::$models = new ResourceModel(); 31 | \Yii::$app->request->setBodyParams([ 32 | 'ResourceModel' => [ 33 | 'field1' => 'test', 34 | 'field2' => 2, 35 | ], 36 | 'relationships' => [ 37 | 'extraField1' => [ 38 | 'ResourceModel' => [ 39 | 'id' => 124 40 | ], 41 | ] 42 | ] 43 | ]); 44 | 45 | $this->assertInstanceOf(ResourceModel::className(), $model = $action->run()); 46 | $this->assertFalse($model->hasErrors()); 47 | $relationships = $model->getResourceRelationships(['extra_field1']); 48 | $this->assertArrayHasKey('extra_field1', $relationships); 49 | $this->assertInstanceOf(ResourceModel::className(), $relationships['extra_field1']); 50 | $this->assertEquals(124, $relationships['extra_field1']->id); 51 | } 52 | } -------------------------------------------------------------------------------- /tests/actions/DeleteActionTest.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\tests\actions; 7 | 8 | use tuyakhov\jsonapi\actions\DeleteAction; 9 | use tuyakhov\jsonapi\tests\data\ActiveQuery; 10 | use tuyakhov\jsonapi\tests\data\ResourceModel; 11 | use tuyakhov\jsonapi\tests\TestCase; 12 | use yii\web\Controller; 13 | 14 | class DeleteActionTest extends TestCase 15 | { 16 | public function testSuccess() 17 | { 18 | \Yii::$app->controller = new Controller('test', \Yii::$app); 19 | $action = new DeleteAction('test', \Yii::$app->controller, [ 20 | 'modelClass' => ResourceModel::className(), 21 | ]); 22 | 23 | ResourceModel::$id = 124; 24 | ActiveQuery::$models = new ResourceModel(); 25 | 26 | $action->run(124); 27 | $this->assertTrue(\Yii::$app->getResponse()->getIsEmpty()); 28 | } 29 | } -------------------------------------------------------------------------------- /tests/actions/DeleteRelationshipActionTest.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\tests\actions; 7 | 8 | use tuyakhov\jsonapi\actions\DeleteRelationshipAction; 9 | use tuyakhov\jsonapi\tests\data\ActiveQuery; 10 | use tuyakhov\jsonapi\tests\data\ResourceModel; 11 | use tuyakhov\jsonapi\tests\TestCase; 12 | use yii\data\ActiveDataProvider; 13 | use yii\web\Controller; 14 | use yii\web\ForbiddenHttpException; 15 | 16 | class DeleteRelationshipActionTest extends TestCase 17 | { 18 | public function testSuccess() 19 | { 20 | $model = new ResourceModel(); 21 | $action = new DeleteRelationshipAction('test', new Controller('test', \Yii::$app), [ 22 | 'modelClass' => ResourceModel::className() 23 | ]); 24 | ResourceModel::$related = [ 25 | 'extraField1' => new ActiveQuery(ResourceModel::className(), ['multiple' => true]), 26 | 'extraField2' => new ActiveQuery(ResourceModel::className()) 27 | ]; 28 | $action->findModel = function ($id, $action) use($model) { 29 | return $model; 30 | }; 31 | $model->extraField1 = [new ResourceModel()]; 32 | \Yii::$app->request->setBodyParams(['ResourceModel' => ['type' => 'resource-models', 'id' => 123]]); 33 | $this->assertInstanceOf(ActiveDataProvider::className(), $dataProvider = $action->run(1, 'extraField1')); 34 | $this->expectException(ForbiddenHttpException::class); 35 | $this->assertInstanceOf(ResourceModel::className(), $action->run(1, 'extraField2')); 36 | } 37 | } -------------------------------------------------------------------------------- /tests/actions/IndexActionTest.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\tests\actions; 7 | 8 | 9 | use tuyakhov\jsonapi\actions\IndexAction; 10 | use tuyakhov\jsonapi\tests\data\ActiveQuery; 11 | use tuyakhov\jsonapi\tests\data\ResourceModel; 12 | use tuyakhov\jsonapi\tests\TestCase; 13 | use yii\base\Controller; 14 | use yii\data\ActiveDataFilter; 15 | use yii\data\ActiveDataProvider; 16 | use yii\db\Query; 17 | 18 | class IndexActionTest extends TestCase 19 | { 20 | public function testSuccess() 21 | { 22 | $action = new IndexAction('test', new Controller('test', \Yii::$app), [ 23 | 'modelClass' => ResourceModel::className(), 24 | 'dataFilter' => [ 25 | 'class' => ActiveDataFilter::className(), 26 | 'searchModel' => ResourceModel::className() 27 | ] 28 | ]); 29 | $filter = [ 30 | 'filter' => ['field1' => 'test,qwe'], 31 | 'sort' => 'field1,-field2' 32 | ]; 33 | \Yii::$app->getRequest()->setQueryParams($filter); 34 | 35 | $this->assertInstanceOf(ActiveDataProvider::className(), $dataProvider = $action->run()); 36 | $this->assertInstanceOf(Query::className(), $dataProvider->query); 37 | $this->assertSame([ 38 | 'IN', 39 | 'field1', 40 | ['test', 'qwe'] 41 | ], $dataProvider->query->where); 42 | $this->assertSame(['field1' => SORT_ASC, 'field2' => SORT_DESC], $dataProvider->getSort()->orders); 43 | } 44 | 45 | public function testValidation() 46 | { 47 | $action = new IndexAction('test', new Controller('test', \Yii::$app), [ 48 | 'modelClass' => ResourceModel::className(), 49 | 'dataFilter' => [ 50 | 'class' => ActiveDataFilter::className(), 51 | 'searchModel' => ResourceModel::className() 52 | ] 53 | ]); 54 | \Yii::$app->getRequest()->setQueryParams(['filter' => ['field1' => 1]]); 55 | 56 | $this->assertInstanceOf(ActiveDataProvider::className(), $dataProvider = $action->run()); 57 | $this->assertInstanceOf(Query::className(), $dataProvider->query); 58 | $this->assertNull($dataProvider->query->where); 59 | } 60 | 61 | public function testPagination() 62 | { 63 | $action = new IndexAction('test', new Controller('test', \Yii::$app), [ 64 | 'modelClass' => ResourceModel::className(), 65 | ]); 66 | ActiveQuery::$models = [new ResourceModel(), new ResourceModel()]; 67 | $params = ['page' => 1]; 68 | \Yii::$app->getRequest()->setQueryParams($params); 69 | 70 | $this->assertInstanceOf(ActiveDataProvider::className(), $dataProvider = $action->run()); 71 | $this->assertSame(0, $dataProvider->getPagination()->page); 72 | 73 | $params = ['page' => ['number' => 2, 'size' => 1]]; 74 | \Yii::$app->getRequest()->setQueryParams($params); 75 | 76 | $this->assertInstanceOf(ActiveDataProvider::className(), $dataProvider = $action->run()); 77 | 78 | $this->assertSame(2, $dataProvider->getCount()); 79 | $this->assertSame(2, $dataProvider->pagination->getPageCount()); 80 | $this->assertSame(1, $dataProvider->pagination->getPageSize()); 81 | $this->assertSame(1, $dataProvider->pagination->getOffset()); 82 | 83 | // test invalid value 84 | $params = ['page' => 1]; 85 | \Yii::$app->getRequest()->setQueryParams($params); 86 | 87 | $this->assertInstanceOf(ActiveDataProvider::className(), $dataProvider = $action->run()); 88 | 89 | $this->assertSame(2, $dataProvider->getCount()); 90 | $this->assertSame(1, $dataProvider->pagination->getPageCount()); 91 | $this->assertSame($dataProvider->pagination->defaultPageSize, $dataProvider->pagination->getPageSize()); 92 | $this->assertSame(0, $dataProvider->pagination->getOffset()); 93 | } 94 | } -------------------------------------------------------------------------------- /tests/actions/UpdateActionTest.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\tests\actions; 7 | 8 | use tuyakhov\jsonapi\actions\UpdateAction; 9 | use tuyakhov\jsonapi\tests\data\ActiveQuery; 10 | use tuyakhov\jsonapi\tests\data\ResourceModel; 11 | use tuyakhov\jsonapi\tests\TestCase; 12 | use yii\base\Controller; 13 | 14 | class UpdateActionTest extends TestCase 15 | { 16 | public function testSuccess() 17 | { 18 | ResourceModel::$extraFields = ['extraField1']; 19 | ResourceModel::$related = [ 20 | 'extraField1' => new ActiveQuery(ResourceModel::className()) 21 | ]; 22 | 23 | \Yii::$app->controller = new Controller('test', \Yii::$app); 24 | $action = new UpdateAction('test', \Yii::$app->controller, [ 25 | 'modelClass' => ResourceModel::className(), 26 | ]); 27 | 28 | ResourceModel::$id = 124; 29 | $model = new ResourceModel(); 30 | $model->field1 = '41231'; 31 | ActiveQuery::$models = $model; 32 | \Yii::$app->request->setBodyParams([ 33 | 'ResourceModel' => [ 34 | 'field1' => 'test', 35 | 'field2' => 2, 36 | ], 37 | 'relationships' => [ 38 | 'extraField1' => [ 39 | 'ResourceModel' => [ 40 | 'id' => 124 41 | ], 42 | ] 43 | ] 44 | ]); 45 | 46 | $this->assertInstanceOf(ResourceModel::className(), $model = $action->run(1)); 47 | $this->assertFalse($model->hasErrors()); 48 | $this->assertEquals('test', $model->field1); 49 | $relationships = $model->getResourceRelationships(['extra_field1']); 50 | $this->assertArrayHasKey('extra_field1', $relationships); 51 | $this->assertInstanceOf(ResourceModel::className(), $relationships['extra_field1']); 52 | $this->assertEquals(124, $relationships['extra_field1']->id); 53 | } 54 | } -------------------------------------------------------------------------------- /tests/actions/UpdateRelationshipActionTest.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\tests\actions; 7 | 8 | 9 | use tuyakhov\jsonapi\actions\UpdateRelationshipAction; 10 | use tuyakhov\jsonapi\tests\data\ActiveQuery; 11 | use tuyakhov\jsonapi\tests\data\ResourceModel; 12 | use tuyakhov\jsonapi\tests\TestCase; 13 | use yii\base\Controller; 14 | use yii\data\ActiveDataProvider; 15 | 16 | class UpdateRelationshipActionTest extends TestCase 17 | { 18 | public function testSuccess() 19 | { 20 | $model = new ResourceModel(); 21 | $action = new UpdateRelationshipAction('test', new Controller('test', \Yii::$app), [ 22 | 'modelClass' => ResourceModel::className() 23 | ]); 24 | ResourceModel::$related = [ 25 | 'extraField1' => new ActiveQuery(ResourceModel::className(), ['multiple' => true]), 26 | 'extraField2' => new ActiveQuery(ResourceModel::className()) 27 | ]; 28 | $action->findModel = function ($id, $action) use($model) { 29 | return $model; 30 | }; 31 | ActiveQuery::$models = [new ResourceModel(), new ResourceModel()]; 32 | \Yii::$app->request->setBodyParams(['ResourceModel' => ['type' => 'resource-models', 'id' => 123]]); 33 | $this->assertInstanceOf(ActiveDataProvider::className(), $dataProvider = $action->run(1, 'extraField1')); 34 | $this->assertEquals(2, count($dataProvider->getModels())); 35 | $this->assertInstanceOf(ResourceModel::className(), $action->run(1, 'extraField2')); 36 | } 37 | } -------------------------------------------------------------------------------- /tests/actions/ViewRelatedActionTest.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | namespace tuyakhov\jsonapi\tests\actions; 6 | 7 | use tuyakhov\jsonapi\tests\TestCase; 8 | use tuyakhov\jsonapi\actions\ViewRelatedAction; 9 | use yii\base\Controller; 10 | use tuyakhov\jsonapi\tests\data\ResourceModel; 11 | use yii\data\ActiveDataProvider; 12 | use \tuyakhov\jsonapi\tests\data\ActiveQuery; 13 | use yii\web\NotFoundHttpException; 14 | 15 | class ViewRelatedActionTest extends TestCase 16 | { 17 | 18 | public function testSuccess() 19 | { 20 | $model = new ResourceModel(); 21 | $action = new ViewRelatedAction('test', new Controller('test', \Yii::$app), [ 22 | 'modelClass' => ResourceModel::className() 23 | ]); 24 | ResourceModel::$related = [ 25 | 'extraField1' => new ActiveQuery(ResourceModel::className(), ['multiple' => true]), 26 | 'extraField2' => new ActiveQuery(ResourceModel::className()) 27 | ]; 28 | $action->findModel = function ($id, $action) use($model) { 29 | return $model; 30 | }; 31 | 32 | $this->assertInstanceOf(ActiveDataProvider::className(), $action->run(1, 'extraField1')); 33 | $this->assertInstanceOf(ResourceModel::className(), $action->run(1, 'extraField2')); 34 | } 35 | 36 | public function testInvalidRelation() 37 | { 38 | $action = new ViewRelatedAction('test', new Controller('test', \Yii::$app), [ 39 | 'modelClass' => ResourceModel::className() 40 | ]); 41 | $action->findModel = function ($id, $action) { 42 | return new ResourceModel(); 43 | }; 44 | $this->expectException(NotFoundHttpException::class); 45 | $action->run(1, 'invalid'); 46 | } 47 | } -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | namespace tuyakhov\jsonapi\tests\data; 6 | 7 | 8 | class ActiveQuery extends \yii\db\ActiveQuery 9 | { 10 | public static $models = []; 11 | 12 | public function one($db = null) 13 | { 14 | return isset(self::$models[0]) ? self::$models[0] : new $this->modelClass; 15 | } 16 | 17 | public function all($db = null) 18 | { 19 | return self::$models; 20 | } 21 | 22 | public function count($q = '*', $db = null) 23 | { 24 | return count(self::$models); 25 | } 26 | } -------------------------------------------------------------------------------- /tests/data/ResourceModel.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace tuyakhov\jsonapi\tests\data; 7 | 8 | use tuyakhov\jsonapi\LinksInterface; 9 | use tuyakhov\jsonapi\ResourceInterface; 10 | use tuyakhov\jsonapi\ResourceTrait; 11 | use yii\base\Model; 12 | use yii\helpers\Url; 13 | use yii\web\Link; 14 | 15 | class ResourceModel extends Model implements ResourceInterface, LinksInterface 16 | { 17 | use ResourceTrait; 18 | 19 | public static $id = '123'; 20 | public static $fields = ['field1', 'field2']; 21 | public static $extraFields = []; 22 | public static $related = []; 23 | public $field1 = 'test'; 24 | public $field2 = 2; 25 | public $first_name = 'Bob'; 26 | public $username = ''; 27 | public $extraField1 = 'testExtra'; 28 | public $extraField2 = 42; 29 | private $_id; 30 | 31 | public function rules() 32 | { 33 | return [ 34 | ['field1', 'string'] 35 | ]; 36 | } 37 | 38 | public function getId() 39 | { 40 | if ($this->_id === null) { 41 | $this->_id = static::$id; 42 | } 43 | return $this->_id; 44 | } 45 | 46 | public function setId($value) 47 | { 48 | $this->_id = $value; 49 | } 50 | 51 | public function fields() 52 | { 53 | return static::$fields; 54 | } 55 | 56 | public function extraFields() 57 | { 58 | return static::$extraFields; 59 | } 60 | 61 | public function getRelation($name) 62 | { 63 | return isset(static::$related[$name]) ? static::$related[$name] : null; 64 | } 65 | 66 | public function setResourceRelationship($name, $relationship) 67 | { 68 | $this->$name = $relationship; 69 | } 70 | 71 | public static function find() 72 | { 73 | return new ActiveQuery(self::className()); 74 | } 75 | 76 | public static function findOne() 77 | { 78 | return self::find()->one(); 79 | } 80 | 81 | public static function primaryKey() 82 | { 83 | return ['id']; 84 | } 85 | 86 | public function getPrimaryKey($asArray = false) 87 | { 88 | return $asArray ? [$this->getId()] : $this->getId(); 89 | } 90 | 91 | public function unlinkAll($name, $delete = false) 92 | { 93 | $this->$name = null; 94 | } 95 | 96 | public function unlink($name, $model) 97 | { 98 | array_pop($this->$name); 99 | } 100 | 101 | public function save() 102 | { 103 | return true; 104 | } 105 | 106 | public function delete() 107 | { 108 | return true; 109 | } 110 | 111 | public function getLinks() 112 | { 113 | return [ 114 | Link::REL_SELF => Url::to('http://example.com/resource/' . $this->getId()) 115 | ]; 116 | } 117 | } 118 | --------------------------------------------------------------------------------