├── .editorconfig ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml ├── resources └── jsonapi-schema-1.0.json ├── src └── EchoIt │ └── JsonApi │ ├── ErrorResponse.php │ ├── Exception.php │ ├── Exception │ └── Validation.php │ ├── Handler.php │ ├── Model.php │ ├── MultiErrorResponse.php │ ├── Request.php │ └── Response.php └── tests ├── ErrorResponseTest.php ├── HandlerTest.php ├── JsonSchemaValidationTrait.php └── ResponseTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | composer.lock 3 | vendor/ 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.5 5 | 6 | before_script: 7 | - composer install 8 | 9 | script: phpunit 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | v3.1.4 5 | ------ 6 | 7 | 1. Ran PHP-CS-Fixer on code to update to PSR-2 standards 8 | 9 | v3.1.3 10 | ------ 11 | 12 | 1. fixed bug preventing type property from being returned 13 | 14 | 15 | v3.1.2 16 | ------ 17 | 18 | 1. Updated error response to include HTTP status code 19 | 2. Updated phpunit tests 20 | 3. Updated HTTP status returned from some exceptions to better match specs. 21 | 22 | v3.1.1 23 | ------ 24 | 25 | 1. Updated readme and composer.json 26 | 27 | v3.1.0 28 | ------ 29 | 30 | 1. Added pagination support 31 | 32 | v3.0.1 33 | ------ 34 | 35 | 1. Made linked resources include type property 36 | 37 | v3.0.0 38 | ------ 39 | 40 | 1. Updated code and docs for basic support for jsonapi 1.0.0.rc2 specs 41 | 2. Updated support for sorting to allow for multiple items and ascending/descending specifications 42 | 3. Added validation of POST/PUT data 43 | 44 | v2.0.0 45 | ------ 46 | 47 | 1. Updated code and docs to support laravel 5.0 48 | 2. Added basic support for sorting and filtering 49 | 3. Updated POST response to return code 201 as specified in jsonapi specs 50 | 51 | v1.1.3 52 | ------ 53 | 54 | 1. Added the ability to pass additional attributes to an ErrorResponse 55 | 56 | v1.2 57 | ---- 58 | 59 | 1. Added default value to `$guarded` on Model 60 | 2. Added the ability to pass JSON encode options to `toJsonResponse` 61 | 62 | v1.2.1 63 | ------ 64 | 65 | 1. Fixed a bug where linked resources would not be associated to requested models 66 | 67 | v1.2.2 68 | ------ 69 | 70 | 1. Implemented proper response codes for various HTTP methods as required by jsonapi.org spec 71 | 2. Added a `ERROR_MISSING_DATA` constant to `Handler` for generic use where insufficient data was provided 72 | 73 | v1.2.3 74 | ------ 75 | 76 | 1. Fix a bug which caused One-to-One relations not to work 77 | 2. Improved handling of linked resources to never include other than the requested 78 | 79 | v1.2.4 80 | ------ 81 | 82 | 1. Bugfixes 83 | 84 | v1.2.5 85 | ------ 86 | 87 | 1. Add ability to pass additional error details (or *attributes*) through an `Exception` 88 | 89 | v1.2.6 90 | ------ 91 | 92 | 1. Fix a bug which caused linked models to not be correctly included 93 | 94 | v1.2.7 95 | ------ 96 | 97 | 1. Fix a mistake in phpDoc block 98 | 99 | v1.2.8 100 | ------ 101 | 102 | 1. Fix a bug which caused the handler not to expose models from a has-many relationship 103 | 104 | v1.2.9 105 | ------ 106 | 107 | 1. Ensure that toMany relations are presented on the entity with a plural key name 108 | 109 | v1.2.10 110 | ------- 111 | 112 | 1. Add the ability to map a relation name to a non-default key when serializing linked objects 113 | 114 | v1.2.11 115 | ------- 116 | 117 | 1. Add workaround for loading of nested relationships, see commit for details 118 | 119 | v1.2.12 120 | ------- 121 | 122 | 1. Fix a bug with previous update 123 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Echo.it 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | __!!!__ Project abandoned. See [cloudcreativity/laravel-json-api](https://github.com/cloudcreativity/json-api/) for a great alternative. 2 | 3 | JSON API helpers for Laravel 5 4 | ===== 5 | 6 | [![Build Status](https://travis-ci.org/echo-it/laravel-jsonapi.svg)](https://travis-ci.org/echo-it/laravel-jsonapi) 7 | 8 | Make it a breeze to create a [jsonapi.org](http://jsonapi.org/) compliant API with Laravel 5. 9 | 10 | This library strives to be up to date with the latest JSON API updates—as the spec is still a work in progress. If you notice that something is missing, please contribute! 11 | 12 | Installation 13 | ----- 14 | 15 | 1. Add `echo-it/laravel-jsonapi` to your composer.json dependency list (version 2.0.0 at the minimum for laravel 5 support) 16 | 17 | 2. Run `composer update`. 18 | 19 | ### Requirements 20 | 21 | * PHP 5.4+ 22 | * Laravel 5 23 | 24 | 25 | Using laravel-jsonapi 26 | ----- 27 | 28 | This library is made with the concept of exposing models in mind, as found in the RESTful API approach. 29 | 30 | In few steps you can expose your models: 31 | 32 | 1. **Create a route to direct the requests** 33 | 34 | In this example, we use a generic route for all models and HTTP methods: 35 | 36 | ```php 37 | Route::any('{model}/{id?}', 'ApiController@handleRequest'); 38 | ``` 39 | 40 | 2. **Create your controller to handle the request** 41 | 42 | Your controller is responsible to handling input, instantiating a handler class and returning the response. 43 | 44 | ```php 45 | fulfillRequest(); 87 | } catch (ApiException $e) { 88 | return $e->response(); 89 | } 90 | 91 | return $res->toJsonResponse(); 92 | } 93 | 94 | // If a handler class does not exist for requested model, it is not considered to be exposed in the API 95 | return new ApiErrorResponse(404, 404, 'Entity not found'); 96 | } 97 | } 98 | ``` 99 | 100 | 3. **Create a handler for your model** 101 | 102 | A handler is responsible for exposing a single model. 103 | 104 | In this example we have create a handler which supports the following requests: 105 | 106 | * GET /users (ie. handleGet function) 107 | * GET /users/[id] (ie. handleGet function) 108 | * PUT /users/[id] (ie. handlePut function) 109 | 110 | Requests are automatically routed to appropriate handle functions. 111 | 112 | ```php 113 | handleGetDefault($request, new User); 145 | } 146 | 147 | /** 148 | * Handles PUT requests. 149 | * @param EchoIt\JsonApi\Request $request 150 | * @return EchoIt\JsonApi\Model|Illuminate\Support\Collection|EchoIt\JsonApi\Response 151 | */ 152 | public function handlePut(ApiRequest $request) 153 | { 154 | //you can use the default PUT functionality, or override with your own 155 | return $this->handlePutDefault($request, new User); 156 | } 157 | } 158 | ``` 159 | 160 | > **Note:** Extend your models from `EchoIt\JsonApi\Model` rather than `Eloquent` to get the proper response for linked resources. 161 | 162 | Current features 163 | ----- 164 | 165 | According to [jsonapi.org](http://jsonapi.org): 166 | 167 | * [Resource Representations](http://jsonapi.org/format/#document-structure-resource-representations) as resource objects 168 | * [Resource Relationships](http://jsonapi.org/format/#document-structure-resource-relationships) 169 | * Only through [Inclusion of Linked Resources](http://jsonapi.org/format/#fetching-includes) 170 | * [Compound Documents](http://jsonapi.org/format/#document-structure-compound-documents) 171 | * [Sorting](http://jsonapi.org/format/#fetching-sorting) 172 | * [Filtering](http://jsonapi.org/format/#fetching-filtering) 173 | * [Pagination] (http://jsonapi.org/format/#fetching-pagination) 174 | 175 | The features in the Handler class are each in their own function (eg. handlePaginationRequest, handleSortRequest, etc.), so you can easily override them with your own behaviour if desired. 176 | 177 | 178 | Wishlist 179 | ----- 180 | 181 | * Nested requests to fetch relations, e.g. /users/[id]/friends 182 | * [Resource URLs](http://jsonapi.org/format/#document-structure-resource-urls) 183 | * Requests for multiple [individual resources](http://jsonapi.org/format/#urls-individual-resources), e.g. `/users/1,2,3` 184 | * [Sparse Fieldsets](http://jsonapi.org/format/#fetching-sparse-fieldsets) 185 | 186 | * Some kind of caching mechanism 187 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "echo-it/laravel-jsonapi", 3 | "license": "MIT", 4 | "authors": [ 5 | { 6 | "name": "Ronni Egeriis Persson", 7 | "email": "ronni@egeriis.me" 8 | }, 9 | { 10 | "name": "Jared Chapiewsky", 11 | "email": "chapiewsky@earthlinginteractive.com" 12 | } 13 | ], 14 | "require": { 15 | "illuminate/database": "5.2.*", 16 | "illuminate/http": "5.2.*", 17 | "illuminate/support": "5.2.*", 18 | "illuminate/pagination" : "5.2.*" 19 | }, 20 | "require-dev": { 21 | "phpunit/phpunit": "4.*", 22 | "justinrainbow/json-schema": "^1.5" 23 | }, 24 | "autoload": { 25 | "psr-0": { 26 | "EchoIt\\JsonApi\\": "src/" 27 | }, 28 | "psr-4": { 29 | "EchoIt\\JsonApi\\Tests\\": "tests/" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | ./tests/ 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /resources/jsonapi-schema-1.0.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "JSON API Schema", 4 | "description": "This is a schema for responses in the JSON API format. For more, see http://jsonapi.org", 5 | "oneOf": [ 6 | { 7 | "$ref": "#/definitions/success" 8 | }, 9 | { 10 | "$ref": "#/definitions/failure" 11 | }, 12 | { 13 | "$ref": "#/definitions/info" 14 | } 15 | ], 16 | 17 | "definitions": { 18 | "success": { 19 | "type": "object", 20 | "required": [ 21 | "data" 22 | ], 23 | "properties": { 24 | "data": { 25 | "$ref": "#/definitions/data" 26 | }, 27 | "included": { 28 | "description": "To reduce the number of HTTP requests, servers **MAY** allow responses that include related resources along with the requested primary resources. Such responses are called \"compound documents\".", 29 | "type": "array", 30 | "items": { 31 | "$ref": "#/definitions/resource" 32 | }, 33 | "uniqueItems": true 34 | }, 35 | "meta": { 36 | "$ref": "#/definitions/meta" 37 | }, 38 | "links": { 39 | "description": "Link members related to the primary data.", 40 | "allOf": [ 41 | { 42 | "$ref": "#/definitions/links" 43 | }, 44 | { 45 | "$ref": "#/definitions/pagination" 46 | } 47 | ] 48 | }, 49 | "jsonapi": { 50 | "$ref": "#/definitions/jsonapi" 51 | } 52 | }, 53 | "additionalProperties": false 54 | }, 55 | "failure": { 56 | "type": "object", 57 | "required": [ 58 | "errors" 59 | ], 60 | "properties": { 61 | "errors": { 62 | "type": "array", 63 | "items": { 64 | "$ref": "#/definitions/error" 65 | }, 66 | "uniqueItems": true 67 | }, 68 | "meta": { 69 | "$ref": "#/definitions/meta" 70 | }, 71 | "jsonapi": { 72 | "$ref": "#/definitions/jsonapi" 73 | } 74 | }, 75 | "additionalProperties": false 76 | }, 77 | "info": { 78 | "type": "object", 79 | "required": [ 80 | "meta" 81 | ], 82 | "properties": { 83 | "meta": { 84 | "$ref": "#/definitions/meta" 85 | }, 86 | "links": { 87 | "$ref": "#/definitions/links" 88 | }, 89 | "jsonapi": { 90 | "$ref": "#/definitions/jsonapi" 91 | } 92 | }, 93 | "additionalProperties": false 94 | }, 95 | 96 | "meta": { 97 | "description": "Non-standard meta-information that can not be represented as an attribute or relationship.", 98 | "type": "object", 99 | "additionalProperties": true 100 | }, 101 | "data": { 102 | "description": "The document's \"primary data\" is a representation of the resource or collection of resources targeted by a request.", 103 | "oneOf": [ 104 | { 105 | "$ref": "#/definitions/resource" 106 | }, 107 | { 108 | "description": "An array of resource objects, an array of resource identifier objects, or an empty array ([]), for requests that target resource collections.", 109 | "type": "array", 110 | "items": { 111 | "$ref": "#/definitions/resource" 112 | }, 113 | "uniqueItems": true 114 | } 115 | ] 116 | }, 117 | "resource": { 118 | "description": "\"Resource objects\" appear in a JSON API document to represent resources.", 119 | "type": "object", 120 | "required": [ 121 | "type", 122 | "id" 123 | ], 124 | "properties": { 125 | "type": { 126 | "type": "string" 127 | }, 128 | "id": { 129 | "type": "string" 130 | }, 131 | "attributes": { 132 | "$ref": "#/definitions/attributes" 133 | }, 134 | "relationships": { 135 | "$ref": "#/definitions/relationships" 136 | }, 137 | "links": { 138 | "$ref": "#/definitions/links" 139 | }, 140 | "meta": { 141 | "$ref": "#/definitions/meta" 142 | } 143 | }, 144 | "additionalProperties": false 145 | }, 146 | 147 | "links": { 148 | "description": "A resource object **MAY** contain references to other resource objects (\"relationships\"). Relationships may be to-one or to-many. Relationships can be specified by including a member in a resource's links object.", 149 | "type": "object", 150 | "properties": { 151 | "self": { 152 | "description": "A `self` member, whose value is a URL for the relationship itself (a \"relationship URL\"). This URL allows the client to directly manipulate the relationship. For example, it would allow a client to remove an `author` from an `article` without deleting the people resource itself.", 153 | "type": "string", 154 | "format": "uri" 155 | }, 156 | "related": { 157 | "$ref": "#/definitions/link" 158 | } 159 | }, 160 | "additionalProperties": true 161 | }, 162 | "link": { 163 | "description": "A link **MUST** be represented as either: a string containing the link's URL or a link object.", 164 | "oneOf": [ 165 | { 166 | "description": "A string containing the link's URL.", 167 | "type": "string", 168 | "format": "uri" 169 | }, 170 | { 171 | "type": "object", 172 | "required": [ 173 | "href" 174 | ], 175 | "properties": { 176 | "href": { 177 | "description": "A string containing the link's URL.", 178 | "type": "string", 179 | "format": "uri" 180 | }, 181 | "meta": { 182 | "$ref": "#/definitions/meta" 183 | } 184 | } 185 | } 186 | ] 187 | }, 188 | 189 | "attributes": { 190 | "description": "Members of the attributes object (\"attributes\") represent information about the resource object in which it's defined.", 191 | "type": "object", 192 | "patternProperties": { 193 | "^(?!relationships$|links$)\\w[-\\w_]*$": { 194 | "description": "Attributes may contain any valid JSON value." 195 | } 196 | }, 197 | "additionalProperties": false 198 | }, 199 | 200 | "relationships": { 201 | "description": "Members of the relationships object (\"relationships\") represent references from the resource object in which it's defined to other resource objects.", 202 | "type": "object", 203 | "patternProperties": { 204 | "^\\w[-\\w_]*$": { 205 | "properties": { 206 | "links": { 207 | "$ref": "#/definitions/links" 208 | }, 209 | "data": { 210 | "description": "Member, whose value represents \"resource linkage\".", 211 | "oneOf": [ 212 | { 213 | "$ref": "#/definitions/relationshipToOne" 214 | }, 215 | { 216 | "$ref": "#/definitions/relationshipToMany" 217 | } 218 | ] 219 | }, 220 | "meta": { 221 | "$ref": "#/definitions/meta" 222 | } 223 | }, 224 | "additionalProperties": false 225 | } 226 | }, 227 | "additionalProperties": false 228 | }, 229 | "relationshipToOne": { 230 | "description": "References to other resource objects in a to-one (\"relationship\"). Relationships can be specified by including a member in a resource's links object.", 231 | "anyOf": [ 232 | { 233 | "$ref": "#/definitions/empty" 234 | }, 235 | { 236 | "$ref": "#/definitions/linkage" 237 | } 238 | ] 239 | }, 240 | "relationshipToMany": { 241 | "description": "An array of objects each containing \"type\" and \"id\" members for to-many relationships.", 242 | "type": "array", 243 | "items": { 244 | "$ref": "#/definitions/linkage" 245 | }, 246 | "uniqueItems": true 247 | }, 248 | "empty": { 249 | "description": "Describes an empty to-one relationship.", 250 | "type": "null" 251 | }, 252 | "linkage": { 253 | "description": "The \"type\" and \"id\" to non-empty members.", 254 | "type": "object", 255 | "required": [ 256 | "type", 257 | "id" 258 | ], 259 | "properties": { 260 | "type": { 261 | "type": "string" 262 | }, 263 | "id": { 264 | "type": "string" 265 | }, 266 | "meta": { 267 | "$ref": "#/definitions/meta" 268 | } 269 | }, 270 | "additionalProperties": false 271 | }, 272 | "pagination": { 273 | "type": "object", 274 | "properties": { 275 | "first": { 276 | "description": "The first page of data", 277 | "oneOf": [ 278 | { "type": "string", "format": "uri" }, 279 | { "type": "null" } 280 | ] 281 | }, 282 | "last": { 283 | "description": "The last page of data", 284 | "oneOf": [ 285 | { "type": "string", "format": "uri" }, 286 | { "type": "null" } 287 | ] 288 | }, 289 | "prev": { 290 | "description": "The previous page of data", 291 | "oneOf": [ 292 | { "type": "string", "format": "uri" }, 293 | { "type": "null" } 294 | ] 295 | }, 296 | "next": { 297 | "description": "The next page of data", 298 | "oneOf": [ 299 | { "type": "string", "format": "uri" }, 300 | { "type": "null" } 301 | ] 302 | } 303 | } 304 | }, 305 | 306 | "jsonapi": { 307 | "description": "An object describing the server's implementation", 308 | "type": "object", 309 | "properties": { 310 | "version": { 311 | "type": "string" 312 | }, 313 | "meta": { 314 | "$ref": "#/definitions/meta" 315 | } 316 | }, 317 | "additionalProperties": false 318 | }, 319 | 320 | "error": { 321 | "type": "object", 322 | "properties": { 323 | "id": { 324 | "description": "A unique identifier for this particular occurrence of the problem.", 325 | "type": "string" 326 | }, 327 | "links": { 328 | "$ref": "#/definitions/links" 329 | }, 330 | "status": { 331 | "description": "The HTTP status code applicable to this problem, expressed as a string value.", 332 | "type": "string" 333 | }, 334 | "code": { 335 | "description": "An application-specific error code, expressed as a string value.", 336 | "type": "string" 337 | }, 338 | "title": { 339 | "description": "A short, human-readable summary of the problem. It **SHOULD NOT** change from occurrence to occurrence of the problem, except for purposes of localization.", 340 | "type": "string" 341 | }, 342 | "detail": { 343 | "description": "A human-readable explanation specific to this occurrence of the problem.", 344 | "type": "string" 345 | }, 346 | "source": { 347 | "type": "object", 348 | "properties": { 349 | "pointer": { 350 | "description": "A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute].", 351 | "type": "string" 352 | }, 353 | "parameter": { 354 | "description": "A string indicating which query parameter caused the error.", 355 | "type": "string" 356 | } 357 | } 358 | }, 359 | "meta": { 360 | "$ref": "#/definitions/meta" 361 | } 362 | }, 363 | "additionalProperties": false 364 | } 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /src/EchoIt/JsonApi/ErrorResponse.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class ErrorResponse extends JsonResponse 11 | { 12 | /** 13 | * Constructor. 14 | * 15 | * @param int $httpStatusCode HTTP status code 16 | * @param mixed $errorCode Internal error code 17 | * @param string $errorTitle Error description 18 | * @param array $additionalAttrs 19 | */ 20 | public function __construct($httpStatusCode, $errorCode, $errorTitle, array $additionalAttrs = array()) 21 | { 22 | $data = [ 23 | 'errors' => [ array_merge( 24 | [ 25 | 'status' => (string) $httpStatusCode, 26 | 'code' => (string) $errorCode, 27 | 'title' => (string) $errorTitle 28 | ], 29 | $additionalAttrs 30 | ) ] 31 | ]; 32 | parent::__construct($data, $httpStatusCode); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/EchoIt/JsonApi/Exception.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class Exception extends \Exception 9 | { 10 | protected $httpStatusCode; 11 | protected $additionalAttrs; 12 | 13 | /** 14 | * Constructor. 15 | * 16 | * @param string $message The Exception message to throw 17 | * @param int $code The Exception code 18 | * @param int $httpStatusCode HTTP status code which can be used for broken request 19 | * @param array $additionalAttrs 20 | */ 21 | public function __construct($message = '', $code = 0, $httpStatusCode = 500, array $additionalAttrs = array()) 22 | { 23 | parent::__construct($message, $code); 24 | 25 | $this->httpStatusCode = $httpStatusCode; 26 | $this->additionalAttrs = $additionalAttrs; 27 | } 28 | 29 | /** 30 | * This method returns a HTTP response representation of the Exception 31 | * 32 | * @return \EchoIt\JsonApi\ErrorResponse 33 | */ 34 | public function response() 35 | { 36 | return new ErrorResponse($this->httpStatusCode, $this->code, $this->message, $this->additionalAttrs); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/EchoIt/JsonApi/Exception/Validation.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class Validation extends Exception 13 | { 14 | protected $httpStatusCode; 15 | protected $validationMessages; 16 | 17 | /** 18 | * Constructor. 19 | * 20 | * @param string $message The Exception message to throw 21 | * @param int $code The Exception code 22 | * @param int $httpStatusCode HTTP status code which can be used for broken request 23 | * @param \Illuminate\Support\MessageBag $messages Validation errors 24 | */ 25 | public function __construct($message = '', $code = 0, $httpStatusCode = 500, ValidationMessages $messages = NULL) 26 | { 27 | parent::__construct($message, $code); 28 | 29 | $this->httpStatusCode = $httpStatusCode; 30 | $this->validationMessages = $messages; 31 | } 32 | 33 | /** 34 | * This method returns a HTTP response representation of the Exception 35 | * 36 | * @return \EchoIt\JsonApi\MultiErrorResponse 37 | */ 38 | public function response() 39 | { 40 | return new MultiErrorResponse($this->httpStatusCode, $this->code, $this->message, $this->validationMessages); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/EchoIt/JsonApi/Handler.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | abstract class Handler 14 | { 15 | /** 16 | * Override this const in the extended to distinguish model handlers from each other. 17 | * 18 | * See under default error codes which bits are reserved. 19 | */ 20 | const ERROR_SCOPE = 0; 21 | 22 | /** 23 | * Default error codes. 24 | */ 25 | const ERROR_UNKNOWN_ID = 1; 26 | const ERROR_UNKNOWN_LINKED_RESOURCES = 2; 27 | const ERROR_NO_ID = 4; 28 | const ERROR_INVALID_ATTRS = 8; 29 | const ERROR_HTTP_METHOD_NOT_ALLOWED = 16; 30 | const ERROR_ID_PROVIDED_NOT_ALLOWED = 32; 31 | const ERROR_MISSING_DATA = 64; 32 | const ERROR_UNKNOWN = 128; 33 | const ERROR_RESERVED_8 = 256; 34 | const ERROR_RESERVED_9 = 512; 35 | 36 | /** 37 | * Constructor. 38 | * 39 | * @param \EchoIt\JsonApi\Request $request 40 | */ 41 | public function __construct(Request $request) 42 | { 43 | $this->request = $request; 44 | } 45 | 46 | /** 47 | * Check whether a method is supported for a model. 48 | * 49 | * @param string $method HTTP method 50 | * @return boolean 51 | */ 52 | public function supportsMethod($method) 53 | { 54 | return method_exists($this, static::methodHandlerName($method)); 55 | } 56 | 57 | /** 58 | * Fulfill the API request and return a response. 59 | * 60 | * @return \EchoIT\JsonApi\Response 61 | * @throws Exception 62 | */ 63 | public function fulfillRequest() 64 | { 65 | if (! $this->supportsMethod($this->request->method)) { 66 | throw new Exception( 67 | 'Method not allowed', 68 | static::ERROR_SCOPE | static::ERROR_HTTP_METHOD_NOT_ALLOWED, 69 | BaseResponse::HTTP_METHOD_NOT_ALLOWED 70 | ); 71 | } 72 | 73 | $methodName = static::methodHandlerName($this->request->method); 74 | $models = $this->{$methodName}($this->request); 75 | 76 | if (is_null($models)) { 77 | throw new Exception( 78 | 'Unknown ID', 79 | static::ERROR_SCOPE | static::ERROR_UNKNOWN_ID, 80 | BaseResponse::HTTP_NOT_FOUND 81 | ); 82 | } 83 | 84 | if ($models instanceof Response) { 85 | $response = $models; 86 | } elseif ($models instanceof LengthAwarePaginator) { 87 | $items = new Collection($models->items()); 88 | foreach ($items as $model) { 89 | $this->loadRelatedModels($model); 90 | } 91 | 92 | $response = new Response($items, static::successfulHttpStatusCode($this->request->method)); 93 | 94 | $response->links = $this->getPaginationLinks($models); 95 | $response->included = $this->getIncludedModels($items); 96 | $response->errors = $this->getNonBreakingErrors(); 97 | } else { 98 | if ($models instanceof Collection) { 99 | foreach ($models as $model) { 100 | $this->loadRelatedModels($model); 101 | } 102 | } else { 103 | $this->loadRelatedModels($models); 104 | } 105 | 106 | $response = new Response($models, static::successfulHttpStatusCode($this->request->method, $models)); 107 | 108 | $response->included = $this->getIncludedModels($models); 109 | $response->errors = $this->getNonBreakingErrors(); 110 | } 111 | 112 | return $response; 113 | } 114 | 115 | /** 116 | * Load a model's relations 117 | * 118 | * @param Model $model the model to load relations of 119 | * @return void 120 | */ 121 | protected function loadRelatedModels(Model $model) { 122 | // get the relations to load 123 | $relations = $this->exposedRelationsFromRequest($model); 124 | 125 | foreach ($relations as $relation) { 126 | // if this relation is loaded via a method, then call said method 127 | if (in_array($relation, $model->relationsFromMethod())) { 128 | $model->$relation = $model->$relation(); 129 | continue; 130 | } 131 | 132 | $model->load($relation); 133 | } 134 | } 135 | 136 | /** 137 | * Returns which requested resources are available to include. 138 | * 139 | * @param Model $model 140 | * @return array 141 | */ 142 | protected function exposedRelationsFromRequest($model = null) 143 | { 144 | $exposedRelations = static::$exposedRelations; 145 | 146 | // if no relations are to be included by request 147 | if (count($this->request->include) == 0) { 148 | // and if we have a model 149 | if ($model !== null && $model instanceof Model) { 150 | // then use the relations exposed by default 151 | $exposedRelations = array_intersect($exposedRelations, $model->defaultExposedRelations()); 152 | $model->setExposedRelations($exposedRelations); 153 | return $exposedRelations; 154 | } 155 | 156 | } 157 | 158 | $exposedRelations = array_intersect($exposedRelations, $this->request->include); 159 | if ($model !== null && $model instanceof Model) { 160 | $model->setExposedRelations($exposedRelations); 161 | } 162 | 163 | return $exposedRelations; 164 | } 165 | 166 | /** 167 | * Returns which of the requested resources are not available to include. 168 | * 169 | * @return array 170 | */ 171 | protected function unknownRelationsFromRequest() 172 | { 173 | return array_diff($this->request->include, static::$exposedRelations); 174 | } 175 | 176 | /** 177 | * Iterate through result set to fetch the requested resources to include. 178 | * 179 | * @param \Illuminate\Database\Eloquent\Collection|\EchoIT\JsonApi\Model $models 180 | * @return array 181 | */ 182 | protected function getIncludedModels($models) 183 | { 184 | $links = new Collection(); 185 | $models = $models instanceof Collection ? $models : [$models]; 186 | 187 | foreach ($models as $model) { 188 | foreach ($this->exposedRelationsFromRequest($model) as $relationName) { 189 | $value = static::getModelsForRelation($model, $relationName); 190 | 191 | if (is_null($value)) { 192 | continue; 193 | } 194 | 195 | foreach ($value as $obj) { 196 | 197 | // Check whether the object is already included in the response on it's ID 198 | $duplicate = false; 199 | $items = $links->where($obj->getKeyName(), '=', $obj->getKey()); 200 | if (count($items) > 0) { 201 | foreach ($items as $item) { 202 | if ($item->getResourceType() === $obj->getResourceType()) { 203 | $duplicate = true; 204 | break; 205 | } 206 | } 207 | if ($duplicate) { 208 | continue; 209 | } 210 | } 211 | 212 | //add type property 213 | $attributes = $obj->getAttributes(); 214 | $attributes['type'] = $obj->getResourceType(); 215 | $obj->setRawAttributes($attributes); 216 | 217 | $links->push($obj); 218 | } 219 | } 220 | } 221 | 222 | return $links->toArray(); 223 | } 224 | 225 | /** 226 | * Return pagination links as array 227 | * @param LengthAwarePaginator $paginator 228 | * @return array 229 | */ 230 | protected function getPaginationLinks($paginator) 231 | { 232 | $links = []; 233 | 234 | $links['self'] = urldecode($paginator->url($paginator->currentPage())); 235 | $links['first'] = urldecode($paginator->url(1)); 236 | $links['last'] = urldecode($paginator->url($paginator->lastPage())); 237 | 238 | $links['prev'] = urldecode($paginator->url($paginator->currentPage() - 1)); 239 | if ($links['prev'] === $links['self'] || $links['prev'] === '') { 240 | $links['prev'] = null; 241 | } 242 | $links['next'] = urldecode($paginator->nextPageUrl()); 243 | if ($links['next'] === $links['self'] || $links['next'] === '') { 244 | $links['next'] = null; 245 | } 246 | return $links; 247 | } 248 | 249 | /** 250 | * Return errors which did not prevent the API from returning a result set. 251 | * 252 | * @return array 253 | */ 254 | protected function getNonBreakingErrors() 255 | { 256 | $errors = []; 257 | 258 | $unknownRelations = $this->unknownRelationsFromRequest(); 259 | if (count($unknownRelations) > 0) { 260 | $errors[] = [ 261 | 'code' => static::ERROR_UNKNOWN_LINKED_RESOURCES, 262 | 'title' => 'Unknown included resource requested', 263 | 'description' => 'These included resources are not available: ' . implode(', ', $unknownRelations) 264 | ]; 265 | } 266 | 267 | return $errors; 268 | } 269 | 270 | /** 271 | * A method for getting the proper HTTP status code for a successful request 272 | * 273 | * @param string $method "PUT", "POST", "DELETE" or "GET" 274 | * @param Model|null $model The model that a PUT request was executed against 275 | * @return int 276 | */ 277 | public static function successfulHttpStatusCode($method, $model = null) 278 | { 279 | // if we did a put request, we need to ensure that the model wasn't 280 | // changed in other ways than those specified by the request 281 | // Ref: http://jsonapi.org/format/#crud-updating-responses-200 282 | if (($method === 'PATCH' || $method === 'PUT') && $model instanceof Model) { 283 | // check if the model has been changed 284 | if ($model->isChanged()) { 285 | // return our response as if there was a GET request 286 | $method = 'GET'; 287 | } 288 | } 289 | 290 | switch ($method) { 291 | case 'POST': 292 | return BaseResponse::HTTP_CREATED; 293 | case 'PATCH': 294 | case 'PUT': 295 | case 'DELETE': 296 | return BaseResponse::HTTP_NO_CONTENT; 297 | case 'GET': 298 | return BaseResponse::HTTP_OK; 299 | } 300 | 301 | // Code shouldn't reach this point, but if it does we assume that the 302 | // client has made a bad request. 303 | return BaseResponse::HTTP_BAD_REQUEST; 304 | } 305 | 306 | /** 307 | * Convert HTTP method to it's handler method counterpart. 308 | * 309 | * @param string $method HTTP method 310 | * @return string 311 | */ 312 | protected static function methodHandlerName($method) 313 | { 314 | return 'handle' . ucfirst(strtolower($method)); 315 | } 316 | 317 | /** 318 | * Returns the models from a relationship. Will always return as array. 319 | * 320 | * @param \Illuminate\Database\Eloquent\Model $model 321 | * @param string $relationKey 322 | * @return array|\Illuminate\Database\Eloquent\Collection 323 | * @throws Exception 324 | */ 325 | protected static function getModelsForRelation($model, $relationKey) 326 | { 327 | if (!method_exists($model, $relationKey)) { 328 | throw new Exception( 329 | 'Relation "' . $relationKey . '" does not exist in model', 330 | static::ERROR_SCOPE | static::ERROR_UNKNOWN_ID, 331 | BaseResponse::HTTP_INTERNAL_SERVER_ERROR 332 | ); 333 | } 334 | $relationModels = $model->{$relationKey}; 335 | if (is_null($relationModels)) { 336 | return null; 337 | } 338 | 339 | if (! $relationModels instanceof Collection) { 340 | return [ $relationModels ]; 341 | } 342 | 343 | return $relationModels; 344 | } 345 | 346 | /** 347 | * This method returns the value from given array and key, and will create a 348 | * new Collection instance on the key if it doesn't already exist 349 | * 350 | * @param array &$array 351 | * @param string $key 352 | * @return \Illuminate\Database\Eloquent\Collection 353 | */ 354 | protected static function getCollectionOrCreate(&$array, $key) 355 | { 356 | if (array_key_exists($key, $array)) { 357 | return $array[$key]; 358 | } 359 | return ($array[$key] = new Collection); 360 | } 361 | 362 | /** 363 | * The return value of this method will be used as the key to store the 364 | * linked or included model from a relationship. Per default it will return the plural 365 | * version of the relation name. 366 | * Override this method to map a relation name to a different key. 367 | * 368 | * @param string $relationName 369 | * @return string 370 | */ 371 | protected static function getModelNameForRelation($relationName) 372 | { 373 | return \str_plural($relationName); 374 | } 375 | 376 | /** 377 | * Function to handle sorting requests. 378 | * 379 | * @param array $cols list of column names to sort on 380 | * @param \EchoIt\JsonApi\Model $model 381 | * @return \EchoIt\JsonApi\Model 382 | * @throws Exception 383 | */ 384 | protected function handleSortRequest($cols, $model) 385 | { 386 | foreach ($cols as $col) { 387 | $dir = 'asc'; 388 | 389 | if (substr($col, 0, 1) == '-') { 390 | $dir = 'desc'; 391 | $col = substr($col, 1); 392 | } 393 | 394 | $model = $model->orderBy($col, $dir); 395 | } 396 | return $model; 397 | } 398 | 399 | /** 400 | * Parses content from request into an array of values. 401 | * 402 | * @param string $content 403 | * @param string $type the type the content is expected to be. 404 | * @return array 405 | * @throws Exception 406 | */ 407 | protected function parseRequestContent($content, $type) 408 | { 409 | $content = json_decode($content, true); 410 | if (empty($content['data'])) { 411 | throw new Exception( 412 | 'Payload either contains misformed JSON or missing "data" parameter.', 413 | static::ERROR_SCOPE | static::ERROR_INVALID_ATTRS, 414 | BaseResponse::HTTP_BAD_REQUEST 415 | ); 416 | } 417 | 418 | $data = $content['data']; 419 | if (!isset($data['type'])) { 420 | throw new Exception( 421 | '"type" parameter not set in request.', 422 | static::ERROR_SCOPE | static::ERROR_INVALID_ATTRS, 423 | BaseResponse::HTTP_BAD_REQUEST 424 | ); 425 | } 426 | if ($data['type'] !== $type) { 427 | throw new Exception( 428 | '"type" parameter is not valid. Expecting ' . $type, 429 | static::ERROR_SCOPE | static::ERROR_INVALID_ATTRS, 430 | BaseResponse::HTTP_CONFLICT 431 | ); 432 | } 433 | unset($data['type']); 434 | 435 | return $data; 436 | } 437 | 438 | /** 439 | * Function to handle pagination requests. 440 | * 441 | * @param \EchoIt\JsonApi\Request $request 442 | * @param \EchoIt\JsonApi\Model $model 443 | * @param integer $total the total number of records 444 | * @return \Illuminate\Pagination\LengthAwarePaginator 445 | */ 446 | protected function handlePaginationRequest($request, $model, $total = null) 447 | { 448 | $page = $request->pageNumber; 449 | $perPage = $request->pageSize; 450 | if (!$total) { 451 | $total = $model->count(); 452 | } 453 | $results = $model->forPage($page, $perPage)->get(array('*')); 454 | $paginator = new LengthAwarePaginator($results, $total, $perPage, $page, [ 455 | 'path' => Paginator::resolveCurrentPath(), 456 | 'pageName' => 'page[number]' 457 | ]); 458 | $paginator->appends('page[size]', $perPage); 459 | if (!empty($request->filter)) { 460 | foreach ($request->filter as $key=>$value) { 461 | $paginator->appends($key, $value); 462 | } 463 | } 464 | if (!empty($request->sort)) { 465 | $paginator->appends('sort', implode(',', $request->sort)); 466 | } 467 | 468 | return $paginator; 469 | } 470 | 471 | /** 472 | * Function to handle filtering requests. 473 | * 474 | * @param array $filters key=>value pairs of column and value to filter on 475 | * @param \EchoIt\JsonApi\Model $model 476 | * @return \EchoIt\JsonApi\Model 477 | */ 478 | protected function handleFilterRequest($filters, $model) 479 | { 480 | foreach ($filters as $key=>$value) { 481 | $model = $model->where($key, '=', $value); 482 | } 483 | return $model; 484 | } 485 | 486 | /** 487 | * Default handling of GET request. 488 | * Must be called explicitly in handleGet function. 489 | * 490 | * @param \EchoIt\JsonApi\Request $request 491 | * @param \EchoIt\JsonApi\Model $model 492 | * @return \EchoIt\JsonApi\Model|\Illuminate\Pagination\LengthAwarePaginator 493 | * @throws Exception 494 | */ 495 | protected function handleGetDefault(Request $request, $model) 496 | { 497 | $total = null; 498 | if (empty($request->id)) { 499 | if (!empty($request->filter)) { 500 | $model = $this->handleFilterRequest($request->filter, $model); 501 | } 502 | if (!empty($request->sort)) { 503 | //if sorting AND paginating, get total count before sorting! 504 | if ($request->pageNumber) { 505 | $total = $model->count(); 506 | } 507 | $model = $this->handleSortRequest($request->sort, $model); 508 | } 509 | } else { 510 | $model = $model->where($model->getKeyName(), '=', $request->id); 511 | } 512 | 513 | try { 514 | if ($request->pageNumber && empty($request->id)) { 515 | $results = $this->handlePaginationRequest($request, $model, $total); 516 | } else { 517 | $results = $model->get(); 518 | } 519 | } catch (\Illuminate\Database\QueryException $e) { 520 | throw new Exception( 521 | 'Database Request Failed', 522 | static::ERROR_SCOPE | static::ERROR_UNKNOWN_ID, 523 | BaseResponse::HTTP_INTERNAL_SERVER_ERROR, 524 | array('detail' => $e->getMessage()) 525 | ); 526 | } 527 | return $results; 528 | } 529 | 530 | /** 531 | * Validates passed data against a model 532 | * Validation performed safely and only if model provides rules 533 | * 534 | * @param \EchoIt\JsonApi\Model $model model to validate against 535 | * @param Array $values passed array of values 536 | * 537 | * @throws Exception\Validation Exception thrown when validation fails 538 | * 539 | * @return Bool true if validation successful 540 | */ 541 | protected function validateModelData(Model $model, Array $values) 542 | { 543 | $validationResponse = $model->validateArray($values); 544 | 545 | if ($validationResponse === true) { 546 | return true; 547 | } 548 | 549 | throw new Exception\Validation( 550 | 'Bad Request', 551 | static::ERROR_SCOPE | static::ERROR_HTTP_METHOD_NOT_ALLOWED, 552 | BaseResponse::HTTP_BAD_REQUEST, 553 | $validationResponse 554 | ); 555 | } 556 | 557 | /** 558 | * Default handling of POST request. 559 | * Must be called explicitly in handlePost function. 560 | * 561 | * @param \EchoIt\JsonApi\Request $request 562 | * @param \EchoIt\JsonApi\Model $model 563 | * @return \EchoIt\JsonApi\Model 564 | * @throws Exception 565 | */ 566 | public function handlePostDefault(Request $request, $model) 567 | { 568 | $values = $this->parseRequestContent($request->content, $model->getResourceType()); 569 | $this->validateModelData($model, $values); 570 | 571 | $model->fill($values); 572 | 573 | if (!$model->save()) { 574 | throw new Exception( 575 | 'An unknown error occurred', 576 | static::ERROR_SCOPE | static::ERROR_UNKNOWN, 577 | BaseResponse::HTTP_INTERNAL_SERVER_ERROR 578 | ); 579 | } 580 | 581 | return $model; 582 | } 583 | 584 | /** 585 | * Default handling of PUT request. 586 | * Must be called explicitly in handlePut function. 587 | * 588 | * @param \EchoIt\JsonApi\Request $request 589 | * @param \EchoIt\JsonApi\Model $model 590 | * @return \EchoIt\JsonApi\Model 591 | * @throws Exception 592 | */ 593 | public function handlePutDefault(Request $request, $model) 594 | { 595 | if (empty($request->id)) { 596 | throw new Exception( 597 | 'No ID provided', 598 | static::ERROR_SCOPE | static::ERROR_NO_ID, 599 | BaseResponse::HTTP_BAD_REQUEST 600 | ); 601 | } 602 | 603 | $updates = $this->parseRequestContent($request->content, $model->getResourceType()); 604 | 605 | $model = $model::find($request->id); 606 | if (is_null($model)) { 607 | return null; 608 | } 609 | 610 | // fetch the original attributes 611 | $originalAttributes = $model->getOriginal(); 612 | 613 | // apply our updates 614 | $model->fill($updates); 615 | 616 | // ensure we can get a succesful save 617 | if (!$model->save()) { 618 | throw new Exception( 619 | 'An unknown error occurred', 620 | static::ERROR_SCOPE | static::ERROR_UNKNOWN, 621 | BaseResponse::HTTP_INTERNAL_SERVER_ERROR 622 | ); 623 | } 624 | 625 | // fetch the current attributes (post save) 626 | $newAttributes = $model->getAttributes(); 627 | 628 | // loop through the new attributes, and ensure they are identical 629 | // to the original ones. if not, then we need to return the model 630 | foreach ($newAttributes as $attribute => $value) { 631 | if (! array_key_exists($attribute, $originalAttributes) || $value !== $originalAttributes[$attribute]) { 632 | $model->markChanged(); 633 | break; 634 | } 635 | } 636 | 637 | return $model; 638 | } 639 | 640 | /** 641 | * Default handling of DELETE request. 642 | * Must be called explicitly in handleDelete function. 643 | * 644 | * @param \EchoIt\JsonApi\Request $request 645 | * @param \EchoIt\JsonApi\Model $model 646 | * @return \EchoIt\JsonApi\Model 647 | * @throws Exception 648 | */ 649 | public function handleDeleteDefault(Request $request, $model) 650 | { 651 | if (empty($request->id)) { 652 | throw new Exception( 653 | 'No ID provided', 654 | static::ERROR_SCOPE | static::ERROR_NO_ID, 655 | BaseResponse::HTTP_BAD_REQUEST 656 | ); 657 | } 658 | 659 | $model = $model::find($request->id); 660 | if (is_null($model)) { 661 | return null; 662 | } 663 | 664 | $model->delete(); 665 | 666 | return $model; 667 | } 668 | } 669 | -------------------------------------------------------------------------------- /src/EchoIt/JsonApi/Model.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class Model extends \Eloquent 15 | { 16 | /** 17 | * Let's guard these fields per default 18 | * 19 | * @var array 20 | */ 21 | protected $guarded = ['id', 'created_at', 'updated_at']; 22 | 23 | /** 24 | * Has this model been changed inother ways than those 25 | * specified by the request 26 | * 27 | * Ref: http://jsonapi.org/format/#crud-updating-responses-200 28 | * 29 | * @var boolean 30 | */ 31 | protected $changed = false; 32 | 33 | /** 34 | * The resource type. If null, when the model is rendered, 35 | * the table name will be used 36 | * 37 | * @var null|string 38 | */ 39 | protected $resourceType = null; 40 | 41 | /** 42 | * Expose the resource relations links by default when viewing a 43 | * resource 44 | * 45 | * @var array 46 | */ 47 | protected $defaultExposedRelations = []; 48 | protected $exposedRelations = []; 49 | 50 | /** 51 | * An array of relation names of relations who 52 | * simply return a collection, and not a Relation instance 53 | * 54 | * @var array 55 | */ 56 | protected $relationsFromMethod = []; 57 | 58 | /** 59 | * Get the model's default exposed relations 60 | * 61 | * @return Array 62 | */ 63 | public function defaultExposedRelations() { 64 | return $this->defaultExposedRelations; 65 | } 66 | 67 | /** 68 | * Get the model's exposed relations 69 | * 70 | * @return Array 71 | */ 72 | public function exposedRelations() { 73 | return $this->exposedRelations; 74 | } 75 | 76 | /** 77 | * Set this model's exposed relations 78 | * 79 | * @param Array $relations 80 | */ 81 | public function setExposedRelations(Array $relations) { 82 | $this->exposedRelations = $relations; 83 | } 84 | 85 | /** 86 | * Get the model's relations that are from methods 87 | * 88 | * @return Array 89 | */ 90 | public function relationsFromMethod() { 91 | return $this->relationsFromMethod; 92 | } 93 | 94 | /** 95 | * mark this model as changed 96 | * 97 | * @param bool $changed 98 | * @return void 99 | */ 100 | public function markChanged($changed = true) 101 | { 102 | $this->changed = (bool) $changed; 103 | } 104 | 105 | /** 106 | * has this model been changed 107 | * 108 | * @return bool 109 | */ 110 | public function isChanged() 111 | { 112 | return $this->changed; 113 | } 114 | 115 | /** 116 | * Get the resource type of the model 117 | * 118 | * @return string 119 | */ 120 | public function getResourceType() 121 | { 122 | // return the resource type if it is not null; table otherwize 123 | return ($this->resourceType ?: $this->getTable()); 124 | } 125 | 126 | /** 127 | * Validate passed values 128 | * 129 | * @param Array $values user passed values (request data) 130 | * 131 | * @return bool|\Illuminate\Support\MessageBag True on pass, MessageBag of errors on fail 132 | */ 133 | public function validateArray(Array $values) 134 | { 135 | if (count($this->getValidationRules())) { 136 | $validator = Validator::make($values, $this->getValidationRules()); 137 | 138 | if ($validator->fails()) { 139 | return $validator->errors(); 140 | } 141 | } 142 | 143 | return True; 144 | } 145 | 146 | /** 147 | * Return model validation rules 148 | * Models should overload this to provide their validation rules 149 | * 150 | * @return Array validation rules 151 | */ 152 | public function getValidationRules() 153 | { 154 | return []; 155 | } 156 | 157 | /** 158 | * Convert the model instance to an array. This method overrides that of 159 | * Eloquent to prevent relations to be serialize into output array. 160 | * 161 | * @return array 162 | */ 163 | public function toArray() 164 | { 165 | $relations = []; 166 | $arrayableRelations = []; 167 | 168 | // include any relations exposed by default 169 | foreach ($this->exposedRelations as $relation) { 170 | // skip loading a relation if it is from a method 171 | if (in_array($relation, $this->relationsFromMethod)) { 172 | // if the relation hasnt been loaded, then load it 173 | if (!isset($this->$relation)) { 174 | $this->$relation = $this->$relation(); 175 | } 176 | 177 | $arrayableRelations[$relation] = $this->$relation; 178 | continue; 179 | } 180 | 181 | $this->load($relation); 182 | } 183 | 184 | // fetch the relations that can be represented as an array 185 | $arrayableRelations = array_merge($this->getArrayableRelations(), $arrayableRelations); 186 | 187 | // add the relations to the linked array 188 | foreach ($arrayableRelations as $relation => $value) { 189 | if (in_array($relation, $this->hidden)) { 190 | continue; 191 | } 192 | 193 | if ($value instanceof Pivot) { 194 | continue; 195 | } 196 | 197 | if ($value instanceof BaseModel) { 198 | $relations[$relation] = array('linkage' => array('id' => (string)$value->getKey(), 'type' => $value->getResourceType())); 199 | } elseif ($value instanceof Collection) { 200 | $relation = \str_plural($relation); 201 | $items = ['linkage' => []]; 202 | foreach ($value as $item) { 203 | $items['linkage'][] = array('id' => (string)$item->getKey(), 'type' => $item->getResourceType()); 204 | } 205 | $relations[$relation] = $items; 206 | } 207 | 208 | // remove models / collections that we loaded from a method 209 | if (in_array($relation, $this->relationsFromMethod)) { 210 | unset($this->$relation); 211 | } 212 | } 213 | 214 | //add type parameter 215 | $model_attributes = $this->attributesToArray(); 216 | unset($model_attributes[$this->primaryKey]); 217 | 218 | $attributes = [ 219 | 'id' => (string)$this->getKey(), 220 | 'type' => $this->getResourceType(), 221 | 'attributes' => $model_attributes 222 | ]; 223 | 224 | if (! count($relations)) { 225 | return $attributes; 226 | } 227 | 228 | return array_merge( 229 | $attributes, 230 | [ 'links' => $relations ] 231 | ); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/EchoIt/JsonApi/MultiErrorResponse.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class MultiErrorResponse extends JsonResponse 12 | { 13 | /** 14 | * Constructor. 15 | * 16 | * @param int $httpStatusCode HTTP status code 17 | * @param mixed $errorCode Internal error code 18 | * @param string $errorTitle Error description 19 | * @param \Illuminate\Support\MessageBag $errors Validation errors 20 | */ 21 | public function __construct($httpStatusCode, $errorCode, $errorTitle, ValidationMessages $errors = NULL) 22 | { 23 | $data = [ 'errors' => [] ]; 24 | 25 | if ($errors) { 26 | foreach ($errors->keys() as $field) { 27 | 28 | foreach ($errors->get($field) as $message) { 29 | 30 | $data['errors'][] = [ 31 | 'status' => (string) $httpStatusCode, 32 | 'code' => (string) $errorCode, 33 | 'title' => 'Validation Fail', 34 | 'detail' => (string) $message, 35 | 'meta' => [ 36 | 'field' => $field 37 | ] 38 | ]; 39 | 40 | } 41 | 42 | } 43 | } 44 | 45 | parent::__construct($data, $httpStatusCode); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/EchoIt/JsonApi/Request.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class Request 9 | { 10 | 11 | /** 12 | * Contains the url of the request 13 | * 14 | * @var string 15 | */ 16 | public $url; 17 | 18 | /** 19 | * Contains the HTTP method of the request 20 | * 21 | * @var string 22 | */ 23 | public $method; 24 | 25 | /** 26 | * Contains an optional model ID from the request 27 | * 28 | * @var int 29 | */ 30 | public $id; 31 | 32 | /** 33 | * Contains any content in request 34 | * 35 | * @var string 36 | */ 37 | public $content; 38 | 39 | /** 40 | * Contains an array of linked resource collections to load 41 | * 42 | * @var array 43 | */ 44 | public $include; 45 | 46 | /** 47 | * Contains an array of column names to sort on 48 | * 49 | * @var array 50 | */ 51 | public $sort; 52 | 53 | /** 54 | * Contains an array of key/value pairs to filter on 55 | * 56 | * @var array 57 | */ 58 | public $filter; 59 | 60 | /** 61 | * Specifies the page number to return results for 62 | * @var integer 63 | */ 64 | public $pageNumber; 65 | 66 | /** 67 | * Specifies the number of results to return per page. Only used if 68 | * pagination is requested (ie. pageNumber is not null) 69 | * 70 | * @var integer 71 | */ 72 | public $pageSize = 50; 73 | 74 | /** 75 | * Constructor. 76 | * 77 | * @param string $url 78 | * @param string $method 79 | * @param int $id 80 | * @param mixed $content 81 | * @param array $include 82 | * @param array $sort 83 | * @param array $filter 84 | * @param integer $pageNumber 85 | * @param integer $pageSize 86 | */ 87 | public function __construct($url, $method, $id = null, $content = null, $include = [], $sort = [], $filter = [], $pageNumber = null, $pageSize = null) 88 | { 89 | $this->url = $url; 90 | $this->method = $method; 91 | $this->id = $id; 92 | $this->content = $content; 93 | $this->include = $include ?: []; 94 | $this->sort = $sort ?: []; 95 | $this->filter = $filter ?: []; 96 | 97 | $this->pageNumber = $pageNumber ?: null; 98 | if ($pageSize) { 99 | $this->pageSize = $pageSize; 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/EchoIt/JsonApi/Response.php: -------------------------------------------------------------------------------- 1 | body = $body; 42 | $this->httpStatusCode = $httpStatusCode; 43 | } 44 | 45 | /** 46 | * Used to set or overwrite a parameter. 47 | * 48 | * @param string $key 49 | * @param mixed $value 50 | */ 51 | public function __set($key, $value) 52 | { 53 | if ($key == 'body') { 54 | $this->body = $value; 55 | return; 56 | } 57 | $this->responseData[$key] = $value; 58 | } 59 | 60 | /** 61 | * Returns a JsonResponse with the set parameters and body. 62 | * 63 | * @param string $bodyKey The key on which to set the main response. 64 | * @return \Illuminate\Http\JsonResponse 65 | */ 66 | public function toJsonResponse($bodyKey = 'data', $options = 0) 67 | { 68 | return new JsonResponse(array_merge( 69 | [ $bodyKey => $this->body ], 70 | array_filter($this->responseData) 71 | ), $this->httpStatusCode, ['Content-Type' => 'application/vnd.api+json'], $options); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/ErrorResponseTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(404, $res->getStatusCode()); 15 | $this->assertEquals('application/json', $res->headers->get('Content-Type')); 16 | $this->assertJsonapiValid($res->getData()); 17 | } 18 | 19 | public function testResponseBody() 20 | { 21 | $res = new ErrorResponse(404, 100, 'An error occurred'); 22 | $this->assertEquals('{"errors":[{"status":"404","code":"100","title":"An error occurred"}]}', $res->getContent()); 23 | $this->assertJsonapiValid($res->getData()); 24 | } 25 | 26 | public function testResponseWithAdditionalAttrs() 27 | { 28 | $res = new ErrorResponse(404, 100, 'An error occurred', [ 29 | 'meta' => [ 30 | 'stacktrace' => [ 31 | 'line' => 100, 32 | 'file' => 'script.php' 33 | ], 34 | ], 35 | ]); 36 | $this->assertEquals('{"errors":[{"status":"404","code":"100","title":"An error occurred","meta":{"stacktrace":{"line":100,"file":"script.php"}}}]}', $res->getContent()); 37 | $this->assertJsonapiValid($res->getData()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/HandlerTest.php: -------------------------------------------------------------------------------- 1 | 1 ]); 12 | } 13 | } 14 | 15 | class HandlerTest extends PHPUnit_Framework_TestCase 16 | { 17 | public function testMockInstanceWithNoGETSupport() 18 | { 19 | $req = new Request('http://www.example.com/', 'GET'); 20 | $stub = $this->getMockForAbstractClass('EchoIt\JsonApi\Handler', [$req]); 21 | 22 | $this->setExpectedException('EchoIt\JsonApi\Exception'); 23 | $stub->fulfillRequest(); 24 | } 25 | 26 | public function testHandler() 27 | { 28 | $req = new Request('http://www.example.com/', 'GET'); 29 | $handler = new HandlerWithGETSupport($req); 30 | $handlerResult = $handler->fulfillRequest(); 31 | 32 | $this->assertInstanceOf('EchoIt\JsonApi\Response', $handlerResult); 33 | } 34 | 35 | public function testHandlerUnsupportedRequest() 36 | { 37 | $req = new Request('http://www.example.com/', 'PUT', null); 38 | $handler = new HandlerWithGETSupport($req); 39 | 40 | $this->setExpectedException('EchoIt\JsonApi\Exception'); 41 | $handler->fulfillRequest(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/JsonSchemaValidationTrait.php: -------------------------------------------------------------------------------- 1 | resolve($schema); 21 | 22 | $validator = new Validator(); 23 | $validator->check($data, $schema); 24 | 25 | if (!$validator->isValid()) { 26 | $msg = "Invalid jsonapi reponse:\n"; 27 | foreach ($validator->getErrors() as $error) { 28 | $msg .= ' path "' . $error['property'] . '" -> ' . $error['message'] . "\n"; 29 | } 30 | throw new \Exception($msg); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/ResponseTest.php: -------------------------------------------------------------------------------- 1 | 1 11 | ]); 12 | 13 | $json = $res->toJsonResponse(); 14 | $this->assertInstanceOf('Illuminate\\Http\\JsonResponse', $json); 15 | 16 | $data = $json->getData(); 17 | 18 | $this->assertObjectHasAttribute('data', $data); 19 | $this->assertInstanceOf('StdClass', $data->data); 20 | } 21 | 22 | public function testResponseWithNamedBodyOnly() 23 | { 24 | $res = new Response([ 25 | 'value' => 1 26 | ]); 27 | 28 | $json = $res->toJsonResponse('body'); 29 | $this->assertInstanceOf('Illuminate\\Http\\JsonResponse', $json); 30 | 31 | $data = $json->getData(); 32 | 33 | $this->assertObjectHasAttribute('body', $data); 34 | $this->assertInstanceOf('StdClass', $data->body); 35 | } 36 | 37 | public function testResponseWithParams() 38 | { 39 | $res = new Response([ 40 | 'value' => 1 41 | ]); 42 | $res->links = [ 43 | [ 'id' => 1 ], 44 | [ 'id' => 2 ] 45 | ]; 46 | 47 | $json = $res->toJsonResponse(); 48 | $data = $json->getData(); 49 | 50 | $this->assertObjectHasAttribute('links', $data); 51 | $this->assertInternalType('array', $data->links); 52 | $this->assertCount(2, $data->links); 53 | $this->assertInstanceOf('StdClass', $data->links[0]); 54 | $this->assertObjectHasAttribute('id', $data->links[0]); 55 | } 56 | 57 | public function testResponseParamAndBodyOrder() 58 | { 59 | $res = new Response([ 'value' => 1 ]); 60 | $res->links = [ [ 'id' => 1 ], [ 'id' => 2 ] ]; 61 | $res->errors = [ [ 'message' => 'Unknown error' ] ]; 62 | 63 | $json = $res->toJsonResponse(); 64 | $data = $json->getData(); 65 | 66 | $this->assertObjectHasAttribute('data', $data); 67 | $this->assertObjectHasAttribute('links', $data); 68 | $this->assertObjectHasAttribute('errors', $data); 69 | 70 | $this->assertNotEquals(['links', 'errors', 'data'], array_keys(get_object_vars($data))); 71 | $this->assertEquals(['data', 'links', 'errors'], array_keys(get_object_vars($data))); 72 | } 73 | } 74 | --------------------------------------------------------------------------------