├── .gitignore ├── .coveralls.yml ├── src ├── Data │ ├── Meta.php │ ├── Attributes.php │ ├── JsonApi.php │ ├── ErrorLinks.php │ ├── ErrorSource.php │ ├── Link.php │ ├── Error.php │ ├── Relationships.php │ ├── Links.php │ ├── Relationship.php │ ├── Resource.php │ ├── AbstractDataObject.php │ └── Root.php ├── Exceptions │ ├── EncodingException.php │ ├── InvalidIncludeException.php │ ├── JsonApiQueryStringValidationException.php │ └── JsonApiValidationException.php ├── Enums │ ├── SchemaType.php │ ├── RootType.php │ └── Key.php ├── Http │ ├── Requests │ │ ├── JsonApiCreateRequest.php │ │ └── JsonApiRequest.php │ ├── Responses │ │ └── JsonApiResponse.php │ └── Middleware │ │ └── RequireJsonApiHeader.php ├── Contracts │ ├── Encoder │ │ ├── TransformerFactoryInterface.php │ │ ├── TransformerInterface.php │ │ └── EncoderInterface.php │ ├── Repositories │ │ ├── ResourceCollectorInterface.php │ │ └── ResourceRepositoryInterface.php │ ├── Support │ │ ├── Resource │ │ │ └── ResourcePathHelperInterface.php │ │ ├── Type │ │ │ └── TypeMakerInterface.php │ │ ├── Validation │ │ │ └── JsonApiValidatorInterface.php │ │ ├── Error │ │ │ └── ErrorDataInterface.php │ │ └── Request │ │ │ └── RequestQueryParserInterface.php │ └── Resource │ │ ├── EloquentResourceInterface.php │ │ └── ResourceInterface.php ├── Facades │ ├── JsonApiEncoderFacade.php │ └── JsonApiRequestFacade.php ├── Encoder │ ├── Transformers │ │ ├── SimpleTransformer.php │ │ ├── ErrorDataTransformer.php │ │ ├── AbstractTransformer.php │ │ ├── ModelCollectionTransformer.php │ │ ├── ExceptionTransformer.php │ │ ├── PaginatedModelsTransformer.php │ │ └── ValidationExceptionTransformer.php │ └── Factories │ │ └── TransformerFactory.php ├── Support │ ├── Resource │ │ ├── RelationData.php │ │ ├── RelationshipTransformData.php │ │ └── ResourcePathHelper.php │ ├── Error │ │ └── ErrorData.php │ ├── schemas │ │ └── unsupported │ │ │ └── create.json │ ├── Validation │ │ └── JsonApiValidator.php │ ├── helpers.php │ └── Type │ │ └── TypeMaker.php ├── Repositories │ ├── ResourceCollector.php │ └── ResourceRepository.php └── Providers │ └── JsonApiServiceProvider.php ├── tests ├── Helpers │ ├── Resources │ │ ├── AbstractTest │ │ │ ├── TestResourceWithRelativeUrl.php │ │ │ ├── TestResourceWithAbsoluteUrl.php │ │ │ ├── TestResourceWithNoReferences.php │ │ │ ├── TestResourceWithAllReferences.php │ │ │ ├── TestResourceWithBlacklistedReferences.php │ │ │ ├── TestAbstractResource.php │ │ │ ├── TestAbstractEloquentResourceWithDateTimeFormat.php │ │ │ ├── TestAbstractEloquentResource.php │ │ │ └── AbstractTestResource.php │ │ ├── TestSimpleModelWithoutAttributesResource.php │ │ ├── TestAlternativeModelResource.php │ │ ├── TestSeoResource.php │ │ ├── TestSimpleModelResource.php │ │ ├── TestAuthorResource.php │ │ ├── TestCommentResource.php │ │ ├── TestPostResource.php │ │ └── TestPostResourceWithDefaults.php │ ├── Models │ │ ├── TestPostTranslation.php │ │ ├── TestAlternativeModel.php │ │ ├── TestSeo.php │ │ ├── TestAuthor.php │ │ ├── TestSimpleModel.php │ │ ├── TestComment.php │ │ └── TestPost.php │ ├── Exceptions │ │ ├── TestStatusException.php │ │ └── Handler.php │ ├── Data │ │ └── TestData.php │ └── Controllers │ │ └── RequestTestController.php ├── Exceptions │ └── JsonApiValidationExceptionTest.php ├── Data │ ├── RelationshipsTest.php │ ├── LinksTest.php │ ├── RelationshipTest.php │ ├── AbstractDataTest.php │ ├── ResourceTest.php │ └── RootTest.php ├── TestCase.php ├── DatabaseTestCase.php ├── Support │ ├── Resource │ │ ├── ResourcePathHelperTest.php │ │ └── JsonApiResourceTest.php │ ├── Error │ │ └── ErrorDataTest.php │ ├── Type │ │ └── TypeMakerTest.php │ ├── HelpersTest.php │ ├── Validation │ │ └── JsonApiValidatorTest.php │ └── Request │ │ └── RequestParserTest.php ├── Encoder │ ├── Transformers │ │ ├── SimpleTransformerTest.php │ │ ├── ErrorDataTransformerTest.php │ │ ├── ValidationExceptionTransformerTest.php │ │ ├── ModelTransformerTest.php │ │ └── ExceptionTransformerTest.php │ └── Factories │ │ └── TransformerFactoryTest.php ├── Repositories │ ├── ResourceCollectorTest.php │ └── ResourceRepositoryTest.php ├── Http │ └── Middleware │ │ └── RequireJsonApiHeaderTest.php └── Integration │ └── Request │ └── JsonApiRequestTest.php ├── .travis.yml ├── .editorconfig ├── CONTRIBUTING.md ├── CHANGELOG.md ├── LICENSE.md ├── phpunit.xml.dist ├── composer.json └── ENCODING.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | coverage_clover: build/logs/clover.xml 2 | -------------------------------------------------------------------------------- /src/Data/Meta.php: -------------------------------------------------------------------------------- 1 | Meta::class, 15 | ]; 16 | 17 | } 18 | -------------------------------------------------------------------------------- /tests/Helpers/Exceptions/TestStatusException.php: -------------------------------------------------------------------------------- 1 | 'float', 15 | ]; 16 | 17 | } 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = true 16 | -------------------------------------------------------------------------------- /tests/Helpers/Resources/AbstractTest/TestResourceWithNoReferences.php: -------------------------------------------------------------------------------- 1 | Meta::class, 14 | 'link' => Link::class . '!', 15 | 'resources' => Resource::class . '[]', 16 | ]; 17 | 18 | } 19 | -------------------------------------------------------------------------------- /tests/Helpers/Resources/TestCommentResource.php: -------------------------------------------------------------------------------- 1 | header('content-type', 'application/vnd.api+json'); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Helpers/Models/TestSeo.php: -------------------------------------------------------------------------------- 1 | morphTo('seoable'); 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /tests/Helpers/Models/TestAuthor.php: -------------------------------------------------------------------------------- 1 | hasMany(TestPost::class); 16 | } 17 | 18 | public function comments() 19 | { 20 | return $this->hasMany(TestComment::class); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/Facades/JsonApiEncoderFacade.php: -------------------------------------------------------------------------------- 1 | 'boolean', 18 | ]; 19 | 20 | // for testing with hide/unhide attributes 21 | protected $hidden = [ 22 | 'hidden', 23 | ]; 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/Data/Error.php: -------------------------------------------------------------------------------- 1 | ErrorLinks::class, 21 | 'meta' => Meta::class, 22 | 'source' => ErrorSource::class, 23 | ]; 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/Encoder/Transformers/SimpleTransformer.php: -------------------------------------------------------------------------------- 1 | toArray(); 18 | } 19 | 20 | if ( ! is_array($data)) { 21 | $data = (array) $data; 22 | } 23 | 24 | return compact('data'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Contracts/Support/Type/TypeMakerInterface.php: -------------------------------------------------------------------------------- 1 | belongsTo(TestAuthor::class, 'test_author_id'); 17 | } 18 | 19 | public function post() 20 | { 21 | return $this->belongsTo(TestPost::class, 'test_post_id'); 22 | } 23 | 24 | public function seos() 25 | { 26 | return $this->morphMany(TestSeo::class, 'seoable'); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /tests/Exceptions/JsonApiValidationExceptionTest.php: -------------------------------------------------------------------------------- 1 | setPrefix('testing-prefix/'); 21 | 22 | static::assertEquals('testing-prefix/', $exception->getPrefix()); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/Enums/Key.php: -------------------------------------------------------------------------------- 1 | attributes[$key])) { 16 | $null = null; 17 | return $null; 18 | } 19 | 20 | if ( ! ($this->attributes[$key] instanceof Relationship)) { 21 | $this->attributes[ $key ] = $this->makeNestedDataObject( 22 | Relationship::class, 23 | (array) $this->attributes[ $key ], 24 | $key 25 | ); 26 | } 27 | 28 | return $this->attributes[$key]; 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/Support/Resource/RelationData.php: -------------------------------------------------------------------------------- 1 | false, 22 | 'singular' => false, 23 | ]; 24 | } 25 | 26 | -------------------------------------------------------------------------------- /src/Support/Resource/RelationshipTransformData.php: -------------------------------------------------------------------------------- 1 | true, 22 | 'sideload' => true, 23 | ]; 24 | } 25 | 26 | -------------------------------------------------------------------------------- /tests/Data/RelationshipsTest.php: -------------------------------------------------------------------------------- 1 | [ 26 | 'data' => [ 27 | ['type' => 'comments', 'id' => '1'], 28 | ], 29 | ], 30 | 'empty' => null, 31 | ]); 32 | 33 | static::assertInstanceOf(Relationship::class, $data->comments); 34 | static::assertNull($data->empty); 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/Data/Links.php: -------------------------------------------------------------------------------- 1 | attributes[$key])) { 19 | $null = null; 20 | return $null; 21 | } 22 | 23 | if (is_string($this->attributes[$key])) { 24 | return $this->attributes[$key]; 25 | } 26 | 27 | if ( ! ($this->attributes[$key] instanceof Link)) { 28 | $this->attributes[ $key ] = $this->makeNestedDataObject( 29 | Link::class, 30 | (array) $this->attributes[ $key ], 31 | $key 32 | ); 33 | } 34 | 35 | return $this->attributes[$key]; 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /tests/Helpers/Resources/TestPostResource.php: -------------------------------------------------------------------------------- 1 | 'author', 26 | 'pivot-related' => 'pivotRelated', 27 | ]; 28 | 29 | public function getSimpleAppendedAttribute() 30 | { 31 | return 'testing'; 32 | } 33 | 34 | public function getDescriptionAdjustedAttribute() 35 | { 36 | return 'Prefix: ' . $this->model->description; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | register(JsonApiServiceProvider::class); 17 | 18 | $app->singleton( 19 | \Illuminate\Contracts\Debug\ExceptionHandler::class, 20 | \Czim\JsonApi\Test\Helpers\Exceptions\Handler::class 21 | ); 22 | 23 | // Setup default database to use sqlite :memory: 24 | $app['config']->set('database.default', 'testbench'); 25 | $app['config']->set('database.connections.testbench', [ 26 | 'driver' => 'sqlite', 27 | 'database' => ':memory:', 28 | 'prefix' => '', 29 | ]); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Helpers/Resources/TestPostResourceWithDefaults.php: -------------------------------------------------------------------------------- 1 | 'author', 24 | ]; 25 | 26 | protected $defaultIncludes = [ 27 | 'main-author', 28 | 'seo', 29 | ]; 30 | 31 | public function getSimpleAppendedAttribute() 32 | { 33 | return 'testing'; 34 | } 35 | 36 | public function getDescriptionAdjustedAttribute() 37 | { 38 | return 'Prefix: ' . $this->model->description; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests. 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)**. 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Create feature branches** - Don't ask us to pull from your master branch. 17 | 18 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 19 | 20 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### [1.5.3] - 2019-11-12 4 | 5 | Now treats empty `attributes` object in request as valid. 6 | 7 | ### [1.5.2] - 2019-03-06 8 | 9 | Now dependent on czim/laravel-dataobject 2.0+. 10 | Requires PHP 7.1.3+. 11 | 12 | ### [1.5.1] - 2019-03-02 13 | 14 | Removed deprecated use of Laravel array and string helper functions. 15 | 16 | ### [1.5.0] - 2019-03-02 17 | 18 | Laravel 5.7 support. 19 | 20 | ### [1.4.16] - 2019-01-08 21 | 22 | Added configurable validation rules for the query string for a request. 23 | This prevents incorrect filter, include, sort and pagination parameters. 24 | Adds the `jsonapi.request.validaton` configuration section. 25 | 26 | [1.5.3]: https://github.com/czim/laravel-jsonapi/compare/1.5.2...1.5.3 27 | [1.5.2]: https://github.com/czim/laravel-jsonapi/compare/1.5.1...1.5.2 28 | [1.5.1]: https://github.com/czim/laravel-jsonapi/compare/1.5.0...1.5.1 29 | [1.5.0]: https://github.com/czim/laravel-jsonapi/compare/1.4.16...1.5.0 30 | 31 | [1.4.16]: https://github.com/czim/laravel-jsonapi/compare/1.4.15...1.4.16 32 | -------------------------------------------------------------------------------- /tests/Data/LinksTest.php: -------------------------------------------------------------------------------- 1 | 'http://link', 26 | 'related' => [ 27 | 'href' => 'http://another', 28 | 'meta' => [], 29 | ], 30 | 'empty' => null, 31 | ]); 32 | 33 | static::assertEquals('http://link', $data->self); 34 | static::assertInstanceOf(Link::class, $data->related); 35 | static::assertEquals('http://another', $data->related->href); 36 | static::assertNull($data->empty); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/Contracts/Support/Error/ErrorDataInterface.php: -------------------------------------------------------------------------------- 1 | $request->data()->getRootType(), 19 | 'data' => $request->data()->toArray(), 20 | 'query-page-number' => $request->jsonApiQuery()->getPageNumber(), 21 | ]); 22 | } 23 | 24 | /** 25 | * @param JsonApiCreateRequest $request 26 | * @return mixed 27 | */ 28 | public function create(JsonApiCreateRequest $request) 29 | { 30 | return response([ 31 | 'data-root-type' => $request->data()->getRootType(), 32 | 'data' => $request->data()->toArray(), 33 | ]); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Coen Zimmerman 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 13 | > all 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 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/DatabaseTestCase.php: -------------------------------------------------------------------------------- 1 | set('database.default', 'testbench'); 17 | 18 | $this->setDatabaseConnectionConfig($app); 19 | } 20 | 21 | /** 22 | * @param Application $app 23 | */ 24 | protected function setDatabaseConnectionConfig($app): void 25 | { 26 | // Setup default database to use sqlite :memory: 27 | $app['config']->set('database.connections.testbench', [ 28 | 'driver' => 'sqlite', 29 | 'database' => ':memory:', 30 | 'prefix' => '', 31 | ]); 32 | } 33 | 34 | public function setUp(): void 35 | { 36 | parent::setUp(); 37 | 38 | $this->migrateDatabase(); 39 | $this->seedDatabase(); 40 | } 41 | 42 | 43 | protected function migrateDatabase(): void 44 | { 45 | } 46 | 47 | protected function seedDatabase(): void 48 | { 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | ./tests/ 15 | 16 | 17 | 18 | 19 | 20 | ./src/ 21 | 22 | ./src/Facades/ 23 | ./tests/ 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /tests/Helpers/Resources/AbstractTest/TestAbstractResource.php: -------------------------------------------------------------------------------- 1 | 'comments', 21 | 'not-a-relation' => 'testMethod', 22 | ]; 23 | 24 | protected $defaultIncludes = [ 25 | 'comments', 26 | ]; 27 | 28 | protected $includeReferences = [ 29 | 'comments', 30 | ]; 31 | 32 | protected $availableFilters = [ 33 | 'some-filter', 34 | 'test', 35 | ]; 36 | 37 | protected $defaultFilters = [ 38 | 'some-filter' => 13, 39 | ]; 40 | 41 | protected $availableSortAttributes = [ 42 | 'title', 43 | 'id', 44 | ]; 45 | 46 | protected $defaultSortAttributes = [ 47 | '-id', 48 | ]; 49 | 50 | public function getAccessorAttribute() 51 | { 52 | return 'custom'; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Data/RelationshipTest.php: -------------------------------------------------------------------------------- 1 | null, 23 | ]); 24 | 25 | static::assertNull($data->data); 26 | 27 | $data = new Relationship([ 28 | 'data' => ['type' => 'test', 'id' => '1'], 29 | ]); 30 | 31 | static::assertInstanceOf(Resource::class, $data->data); 32 | // Load it again to check when already eager loaded 33 | static::assertInstanceOf(Resource::class, $data->data); 34 | 35 | $data = new Relationship([ 36 | 'data' => [ 37 | ['type' => 'test', 'id' => '1'], 38 | null, 39 | ], 40 | ]); 41 | 42 | static::assertIsArray($data->data); 43 | static::assertInstanceOf(Resource::class, head($data->data)); 44 | static::assertNull(last($data->data)); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /tests/Helpers/Resources/AbstractTest/TestAbstractEloquentResourceWithDateTimeFormat.php: -------------------------------------------------------------------------------- 1 | 'comments', 23 | 'not-a-relation' => 'testMethod', 24 | ]; 25 | 26 | protected $defaultIncludes = [ 27 | 'comments', 28 | ]; 29 | 30 | protected $includeReferences = [ 31 | 'comments', 32 | ]; 33 | 34 | protected $availableFilters = [ 35 | 'some-filter', 36 | 'test', 37 | ]; 38 | 39 | protected $defaultFilters = [ 40 | 'some-filter' => 13, 41 | ]; 42 | 43 | protected $availableSortAttributes = [ 44 | 'title', 45 | 'id', 46 | ]; 47 | 48 | protected $defaultSortAttributes = [ 49 | '-id', 50 | ]; 51 | 52 | public function getAccessorAttribute() 53 | { 54 | return 'custom'; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/Support/Resource/ResourcePathHelperTest.php: -------------------------------------------------------------------------------- 1 | app['config']->set('jsonapi.repository.resource.namespace', 'Czim\\JsonApi\\Test\\Helpers\\Resources\\'); 20 | 21 | $resource = new TestAbstractResource; 22 | 23 | $helper = new ResourcePathHelper; 24 | 25 | static::assertEquals('abstract-test/test-resource', $helper->makePath($resource)); 26 | } 27 | 28 | /** 29 | * @test 30 | */ 31 | function it_uses_top_level_type_only_if_config_prefix_does_not_match() 32 | { 33 | $this->app['config']->set('jsonapi.repository.resource.namespace', 'Does\\NotMatch\\'); 34 | 35 | $resource = new TestAbstractResource; 36 | 37 | $helper = new ResourcePathHelper; 38 | 39 | static::assertEquals( 40 | 'test-resource', 41 | $helper->makePath($resource) 42 | ); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/Encoder/Transformers/ErrorDataTransformer.php: -------------------------------------------------------------------------------- 1 | checkErrorDataArray($errors); 21 | 22 | return [ 23 | Key::ERRORS => array_map( 24 | function (ErrorDataInterface $error) { 25 | return $error->toCleanArray(); 26 | }, 27 | $errors 28 | ), 29 | ]; 30 | } 31 | 32 | /** 33 | * Checks all error objects in a given array, throw exception if one does not match expected interface. 34 | * 35 | * @param array $errors 36 | */ 37 | protected function checkErrorDataArray(array $errors): void 38 | { 39 | foreach ($errors as $error) { 40 | 41 | if ( ! ($error instanceof ErrorDataInterface)) { 42 | throw new InvalidArgumentException("ErrorDataTransformer expects (array of) ErrorDataInterface instance(s)"); 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "czim/laravel-jsonapi", 3 | "description": "Laravel JSON-API Base.", 4 | "keywords": [ 5 | "laravel", 6 | "api", 7 | "json-api" 8 | ], 9 | "homepage": "https://github.com/czim", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Coen Zimmerman", 14 | "email": "coen@pxlwidgets.com", 15 | "homepage": "https://github.com/czim", 16 | "role": "Developer" 17 | } 18 | ], 19 | "require": { 20 | "php": ">=7.1.3", 21 | "czim/laravel-dataobject": "^2.0", 22 | "myclabs/php-enum": "^1.5", 23 | "doctrine/dbal": "^2.5", 24 | "justinrainbow/json-schema": "^5.2" 25 | }, 26 | "require-dev": { 27 | "phpunit/phpunit": "^7.0|^8.0", 28 | "mockery/mockery": "^1.0", 29 | "orchestra/testbench": "^3.8.0|^4.0", 30 | "orchestra/database": "^3.8.0|^4.0", 31 | "php-coveralls/php-coveralls": "^2.1" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Czim\\JsonApi\\": "src" 36 | }, 37 | "files": [ 38 | "src/Support/helpers.php" 39 | ] 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Czim\\JsonApi\\Test\\": "tests" 44 | } 45 | }, 46 | "scripts": { 47 | "test": "phpunit" 48 | }, 49 | "minimum-stability": "dev", 50 | "prefer-stable": true 51 | } 52 | -------------------------------------------------------------------------------- /src/Contracts/Encoder/TransformerInterface.php: -------------------------------------------------------------------------------- 1 | 'boolean', 27 | ]; 28 | 29 | public $test = false; 30 | 31 | 32 | public function author() 33 | { 34 | return $this->belongsTo(TestAuthor::class, 'test_author_id'); 35 | } 36 | 37 | public function comments() 38 | { 39 | return $this->hasMany(TestComment::class); 40 | } 41 | 42 | public function seo() 43 | { 44 | return $this->morphOne(TestSeo::class, 'seoable'); 45 | } 46 | 47 | public function related() 48 | { 49 | return $this->belongsToMany(TestPost::class, 'post_related', 'from_id', 'to_id'); 50 | } 51 | 52 | public function pivotRelated() 53 | { 54 | return $this->belongsToMany(TestPost::class, 'post_pivot_related', 'from_id', 'to_id') 55 | ->withPivot([ 56 | 'type', 57 | 'date', 58 | ]) 59 | ->withTimestamps(); 60 | } 61 | 62 | /** 63 | * @return string 64 | */ 65 | public function testMethod() 66 | { 67 | return 'testing method value'; 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /tests/Helpers/Resources/AbstractTest/TestAbstractEloquentResource.php: -------------------------------------------------------------------------------- 1 | 'comments', 24 | 'not-a-relation' => 'testMethod', 25 | ]; 26 | 27 | protected $defaultIncludes = [ 28 | 'comments', 29 | ]; 30 | 31 | protected $includeReferences = [ 32 | 'comments', 33 | ]; 34 | 35 | protected $availableFilters = [ 36 | 'some-filter', 37 | 'test', 38 | ]; 39 | 40 | protected $defaultFilters = [ 41 | 'some-filter' => 13, 42 | ]; 43 | 44 | protected $availableSortAttributes = [ 45 | 'title', 46 | 'id', 47 | ]; 48 | 49 | protected $defaultSortAttributes = [ 50 | '-id', 51 | ]; 52 | 53 | protected $dateAttributes = [ 54 | 'date-accessor', 55 | ]; 56 | 57 | protected $dateAttributeFormats = [ 58 | 'updated-at' => 'Y-m-d H:i', 59 | 'date-accessor' => 'Y-m-d', 60 | ]; 61 | 62 | public function getAccessorAttribute() 63 | { 64 | return 'custom'; 65 | } 66 | 67 | public function getDateAccessorAttribute() 68 | { 69 | return '2017-01-02 03:04:05'; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Contracts/Repositories/ResourceRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | setEncoder($this->getMockEncoder()); 25 | 26 | static::assertEquals(['data' => ['simple']], $transformer->transform(['simple'])); 27 | } 28 | 29 | /** 30 | * @test 31 | */ 32 | function it_transforms_non_arrays_by_casting_to_array() 33 | { 34 | $transformer = new SimpleTransformer; 35 | $transformer->setEncoder($this->getMockEncoder()); 36 | 37 | static::assertEquals(['data' => ['simple']], $transformer->transform('simple')); 38 | } 39 | 40 | /** 41 | * @test 42 | */ 43 | function it_transforms_arrayables_by_to_arraying() 44 | { 45 | $transformer = new SimpleTransformer; 46 | $transformer->setEncoder($this->getMockEncoder()); 47 | 48 | $data = new RelationData(['variable' => false, 'singular' => false]); 49 | $array = $data->toArray(); 50 | 51 | static::assertEquals(['data' => $array], $transformer->transform($data)); 52 | } 53 | 54 | 55 | /** 56 | * @return EncoderInterface|Mockery\MockInterface 57 | */ 58 | protected function getMockEncoder() 59 | { 60 | return Mockery::mock(EncoderInterface::class); 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/Exceptions/JsonApiValidationException.php: -------------------------------------------------------------------------------- 1 | attributes, set the prefix to 'data/attributes/'. 23 | * Each key will be prefixed to allow using it for JSON-API pointers. 24 | * 25 | * @var string|null 26 | */ 27 | protected $prefix; 28 | 29 | /** 30 | * @var int 31 | */ 32 | protected $statusCode = 422; 33 | 34 | /** 35 | * Sets the validation errors. 36 | * 37 | * @param array $errors 38 | * @return $this|JsonApiValidationException 39 | */ 40 | public function setErrors(array $errors): JsonApiValidationException 41 | { 42 | $this->errors = $errors; 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * @param string $prefix 49 | * @return $this|JsonApiValidationException 50 | */ 51 | public function setPrefix($prefix): JsonApiValidationException 52 | { 53 | $this->prefix = $prefix; 54 | 55 | return $this; 56 | } 57 | 58 | /** 59 | * @return array[] 60 | */ 61 | public function getErrors(): array 62 | { 63 | return $this->errors; 64 | } 65 | 66 | public function getPrefix(): ?string 67 | { 68 | return $this->prefix; 69 | } 70 | 71 | public function getStatusCode(): int 72 | { 73 | return $this->statusCode; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Support/Resource/ResourcePathHelper.php: -------------------------------------------------------------------------------- 1 | type(); 30 | } 31 | 32 | $classname = ltrim(substr($classname, strlen($prefix)), '\\'); 33 | 34 | // Dasherize path elements 35 | $segments = explode('\\', $classname); 36 | $segments = array_map( 37 | function ($segment) { 38 | return Str::snake($segment, '-'); 39 | }, 40 | $segments 41 | ); 42 | 43 | // The final segment should not be trusted, but replaced with the resource type, 44 | // to avoid creating paths that don't match up with defined resource types. 45 | array_pop($segments); 46 | 47 | $segments[] = $resource->type(); 48 | 49 | return implode('/', $segments); 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /tests/Encoder/Transformers/ErrorDataTransformerTest.php: -------------------------------------------------------------------------------- 1 | setEncoder($this->getMockEncoder()); 30 | 31 | $data = new ErrorData([ 32 | 'title' => 'Testing', 33 | ]); 34 | 35 | static::assertEquals( 36 | [ 37 | 'errors' => [ 38 | ['title' => 'Testing'], 39 | ], 40 | ], 41 | $transformer->transform($data) 42 | ); 43 | } 44 | 45 | /** 46 | * @test 47 | */ 48 | function it_throws_an_exception_if_data_is_not_an_error_object() 49 | { 50 | $this->expectException(InvalidArgumentException::class); 51 | 52 | $transformer = new ErrorDataTransformer; 53 | $transformer->setEncoder($this->getMockEncoder()); 54 | 55 | $transformer->transform($this); 56 | } 57 | 58 | /** 59 | * @return EncoderInterface|Mockery\MockInterface 60 | */ 61 | protected function getMockEncoder() 62 | { 63 | return Mockery::mock(EncoderInterface::class); 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /src/Contracts/Support/Request/RequestQueryParserInterface.php: -------------------------------------------------------------------------------- 1 | Links::class, 16 | 'meta' => Meta::class, 17 | ]; 18 | 19 | /** 20 | * {@inheritdoc} 21 | */ 22 | public function &getAttributeValue(string $key) 23 | { 24 | if ($key === 'data') { 25 | 26 | if ($this->attributes['data'] instanceof Resource) { 27 | return $this->attributes['data']; 28 | } 29 | 30 | if (is_array($this->attributes['data'])) { 31 | 32 | // The primary data may be either a single resoure (identifier), 33 | // or an array of them (or null) 34 | if (array_key_exists('type', $this->attributes['data'])) { 35 | 36 | $this->attributes['data'] = $this->makeNestedDataObject( 37 | Resource::class, 38 | $this->attributes['data'], 39 | 'data' 40 | ); 41 | 42 | return $this->attributes['data']; 43 | 44 | } else { 45 | 46 | foreach ($this->attributes['data'] as $index => &$item) { 47 | 48 | if (null === $item) { 49 | continue; 50 | } 51 | 52 | if ( ! is_a($item, Resource::class)) { 53 | $item = $this->makeNestedDataObject(Resource::class, $item, 'data.' . $index); 54 | } 55 | } 56 | 57 | unset($item); 58 | } 59 | } 60 | } 61 | 62 | return parent::getAttributeValue($key); 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /tests/Repositories/ResourceCollectorTest.php: -------------------------------------------------------------------------------- 1 | app['config']->set('jsonapi.repository.resource.collect', false); 25 | $this->app['config']->set('jsonapi.repository.resource.map', [ 26 | TestPost::class => TestPostResource::class, 27 | TestComment::class => TestCommentResource::class, 28 | ]); 29 | 30 | $collector = new ResourceCollector(); 31 | $collected = $collector->collect(); 32 | 33 | static::assertInstanceOf(Collection::class, $collected); 34 | static::assertCount(2, $collected); 35 | static::assertEquals(['test-posts', 'test-comments'], $collected->keys()->toArray()); 36 | static::assertInstanceof(TestPostResource::class, $collected->get('test-posts')); 37 | } 38 | 39 | /** 40 | * @test 41 | */ 42 | function it_throws_an_exception_if_a_mapped_resource_class_does_not_implement_the_correct_interface() 43 | { 44 | $this->expectException(InvalidArgumentException::class); 45 | 46 | $this->app['config']->set('jsonapi.repository.resource.collect', false); 47 | $this->app['config']->set('jsonapi.repository.resource.map', [ 48 | TestPost::class => static::class, 49 | ]); 50 | 51 | $collector = new ResourceCollector(); 52 | $collector->collect(); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/Support/Error/ErrorData.php: -------------------------------------------------------------------------------- 1 | id ?: ''; 25 | } 26 | 27 | /** 28 | * @return array 29 | */ 30 | public function links(): array 31 | { 32 | return $this->links ?: []; 33 | } 34 | 35 | /** 36 | * @return string 37 | */ 38 | public function status(): string 39 | { 40 | return (string) $this->status ?: ''; 41 | } 42 | 43 | /** 44 | * @return string 45 | */ 46 | public function code(): string 47 | { 48 | return (string) $this->code ?: ''; 49 | } 50 | 51 | /** 52 | * @return string 53 | */ 54 | public function title(): string 55 | { 56 | return $this->title ?: ''; 57 | } 58 | 59 | /** 60 | * @return string 61 | */ 62 | public function detail(): string 63 | { 64 | return $this->detail ?: ''; 65 | } 66 | 67 | /** 68 | * @return array 69 | */ 70 | public function source(): array 71 | { 72 | return $this->source ?: []; 73 | } 74 | 75 | /** 76 | * @return array 77 | */ 78 | public function meta(): array 79 | { 80 | return $this->meta ?: []; 81 | } 82 | 83 | /** 84 | * Returns array without empty values. 85 | * 86 | * @return array 87 | */ 88 | public function toCleanArray(): array 89 | { 90 | return array_filter($this->toArray()); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Data/Resource.php: -------------------------------------------------------------------------------- 1 | Attributes::class . '!', 19 | 'relationships' => Relationships::class . '!', 20 | 'links' => Links::class . '!', 21 | 'meta' => Meta::class . '!', 22 | ]; 23 | 24 | /** 25 | * Returns whether the attributes key is set. 26 | * 27 | * @return bool 28 | */ 29 | public function hasAttributes(): bool 30 | { 31 | return array_key_exists('attributes', $this->attributes); 32 | } 33 | 34 | /** 35 | * Returns whether the relationships key is set. 36 | * 37 | * @return bool 38 | */ 39 | public function hasRelationships(): bool 40 | { 41 | return array_key_exists('relationships', $this->attributes); 42 | } 43 | 44 | /** 45 | * Returns whether the links key is set. 46 | * 47 | * @return bool 48 | */ 49 | public function hasLinks(): bool 50 | { 51 | return array_key_exists('links', $this->attributes); 52 | } 53 | 54 | /** 55 | * Returns whether the meta key is set. 56 | * 57 | * @return bool 58 | */ 59 | public function hasMeta(): bool 60 | { 61 | return array_key_exists('meta', $this->attributes); 62 | } 63 | 64 | /** 65 | * Returns whether this resource is a type+id identifier only. 66 | * 67 | * @return bool 68 | */ 69 | public function isResourceIdentifier(): bool 70 | { 71 | return array_key_exists('id', $this->attributes) 72 | && array_key_exists('type', $this->attributes) 73 | && ! $this->hasAttributes() 74 | && ! $this->hasRelationships(); 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /tests/Data/AbstractDataTest.php: -------------------------------------------------------------------------------- 1 | meta); 30 | 31 | $data->meta = ['test' => 'value']; 32 | 33 | static::assertInstanceOf(Meta::class, $data->meta); 34 | } 35 | 36 | /** 37 | * @test 38 | */ 39 | function it_forces_decorating_an_attribute_as_a_data_object() 40 | { 41 | $data = new TestData; 42 | 43 | static::assertInstanceOf(Link::class, $data->link); 44 | 45 | $data->link = ['self' => 'value']; 46 | 47 | static::assertInstanceOf(Link::class, $data->link); 48 | } 49 | 50 | /** 51 | * @test 52 | */ 53 | function it_decorates_an_array_of_attributes_as_data_objects() 54 | { 55 | $data = new TestData; 56 | 57 | static::assertNull($data->resources); 58 | 59 | $data->resources = [ ['type' => 'value'] ]; 60 | 61 | static::assertInstanceOf(Resource::class, head($data->resources)); 62 | 63 | $data->resources = [ null ]; 64 | 65 | static::assertNull(head($data->resources)); 66 | } 67 | 68 | /** 69 | * @test 70 | */ 71 | function it_throws_an_exception_if_a_value_to_decorate_is_not_an_array() 72 | { 73 | $this->expectException(UnexpectedValueException::class); 74 | 75 | $data = new TestData; 76 | 77 | static::assertNull($data->meta); 78 | 79 | $data->meta = 'string value'; 80 | 81 | $data->meta; 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/Http/Middleware/RequireJsonApiHeader.php: -------------------------------------------------------------------------------- 1 | acceptHeaderValid($request)) { 24 | return abort(406); 25 | } 26 | 27 | if ( ! $this->contentTypeHeaderValid(($request))) { 28 | return abort(415); 29 | } 30 | 31 | return $next($request); 32 | } 33 | 34 | protected function acceptHeaderValid(Request $request): bool 35 | { 36 | $acceptHeader = AcceptHeader::fromString($request->header('accept')); 37 | 38 | if ($acceptHeader->has('application/vnd.api+json')) { 39 | return empty($acceptHeader->get('application/vnd.api+json')->getAttributes()); 40 | } 41 | 42 | return false; 43 | } 44 | 45 | protected function contentTypeHeaderValid(Request $request): bool 46 | { 47 | $contentTypeHeader = AcceptHeader::fromString($request->header('content-type')); 48 | 49 | // also allowed to be multipart formdata and exceptional standard json 50 | if ( 51 | $contentTypeHeader->has('multipart/form-data') 52 | || $contentTypeHeader->has('application/json') 53 | ) { 54 | return true; 55 | } 56 | 57 | if ($contentTypeHeader->has('application/vnd.api+json')) { 58 | $attributes = $contentTypeHeader->get('application/vnd.api+json')->getAttributes(); 59 | 60 | return ( 61 | empty($attributes) 62 | || ( 63 | count($attributes) === 1 64 | && strtolower(Arr::get($attributes, 'charset')) === 'utf-8' 65 | ) 66 | ); 67 | } 68 | 69 | return false; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Data/ResourceTest.php: -------------------------------------------------------------------------------- 1 | hasAttributes()); 26 | 27 | $data = new Resource(['attributes' => []]); 28 | 29 | static::assertTrue($data->hasAttributes()); 30 | } 31 | 32 | /** 33 | * @test 34 | */ 35 | function it_returns_whether_relationships_key_is_set() 36 | { 37 | $data = new Resource; 38 | 39 | static::assertFalse($data->hasRelationships()); 40 | 41 | $data = new Resource(['relationships' => []]); 42 | 43 | static::assertTrue($data->hasRelationships()); 44 | } 45 | 46 | /** 47 | * @test 48 | */ 49 | function it_returns_whether_links_key_is_set() 50 | { 51 | $data = new Resource; 52 | 53 | static::assertFalse($data->hasLinks()); 54 | 55 | $data = new Resource(['links' => []]); 56 | 57 | static::assertTrue($data->hasLinks()); 58 | } 59 | 60 | /** 61 | * @test 62 | */ 63 | function it_returns_whether_meta_key_is_set() 64 | { 65 | $data = new Resource; 66 | 67 | static::assertFalse($data->hasMeta()); 68 | 69 | $data = new Resource(['meta' => []]); 70 | 71 | static::assertTrue($data->hasMeta()); 72 | } 73 | 74 | /** 75 | * @test 76 | */ 77 | function it_returns_whether_it_is_a_resource_identifier() 78 | { 79 | $data = new Resource; 80 | 81 | static::assertFalse($data->isResourceIdentifier()); 82 | 83 | $data->type = 'test'; 84 | $data->id = '1'; 85 | 86 | static::assertTrue($data->isResourceIdentifier()); 87 | 88 | $data->attributes = [ 89 | 'title' => 'testing', 90 | ]; 91 | 92 | static::assertFalse($data->isResourceIdentifier()); 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /src/Encoder/Transformers/AbstractTransformer.php: -------------------------------------------------------------------------------- 1 | encoder = $encoder; 49 | 50 | return $this; 51 | } 52 | 53 | /** 54 | * Sets that the transformation is for a top-level resource. 55 | * 56 | * @param bool $top 57 | * @return $this 58 | */ 59 | public function setIsTop(bool $top = true): TransformerInterface 60 | { 61 | $this->isTop = (bool) $top; 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * Sets the dot-notation parent chain. 68 | * 69 | * @param string|null $parentChain 70 | * @return $this 71 | */ 72 | public function setParent(?string $parentChain): TransformerInterface 73 | { 74 | $this->parent = $parentChain; 75 | 76 | return $this; 77 | } 78 | 79 | /** 80 | * Sets whether the collection may contain more than one type of model. 81 | * 82 | * @param bool $variable 83 | * @return $this 84 | */ 85 | public function setIsVariable(bool $variable = true): TransformerInterface 86 | { 87 | $this->isVariable = (bool) $variable; 88 | 89 | return $this; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Encoder/Transformers/ModelCollectionTransformer.php: -------------------------------------------------------------------------------- 1 | isEmpty()) { 34 | return [ 35 | Key::DATA => [], 36 | ]; 37 | } 38 | 39 | if ($this->isVariable) { 40 | $this->resource = null; 41 | } else { 42 | $this->resource = $this->getResourceForCollection($models); 43 | } 44 | 45 | $data = []; 46 | 47 | foreach ($models as $model) { 48 | $data[] = parent::transform($model)[ Key::DATA ]; 49 | } 50 | 51 | return [ 52 | Key::DATA => $data, 53 | ]; 54 | } 55 | 56 | /** 57 | * Returns resource that all models in a collection are expected to share. 58 | * 59 | * @param Collection $models 60 | * @return null|ResourceInterface 61 | */ 62 | protected function getResourceForCollection(Collection $models): ?ResourceInterface 63 | { 64 | return $this->encoder->getResourceForModel($models->first()); 65 | } 66 | 67 | /** 68 | * Overidden to prevent redundant lookups. 69 | * 70 | * {@inheritdoc} 71 | */ 72 | protected function getResourceForModel(Model $model): ?ResourceInterface 73 | { 74 | if (null === $this->resource) { 75 | return parent::getResourceForModel($model); 76 | } 77 | 78 | return $this->resource; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Encoder/Transformers/ExceptionTransformer.php: -------------------------------------------------------------------------------- 1 | convertExceptionToErrorData($exception); 25 | 26 | return parent::transform($data); 27 | } 28 | 29 | 30 | protected function convertExceptionToErrorData(Exception $exception): ErrorDataInterface 31 | { 32 | return new ErrorData([ 33 | 'status' => (string) $this->getStatusCode($exception), 34 | 'code' => (string) $exception->getCode(), 35 | 'title' => $this->getTitle($exception), 36 | 'detail' => $exception->getMessage(), 37 | ]); 38 | } 39 | 40 | /** 41 | * @param Exception $exception 42 | * @return int|mixed 43 | */ 44 | protected function getStatusCode(Exception $exception) 45 | { 46 | // special case: fully formed response exception (laravel 5.2 validation) 47 | if (is_a($exception, \Illuminate\Http\Exceptions\HttpResponseException::class)) { 48 | /** @var \Illuminate\Http\Exceptions\HttpResponseException $exception */ 49 | return $exception->getResponse()->getStatusCode(); 50 | } 51 | 52 | $mapping = config('jsonapi.exceptions.status', []); 53 | 54 | if (array_key_exists(get_class($exception), $mapping)) { 55 | return $mapping[ get_class($exception) ]; 56 | } 57 | 58 | if (method_exists($exception, 'getStatusCode')) { 59 | return $exception->getStatusCode(); 60 | } 61 | 62 | return 500; 63 | } 64 | 65 | protected function getTitle(Exception $exception): string 66 | { 67 | return ucfirst(Str::snake(class_basename($exception), ' ')); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Support/schemas/unsupported/create.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://jsonapi.org/schemas/1_1/creation.json", 4 | "title": "JSON API Schema", 5 | "description": "This is the v 1.1 schema for creating (e.g. via POST) in the JSON API format. For more, see http://jsonapi.org ...", 6 | "allOf": [ 7 | { 8 | "$ref": "#/definitions/creation" 9 | } 10 | ], 11 | "definitions": { 12 | "creation": { 13 | "allOf": [ 14 | { 15 | "$ref": "http://jsonapi.org/schemas/1_1/request.json#/definitions/request" 16 | }, 17 | { 18 | "properties": { 19 | "data": { 20 | "$ref": "#/definitions/data" 21 | } 22 | } 23 | } 24 | ] 25 | }, 26 | "data": { 27 | "description": "The document's \"primary data\" is a representation of the resource or collection of resources targeted by a request.", 28 | "oneOf": [ 29 | { 30 | "$ref": "#/definitions/resource" 31 | }, 32 | { 33 | "description": "An array of resource objects, an array of resource identifier objects, or an empty array ([]), for requests that target resource collections.", 34 | "type": "array", 35 | "items": { 36 | "$ref": "#/definitions/resource" 37 | }, 38 | "uniqueItems": true 39 | } 40 | ] 41 | }, 42 | "resource": { 43 | "description": "\"Resource objects\" appear in a JSON API document to represent resources. Ids are required, except on the creation request.", 44 | "type": "object", 45 | "required": [ 46 | "type" 47 | ], 48 | "properties": { 49 | "type": { 50 | "type": "string" 51 | }, 52 | "id": { 53 | "type": "string" 54 | }, 55 | "attributes": { 56 | "$ref": "http://jsonapi.org/schemas/1_1/request.json#/definitions/attributes" 57 | }, 58 | "relationships": { 59 | "$ref": "http://jsonapi.org/schemas/1_1/request.json#/definitions/relationships" 60 | }, 61 | "links": { 62 | "$ref": "http://jsonapi.org/schemas/1_1/request.json#/definitions/links" 63 | }, 64 | "meta": { 65 | "$ref": "http://jsonapi.org/schemas/1_1/response.json#/definitions/meta" 66 | } 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/Helpers/Resources/AbstractTest/AbstractTestResource.php: -------------------------------------------------------------------------------- 1 | injectPaginationLinks($paginator); 26 | 27 | return parent::transform($paginator->getCollection()); 28 | } 29 | 30 | protected function injectPaginationLinks(AbstractPaginator $paginator) 31 | { 32 | $this->encoder->setLink( 33 | Key::LINK_SELF, 34 | $this->makePaginationLink($paginator, $paginator->currentPage()) 35 | ); 36 | 37 | $this->encoder->setLink( 38 | Key::PAGE_FIRST, 39 | $this->makePaginationLink($paginator, static::FIRST_PAGE) 40 | ); 41 | 42 | if ($paginator->currentPage() - 1 >= static::FIRST_PAGE) { 43 | $this->encoder->setLink( 44 | Key::PAGE_PREV, 45 | $this->makePaginationLink($paginator, max($paginator->currentPage() - 1, static::FIRST_PAGE)) 46 | ); 47 | } 48 | 49 | if ($paginator instanceof LengthAwarePaginator) { 50 | 51 | if ($paginator->hasMorePages()) { 52 | $this->encoder->setLink( 53 | Key::PAGE_NEXT, 54 | $this->makePaginationLink($paginator, min($paginator->currentPage() + 1, $paginator->lastPage())) 55 | ); 56 | } 57 | 58 | $this->encoder->setLink( 59 | Key::PAGE_LAST, 60 | $this->makePaginationLink($paginator, $paginator->lastPage()) 61 | ); 62 | } 63 | } 64 | 65 | protected function makePaginationLink(AbstractPaginator $paginator, int $page): string 66 | { 67 | if ($topUrl = $this->encoder->getTopResourceUrl()) { 68 | return $topUrl . '?' . config('jsonapi.request.keys.page') . '[number]=' . $page; 69 | } 70 | 71 | return $paginator->url($page); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Repositories/ResourceCollector.php: -------------------------------------------------------------------------------- 1 | collectByNamespace(); 26 | 27 | if ( ! $mapped->isEmpty()) { 28 | $resources = $resources->merge($mapped); 29 | } 30 | } 31 | 32 | 33 | $mapped = $this->collectByMapping(); 34 | 35 | if ( ! $mapped->isEmpty()) { 36 | $resources = $resources->merge($mapped); 37 | } 38 | 39 | return $resources; 40 | } 41 | 42 | /** 43 | * Collects resources mapped by configuration. 44 | * 45 | * @return Collection|ResourceInterface[] keyed by model class string 46 | */ 47 | protected function collectByMapping(): Collection 48 | { 49 | $mapping = config('jsonapi.repository.resource.map', []); 50 | $mapped = new Collection; 51 | 52 | foreach ($mapping as $modelClass => $resourceClass) { 53 | 54 | $resource = $this->instantiateResource($resourceClass); 55 | $resource->setModel(new $modelClass); 56 | 57 | $mapped->put($resource->type(), $resource); 58 | } 59 | 60 | return $mapped; 61 | } 62 | 63 | /** 64 | * Collects resources mapped by name/namespace. 65 | * 66 | * @return Collection|ResourceInterface[] keyed by model class string 67 | */ 68 | protected function collectByNamespace(): Collection 69 | { 70 | // todo 71 | // launch resource-reader 72 | // which should traverse the namespace to find all resources 73 | // and later might have caching 74 | 75 | return new Collection; 76 | } 77 | 78 | /** 79 | * Makes an instance of a resource by FQN. 80 | * 81 | * @param string $class 82 | * @return ResourceInterface|EloquentResourceInterface 83 | */ 84 | protected function instantiateResource(string $class): ResourceInterface 85 | { 86 | $resource = app($class); 87 | 88 | if ( ! ($resource instanceof ResourceInterface)) { 89 | throw new InvalidArgumentException("{$class} does not implement ResourceInterface"); 90 | } 91 | 92 | return $resource; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Providers/JsonApiServiceProvider.php: -------------------------------------------------------------------------------- 1 | bootConfig(); 28 | } 29 | 30 | public function register(): void 31 | { 32 | $this->registerConfig(); 33 | $this->registerInterfaces(); 34 | $this->loadAliases(); 35 | } 36 | 37 | 38 | protected function registerInterfaces(): void 39 | { 40 | $this->app->singleton(RequestQueryParserInterface::class, RequestQueryParser::class); 41 | $this->app->singleton(JsonApiValidatorInterface::class, JsonApiValidator::class); 42 | $this->app->singleton(TypeMakerInterface::class, TypeMaker::class); 43 | $this->app->singleton(ResourceRepositoryInterface::class, ResourceRepository::class); 44 | $this->app->singleton(ResourceCollectorInterface::class, ResourceCollector::class); 45 | $this->app->singleton(EncoderInterface::class, Encoder::class); 46 | $this->app->singleton(TransformerFactoryInterface::class, TransformerFactory::class); 47 | $this->app->singleton(ResourcePathHelperInterface::class, ResourcePathHelper::class); 48 | } 49 | 50 | protected function loadAliases(): void 51 | { 52 | $loader = \Illuminate\Foundation\AliasLoader::getInstance(); 53 | 54 | $loader->alias('JsonApiRequest', Facades\JsonApiRequestFacade::class); 55 | $loader->alias('JsonApiEncoder', Facades\JsonApiEncoderFacade::class); 56 | } 57 | 58 | protected function registerConfig(): void 59 | { 60 | $this->mergeConfigFrom(__DIR__ . '/../../config/jsonapi.php', 'jsonapi'); 61 | } 62 | 63 | protected function bootConfig(): void 64 | { 65 | $this->publishes( 66 | [ 67 | realpath(__DIR__ . '/../../config/jsonapi.php') => config_path('jsonapi.php'), 68 | ], 69 | 'jsonapi' 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/Support/Error/ErrorDataTest.php: -------------------------------------------------------------------------------- 1 | id()); 21 | 22 | $data->id = 'test'; 23 | 24 | static::assertSame('test', $data->id()); 25 | } 26 | 27 | /** 28 | * @test 29 | */ 30 | function it_returns_links() 31 | { 32 | $data = new ErrorData; 33 | 34 | static::assertEquals([], $data->links()); 35 | 36 | $data->links = ['test']; 37 | 38 | static::assertEquals(['test'], $data->links()); 39 | } 40 | 41 | /** 42 | * @test 43 | */ 44 | function it_returns_status() 45 | { 46 | $data = new ErrorData; 47 | 48 | static::assertSame('', $data->status()); 49 | 50 | $data->status = 'test'; 51 | 52 | static::assertSame('test', $data->status()); 53 | } 54 | 55 | /** 56 | * @test 57 | */ 58 | function it_returns_code() 59 | { 60 | $data = new ErrorData; 61 | 62 | static::assertSame('', $data->code()); 63 | 64 | $data->code = 'test'; 65 | 66 | static::assertSame('test', $data->code()); 67 | } 68 | 69 | /** 70 | * @test 71 | */ 72 | function it_returns_title() 73 | { 74 | $data = new ErrorData; 75 | 76 | static::assertSame('', $data->title()); 77 | 78 | $data->title = 'test'; 79 | 80 | static::assertSame('test', $data->title()); 81 | } 82 | 83 | /** 84 | * @test 85 | */ 86 | function it_returns_detail() 87 | { 88 | $data = new ErrorData; 89 | 90 | static::assertSame('', $data->detail()); 91 | 92 | $data->detail = 'test'; 93 | 94 | static::assertSame('test', $data->detail()); 95 | } 96 | 97 | /** 98 | * @test 99 | */ 100 | function it_returns_source() 101 | { 102 | $data = new ErrorData; 103 | 104 | static::assertEquals([], $data->source()); 105 | 106 | $data->source = ['test']; 107 | 108 | static::assertEquals(['test'], $data->source()); 109 | } 110 | 111 | /** 112 | * @test 113 | */ 114 | function it_returns_meta() 115 | { 116 | $data = new ErrorData; 117 | 118 | static::assertEquals([], $data->meta()); 119 | 120 | $data->meta = ['test']; 121 | 122 | static::assertEquals(['test'], $data->meta()); 123 | } 124 | 125 | /** 126 | * @test 127 | */ 128 | function it_returns_a_clean_to_array() 129 | { 130 | $data = new ErrorData; 131 | 132 | $data->links = ['testing']; 133 | $data->meta = []; 134 | 135 | static::assertCount(1, $data->toCleanArray()); 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /src/Encoder/Transformers/ValidationExceptionTransformer.php: -------------------------------------------------------------------------------- 1 | convertExceptionToErrorData($exception); 24 | 25 | return parent::transform($data); 26 | } 27 | 28 | /** 29 | * Converts exception instance to ErrorDataInterface instance array. 30 | * 31 | * @param JsonApiValidationException $exception 32 | * @return ErrorDataInterface[] 33 | */ 34 | protected function convertExceptionToErrorData(JsonApiValidationException $exception): array 35 | { 36 | $errorsData = []; 37 | 38 | $prefix = $exception->getPrefix(); 39 | 40 | foreach ($exception->getErrors() as $key => $errors) { 41 | 42 | if (config('jsonapi.transform.group-validation-errors-by-key')) { 43 | 44 | $errorsData[] = new ErrorData([ 45 | 'status' => (string) $this->getStatusCode($exception), 46 | 'code' => (string) $exception->getCode(), 47 | 'title' => $exception->getMessage(), 48 | 'detail' => implode("\n", $errors), 49 | 'source' => [ 50 | 'pointer' => $this->formatAttributePointer($key, $prefix), 51 | ], 52 | ]); 53 | continue; 54 | } 55 | 56 | foreach ($errors as $error) { 57 | 58 | $errorsData[] = new ErrorData([ 59 | 'status' => (string) $this->getStatusCode($exception), 60 | 'code' => (string) $exception->getCode(), 61 | 'title' => $exception->getMessage(), 62 | 'detail' => $error, 63 | 'source' => [ 64 | 'pointer' => $this->formatAttributePointer($key, $prefix), 65 | ], 66 | ]); 67 | } 68 | } 69 | 70 | return $errorsData; 71 | } 72 | 73 | /** 74 | * @param JsonApiValidationException $exception 75 | * @return int|mixed 76 | */ 77 | protected function getStatusCode(JsonApiValidationException $exception) 78 | { 79 | return $exception->getStatusCode() ?: 500; 80 | } 81 | 82 | /** 83 | * Returns pointer notation with optional prefix for source object. 84 | * 85 | * @param string $key 86 | * @param string|null $prefix 87 | * @return string 88 | */ 89 | protected function formatAttributePointer(string $key, ?string $prefix = null): string 90 | { 91 | return str_replace('.', '/', $prefix . $key); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/Encoder/Factories/TransformerFactoryTest.php: -------------------------------------------------------------------------------- 1 | makeFor(new TestSimpleModel)); 36 | } 37 | 38 | /** 39 | * @test 40 | */ 41 | function it_makes_a_model_collection_transformer_for_a_model_collection() 42 | { 43 | $factory = new TransformerFactory; 44 | 45 | static::assertInstanceOf(ModelCollectionTransformer::class, $factory->makeFor(new EloquentCollection)); 46 | static::assertInstanceOf(ModelCollectionTransformer::class, $factory->makeFor(new Collection([ 47 | new TestSimpleModel 48 | ]))); 49 | } 50 | 51 | /** 52 | * @test 53 | */ 54 | function it_makes_an_exception_transformer_for_an_exception() 55 | { 56 | $factory = new TransformerFactory; 57 | 58 | static::assertInstanceOf(ExceptionTransformer::class, $factory->makeFor(new Exception('test'))); 59 | } 60 | 61 | /** 62 | * @test 63 | */ 64 | function it_makes_a_paginator_transformer_for_a_paginated_collection_of_models() 65 | { 66 | $factory = new TransformerFactory; 67 | 68 | $collection = new LengthAwarePaginator([new TestSimpleModel], 1, 1); 69 | 70 | static::assertInstanceOf(PaginatedModelsTransformer::class, $factory->makeFor($collection)); 71 | 72 | $collection = new LengthAwarePaginator(new EloquentCollection, 1, 1); 73 | 74 | static::assertInstanceOf(PaginatedModelsTransformer::class, $factory->makeFor($collection)); 75 | } 76 | 77 | /** 78 | * @test 79 | */ 80 | function it_defaults_to_a_simple_transformer() 81 | { 82 | $factory = new TransformerFactory; 83 | 84 | static::assertInstanceOf(SimpleTransformer::class, $factory->makeFor($this)); 85 | } 86 | 87 | /** 88 | * @test 89 | */ 90 | function it_makes_a_custom_transformer_for_a_mapped_object() 91 | { 92 | $this->app['config']->set('jsonapi.transform.map', [ static::class => ExceptionTransformer::class ]); 93 | 94 | $factory = new TransformerFactory; 95 | 96 | static::assertInstanceOf(ExceptionTransformer::class, $factory->makeFor($this)); 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /tests/Support/Type/TypeMakerTest.php: -------------------------------------------------------------------------------- 1 | makeFor($model)); 25 | } 26 | 27 | /** 28 | * @test 29 | */ 30 | function it_makes_a_type_for_a_model_fqn() 31 | { 32 | $maker = new TypeMaker; 33 | 34 | static::assertEquals('test-simple-models', $maker->makeFor(TestSimpleModel::class)); 35 | } 36 | 37 | 38 | /** 39 | * @test 40 | */ 41 | function it_makes_a_type_from_an_object() 42 | { 43 | $maker = new TypeMaker; 44 | 45 | static::assertEquals('root-types', $maker->makeFor(new RootType('meta'))); 46 | } 47 | 48 | /** 49 | * @test 50 | */ 51 | function it_makes_a_type_from_a_string() 52 | { 53 | $maker = new TypeMaker; 54 | 55 | static::assertEquals('some-string-here', $maker->makeFor('SomeStringHere')); 56 | } 57 | 58 | /** 59 | * @test 60 | */ 61 | function it_throws_an_exception_if_it_cannot_make_a_type() 62 | { 63 | $this->expectException(InvalidArgumentException::class); 64 | 65 | $maker = new TypeMaker; 66 | 67 | $maker->makeFor(['some', 'array']); 68 | } 69 | 70 | 71 | // ------------------------------------------------------------------------------ 72 | // Model 73 | // ------------------------------------------------------------------------------ 74 | 75 | /** 76 | * @test 77 | */ 78 | function it_dasherizes_and_pluralizes_a_model_class_name() 79 | { 80 | $maker = new TypeMaker; 81 | 82 | static::assertEquals('test-simple-models', $maker->makeForModelClass(TestSimpleModel::class)); 83 | } 84 | 85 | /** 86 | * @test 87 | */ 88 | function it_can_use_the_entire_classname_for_empty_parameter() 89 | { 90 | $maker = new TypeMaker; 91 | 92 | static::assertEquals('czim--json-api--test--helpers--models--test-simple-models', $maker->makeForModelClass(TestSimpleModel::class, '')); 93 | } 94 | 95 | /** 96 | * @test 97 | */ 98 | function it_can_trim_part_of_the_classname_given_as_parameter() 99 | { 100 | $maker = new TypeMaker; 101 | 102 | static::assertEquals('test--helpers--models--test-simple-models', $maker->makeForModelClass(TestSimpleModel::class, 'Czim\\JsonApi\\')); 103 | } 104 | 105 | /** 106 | * @test 107 | */ 108 | function it_uses_config_value_to_trim_classname_by_default() 109 | { 110 | $this->app['config']->set('jsonapi.transform.type.trim-namespace', 'Czim\\JsonApi\\Test'); 111 | 112 | $maker = new TypeMaker; 113 | 114 | static::assertEquals('helpers--models--test-simple-models', $maker->makeForModelClass(TestSimpleModel::class)); 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /src/Support/Validation/JsonApiValidator.php: -------------------------------------------------------------------------------- 1 | validate( 38 | $data, 39 | (object) [ 40 | '$ref' => 'file://' . $this->getSchemaOrgPath($type) 41 | ] 42 | ); 43 | 44 | $this->storeErrors($validator->getErrors()); 45 | 46 | return $validator->isValid(); 47 | } 48 | 49 | /** 50 | * Returns the errors detected in the last validate call. 51 | * 52 | * @return MessageBagContract 53 | */ 54 | public function getErrors(): MessageBagContract 55 | { 56 | if ( ! $this->lastErrors) { 57 | return new MessageBag; 58 | } 59 | 60 | return $this->lastErrors; 61 | } 62 | 63 | protected function getSchemaOrgPath(string $type = SchemaType::REQUEST): string 64 | { 65 | switch ($type) { 66 | 67 | case SchemaType::CREATE: 68 | $path = static::SCHEMA_CREATE_PATH; 69 | break; 70 | 71 | case SchemaType::REQUEST: 72 | default: 73 | $path = static::SCHEMA_REQUEST_PATH; 74 | } 75 | 76 | return realpath(__DIR__ . '/' . $path); 77 | } 78 | 79 | /** 80 | * Stores list of errors as a messagebag, if there are any. 81 | * 82 | * @param array $errors 83 | */ 84 | protected function storeErrors(array $errors): void 85 | { 86 | if ( ! count($errors)) { 87 | $this->lastErrors = false; 88 | return; 89 | } 90 | 91 | $normalizedErrors = (new Collection($errors)) 92 | ->groupBy(function ($error) { 93 | $property = Arr::get($error, 'property'); 94 | if ('' === $property || null === $property) { 95 | return '*'; 96 | } 97 | return $property; 98 | }) 99 | ->transform(function (Collection $errors) { 100 | return $errors->pluck('message'); 101 | }) 102 | ->toArray(); 103 | 104 | ksort($normalizedErrors); 105 | 106 | $this->lastErrors = new MessageBag($normalizedErrors); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Support/helpers.php: -------------------------------------------------------------------------------- 1 | encode($data, $includes); 76 | } 77 | } 78 | 79 | if ( ! function_exists('jsonapi_error')) { 80 | /** 81 | * Makes a JSON-API response instance for an error or exception. 82 | * 83 | * @param mixed $data 84 | * @return \Czim\JsonApi\Http\Responses\JsonApiResponse 85 | */ 86 | function jsonapi_error($data): \Czim\JsonApi\Http\Responses\JsonApiResponse 87 | { 88 | $encoded = jsonapi_encode($data); 89 | 90 | $status = (int) Arr::get($encoded, 'errors.0.status', 500); 91 | 92 | return jsonapi_response($encoded, $status); 93 | } 94 | } 95 | 96 | if ( ! function_exists('is_jsonapi_request')) { 97 | /** 98 | * Returns whether the current request is JSON-API. 99 | * 100 | * @return bool 101 | */ 102 | function is_jsonapi_request(): bool 103 | { 104 | $acceptHeader = AcceptHeader::fromString(request()->header('accept')); 105 | 106 | return $acceptHeader->has('application/vnd.api+json'); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Encoder/Factories/TransformerFactory.php: -------------------------------------------------------------------------------- 1 | determineTransformerClass($data); 24 | 25 | return app($class); 26 | } 27 | 28 | 29 | /** 30 | * Returns classname of transformer to make for given data. 31 | * 32 | * @param mixed $data 33 | * @return string 34 | */ 35 | protected function determineTransformerClass($data): string 36 | { 37 | // Specific class fqn map to transformers with is_a() checking 38 | 39 | if (is_object($data)) { 40 | if ($class = $this->determineMappedTransformer($data)) { 41 | return $class; 42 | } 43 | } 44 | 45 | // Fallback: pick best available by type 46 | 47 | if ($data instanceof Model) { 48 | return Transformers\ModelTransformer::class; 49 | } 50 | 51 | if ($data instanceof ModelCollection) { 52 | return Transformers\ModelCollectionTransformer::class; 53 | } 54 | 55 | if ($data instanceof Exception) { 56 | return Transformers\ExceptionTransformer::class; 57 | } 58 | 59 | // If we get a collection with only models in it, treat it as a model collection 60 | if ($data instanceof Collection && $this->isCollectionWithOnlyModels($data)) { 61 | return Transformers\ModelCollectionTransformer::class; 62 | } 63 | 64 | if ($data instanceof AbstractPaginator && $this->isPaginatorWithOnlyModels($data)) { 65 | return Transformers\PaginatedModelsTransformer::class; 66 | } 67 | 68 | return Transformers\SimpleTransformer::class; 69 | } 70 | 71 | protected function isCollectionWithOnlyModels(Collection $collection): bool 72 | { 73 | $filtered = $collection->filter(function ($item) { return $item instanceof Model; }); 74 | 75 | return $collection->count() === $filtered->count(); 76 | } 77 | 78 | protected function isPaginatorWithOnlyModels(AbstractPaginator $paginator): bool 79 | { 80 | $collection = $paginator->getCollection(); 81 | 82 | if ($collection instanceof ModelCollection) { 83 | return true; 84 | } 85 | 86 | return $this->isCollectionWithOnlyModels($collection); 87 | } 88 | 89 | /** 90 | * Returns mapped transformer class, if a match could be found. 91 | * 92 | * @param object $object 93 | * @return null|string 94 | */ 95 | protected function determineMappedTransformer($object): ?string 96 | { 97 | $map = config('jsonapi.transform.map', []); 98 | 99 | if (empty($map)) { 100 | // @codeCoverageIgnoreStart 101 | return null; 102 | // @codeCoverageIgnoreEnd 103 | } 104 | 105 | foreach ($map as $class => $transformer) { 106 | 107 | if (is_a($object, $class)) { 108 | return $transformer; 109 | } 110 | } 111 | 112 | return null; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tests/Encoder/Transformers/ValidationExceptionTransformerTest.php: -------------------------------------------------------------------------------- 1 | setEncoder($this->getMockEncoder()); 29 | 30 | $exception = (new JsonApiValidationException('Validation problem')) 31 | ->setErrors(['test' => ['problem 1', 'problem 2'] ]); 32 | 33 | static::assertEquals( 34 | [ 35 | 'errors' => [ 36 | [ 37 | 'title' => 'Validation problem', 38 | 'status' => 422, 39 | 'detail' => 'problem 1', 40 | 'source' => ['pointer' => 'test'], 41 | ], 42 | [ 43 | 'title' => 'Validation problem', 44 | 'status' => 422, 45 | 'detail' => 'problem 2', 46 | 'source' => ['pointer' => 'test'], 47 | ], 48 | ], 49 | ], 50 | $transformer->transform($exception) 51 | ); 52 | } 53 | 54 | /** 55 | * @test 56 | */ 57 | function it_transforms_a_validation_exception_as_a_merged_error_object_per_key_if_configured_to() 58 | { 59 | $this->app['config']->set('jsonapi.transform.group-validation-errors-by-key', true); 60 | 61 | $transformer = new ValidationExceptionTransformer; 62 | $transformer->setEncoder($this->getMockEncoder()); 63 | 64 | $exception = (new JsonApiValidationException('Validation problem')) 65 | ->setErrors(['test' => ['problem 1', 'problem 2'], 'separate' => ['problem 3'] ]); 66 | 67 | static::assertEquals( 68 | [ 69 | 'errors' => [ 70 | [ 71 | 'title' => 'Validation problem', 72 | 'status' => 422, 73 | 'detail' => "problem 1\nproblem 2", 74 | 'source' => ['pointer' => 'test'], 75 | ], 76 | [ 77 | 'title' => 'Validation problem', 78 | 'status' => 422, 79 | 'detail' => 'problem 3', 80 | 'source' => ['pointer' => 'separate'], 81 | ], 82 | 83 | ], 84 | ], 85 | $transformer->transform($exception) 86 | ); 87 | } 88 | 89 | /** 90 | * @test 91 | */ 92 | function it_throws_an_exception_if_data_is_not_a_jsonapi_validation_exception() 93 | { 94 | $this->expectException(InvalidArgumentException::class); 95 | 96 | $transformer = new ValidationExceptionTransformer; 97 | $transformer->setEncoder($this->getMockEncoder()); 98 | 99 | $transformer->transform($this); 100 | } 101 | 102 | /** 103 | * @return EncoderInterface|Mockery\MockInterface 104 | */ 105 | protected function getMockEncoder() 106 | { 107 | return Mockery::mock(EncoderInterface::class); 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /tests/Support/HelpersTest.php: -------------------------------------------------------------------------------- 1 | app->instance(JsonApiRequest::class, $request); 26 | 27 | static::assertSame($request, jsonapi_request()); 28 | } 29 | 30 | /** 31 | * @test 32 | */ 33 | function helper_method_returns_a_jsonapi_create_request() 34 | { 35 | $request = new JsonApiCreateRequest(); 36 | $this->app->instance(JsonApiCreateRequest::class, $request); 37 | 38 | static::assertSame($request, jsonapi_request_create()); 39 | } 40 | 41 | /** 42 | * @test 43 | */ 44 | function helper_method_returns_a_jsonapi_query_parser() 45 | { 46 | $parser = new RequestQueryParser(new JsonApiRequest()); 47 | $this->app->instance(RequestQueryParser::class, $parser); 48 | 49 | static::assertSame($parser, jsonapi_query()); 50 | } 51 | 52 | /** 53 | * @test 54 | */ 55 | function helper_method_returns_a_jsonapi_response() 56 | { 57 | $response = jsonapi_response(['test'], 422); 58 | 59 | static::assertInstanceOf(JsonApiResponse::class, $response); 60 | static::assertEquals(422, $response->getStatusCode()); 61 | static::assertEquals('application/vnd.api+json', $response->headers->get('content-type')); 62 | } 63 | 64 | /** 65 | * @test 66 | */ 67 | function helper_method_encodes_data() 68 | { 69 | /** @var EncoderInterface|Mockery\Mock $encoderMock */ 70 | $encoderMock = Mockery::mock(EncoderInterface::class); 71 | $encoderMock->shouldReceive('encode')->with('data', ['include'])->once()->andReturn(['encoder output']); 72 | 73 | $this->app->instance(EncoderInterface::class, $encoderMock); 74 | 75 | static::assertEquals(['encoder output'], jsonapi_encode('data', ['include'])); 76 | } 77 | 78 | /** 79 | * @test 80 | */ 81 | function helper_method_encodes_error_response() 82 | { 83 | /** @var EncoderInterface|Mockery\Mock $encoderMock */ 84 | $encoderMock = Mockery::mock(EncoderInterface::class); 85 | $encoderMock->shouldReceive('encode')->with('problem', Mockery::any())->once()->andReturn(['encoder output']); 86 | 87 | $this->app->instance(EncoderInterface::class, $encoderMock); 88 | 89 | $response = jsonapi_error('problem'); 90 | 91 | static::assertInstanceOf(JsonApiResponse::class, $response); 92 | static::assertEquals(['encoder output'], $response->getData()); 93 | } 94 | 95 | /** 96 | * @test 97 | */ 98 | function helper_method_returns_whether_current_request_is_jsonapi() 99 | { 100 | /** @var Request|Mockery\Mock $requestMock */ 101 | $requestMock = Mockery::mock(Request::class . '[header]'); 102 | $requestMock->shouldReceive('header')->with('accept')->twice()->andReturn('application/vnd.api+json', 'text/plain'); 103 | 104 | $this->app->instance('request', $requestMock); 105 | 106 | static::assertTrue(is_jsonapi_request(), 'First check should be true'); 107 | static::assertFalse(is_jsonapi_request(), 'Second check should be false'); 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /src/Http/Requests/JsonApiRequest.php: -------------------------------------------------------------------------------- 1 | jsonApiQuery = new RequestQueryParser($this); 55 | } 56 | 57 | public function jsonApiQuery(): RequestQueryParser 58 | { 59 | return $this->jsonApiQuery; 60 | } 61 | 62 | /** 63 | * Returns data object tree for request body. 64 | * 65 | * @return Root 66 | */ 67 | public function data(): Root 68 | { 69 | if ( ! $this->rootData) { 70 | $this->rootData = new Root($this->all()); 71 | } 72 | 73 | return $this->rootData; 74 | } 75 | 76 | /** 77 | * Default authorization: allow. 78 | * 79 | * @return bool 80 | */ 81 | public function authorize(): bool 82 | { 83 | return true; 84 | } 85 | 86 | /** 87 | * Default rules: none. 88 | * 89 | * @return array 90 | */ 91 | public function rules(): array 92 | { 93 | return []; 94 | } 95 | 96 | /** 97 | * {@inheritdoc} 98 | */ 99 | public function validateResolved(): void 100 | { 101 | $this->validateAgainstSchema(); 102 | 103 | parent::validateResolved(); 104 | } 105 | 106 | /** 107 | * Validates the request's contents against the relevant JSON Schema. 108 | */ 109 | protected function validateAgainstSchema(): void 110 | { 111 | if ( ! $this->schemaValidation 112 | || ! $this->schemaValidationType 113 | || ! in_array($this->getMethod(), ['PATCH', 'POST', 'PUT']) 114 | ) { 115 | return; 116 | } 117 | 118 | $validator = $this->getSchemaValidator(); 119 | 120 | if ( ! $validator->validateSchema(json_decode($this->getContent()), $this->schemaValidationType)) { 121 | 122 | throw (new JsonApiValidationException('JSON-API Schema validation error')) 123 | ->setErrors($validator->getErrors()->toArray()); 124 | } 125 | } 126 | 127 | protected function getSchemaValidator(): JsonApiValidatorInterface 128 | { 129 | return app(JsonApiValidatorInterface::class); 130 | } 131 | 132 | /** 133 | * {@inheritdoc} 134 | * 135 | * @throws JsonApiValidationException 136 | */ 137 | protected function failedValidation(Validator $validator): void 138 | { 139 | throw (new JsonApiValidationException('The given data failed to pass validation.')) 140 | ->setErrors( 141 | $validator->getMessageBag()->toArray() 142 | ); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Support/Type/TypeMaker.php: -------------------------------------------------------------------------------- 1 | makeForModelClass(get_class($source)); 24 | } 25 | 26 | if (is_a($source, Model::class, true)) { 27 | return $this->makeForModelClass($source); 28 | } 29 | 30 | if (is_object($source)) { 31 | return $this->pluralizeIfConfiguredTo( 32 | Str::snake(class_basename($source), static::WORD_SEPARATOR) 33 | ); 34 | } 35 | 36 | if (is_string($source)) { 37 | return Str::snake($source, static::WORD_SEPARATOR); 38 | } 39 | 40 | throw new InvalidArgumentException('Cannot make type for given source'); 41 | } 42 | 43 | /** 44 | * Makes a JSON-API type for a given model FQN. 45 | * 46 | * @param string $class 47 | * @param null|string $offsetNamespace 48 | * @return string 49 | */ 50 | public function makeForModelClass(string $class, ?string $offsetNamespace = null): string 51 | { 52 | if (null === $offsetNamespace) { 53 | $offsetNamespace = config('jsonapi.transform.type.trim-namespace'); 54 | } 55 | 56 | $baseDasherized = $this->pluralizeIfConfiguredTo( 57 | Str::snake(class_basename($class), static::WORD_SEPARATOR) 58 | ); 59 | 60 | if (null !== $offsetNamespace) { 61 | 62 | $namespaceDasherized = $this->dasherizeNamespace( 63 | $this->trimNamespace($class, $offsetNamespace, class_basename($class)) 64 | ); 65 | 66 | $baseDasherized = ($namespaceDasherized ? $namespaceDasherized . static::NAMESPACE_SEPARATOR : null) 67 | . $baseDasherized; 68 | } 69 | 70 | return $baseDasherized; 71 | } 72 | 73 | /** 74 | * Strips the first part of a namespace if it matches offset. 75 | * 76 | * @param string $namespace 77 | * @param string $offset bit to cut off the start 78 | * @param string $trail bit to cut off the end 79 | * @return string 80 | */ 81 | protected function trimNamespace(string $namespace, string $offset, string $trail): string 82 | { 83 | if (Str::startsWith($namespace, $offset)) { 84 | $namespace = substr($namespace, strlen($offset)); 85 | } 86 | 87 | if (Str::endsWith($namespace, $trail)) { 88 | $namespace = substr($namespace, 0, -1 * strlen($trail)); 89 | } 90 | 91 | $namespace = trim($namespace, '\\'); 92 | 93 | return $namespace; 94 | } 95 | 96 | protected function dasherizeNamespace(string $namespace): string 97 | { 98 | $parts = explode('\\', $namespace); 99 | $parts = array_map( 100 | function ($part) { 101 | return Str::snake($part, static::WORD_SEPARATOR); 102 | }, 103 | array_filter($parts) 104 | ); 105 | 106 | return implode(static::NAMESPACE_SEPARATOR, $parts); 107 | } 108 | 109 | protected function pluralizeIfConfiguredTo(string $type): string 110 | { 111 | if ($this->shouldBePlural()) { 112 | return Str::plural($type); 113 | } 114 | 115 | return $type; 116 | } 117 | 118 | protected function shouldBePlural(): bool 119 | { 120 | return (bool) config('jsonapi.type.plural', true); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tests/Support/Resource/JsonApiResourceTest.php: -------------------------------------------------------------------------------- 1 | availableAttributes() 31 | ); 32 | } 33 | 34 | /** 35 | * @test 36 | */ 37 | function it_returns_available_includes() 38 | { 39 | $resource = new TestAbstractResource; 40 | 41 | static::assertEquals( 42 | ['comments', 'alternative-key', 'not-a-relation', 'method-does-not-exist'], 43 | $resource->availableIncludes() 44 | ); 45 | } 46 | 47 | /** 48 | * @test 49 | */ 50 | function it_returns_default_includes() 51 | { 52 | $resource = new TestAbstractResource; 53 | 54 | static::assertEquals( 55 | ['comments'], 56 | $resource->defaultIncludes() 57 | ); 58 | } 59 | 60 | /** 61 | * @test 62 | */ 63 | function it_returns_whether_to_include_references_for_an_include_key() 64 | { 65 | // For an explicit whitelist 66 | $resource = new TestAbstractResource; 67 | static::assertFalse($resource->includeReferencesForRelation('unknown-key')); 68 | static::assertTrue($resource->includeReferencesForRelation('comments')); 69 | static::assertFalse($resource->includeReferencesForRelation('alternative-key')); 70 | 71 | // For an implicit whitelist 72 | $resource = new TestResourceWithAllReferences; 73 | static::assertTrue($resource->includeReferencesForRelation('comments')); 74 | static::assertTrue($resource->includeReferencesForRelation('post')); 75 | 76 | // For an explicit blacklist 77 | $resource = new TestResourceWithBlacklistedReferences; 78 | static::assertFalse($resource->includeReferencesForRelation('comments')); 79 | static::assertTrue($resource->includeReferencesForRelation('post')); 80 | 81 | // For an implicit blacklist 82 | $resource = new TestResourceWithNoReferences; 83 | static::assertFalse($resource->includeReferencesForRelation('comments')); 84 | static::assertFalse($resource->includeReferencesForRelation('post')); 85 | } 86 | 87 | /** 88 | * @test 89 | */ 90 | function it_returns_available_filters() 91 | { 92 | $resource = new TestAbstractResource; 93 | 94 | static::assertEquals( 95 | ['some-filter', 'test'], 96 | $resource->availableFilters() 97 | ); 98 | } 99 | 100 | /** 101 | * @test 102 | */ 103 | function it_returns_default_filters() 104 | { 105 | $resource = new TestAbstractResource; 106 | 107 | static::assertEquals( 108 | ['some-filter' => 13], 109 | $resource->defaultFilters() 110 | ); 111 | } 112 | 113 | /** 114 | * @test 115 | */ 116 | function it_returns_available_sort_attributes() 117 | { 118 | $resource = new TestAbstractResource; 119 | 120 | static::assertEquals( 121 | ['title', 'id'], 122 | $resource->availableSortAttributes() 123 | ); 124 | } 125 | 126 | /** 127 | * @test 128 | */ 129 | function it_returns_default_sort_attributes() 130 | { 131 | $resource = new TestAbstractResource; 132 | 133 | static::assertEquals( 134 | ['-id'], 135 | $resource->defaultSortAttributes() 136 | ); 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /tests/Encoder/Transformers/ModelTransformerTest.php: -------------------------------------------------------------------------------- 1 | id = 13; 32 | $model->unique_field = 'test123'; 33 | $model->second_field = 'test'; 34 | $model->name = 'Testing!'; 35 | $model->active = false; 36 | 37 | $resource = new TestSimpleModelResource; 38 | $resource->setModel($model); 39 | 40 | $encoderMock = $this->getMockEncoder(); 41 | $encoderMock->shouldReceive('getResourceForModel')->with($model)->andReturn($resource); 42 | 43 | $transformer = new ModelTransformer; 44 | $transformer->setEncoder($encoderMock); 45 | 46 | static::assertEquals( 47 | [ 48 | 'data' => [ 49 | 'id' => '13', 50 | 'type' => 'test-simple-models', 51 | 'attributes' => [ 52 | 'unique-field' => 'test123', 53 | 'second-field' => 'test', 54 | 'name' => 'Testing!', 55 | 'active' => false, 56 | ], 57 | ], 58 | ], 59 | $transformer->transform($model) 60 | ); 61 | } 62 | 63 | /** 64 | * @test 65 | */ 66 | function it_transforms_a_simple_model_using_a_resource_without_attributes() 67 | { 68 | $model = new TestSimpleModel; 69 | 70 | $model->id = 13; 71 | $model->unique_field = 'test123'; 72 | 73 | $resource = new TestSimpleModelWithoutAttributesResource; 74 | $resource->setModel($model); 75 | 76 | $encoderMock = $this->getMockEncoder(); 77 | $encoderMock->shouldReceive('getResourceForModel')->with($model)->andReturn($resource); 78 | 79 | $transformer = new ModelTransformer; 80 | $transformer->setEncoder($encoderMock); 81 | 82 | static::assertEquals( 83 | [ 84 | 'data' => [ 85 | 'id' => '13', 86 | 'type' => 'test-simple-models', 87 | ], 88 | ], 89 | $transformer->transform($model) 90 | ); 91 | } 92 | 93 | /** 94 | * @test 95 | */ 96 | function it_throws_an_exception_if_data_is_not_a_model_instance() 97 | { 98 | $this->expectException(InvalidArgumentException::class); 99 | 100 | $transformer = new ModelTransformer; 101 | $transformer->setEncoder($this->getMockEncoder()); 102 | 103 | $transformer->transform($this); 104 | } 105 | 106 | /** 107 | * @test 108 | */ 109 | function it_throws_an_exception_if_no_resource_is_registered_for_the_model() 110 | { 111 | $this->expectException(EncodingException::class); 112 | 113 | $model = new TestSimpleModel; 114 | 115 | $encoderMock = $this->getMockEncoder(); 116 | $encoderMock->shouldReceive('getResourceForModel')->with($model)->andReturn(null); 117 | 118 | $transformer = new ModelTransformer; 119 | $transformer->setEncoder($encoderMock); 120 | 121 | $transformer->transform($model); 122 | } 123 | 124 | /** 125 | * @return EncoderInterface|Mockery\MockInterface 126 | */ 127 | protected function getMockEncoder() 128 | { 129 | return Mockery::mock(EncoderInterface::class); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Contracts/Resource/ResourceInterface.php: -------------------------------------------------------------------------------- 1 | setEncoder($this->getMockEncoder()); 30 | 31 | $exception = new \Exception('Some problem occurred'); 32 | 33 | static::assertEquals( 34 | [ 35 | 'errors' => [ 36 | [ 37 | 'title' => 'Exception', 38 | 'status' => 500, 39 | 'detail' => 'Some problem occurred', 40 | ], 41 | ], 42 | ], 43 | $transformer->transform($exception) 44 | ); 45 | } 46 | 47 | /** 48 | * @test 49 | */ 50 | function it_transforms_a_http_response_exception_as_an_error_using_its_status_code() 51 | { 52 | $transformer = new ExceptionTransformer; 53 | $transformer->setEncoder($this->getMockEncoder()); 54 | 55 | $exception = new HttpResponseException(response()->json([], 418)); 56 | 57 | static::assertEquals( 58 | [ 59 | 'errors' => [ 60 | [ 61 | 'title' => 'Http response exception', 62 | 'status' => 418, 63 | ], 64 | ], 65 | ], 66 | $transformer->transform($exception) 67 | ); 68 | } 69 | 70 | /** 71 | * @test 72 | */ 73 | function it_transforms_an_exception_as_an_error_using_its_get_status_code_method() 74 | { 75 | $transformer = new ExceptionTransformer; 76 | $transformer->setEncoder($this->getMockEncoder()); 77 | 78 | $exception = new TestStatusException('Some problem occurred'); 79 | 80 | static::assertEquals( 81 | [ 82 | 'errors' => [ 83 | [ 84 | 'title' => 'Test status exception', 85 | 'status' => 418, 86 | 'detail' => 'Some problem occurred', 87 | ], 88 | ], 89 | ], 90 | $transformer->transform($exception) 91 | ); 92 | } 93 | 94 | /** 95 | * @test 96 | */ 97 | function it_transforms_an_exception_as_an_error_using_a_mapped_status_code_for_the_exception_class() 98 | { 99 | $transformer = new ExceptionTransformer; 100 | $transformer->setEncoder($this->getMockEncoder()); 101 | 102 | $this->app['config']->set('jsonapi.exceptions.status', [ 103 | \Exception::class => 400, 104 | ]); 105 | 106 | $exception = new \Exception('Some problem occurred'); 107 | 108 | static::assertEquals( 109 | [ 110 | 'errors' => [ 111 | [ 112 | 'title' => 'Exception', 113 | 'status' => 400, 114 | 'detail' => 'Some problem occurred', 115 | ], 116 | ], 117 | ], 118 | $transformer->transform($exception) 119 | ); 120 | } 121 | 122 | /** 123 | * @test 124 | */ 125 | function it_throws_an_exception_if_data_is_not_an_exception() 126 | { 127 | $this->expectException(InvalidArgumentException::class); 128 | 129 | $transformer = new ExceptionTransformer; 130 | $transformer->setEncoder($this->getMockEncoder()); 131 | 132 | $transformer->transform($this); 133 | } 134 | 135 | /** 136 | * @return EncoderInterface|Mockery\MockInterface 137 | */ 138 | protected function getMockEncoder() 139 | { 140 | return Mockery::mock(EncoderInterface::class); 141 | } 142 | 143 | } 144 | -------------------------------------------------------------------------------- /src/Contracts/Encoder/EncoderInterface.php: -------------------------------------------------------------------------------- 1 | [ 26 | 'type' => 'test', 27 | 'id' => '1', 28 | ], 29 | ]); 30 | 31 | static::assertEquals(RootType::RESOURCE, $data->getRootType()); 32 | 33 | $data = new Root([ 34 | 'errors' => [ 35 | [ 36 | 'detail' => 'Testing', 37 | ] 38 | ], 39 | ]); 40 | 41 | static::assertEquals(RootType::ERROR, $data->getRootType()); 42 | 43 | $data = new Root([ 44 | 'meta' => [], 45 | ]); 46 | 47 | static::assertEquals(RootType::META, $data->getRootType()); 48 | 49 | $data = new Root; 50 | 51 | static::assertEquals(RootType::UNKNOWN, $data->getRootType()); 52 | } 53 | 54 | /** 55 | * @test 56 | */ 57 | function it_returns_whether_data_key_is_set() 58 | { 59 | $data = new Root; 60 | 61 | static::assertFalse($data->hasData()); 62 | 63 | $data = new Root(['data' => []]); 64 | 65 | static::assertTrue($data->hasData()); 66 | } 67 | 68 | /** 69 | * @test 70 | */ 71 | function it_returns_whether_errors_key_is_set() 72 | { 73 | $data = new Root; 74 | 75 | static::assertFalse($data->hasErrors()); 76 | 77 | $data = new Root(['errors' => []]); 78 | 79 | static::assertTrue($data->hasErrors()); 80 | } 81 | 82 | /** 83 | * @test 84 | */ 85 | function it_returns_whether_included_key_is_set() 86 | { 87 | $data = new Root; 88 | 89 | static::assertFalse($data->hasIncluded()); 90 | 91 | $data = new Root(['included' => []]); 92 | 93 | static::assertTrue($data->hasIncluded()); 94 | } 95 | 96 | /** 97 | * @test 98 | */ 99 | function it_returns_whether_jsonapi_key_is_set() 100 | { 101 | $data = new Root; 102 | 103 | static::assertFalse($data->hasJsonApi()); 104 | 105 | $data = new Root(['jsonapi' => []]); 106 | 107 | static::assertTrue($data->hasJsonApi()); 108 | } 109 | 110 | /** 111 | * @test 112 | */ 113 | function it_returns_whether_links_key_is_set() 114 | { 115 | $data = new Root; 116 | 117 | static::assertFalse($data->hasLinks()); 118 | 119 | $data = new Root(['links' => []]); 120 | 121 | static::assertTrue($data->hasLinks()); 122 | } 123 | 124 | /** 125 | * @test 126 | */ 127 | function it_returns_whether_meta_key_is_set() 128 | { 129 | $data = new Root; 130 | 131 | static::assertFalse($data->hasMeta()); 132 | 133 | $data = new Root(['meta' => []]); 134 | 135 | static::assertTrue($data->hasMeta()); 136 | } 137 | 138 | /** 139 | * @test 140 | */ 141 | function it_returns_whether_data_is_explicitly_null() 142 | { 143 | $data = new Root; 144 | 145 | static::assertFalse($data->hasNullData()); 146 | 147 | $data = new Root(['data' => null]); 148 | 149 | static::assertTrue($data->hasNullData()); 150 | } 151 | 152 | /** 153 | * @test 154 | */ 155 | function it_returns_whether_it_has_single_resource_data() 156 | { 157 | $data = new Root; 158 | 159 | static::assertFalse($data->hasSingleResourceData()); 160 | 161 | $data = new Root(['data' => ['type' => 'test', 'id' => '1']]); 162 | 163 | static::assertTrue($data->hasSingleResourceData()); 164 | // Load it again to check when already eager loaded 165 | static::assertTrue($data->hasSingleResourceData()); 166 | 167 | $data['data'] = [ ['type' => 'test', 'id' => '1'] ]; 168 | 169 | static::assertFalse($data->hasSingleResourceData()); 170 | } 171 | 172 | /** 173 | * @test 174 | */ 175 | function it_returns_whether_it_has_multiple_resource_data() 176 | { 177 | $data = new Root; 178 | 179 | static::assertFalse($data->hasMultipleResourceData()); 180 | 181 | $data = new Root(['data' => ['type' => 'test', 'id' => '1']]); 182 | 183 | static::assertFalse($data->hasMultipleResourceData()); 184 | 185 | $data['data'] = [ ['type' => 'test', 'id' => '1'] ]; 186 | 187 | static::assertTrue($data->hasMultipleResourceData()); 188 | } 189 | 190 | } 191 | -------------------------------------------------------------------------------- /src/Data/AbstractDataObject.php: -------------------------------------------------------------------------------- 1 | Brand::class, 20 | * 21 | * If the attribute is an array of arrays that should be decorated as data objects, 22 | * i.e. when it is a list of related items, you can set it to make individual 23 | * data objects for each item: 24 | * 25 | * 'brand' => Brand::class . '[]', 26 | * 27 | * @var array 28 | */ 29 | protected $objects = []; 30 | 31 | 32 | /** 33 | * Converts attributes to specific dataobjects if configured to 34 | * 35 | * @param string $key 36 | * @return mixed|DataObjectInterface 37 | */ 38 | public function &getAttributeValue(string $key) 39 | { 40 | if ( ! count($this->objects) 41 | || ! array_key_exists($key, $this->objects) 42 | ) { 43 | return parent::getAttributeValue($key); 44 | } 45 | 46 | $dataObjectClass = $this->objects[$key]; 47 | $dataObjectArray = false; 48 | $dataObjectForce = false; 49 | 50 | // Following an object class with ! enforces the indicated object, even if the key is unset. 51 | // This only works for singular objects (without []) 52 | if (substr($dataObjectClass, -1) === '!') { 53 | $dataObjectClass = substr($dataObjectClass, 0, -1); 54 | $dataObjectForce = true; 55 | } 56 | 57 | if ( ! isset($this->attributes[$key])) { 58 | 59 | if ($dataObjectForce) { 60 | $this->attributes[$key] = new $dataObjectClass; 61 | return $this->attributes[$key]; 62 | } 63 | 64 | $null = null; 65 | return $null; 66 | } 67 | 68 | // Following an object class with [] interprets it as an array of instances 69 | if (substr($dataObjectClass, -2) === '[]') { 70 | $dataObjectClass = substr($dataObjectClass, 0, -2); 71 | $dataObjectArray = true; 72 | } 73 | 74 | if ($dataObjectArray) { 75 | 76 | if (is_array($this->attributes[$key])) { 77 | 78 | foreach ($this->attributes[$key] as $index => &$item) { 79 | 80 | if (null === $item) { 81 | continue; 82 | } 83 | 84 | if ( ! is_a($item, $dataObjectClass)) { 85 | 86 | $item = $this->makeNestedDataObject($dataObjectClass, $item, $key . '.' . $index); 87 | } 88 | } 89 | } 90 | 91 | unset($item); 92 | 93 | } else { 94 | 95 | if ( ! is_a($this->attributes[ $key ], $dataObjectClass)) { 96 | 97 | $this->attributes[ $key ] = $this->makeNestedDataObject($dataObjectClass, $this->attributes[ $key ], $key); 98 | } 99 | } 100 | 101 | return $this->attributes[$key]; 102 | } 103 | 104 | /** 105 | * @param string $class 106 | * @param mixed $data 107 | * @param string $key 108 | * @return DataObjectInterface 109 | */ 110 | protected function makeNestedDataObject(string $class, $data, string $key): DataObjectInterface 111 | { 112 | $data = ($data instanceof Arrayable) ? $data->toArray() : $data; 113 | 114 | if ( ! is_array($data)) { 115 | throw new UnexpectedValueException( 116 | "Cannot instantiate data object '{$class}' with non-array data for key '{$key}'" 117 | . (is_scalar($data) || is_object($data) && method_exists($data, '__toString') 118 | ? ' (data: ' . (string) $data . ')' 119 | : null) 120 | ); 121 | } 122 | 123 | /** @var DataObjectInterface $data */ 124 | return new $class($data); 125 | } 126 | 127 | /** 128 | * Get the value for a given offset. 129 | * 130 | * @param mixed $offset 131 | * @return mixed 132 | * @codeCoverageIgnore 133 | */ 134 | public function offsetGet($offset) 135 | { 136 | // let it behave like the magic getter, return null if it doesn't exist 137 | if ( ! $this->offsetExists($offset)) { 138 | return null; 139 | } 140 | 141 | return $this->getAttribute($offset); 142 | } 143 | 144 | } 145 | -------------------------------------------------------------------------------- /tests/Http/Middleware/RequireJsonApiHeaderTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive('header')->with('accept')->once()->andReturn('application/vnd.api+json'); 29 | $requestMock->shouldReceive('header')->with('content-type')->once()->andReturn('application/vnd.api+json'); 30 | 31 | $middleware = new RequireJsonApiHeader; 32 | 33 | $next = function ($request) { return $request; }; 34 | 35 | static::assertSame($requestMock, $middleware->handle($requestMock, $next)); 36 | } 37 | 38 | /** 39 | * @test 40 | */ 41 | function it_returns_406_status_code_if_accept_header_is_invalid() 42 | { 43 | /** @var Request|Mockery\Mock $requestMock */ 44 | $requestMock = Mockery::mock(Request::class); 45 | $requestMock->shouldReceive('header')->with('accept')->once()->andReturn('application/json'); 46 | 47 | $middleware = new RequireJsonApiHeader; 48 | 49 | $next = function ($request) { return $request; }; 50 | 51 | try { 52 | $middleware->handle($requestMock, $next); 53 | } catch (HttpException $e) { 54 | static::assertEquals(406, $e->getStatusCode()); 55 | } 56 | } 57 | 58 | /** 59 | * @test 60 | */ 61 | function it_returns_406_status_code_if_content_type_header_is_invalid() 62 | { 63 | /** @var Request|Mockery\Mock $requestMock */ 64 | $requestMock = Mockery::mock(Request::class); 65 | $requestMock->shouldReceive('header')->with('accept')->once()->andReturn('application/vnd.api+json'); 66 | $requestMock->shouldReceive('header')->with('content-type')->once()->andReturn('text/html'); 67 | 68 | $middleware = new RequireJsonApiHeader; 69 | 70 | $next = function ($request) { return $request; }; 71 | 72 | try { 73 | $middleware->handle($requestMock, $next); 74 | } catch (HttpException $e) { 75 | static::assertEquals(415, $e->getStatusCode()); 76 | } 77 | } 78 | 79 | /** 80 | * @test 81 | * @depends it_passes_through_if_accept_and_content_type_headers_are_valid 82 | */ 83 | function it_accepts_application_json_for_content_type() 84 | { 85 | /** @var Request|Mockery\Mock $requestMock */ 86 | $requestMock = Mockery::mock(Request::class); 87 | $requestMock->shouldReceive('header')->with('accept')->once()->andReturn('application/vnd.api+json'); 88 | $requestMock->shouldReceive('header')->with('content-type')->once()->andReturn('application/json'); 89 | 90 | $middleware = new RequireJsonApiHeader; 91 | 92 | $next = function ($request) { return $request; }; 93 | 94 | static::assertSame($requestMock, $middleware->handle($requestMock, $next)); 95 | } 96 | 97 | /** 98 | * @test 99 | * @depends it_passes_through_if_accept_and_content_type_headers_are_valid 100 | */ 101 | function it_accepts_multipart_formdata_for_content_type() 102 | { 103 | /** @var Request|Mockery\Mock $requestMock */ 104 | $requestMock = Mockery::mock(Request::class); 105 | $requestMock->shouldReceive('header')->with('accept')->once()->andReturn('application/vnd.api+json'); 106 | $requestMock->shouldReceive('header')->with('content-type')->once()->andReturn('multipart/form-data'); 107 | 108 | $middleware = new RequireJsonApiHeader; 109 | 110 | $next = function ($request) { return $request; }; 111 | 112 | static::assertSame($requestMock, $middleware->handle($requestMock, $next)); 113 | } 114 | 115 | /** 116 | * @test 117 | */ 118 | function it_accepts_a_content_type_attribute_only_if_it_is_charset_utf8() 119 | { 120 | /** @var Request|Mockery\Mock $requestMock */ 121 | $requestMock = Mockery::mock(Request::class); 122 | $requestMock->shouldReceive('header')->with('accept')->once()->andReturn('application/vnd.api+json'); 123 | $requestMock->shouldReceive('header')->with('content-type')->once() 124 | ->andReturn('application/vnd.api+json; charset=utf-8'); 125 | 126 | $middleware = new RequireJsonApiHeader; 127 | 128 | $next = function ($request) { return $request; }; 129 | 130 | static::assertSame($requestMock, $middleware->handle($requestMock, $next)); 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /tests/Integration/Request/JsonApiRequestTest.php: -------------------------------------------------------------------------------- 1 | any('request', RequestTestController::class . '@request'); 22 | $app['router']->post('create', RequestTestController::class . '@create'); 23 | } 24 | 25 | /** 26 | * @test 27 | */ 28 | function it_does_not_validate_get_request_parameters_against_json_schema() 29 | { 30 | $response = $this->json( 31 | 'GET', 32 | 'request?page[number]=44' 33 | ); 34 | 35 | $response->assertStatus(200); 36 | 37 | $data = json_decode($response->content()); 38 | 39 | static::assertEquals(44, $data->{'query-page-number'}); 40 | } 41 | 42 | /** 43 | * @test 44 | */ 45 | function it_parses_query_string_data() 46 | { 47 | $response = $this->json( 48 | 'POST', 49 | 'request?page[number]=44', 50 | $this->getValidRequestData() 51 | ); 52 | 53 | $response->assertStatus(200); 54 | 55 | $data = json_decode($response->content()); 56 | 57 | static::assertEquals(44, $data->{'query-page-number'}); 58 | } 59 | 60 | /** 61 | * @test 62 | */ 63 | function it_returns_a_422_response_for_invalid_request_data() 64 | { 65 | $response = $this->call('POST', 'request', ['test']); 66 | 67 | $response->assertStatus(422); 68 | } 69 | 70 | /** 71 | * @test 72 | */ 73 | function it_parses_valid_request_data() 74 | { 75 | $response = $this->json( 76 | 'POST', 77 | 'request', 78 | $this->getValidRequestData() 79 | ); 80 | 81 | $response->assertStatus(200); 82 | 83 | $data = json_decode($response->content()); 84 | 85 | static::assertIsObject($data, 'Invalid JSON returned'); 86 | static::assertEquals(RootType::RESOURCE, $data->{'data-root-type'}); 87 | } 88 | 89 | /** 90 | * @test 91 | */ 92 | function it_parses_valid_create_data() 93 | { 94 | $response = $this->json( 95 | 'POST', 96 | 'create', 97 | $this->getValidCreateData() 98 | ); 99 | 100 | $response->assertStatus(200); 101 | 102 | $data = json_decode($response->content()); 103 | 104 | static::assertIsObject($data, 'Invalid JSON returned'); 105 | static::assertEquals(RootType::RESOURCE, $data->{'data-root-type'}); 106 | } 107 | 108 | /** 109 | * @test 110 | */ 111 | function it_parses_valid_create_data_with_empty_attributes() 112 | { 113 | $response = $this->json( 114 | 'POST', 115 | 'create', 116 | $this->getValidCreateDataWithEmptyAttributes() 117 | ); 118 | 119 | $response->assertStatus(200); 120 | 121 | $data = json_decode($response->content()); 122 | 123 | static::assertIsObject($data, 'Invalid JSON returned'); 124 | static::assertEquals(RootType::RESOURCE, $data->{'data-root-type'}); 125 | } 126 | 127 | 128 | /** 129 | * @return array 130 | */ 131 | protected function getValidRequestData() 132 | { 133 | return [ 134 | 'data' => [ 135 | 'id' => '1', 136 | 'type' => 'test-posts', 137 | 'attributes' => [ 138 | 'title' => 'Some Basic Title', 139 | 'type' => 'notice', 140 | 'checked' => true, 141 | ], 142 | ], 143 | 'meta' => [ 144 | 'test' => 'value', 145 | ], 146 | 'links' => [ 147 | 'self' => 'http://localhost/test', 148 | ], 149 | ]; 150 | } 151 | 152 | /** 153 | * @return array 154 | */ 155 | protected function getValidCreateData() 156 | { 157 | return [ 158 | 'data' => [ 159 | 'type' => 'test-posts', 160 | 'attributes' => [ 161 | 'title' => 'Some Basic Title', 162 | 'type' => 'notice', 163 | 'checked' => true, 164 | ], 165 | ], 166 | ]; 167 | } 168 | 169 | /** 170 | * @return array 171 | */ 172 | protected function getValidCreateDataWithEmptyAttributes() 173 | { 174 | return [ 175 | 'data' => [ 176 | 'type' => 'test-posts', 177 | 'attributes' => (object) [ 178 | ], 179 | ], 180 | ]; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Data/Root.php: -------------------------------------------------------------------------------- 1 | Error::class . '[]', 21 | 'included' => Resource::class . '[]', 22 | 'jsonapi' => JsonApi::class, 23 | 'links' => Links::class, 24 | 'meta' => Meta::class, 25 | ]; 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | public function &getAttributeValue(string $key) 31 | { 32 | if ($key === 'data') { 33 | 34 | if ($this->attributes['data'] instanceof Resource) { 35 | return $this->attributes['data']; 36 | } 37 | 38 | if (is_array($this->attributes['data'])) { 39 | 40 | // The primary data may be either a single resoure (identifier), 41 | // or an array of them (or null) 42 | if (array_key_exists('type', $this->attributes['data'])) { 43 | 44 | $this->attributes['data'] = $this->makeNestedDataObject( 45 | Resource::class, 46 | $this->attributes['data'], 47 | 'data' 48 | ); 49 | 50 | return $this->attributes['data']; 51 | 52 | } else { 53 | 54 | foreach ($this->attributes['data'] as $index => &$item) { 55 | 56 | if (null === $item) { 57 | // @codeCoverageIgnoreStart 58 | continue; 59 | // @codeCoverageIgnoreEnd 60 | } 61 | 62 | if ( ! is_a($item, Resource::class)) { 63 | $item = $this->makeNestedDataObject(Resource::class, $item, 'data.' . $index); 64 | } 65 | } 66 | 67 | unset($item); 68 | } 69 | } 70 | } 71 | 72 | return parent::getAttributeValue($key); 73 | } 74 | 75 | /** 76 | * Returns whether the data key is set. 77 | * 78 | * @return bool 79 | */ 80 | public function hasData(): bool 81 | { 82 | return array_key_exists('data', $this->attributes); 83 | } 84 | 85 | /** 86 | * Returns whether the errors key is set. 87 | * 88 | * @return bool 89 | */ 90 | public function hasErrors(): bool 91 | { 92 | return array_key_exists('errors', $this->attributes); 93 | } 94 | 95 | /** 96 | * Returns whether the included key is set. 97 | * 98 | * @return bool 99 | */ 100 | public function hasIncluded(): bool 101 | { 102 | return array_key_exists('included', $this->attributes); 103 | } 104 | 105 | /** 106 | * Returns whether the jsonapi key is set. 107 | * 108 | * @return bool 109 | */ 110 | public function hasJsonApi(): bool 111 | { 112 | return array_key_exists('jsonapi', $this->attributes); 113 | } 114 | 115 | /** 116 | * Returns whether the links key is set. 117 | * 118 | * @return bool 119 | */ 120 | public function hasLinks(): bool 121 | { 122 | return array_key_exists('links', $this->attributes); 123 | } 124 | 125 | /** 126 | * Returns whether the meta key is set. 127 | * 128 | * @return bool 129 | */ 130 | public function hasMeta(): bool 131 | { 132 | return array_key_exists('meta', $this->attributes); 133 | } 134 | 135 | /** 136 | * Determines and returns the root type based on available keys. 137 | * 138 | * @return string 139 | * @see RootType 140 | */ 141 | public function getRootType(): string 142 | { 143 | if ($this->hasData()) { 144 | return RootType::RESOURCE; 145 | } 146 | 147 | if ($this->hasErrors()) { 148 | return RootType::ERROR; 149 | } 150 | 151 | if ($this->hasMeta()) { 152 | return RootType::META; 153 | } 154 | 155 | return RootType::UNKNOWN; 156 | } 157 | 158 | /** 159 | * 160 | * @return bool 161 | */ 162 | public function hasNullData(): bool 163 | { 164 | return $this->hasData() && null === $this->attributes['data']; 165 | } 166 | 167 | /** 168 | * Returns whether the data key contains a single resource. 169 | * 170 | * @return bool 171 | */ 172 | public function hasSingleResourceData(): bool 173 | { 174 | if ( ! $this->hasData()) { 175 | return false; 176 | } 177 | 178 | return $this->data instanceof Resource; 179 | } 180 | 181 | /** 182 | * Returns whether the data key contains a list of resources. 183 | * 184 | * @return bool 185 | */ 186 | public function hasMultipleResourceData(): bool 187 | { 188 | if ( ! $this->hasData()) { 189 | return false; 190 | } 191 | 192 | return ! $this->hasSingleResourceData(); 193 | } 194 | 195 | } 196 | -------------------------------------------------------------------------------- /tests/Support/Validation/JsonApiValidatorTest.php: -------------------------------------------------------------------------------- 1 | validateSchema($this->getValidCreateData(), SchemaType::CREATE)); 24 | 25 | $errors = $validator->getErrors(); 26 | static::assertInstanceOf(MessageBag::class, $errors); 27 | static::assertTrue($errors->isEmpty()); 28 | 29 | // Invalid data 30 | static::assertFalse($validator->validateSchema($this->getInvalidCreateData(), SchemaType::CREATE)); 31 | 32 | $errors = $validator->getErrors(); 33 | static::assertInstanceOf(MessageBag::class, $errors); 34 | static::assertFalse($errors->isEmpty()); 35 | } 36 | 37 | /** 38 | * @test 39 | */ 40 | function it_validates_create_json_api_data_with_empty_attributes() 41 | { 42 | $validator = new JsonApiValidator; 43 | 44 | // Valid data 45 | static::assertTrue($validator->validateSchema($this->getValidCreateDataWithEmptyAttributes(), SchemaType::CREATE)); 46 | 47 | $errors = $validator->getErrors(); 48 | static::assertInstanceOf(MessageBag::class, $errors); 49 | static::assertTrue($errors->isEmpty()); 50 | } 51 | 52 | /** 53 | * @test 54 | */ 55 | function it_validates_normal_request_json_api_data() 56 | { 57 | $validator = new JsonApiValidator; 58 | 59 | // Valid data 60 | static::assertTrue($validator->validateSchema($this->getValidRequestData())); 61 | 62 | $errors = $validator->getErrors(); 63 | static::assertInstanceOf(MessageBag::class, $errors); 64 | static::assertTrue($errors->isEmpty()); 65 | 66 | // Invalid data 67 | static::assertFalse($validator->validateSchema($this->getInvalidRequestData())); 68 | 69 | $errors = $validator->getErrors(); 70 | static::assertInstanceOf(MessageBag::class, $errors); 71 | static::assertFalse($errors->isEmpty()); 72 | } 73 | 74 | /** 75 | * @test 76 | */ 77 | function it_returns_empty_message_bag_for_errors_before_validation() 78 | { 79 | $validator = new JsonApiValidator; 80 | 81 | $errors = $validator->getErrors(); 82 | 83 | static::assertInstanceOf(MessageBag::class, $errors); 84 | static::assertTrue($errors->isEmpty()); 85 | } 86 | 87 | /** 88 | * @return array 89 | */ 90 | protected function getValidCreateData() 91 | { 92 | return json_decode('{ 93 | "data": { 94 | "type": "photos", 95 | "attributes": { 96 | "title": "Ember Hamster", 97 | "src": "http://example.com/images/productivity.png" 98 | }, 99 | "relationships": { 100 | "photographer": { 101 | "data": { "type": "people", "id": "9" } 102 | } 103 | } 104 | } 105 | }'); 106 | } 107 | 108 | /** 109 | * @return array 110 | */ 111 | protected function getValidCreateDataWithEmptyAttributes() 112 | { 113 | return json_decode('{ 114 | "data": { 115 | "type": "photos", 116 | "attributes": { 117 | }, 118 | "relationships": { 119 | "photographer": { 120 | "data": { "type": "people", "id": "9" } 121 | } 122 | } 123 | } 124 | }'); 125 | } 126 | 127 | /** 128 | * @return array 129 | */ 130 | protected function getInvalidCreateData() 131 | { 132 | return json_decode('{ 133 | "data": { 134 | "type": 3425, 135 | "relationships": "test" 136 | } 137 | }'); 138 | } 139 | 140 | /** 141 | * @return array 142 | */ 143 | protected function getValidRequestData() 144 | { 145 | return json_decode('{ 146 | "data": { 147 | "id": "324", 148 | "type": "photos", 149 | "attributes": { 150 | "title": "Ember Hamster", 151 | "src": "http://example.com/images/productivity.png" 152 | }, 153 | "relationships": { 154 | "photographer": { 155 | "data": { "type": "people", "id": "9" } 156 | } 157 | } 158 | } 159 | }'); 160 | } 161 | 162 | /** 163 | * @return array 164 | */ 165 | protected function getInvalidRequestData() 166 | { 167 | return $this->getValidCreateData(); 168 | } 169 | 170 | } 171 | -------------------------------------------------------------------------------- /src/Repositories/ResourceRepository.php: -------------------------------------------------------------------------------- 1 | collector = $collector; 48 | 49 | $this->resources = new Collection; 50 | } 51 | 52 | 53 | /** 54 | * Initializes repository, registering resources where possible. 55 | */ 56 | public function initialize(): void 57 | { 58 | if ($this->initialized) { 59 | return; 60 | } 61 | 62 | $resources = $this->collector->collect(); 63 | 64 | if ( ! $this->resources->isEmpty()) { 65 | $this->resources = $this->resources->merge($resources); 66 | } else { 67 | $this->resources = $resources; 68 | } 69 | 70 | $this->generateClassMap(); 71 | 72 | $this->initialized = true; 73 | } 74 | 75 | /** 76 | * Registers a resource instance for a given model or model class. 77 | * 78 | * This will overwrite any previous resource assigned for the model, 79 | * regardless of whether this is done before or after initialization. 80 | * 81 | * @param Model|string $model 82 | * @param ResourceInterface|string $resource 83 | * @return $this 84 | */ 85 | public function register($model, $resource): ResourceRepositoryInterface 86 | { 87 | if ($model instanceof Model) { 88 | $model = get_class($model); 89 | } 90 | 91 | if ( ! ($resource instanceof ResourceInterface)) { 92 | $resource = $this->instantiateResource($resource); 93 | } 94 | 95 | // Resource must have a model set before type() is guaranteed to work. 96 | if ( ! $resource->getModel()) { 97 | $resource->setModel(new $model); 98 | } 99 | 100 | $type = $resource->type(); 101 | 102 | $this->resources->put($type, $resource); 103 | 104 | $this->classMap[ $model ] = $type; 105 | 106 | return $this; 107 | } 108 | 109 | /** 110 | * Returns all registered resources. 111 | * 112 | * @return Collection|ResourceInterface[] 113 | */ 114 | public function getAll(): Collection 115 | { 116 | $this->initialize(); 117 | 118 | return $this->resources; 119 | } 120 | 121 | /** 122 | * Returns resource for JSON-API type, if available. 123 | * 124 | * @param string $type 125 | * @return ResourceInterface|null 126 | */ 127 | public function getByType(string $type): ?ResourceInterface 128 | { 129 | $this->initialize(); 130 | 131 | if ($resource = $this->resources->get($type)) { 132 | return clone $resource; 133 | } 134 | 135 | return null; 136 | } 137 | 138 | /** 139 | * Returns resource for given model instance, if available. 140 | * 141 | * @param Model $model 142 | * @return null|ResourceInterface 143 | */ 144 | public function getByModel(Model $model): ?ResourceInterface 145 | { 146 | return $this->getByModelClass(get_class($model)); 147 | } 148 | 149 | /** 150 | * Returns resource for given model class, if available. 151 | * 152 | * @param string $modelClass 153 | * @return ResourceInterface|null 154 | */ 155 | public function getByModelClass(string $modelClass): ?ResourceInterface 156 | { 157 | $this->initialize(); 158 | 159 | if ( ! array_key_exists($modelClass, $this->classMap)) { 160 | return null; 161 | } 162 | 163 | return $this->getByType($this->classMap[ $modelClass ]); 164 | } 165 | 166 | /** 167 | * Generates a fresh class map. 168 | */ 169 | protected function generateClassMap(): void 170 | { 171 | $this->classMap = []; 172 | 173 | foreach ($this->resources as $type => $resource) { 174 | 175 | if ( ! $resource->getModel()) { 176 | // @codeCoverageIgnoreStart 177 | continue; 178 | // @codeCoverageIgnoreEnd 179 | } 180 | 181 | $this->classMap[ get_class($resource->getModel()) ] = $type; 182 | } 183 | } 184 | 185 | /** 186 | * Makes an instance of a resource by FQN. 187 | * 188 | * @param string $class 189 | * @return ResourceInterface 190 | */ 191 | protected function instantiateResource(string $class): ?ResourceInterface 192 | { 193 | $resource = app($class); 194 | 195 | if ( ! ($resource instanceof ResourceInterface)) { 196 | throw new InvalidArgumentException("{$class} does not implement ResourceInterface"); 197 | } 198 | 199 | return $resource; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /tests/Support/Request/RequestParserTest.php: -------------------------------------------------------------------------------- 1 | filter = ['id' => 2]; 26 | 27 | $parser = new RequestQueryParser($this->getSetUpRequest()); 28 | 29 | static::assertEquals(['id' => 2], $parser->getFilter()); 30 | } 31 | 32 | /** 33 | * @test 34 | */ 35 | function it_returns_filter_value() 36 | { 37 | $this->filter = ['id' => 2]; 38 | 39 | $parser = new RequestQueryParser($this->getSetUpRequest()); 40 | 41 | static::assertEquals(2, $parser->getFilterValue('id')); 42 | 43 | // Check it again (this covers the single-analysis check) 44 | static::assertEquals(2, $parser->getFilterValue('id')); 45 | } 46 | 47 | /** 48 | * @test 49 | */ 50 | function it_returns_default_value_if_filter_value_not_set() 51 | { 52 | $this->page = ['id' => 2]; 53 | 54 | $parser = new RequestQueryParser($this->getSetUpRequest()); 55 | 56 | static::assertEquals(333, $parser->getFilterValue('test', 333)); 57 | } 58 | 59 | /** 60 | * @test 61 | */ 62 | function it_returns_raw_includes() 63 | { 64 | $this->include = 'test,include.attribute'; 65 | 66 | $parser = new RequestQueryParser($this->getSetUpRequest()); 67 | 68 | static::assertEquals('test,include.attribute', $parser->getRawIncludes()); 69 | } 70 | 71 | /** 72 | * @test 73 | */ 74 | function it_returns_include_array() 75 | { 76 | $this->include = null; 77 | 78 | $parser = new RequestQueryParser($this->getSetUpRequest()); 79 | 80 | static::assertEquals([], $parser->getIncludes()); 81 | 82 | 83 | $this->include = 'test,include.attribute'; 84 | 85 | $parser = new RequestQueryParser($this->getSetUpRequest()); 86 | 87 | static::assertEquals(['test', 'include.attribute'], $parser->getIncludes()); 88 | } 89 | 90 | /** 91 | * @test 92 | */ 93 | function it_returns_page_data() 94 | { 95 | $this->page = ['number' => 2]; 96 | 97 | $parser = new RequestQueryParser($this->getSetUpRequest()); 98 | 99 | static::assertEquals(['number' => 2], $parser->getPageData()); 100 | } 101 | 102 | /** 103 | * @test 104 | */ 105 | function it_returns_page_number() 106 | { 107 | $this->page = ['number' => 2]; 108 | 109 | $parser = new RequestQueryParser($this->getSetUpRequest()); 110 | 111 | static::assertEquals(2, $parser->getPageNumber()); 112 | } 113 | 114 | /** 115 | * @test 116 | */ 117 | function it_returns_page_size() 118 | { 119 | $this->page = ['size' => 10]; 120 | 121 | $parser = new RequestQueryParser($this->getSetUpRequest()); 122 | 123 | static::assertEquals(10, $parser->getPageSize()); 124 | } 125 | 126 | /** 127 | * @test 128 | */ 129 | function it_returns_page_offset() 130 | { 131 | $this->page = ['offset' => 10]; 132 | 133 | $parser = new RequestQueryParser($this->getSetUpRequest()); 134 | 135 | static::assertEquals(10, $parser->getPageOffset()); 136 | } 137 | 138 | /** 139 | * @test 140 | */ 141 | function it_returns_page_limit() 142 | { 143 | $this->page = ['limit' => 20]; 144 | 145 | $parser = new RequestQueryParser($this->getSetUpRequest()); 146 | 147 | static::assertEquals(20, $parser->getPageLimit()); 148 | } 149 | 150 | /** 151 | * @test 152 | */ 153 | function it_returns_page_cursor() 154 | { 155 | $this->page = ['cursor' => 15]; 156 | 157 | $parser = new RequestQueryParser($this->getSetUpRequest()); 158 | 159 | static::assertEquals(15, $parser->getPageCursor()); 160 | } 161 | 162 | /** 163 | * @test 164 | */ 165 | function it_returns_raw_sorting_string() 166 | { 167 | $this->sort = 'test,-include'; 168 | 169 | $parser = new RequestQueryParser($this->getSetUpRequest()); 170 | 171 | static::assertEquals('test,-include', $parser->getRawSort()); 172 | } 173 | 174 | /** 175 | * @test 176 | */ 177 | function it_returns_sort_array() 178 | { 179 | $this->sort = null; 180 | 181 | $parser = new RequestQueryParser($this->getSetUpRequest()); 182 | 183 | static::assertEquals([], $parser->getSort()); 184 | 185 | 186 | $this->sort = '-test,include'; 187 | 188 | $parser = new RequestQueryParser($this->getSetUpRequest()); 189 | 190 | static::assertEquals(['-test', 'include'], $parser->getSort()); 191 | } 192 | 193 | 194 | /** 195 | * @return Request|Mockery\Mock 196 | */ 197 | protected function getSetUpRequest() 198 | { 199 | /** @var Request|Mockery\Mock $request */ 200 | $request = Mockery::mock(Request::class); 201 | 202 | $request->shouldReceive('query')->with('filter', Mockery::any())->andReturn($this->filter); 203 | $request->shouldReceive('query')->with('include')->andReturn($this->include); 204 | $request->shouldReceive('query')->with('page', Mockery::any())->andReturn($this->page); 205 | $request->shouldReceive('query')->with('sort')->andReturn($this->sort); 206 | 207 | return $request; 208 | } 209 | 210 | } 211 | -------------------------------------------------------------------------------- /tests/Repositories/ResourceRepositoryTest.php: -------------------------------------------------------------------------------- 1 | getMockCollector(); 28 | $collector->shouldReceive('collect')->once()->andReturn(new Collection); 29 | 30 | $repository = new ResourceRepository($collector); 31 | 32 | $repository->register(TestSimpleModel::class, TestSimpleModelResource::class); 33 | 34 | $all = $repository->getAll(); 35 | 36 | static::assertInstanceOf(Collection::class, $all); 37 | static::assertCount(1, $all); 38 | static::assertInstanceOf(ResourceInterface::class, $all->first()); 39 | } 40 | 41 | /** 42 | * @test 43 | */ 44 | function it_returns_resource_by_model() 45 | { 46 | $collector = $this->getMockCollector(); 47 | $collector->shouldReceive('collect')->andReturn(new Collection); 48 | 49 | $repository = new ResourceRepository($collector); 50 | 51 | $repository->register(TestSimpleModel::class, TestSimpleModelResource::class); 52 | 53 | static::assertInstanceOf(TestSimpleModelResource::class, $repository->getByModel(new TestSimpleModel)); 54 | static::assertInstanceOf(TestSimpleModelResource::class, $repository->getByModelClass(TestSimpleModel::class)); 55 | } 56 | 57 | /** 58 | * @test 59 | */ 60 | function it_registers_a_model_by_instance() 61 | { 62 | $collector = $this->getMockCollector(); 63 | $collector->shouldReceive('collect')->once()->andReturn(new Collection); 64 | 65 | $repository = new ResourceRepository($collector); 66 | 67 | $repository->register(new TestSimpleModel, TestSimpleModelResource::class); 68 | static::assertInstanceOf(TestSimpleModelResource::class, $repository->getByModelClass(TestSimpleModel::class)); 69 | } 70 | 71 | /** 72 | * @test 73 | */ 74 | function it_combines_collected_and_manually_registered_resources() 75 | { 76 | $collector = $this->getMockCollector(); 77 | $collector->shouldReceive('collect')->andReturn(new Collection([ 78 | 'test-alternative-models' => (new TestAlternativeModelResource)->setModel(new TestAlternativeModel), 79 | ])); 80 | 81 | $repository = new ResourceRepository($collector); 82 | 83 | $repository->register(TestSimpleModel::class, TestSimpleModelResource::class); 84 | 85 | static::assertInstanceOf( 86 | TestSimpleModelResource::class, 87 | $repository->getByModelClass(TestSimpleModel::class) 88 | ); 89 | static::assertInstanceOf( 90 | TestAlternativeModelResource::class, 91 | $repository->getByModelClass(TestAlternativeModel::class) 92 | ); 93 | } 94 | 95 | /** 96 | * @test 97 | */ 98 | function it_returns_resource_by_type() 99 | { 100 | $collector = $this->getMockCollector(); 101 | $collector->shouldReceive('collect')->andReturn(new Collection); 102 | 103 | $repository = new ResourceRepository($collector); 104 | 105 | $repository->register(TestSimpleModel::class, TestSimpleModelResource::class); 106 | 107 | static::assertInstanceOf(TestSimpleModelResource::class, $repository->getByType('test-simple-models')); 108 | } 109 | 110 | /** 111 | * @test 112 | */ 113 | function it_returns_null_when_resource_could_not_be_found() 114 | { 115 | $collector = $this->getMockCollector(); 116 | $collector->shouldReceive('collect')->andReturn(new Collection); 117 | 118 | $repository = new ResourceRepository($collector); 119 | 120 | static::assertNull($repository->getByModel(new TestSimpleModel)); 121 | static::assertNull($repository->getByModelClass(TestSimpleModel::class)); 122 | static::assertNull($repository->getByType('unknown-type')); 123 | } 124 | 125 | /** 126 | * @test 127 | */ 128 | function it_only_initializes_once() 129 | { 130 | $collector = $this->getMockCollector(); 131 | $collector->shouldReceive('collect')->once()->andReturn(new Collection); 132 | 133 | $repository = new ResourceRepository($collector); 134 | 135 | $repository->getAll(); 136 | $repository->getAll(); 137 | } 138 | 139 | /** 140 | * @test 141 | */ 142 | function it_throws_an_exception_if_a_registered_resource_is_invalid() 143 | { 144 | $this->expectException(InvalidArgumentException::class); 145 | 146 | $collector = $this->getMockCollector(); 147 | $collector->shouldReceive('collect')->andReturn(new Collection); 148 | 149 | $repository = new ResourceRepository($collector); 150 | 151 | $repository->register(TestSimpleModel::class, TestSimpleModel::class); 152 | $repository->getByModelClass(TestSimpleModel::class); 153 | } 154 | 155 | /** 156 | * @return ResourceCollectorInterface|Mockery\MockInterface 157 | */ 158 | protected function getMockCollector() 159 | { 160 | return Mockery::mock(ResourceCollectorInterface::class); 161 | } 162 | 163 | } 164 | -------------------------------------------------------------------------------- /ENCODING.md: -------------------------------------------------------------------------------- 1 | # Encoding JSON-API Responses 2 | 3 | Encoding is automatically handled depending on the data given to be encoded. 4 | 5 | Exceptions and scalar data will be rendered in the best way available. 6 | 7 | Eloquent models will be encoded as JSON-API resources, identified by unique types. 8 | You must set up `Resource` classes that describe how the encoder should handle this, 9 | see below for more information. 10 | 11 | 12 | ## Creating encoded responses 13 | 14 | To manually create a JSON-API response, you can use the following helper methods or facades. 15 | 16 | ```php 17 | encode($data) ); 23 | ``` 24 | 25 | ### Includes 26 | 27 | Includes may be set on an encoder instance ... 28 | 29 | ```php 30 | setRequestedIncludes([ 'some', 'inclu.des' ]); 32 | $encoder->encode($resource); 33 | ``` 34 | 35 | ... or passed in to the encoder as a parameter of the `encode()` method: 36 | 37 | ```php 38 | encode($resource, [ 'some', 'inclu.des' ]); 40 | ``` 41 | 42 | 43 | ## Encoding Eloquent Models as JSON-API Resources 44 | 45 | When an `Eloquent` model (or Eloquent `Collection`) is encoded, it is parsed as a JSON-API resource, 46 | according to logic specified in an instance of `Czim\JsonApi\Contracts\Resource\ResourceInterface`. 47 | 48 | 49 | ### Resources 50 | 51 | A `Resource` describes how the encoder should serialize a model instance. 52 | 53 | Resources must be provided for all (related or includable) models that are accessible through your API. 54 | 55 | This resource must be registered in the `ResourceRepository`, after which the encoder will automatically 56 | make use of it. 57 | 58 | Resources may describe: 59 | 60 | - Attributes to be included in the data. 61 | - What includes are allowed, and/or should be included by default. 62 | - Available filter options and defaults. 63 | - Available sorting options and defaults. 64 | - Optional relationship name mapping to Eloquent Relation methods on the model. 65 | - Whether attributes should be formatted as date values, and what format they should use. 66 | 67 | 68 | ... TO DO: chapter on resources ... 69 | 70 | 71 | ### Configure Mapped Resources 72 | 73 | You can register resources for Eloquent models by defining them in the configuration file, under the `jsonapi.repository.resource.map` key. 74 | 75 | List the mapping as key-value pairs, with model FQNs as keys, and Resource class FQNs as values. 76 | 77 | ```php 78 | [ 80 | 'resource' => [ 81 | 82 | // ... 83 | 84 | 'map' => [ 85 | \App\Models\YourModel::class => \App\JsonApi\Resources\YourModelResource::class, 86 | ], 87 | 88 | ], 89 | ], 90 | 91 | // ... 92 | ``` 93 | 94 | 95 | ### Automatic Collection of Resources 96 | 97 | Note: this is a W.I.P., please use a configured map or manual registration for now. 98 | 99 | ... TO DO: describe resource collector, namespace & config options ... 100 | 101 | 102 | ### Manually Registering Resources 103 | 104 | Keep in mind that it is recommended to use the configuration or automatic collection methods described above. 105 | 106 | If you want to manually set resources for your encoding, you can replace or append normal collection of resources 107 | with manual registration on the repository: 108 | 109 | ```php 110 | register(\Your\ClassName::class, $resource); 118 | ``` 119 | 120 | This may be done before or after normal collection is performed. 121 | Note, however, that the last registration of the model will hold. 122 | If you need to overwrite a collected resource, make sure the manual registration is performed after collection. 123 | 124 | Normally, collection performed lazily by the repository. To force it, run `initialize()` on the repository. 125 | Repository initialization will be performed only once. 126 | 127 | 128 | ## Encoding Exceptions 129 | 130 | When an `Exception` instance is encoded, a standardized JSON-API error is generated, 131 | using the exception code, message, class name and status code where available. 132 | 133 | To automatically let Laravel respond with JSON-API encoded error messages when exceptions are caught, 134 | adjust the `render` method on your `App\Exceptions\Handler`: 135 | 136 | You can use the global `jsonapi_error($exception)` helper method to quickly make a JSON-API response. 137 | 138 | 139 | ## Encoding Custom Errors 140 | 141 | For custom errors, you can create an instance of `Czim\JsonApi\Support\Error\ErrorData` (or another class 142 | that implements `Czim\JsonApi\Contracts\Support\Error\ErrorDataInterface`) and feed it to the encoder: 143 | 144 | ```php 145 | '13', 151 | 'title' => 'Whoops, something went wrong!', 152 | 'detail' => 'The thingamagig got tangled in the gobbledygooker', 153 | ]); 154 | 155 | $encodedError = $encoder->encode($error); 156 | ``` 157 | 158 | 159 | ## Custom Encoding & Transformation 160 | 161 | To use your own transformers for specific class FQNs for the content to be encoded, map them in the `jsonapi.transform.map` 162 | configuration key: 163 | 164 | ```php 165 | [ 167 | \Your\ContentClassFqn\Here::class => \Your\TransformerClassFqn\Here::class, 168 | ], 169 | ``` 170 | 171 | This mapping will return the first-matched for content using `is_a()` checks. 172 | More specific matches should be higher in the list. 173 | 174 | 175 | As a last resort, you can always extend and/or rebind the `Czim\JsonApi\Encoder\Factories\TransformerFactory` 176 | to provide your own transformers based on given content type. 177 | --------------------------------------------------------------------------------