├── pint.json ├── .gitattributes ├── .gitignore ├── .vscode ├── settings.json └── extensions.json ├── src ├── Attributes │ ├── QueryParam.php │ ├── SearchQueryParam.php │ ├── SearchFilterQueryParam.php │ ├── IncludeQueryParam.php │ ├── FieldsQueryParam.php │ ├── AppendsQueryParam.php │ ├── ApplyDefaultSort.php │ ├── FilterQueryParam.php │ ├── ApplyDefaultFilter.php │ └── SortQueryParam.php ├── Http │ ├── SortDirection.php │ ├── Request.php │ ├── AllowedAppends.php │ ├── Concerns │ │ ├── ValidatesParams.php │ │ ├── AllowsIncludes.php │ │ ├── AllowsFields.php │ │ ├── AllowsAppends.php │ │ ├── AllowsSorts.php │ │ ├── AllowsSearch.php │ │ ├── ResolvesFromRouteAction.php │ │ ├── IteratesResultsAfterQuery.php │ │ └── AllowsFilters.php │ ├── Resources │ │ ├── JsonApiCollection.php │ │ ├── CollectsWithIncludes.php │ │ ├── CollectsResources.php │ │ ├── JsonApiResource.php │ │ ├── Json │ │ │ └── ResourceCollection.php │ │ └── RelationshipsWithIncludes.php │ ├── AllowedInclude.php │ ├── DefaultFilter.php │ ├── AllowedSearchFilter.php │ ├── AllowedFields.php │ ├── ApplyIncludesToQuery.php │ ├── DefaultSort.php │ ├── AllowedSort.php │ ├── ApplyFulltextSearchToQuery.php │ ├── ApplyFieldsToQuery.php │ ├── QueryParamsValidator.php │ ├── ApplySortsToQuery.php │ ├── RequestQueryObject.php │ └── AllowedFilter.php ├── Contracts │ ├── JsonApiable.php │ ├── ViewableBuilder.php │ ├── ViewQueryable.php │ └── HandlesRequestQueries.php ├── Concerns │ └── HasJsonApi.php ├── Support │ ├── Facades │ │ └── Apiable.php │ └── Apiable.php ├── Testing │ ├── TestResponseMacros.php │ ├── Concerns │ │ ├── HasIdentifications.php │ │ ├── HasCollections.php │ │ ├── HasAttributes.php │ │ └── HasRelationships.php │ └── AssertableJsonApi.php ├── Collection.php ├── JsonApiException.php ├── ServiceProvider.php ├── Builder.php └── Handler.php ├── docs ├── SUMMARY.md ├── frontend.md ├── comparison.md ├── README.md ├── introduction.md ├── api.md ├── testing.md └── responses.md ├── tests ├── Fixtures │ ├── Plan.php │ ├── Tag.php │ ├── User.php │ └── Post.php ├── Http │ ├── RequestTest.php │ └── Resources │ │ ├── JsonApiCollectionTest.php │ │ └── JsonApiResourceTest.php ├── database │ ├── 2012_05_30_163139_create_plans_table.php │ ├── 2022_05_30_163139_create_posts_table .php │ └── 2022_05_30_163424_create_tags_table.php ├── TestCase.php ├── JsonApiRelationshipsTest.php ├── Helpers │ └── GeneratesPredictableTestData.php ├── JsonApiPaginationTest.php └── ApiableTest.php ├── .github ├── dependabot.yml └── workflows │ ├── pint.yml │ ├── phpstan.yml │ ├── publish.yml │ └── tests.yml ├── helpers.php ├── .editorconfig ├── phpstan-baseline.neon ├── phpstan.neon ├── SECURITY.md ├── phpunit.dist.xml ├── README.md ├── phpunit.coverage.dist.xml ├── LICENSE ├── config └── apiable.php ├── composer.json └── CONTRIBUTING.md /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel" 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.lock -diff 2 | *.min.js -diff 3 | *.min.css -diff 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock 3 | phpunit.xml 4 | .DS_Store 5 | coverage 6 | clover.xml 7 | .phpunit.cache 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "php-cs-fixer.executablePath": "${workspaceFolder}/vendor/bin/php-cs-fixer" 3 | } 4 | -------------------------------------------------------------------------------- /src/Attributes/QueryParam.php: -------------------------------------------------------------------------------- 1 | header('Accept') === 'application/vnd.api+json'; 14 | }; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Attributes/SearchQueryParam.php: -------------------------------------------------------------------------------- 1 | $attributes 11 | */ 12 | public static function make(string $type, string|array $attributes): self 13 | { 14 | return new self($type, $attributes); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Attributes/ApplyDefaultFilter.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | public function viewable(?Authenticatable $user = null); 18 | } 19 | -------------------------------------------------------------------------------- /src/Support/Facades/Apiable.php: -------------------------------------------------------------------------------- 1 | \" between 0 and 0 is always false\\.$#" 15 | count: 2 16 | path: src/Http/QueryParamsValidator.php 17 | -------------------------------------------------------------------------------- /.github/workflows/pint.yml: -------------------------------------------------------------------------------- 1 | name: pint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | analyze: 7 | runs-on: ubuntu-latest 8 | 9 | name: Laravel Pint 10 | 11 | steps: 12 | - name: 🏗 Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: 🏗 Setup PHP 16 | uses: shivammathur/setup-php@v2 17 | with: 18 | php-version: "8.4" 19 | coverage: none 20 | tools: laravel/pint 21 | 22 | - name: 🧪 Analyse code 23 | run: pint 24 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | - vendor/larastan/larastan/extension.neon 4 | - vendor/tomasvotruba/type-coverage/config/extension.neon 5 | 6 | parameters: 7 | 8 | paths: 9 | - src 10 | 11 | # The level 8 is the highest level 12 | level: 5 13 | 14 | type_coverage: 15 | return_type: 20 16 | param_type: 20 17 | property_type: 20 18 | 19 | excludePaths: 20 | - src/Builder.php 21 | 22 | checkMissingIterableValueType: false 23 | -------------------------------------------------------------------------------- /src/Testing/TestResponseMacros.php: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Send us an email at security@opensoutheners.com 18 | -------------------------------------------------------------------------------- /src/Contracts/ViewQueryable.php: -------------------------------------------------------------------------------- 1 | $query 17 | * @return void 18 | */ 19 | public function scopeViewable(Builder $query, ?Authenticatable $user = null); 20 | } 21 | -------------------------------------------------------------------------------- /src/Contracts/HandlesRequestQueries.php: -------------------------------------------------------------------------------- 1 | filter(function ($item) { 18 | return is_object($item) && $item instanceof JsonApiable; 19 | }) 20 | ); 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Http/RequestTest.php: -------------------------------------------------------------------------------- 1 | wantsJsonApi() ? 'foo' : 'bar'; 15 | }); 16 | 17 | $this->get('/', ['Accept' => 'application/vnd.api+json'])->assertSee('foo'); 18 | 19 | $this->get('/', ['Accept' => 'application/json'])->assertSee('bar'); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Fixtures/Tag.php: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | tests 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Http/Concerns/ValidatesParams.php: -------------------------------------------------------------------------------- 1 | enforcesValidation() 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Http/Resources/JsonApiCollection.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class JsonApiCollection extends ResourceCollection 13 | { 14 | use CollectsWithIncludes; 15 | 16 | /** 17 | * Create a new resource instance. 18 | * 19 | * @param TCollectedResource $resource 20 | * @param class-string<\OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource>|null $collects 21 | * @return void 22 | */ 23 | public function __construct($resource, $collects = null) 24 | { 25 | $this->collects = $collects ?: JsonApiResource::class; 26 | 27 | parent::__construct($resource); 28 | 29 | $this->withIncludes(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Http/AllowedInclude.php: -------------------------------------------------------------------------------- 1 | relationship; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/database/2012_05_30_163139_create_plans_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('name'); 19 | $table->string('price'); 20 | $table->string('price_unit'); 21 | $table->string('stripe_id')->index(); 22 | $table->text('description')->nullable(); 23 | 24 | $table->timestamps(); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::dropIfExists('plans'); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /docs/frontend.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Frontend 4 | category: Digging deeper 5 | --- 6 | 7 | # Implementations 8 | 9 | To complement this package functionality you'll want to add one of these packages we described here. 10 | 11 | ## What about the frontend? 12 | 13 | So many of you will ask this same question. The answer is simple as JSON:API is a common protocol, you could do your own implementation or use one of these: 14 | 15 | - JavaScript browser & server (NodeJS / SSR-capable): [https://github.com/olosegres/jsona](https://github.com/olosegres/jsona) 16 | - Check any other libraries in: [https://jsonapi.org/implementations/](https://jsonapi.org/implementations/) 17 | 18 | ### Flex URL 19 | 20 | If you want to parse the URL following the JSON:API standard from your Node or browser app, you should check out this package also maintained by us: https://github.com/open-southeners/flex-url 21 | 22 | [And its documentation here](https://docs.opensoutheners.com/flex-url/) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Laravel Apiable [![required php version](https://img.shields.io/packagist/php-v/open-southeners/laravel-apiable)](https://www.php.net/supported-versions.php) [![codecov](https://codecov.io/gh/open-southeners/laravel-apiable/branch/main/graph/badge.svg?token=EAU2JHBG2A)](https://codecov.io/gh/open-southeners/laravel-apiable) [![Edit on VSCode online](https://img.shields.io/badge/vscode-edit%20online-blue?logo=visualstudiocode)](https://vscode.dev/github/open-southeners/laravel-apiable) 2 | === 3 | 4 | Integrate JSON:API resources on your Laravel API project. 5 | 6 | ## Getting started 7 | 8 | ``` 9 | composer require open-southeners/laravel-apiable 10 | ``` 11 | 12 | ## Documentation 13 | 14 | [Official documentation](https://docs.opensoutheners.com/laravel-apiable/) 15 | 16 | ## Partners 17 | 18 | [![skore logo](https://github.com/open-southeners/partners/raw/main/logos/skore_logo.png)](https://getskore.com) 19 | 20 | ## License 21 | 22 | This package is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). 23 | -------------------------------------------------------------------------------- /.github/workflows/phpstan.yml: -------------------------------------------------------------------------------- 1 | name: phpstan 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | analyze: 7 | runs-on: ubuntu-latest 8 | 9 | name: PHPStan 10 | 11 | steps: 12 | - name: 🏗 Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: 🏗 Setup PHP 16 | uses: shivammathur/setup-php@v2 17 | with: 18 | php-version: "8.4" 19 | coverage: none 20 | tools: phpstan 21 | 22 | - name: 🏗 Get composer cache directory 23 | id: composer-cache 24 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 25 | 26 | - name: 🏗 Cache dependencies 27 | uses: actions/cache@v4 28 | with: 29 | path: ${{ steps.composer-cache.outputs.dir }} 30 | key: phpstan-composer-${{ hashFiles('**/composer.lock') }} 31 | restore-keys: phpstan-composer- 32 | 33 | - name: 📦 Install dependencies 34 | run: composer install --no-interaction --no-suggest 35 | 36 | - name: 🧪 Analyse code 37 | run: phpstan analyse 38 | -------------------------------------------------------------------------------- /phpunit.coverage.dist.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | src 14 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | tests 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Open Southeners 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/Fixtures/User.php: -------------------------------------------------------------------------------- 1 | 31 | */ 32 | protected $hidden = [ 33 | 'password', 34 | 'remember_token', 35 | ]; 36 | 37 | /** 38 | * The attributes that should be cast. 39 | * 40 | * @var array 41 | */ 42 | protected $casts = [ 43 | 'email_verified_at' => 'datetime', 44 | ]; 45 | } 46 | -------------------------------------------------------------------------------- /src/Testing/Concerns/HasIdentifications.php: -------------------------------------------------------------------------------- 1 | id === $value, sprintf('JSON:API response does not have id "%s"', $value)); 33 | 34 | return $this; 35 | } 36 | 37 | /** 38 | * Check that a resource has the specified type. 39 | * 40 | * @param mixed $value 41 | * @return $this 42 | */ 43 | public function hasType($value) 44 | { 45 | PHPUnit::assertSame($this->type, $value, sprintf('JSON:API response does not have type "%s"', $value)); 46 | 47 | return $this; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Http/Resources/CollectsWithIncludes.php: -------------------------------------------------------------------------------- 1 | with['included'] ?? [] 21 | ); 22 | 23 | /** @var \OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource $jsonResource */ 24 | foreach ($this->collection->toArray() as $jsonResource) { 25 | /** @var \OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource $resource */ 26 | foreach ($jsonResource->getIncluded() as $resource) { 27 | $collectionIncludes->push($resource); 28 | } 29 | } 30 | 31 | $included = $this->checkUniqueness($collectionIncludes)->values()->all(); 32 | 33 | if (! empty($included)) { 34 | $this->with = array_merge_recursive($this->with, compact('included')); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Http/DefaultFilter.php: -------------------------------------------------------------------------------- 1 | isValidOperator($operator)) { 15 | throw new \Exception( 16 | sprintf('Operator value "%s" for filtered attribute "%s" is not valid', $operator, $attribute) 17 | ); 18 | } 19 | 20 | $this->attribute = $attribute; 21 | $this->operator = $operator ?? static::SIMILAR; 22 | $this->values = $values; 23 | } 24 | 25 | /** 26 | * Get the instance as an array. 27 | * 28 | * @return array> 29 | */ 30 | public function toArray(): array 31 | { 32 | $defaultFilterArr = parent::toArray(); 33 | 34 | $attribute = array_key_first($defaultFilterArr); 35 | 36 | return [ 37 | $attribute => [ 38 | $defaultFilterArr[$attribute]['operator'] => $defaultFilterArr[$attribute]['values'], 39 | ], 40 | ]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Http/AllowedSearchFilter.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | protected $values; 18 | 19 | /** 20 | * Make an instance of this class. 21 | * 22 | * @param string $attribute 23 | * @param string|array $values 24 | * @return void 25 | */ 26 | public function __construct($attribute, $values = '*') 27 | { 28 | $this->attribute = $attribute; 29 | $this->values = $values; 30 | } 31 | 32 | /** 33 | * Allow default filter by attribute 34 | * 35 | * @param string $attribute 36 | * @param string|array $values 37 | */ 38 | public static function make($attribute, $values = '*'): self 39 | { 40 | return new self($attribute, $values); 41 | } 42 | 43 | /** 44 | * Get the instance as an array. 45 | * 46 | * @return array>> 47 | */ 48 | public function toArray() 49 | { 50 | return [ 51 | $this->attribute => [ 52 | 'values' => $this->values, 53 | ], 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/database/2022_05_30_163139_create_posts_table .php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('title'); 19 | $table->string('status')->index(); 20 | $table->text('content')->nullable(); 21 | 22 | $table->unsignedBigInteger('parent_id')->nullable(); 23 | $table->foreign('parent_id') 24 | ->references('id') 25 | ->on('posts') 26 | ->nullOnDelete(); 27 | 28 | $table->unsignedBigInteger('author_id')->nullable(); 29 | $table->foreign('author_id') 30 | ->references('id') 31 | ->on('users') 32 | ->nullOnDelete(); 33 | 34 | $table->timestamps(); 35 | // TODO: SQLite doesn't support this, needs MySQL 36 | // $table->fullText(['title', 'content']); 37 | }); 38 | } 39 | 40 | /** 41 | * Reverse the migrations. 42 | * 43 | * @return void 44 | */ 45 | public function down() 46 | { 47 | Schema::dropIfExists('posts'); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /src/Http/AllowedFields.php: -------------------------------------------------------------------------------- 1 | > 14 | */ 15 | protected $attributes; 16 | 17 | /** 18 | * Make an instance of this class. 19 | * 20 | * @param string|array $attributes 21 | * @return void 22 | */ 23 | public function __construct(string $type, string|array $attributes) 24 | { 25 | $this->type = class_exists($type) ? Apiable::getResourceType($type) : $type; 26 | $this->attributes = (array) $attributes; 27 | } 28 | 29 | /** 30 | * Allow restrict the result to specified columns in the resource type. 31 | * 32 | * @param string|array $attributes 33 | */ 34 | public static function make(string $type, string|array $attributes): self 35 | { 36 | return new self($type, $attributes); 37 | } 38 | 39 | /** 40 | * Get the instance as an array. 41 | * 42 | * @return array> 43 | */ 44 | public function toArray(): array 45 | { 46 | return [ 47 | $this->type => is_array(head($this->attributes)) 48 | ? array_merge(...$this->attributes) 49 | : $this->attributes, 50 | ]; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "**" 7 | 8 | jobs: 9 | # @see https://stackoverflow.com/a/72959712/8179249 10 | check-current-branch: 11 | runs-on: ubuntu-latest 12 | 13 | outputs: 14 | branch: ${{ steps.check_step.outputs.branch }} 15 | 16 | steps: 17 | - name: 🏗 Checkout code 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: 🏗 Get current branch 23 | id: check_step 24 | run: | 25 | raw=$(git branch -r --contains ${{ github.ref }}) 26 | branch="$(echo ${raw//origin\//} | tr -d '\n')" 27 | echo "{name}=branch" >> $GITHUB_OUTPUT 28 | echo "Branches where this tag exists : $branch." 29 | 30 | build: 31 | runs-on: ubuntu-latest 32 | 33 | needs: check-current-branch 34 | 35 | steps: 36 | - name: 🏗 Checkout code 37 | uses: actions/checkout@v4 38 | 39 | - name: 🏗 Get release info 40 | id: query-release-info 41 | uses: release-flow/keep-a-changelog-action@v3 42 | with: 43 | command: query 44 | version: latest 45 | 46 | - name: 🚀 Publish to Github releases 47 | uses: softprops/action-gh-release@v2 48 | with: 49 | body: ${{ steps.query-release-info.outputs.release-notes }} 50 | make_latest: contains(${{ needs.check.outputs.branch }}, 'main') 51 | # prerelease: true 52 | # files: '*.vsix' 53 | -------------------------------------------------------------------------------- /config/apiable.php: -------------------------------------------------------------------------------- 1 | [], 14 | 15 | /** 16 | * Default options for request query filters, sorts, etc. 17 | * 18 | * @see https://docs.opensoutheners.com/laravel-apiable/guide/requests.html 19 | */ 20 | 'requests' => [ 21 | 'validate_params' => false, 22 | 23 | 'filters' => [ 24 | 'default_operator' => AllowedFilter::SIMILAR, 25 | 'enforce_scoped_names' => false, 26 | ], 27 | 28 | 'sorts' => [ 29 | 'default_direction' => AllowedSort::BOTH, 30 | ], 31 | ], 32 | 33 | /** 34 | * Default options for responses like: normalize relations names, include allowed filters and sorts, etc. 35 | * 36 | * @see https://docs.opensoutheners.com/laravel-apiable/guide/responses.html 37 | */ 38 | 'responses' => [ 39 | 'formatting' => [ 40 | 'type' => 'application/vnd.api+json', 41 | 'force' => false, 42 | ], 43 | 44 | 'normalize_relations' => false, 45 | 46 | 'include_allowed' => false, 47 | 48 | 'pagination' => [ 49 | 'default_size' => 50, 50 | ], 51 | 52 | 'viewable' => true, 53 | 54 | 'include_ids_on_attributes' => false, 55 | ], 56 | 57 | ]; 58 | -------------------------------------------------------------------------------- /tests/database/2022_05_30_163424_create_tags_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('name'); 19 | $table->string('slug')->unique(); 20 | 21 | $table->unsignedBigInteger('created_by')->nullable(); 22 | $table->foreign('created_by') 23 | ->references('id') 24 | ->on('users') 25 | ->nullOnDelete(); 26 | 27 | $table->timestamps(); 28 | }); 29 | 30 | Schema::create('post_tag', function (Blueprint $table) { 31 | $table->bigIncrements('id'); 32 | 33 | $table->unsignedBigInteger('post_id')->nullable(); 34 | $table->foreign('post_id') 35 | ->references('id') 36 | ->on('posts') 37 | ->nullOnDelete(); 38 | 39 | $table->unsignedBigInteger('tag_id')->nullable(); 40 | $table->foreign('tag_id') 41 | ->references('id') 42 | ->on('tags') 43 | ->nullOnDelete(); 44 | 45 | $table->timestamps(); 46 | }); 47 | } 48 | 49 | /** 50 | * Reverse the migrations. 51 | * 52 | * @return void 53 | */ 54 | public function down() 55 | { 56 | Schema::dropIfExists('tags'); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/JsonApiException.php: -------------------------------------------------------------------------------- 1 | $source]; 31 | } 32 | 33 | if ($status) { 34 | $error['status'] = (string) $status; 35 | } 36 | 37 | if ($code) { 38 | $error['code'] = (string) $code; 39 | } 40 | 41 | if (! empty($trace)) { 42 | $error['trace'] = $trace; 43 | } 44 | 45 | $this->errors[] = $error; 46 | } 47 | 48 | /** 49 | * Get errors array. 50 | */ 51 | public function getErrors(): array 52 | { 53 | return $this->errors; 54 | } 55 | 56 | /** 57 | * Get exception errors to array. 58 | */ 59 | public function toArray(): array 60 | { 61 | return [ 62 | 'errors' => $this->errors, 63 | ]; 64 | } 65 | 66 | /** 67 | * Gets the exception as string. 68 | */ 69 | public function __toString(): string 70 | { 71 | return json_encode($this->toArray()); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Http/ApplyIncludesToQuery.php: -------------------------------------------------------------------------------- 1 | includes())) { 21 | return $next($request); 22 | } 23 | 24 | $this->applyIncludes( 25 | $request->query, 26 | $request->userAllowedIncludes() 27 | ); 28 | 29 | return $next($request); 30 | } 31 | 32 | /** 33 | * Apply array of includes to the query. 34 | * 35 | * @return \Illuminate\Database\Eloquent\Builder 36 | */ 37 | protected function applyIncludes(Builder $query, array $includes) 38 | { 39 | $eagerLoadedRelationships = $query->getEagerLoads(); 40 | 41 | foreach ($includes as $include) { 42 | match (true) { 43 | Str::endsWith($include, '_count') => $query->withCount(str_replace('_count', '', $include)), 44 | ! in_array($include, $eagerLoadedRelationships) => $query->with($include), 45 | default => null 46 | }; 47 | } 48 | 49 | return $query; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Http/Concerns/AllowsIncludes.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | protected $allowedIncludes = []; 16 | 17 | /** 18 | * Get user includes relationships from request. 19 | * 20 | * @return array 21 | */ 22 | public function includes() 23 | { 24 | return array_filter(explode(',', $this->request->get('include', ''))); 25 | } 26 | 27 | /** 28 | * Allow include relationship to the response. 29 | * 30 | * @param \OpenSoutheners\LaravelApiable\Http\AllowedInclude|array|string $relationship 31 | * @return $this 32 | */ 33 | public function allowInclude($relationship) 34 | { 35 | $this->allowedIncludes = array_merge($this->allowedIncludes, (array) $relationship); 36 | 37 | return $this; 38 | } 39 | 40 | public function userAllowedIncludes() 41 | { 42 | return $this->validator($this->includes()) 43 | ->givingRules(false) 44 | ->when( 45 | fn ($key, $modifiers, $values, $rules) => in_array($values, $this->allowedIncludes), 46 | fn ($key, $values) => throw new Exception(sprintf('"%s" cannot be included', $values)) 47 | ) 48 | ->validate(); 49 | } 50 | 51 | /** 52 | * Get list of allowed includes. 53 | * 54 | * @return array 55 | */ 56 | public function getAllowedIncludes() 57 | { 58 | return $this->allowedIncludes; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Http/DefaultSort.php: -------------------------------------------------------------------------------- 1 | attribute = $attribute; 23 | $this->direction = $direction ?? static::ASCENDANT; 24 | } 25 | 26 | /** 27 | * Allow default sort by attribute. 28 | */ 29 | public static function make(string $attribute): self 30 | { 31 | return new self($attribute); 32 | } 33 | 34 | /** 35 | * Allow sort by attribute as ascendant. 36 | */ 37 | public static function ascendant(string $attribute): self 38 | { 39 | return new self($attribute, static::ASCENDANT); 40 | } 41 | 42 | /** 43 | * Allow sort by attribute as descendant. 44 | */ 45 | public static function descendant(string $attribute): self 46 | { 47 | return new self($attribute, static::DESCENDANT); 48 | } 49 | 50 | /** 51 | * Get the instance as an array. 52 | * 53 | * @return array 54 | */ 55 | public function toArray(): array 56 | { 57 | return [ 58 | $this->attribute => match ($this->direction) { 59 | default => 'asc', 60 | static::ASCENDANT => 'asc', 61 | static::DESCENDANT => 'desc', 62 | }, 63 | ]; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 22 | $this->publishes([ 23 | __DIR__.'/../config/apiable.php' => config_path('apiable.php'), 24 | ], 'config'); 25 | } 26 | } 27 | 28 | /** 29 | * Register any application services. 30 | * 31 | * @return void 32 | */ 33 | public function register() 34 | { 35 | $this->app->singleton('apiable', function () { 36 | return new Apiable(); 37 | }); 38 | 39 | $this->registerMacros(); 40 | } 41 | 42 | /** 43 | * Register package macros for framework built-ins. 44 | * 45 | * @return void 46 | */ 47 | public function registerMacros() 48 | { 49 | \Illuminate\Testing\TestResponse::mixin(new \OpenSoutheners\LaravelApiable\Testing\TestResponseMacros()); 50 | \Illuminate\Http\Request::mixin(new \OpenSoutheners\LaravelApiable\Http\Request()); 51 | \Illuminate\Database\Eloquent\Builder::mixin(new \OpenSoutheners\LaravelApiable\Builder()); 52 | \Illuminate\Support\Collection::mixin(new \OpenSoutheners\LaravelApiable\Collection()); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Testing/Concerns/HasCollections.php: -------------------------------------------------------------------------------- 1 | collection, 'Failed asserting that response is a collection'); 25 | 26 | return $this; 27 | } 28 | 29 | /** 30 | * Get resource based on its zero-based position in the collection. 31 | * 32 | * @return \OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi 33 | */ 34 | public function at(int $position) 35 | { 36 | if (! array_key_exists($position, $this->collection)) { 37 | PHPUnit::fail(sprintf('There is no item at position "%d" on the collection response.', $position)); 38 | } 39 | 40 | $data = $this->collection[$position]; 41 | 42 | $this->atPosition = $position; 43 | 44 | return new self($data['id'], $data['type'], $data['attributes'], $data['relationships'] ?? [], $this->includeds, $this->collection); 45 | } 46 | 47 | /** 48 | * Assert the number of resources that are at the collection (alias of count). 49 | * 50 | * @return $this 51 | */ 52 | public function hasSize(int $value) 53 | { 54 | PHPUnit::assertCount($value, $this->collection, sprintf('The collection size is not same as "%d"', $value)); 55 | 56 | return $this; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Http/AllowedSort.php: -------------------------------------------------------------------------------- 1 | attribute = $attribute; 28 | $this->direction = (int) ($direction ?? Apiable::config('requests.sorts.default_direction') ?? static::BOTH); 29 | } 30 | 31 | /** 32 | * Allow default sort by attribute. 33 | */ 34 | public static function make(string $attribute): self 35 | { 36 | return new self($attribute); 37 | } 38 | 39 | /** 40 | * Allow sort by attribute as ascendant. 41 | */ 42 | public static function ascendant(string $attribute): self 43 | { 44 | return new self($attribute, static::ASCENDANT); 45 | } 46 | 47 | /** 48 | * Allow sort by attribute as descendant. 49 | */ 50 | public static function descendant(string $attribute): self 51 | { 52 | return new self($attribute, static::DESCENDANT); 53 | } 54 | 55 | /** 56 | * Get the instance as an array. 57 | * 58 | * @return array 59 | */ 60 | public function toArray(): array 61 | { 62 | return [ 63 | $this->attribute => match ($this->direction) { 64 | default => '*', 65 | AllowedSort::BOTH => '*', 66 | AllowedSort::ASCENDANT => 'asc', 67 | AllowedSort::DESCENDANT => 'desc', 68 | }, 69 | ]; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "open-southeners/laravel-apiable", 3 | "description": "Integrate JSON:API resources on your Laravel API project.", 4 | "license": "MIT", 5 | "keywords": [ 6 | "open-southeners", 7 | "apiable", 8 | "json-api", 9 | "laravel", 10 | "laravel-package", 11 | "request-query", 12 | "request-search", 13 | "request-params-filtering" 14 | ], 15 | "authors": [ 16 | { 17 | "name": "Ruben Robles", 18 | "email": "me@d8vjork.com" 19 | } 20 | ], 21 | "homepage": "https://docs.opensoutheners.com/laravel-apiable/", 22 | "require": { 23 | "php": "^8.2", 24 | "illuminate/support": "^11.0 || ^12.0", 25 | "open-southeners/extended-php": "~1.0" 26 | }, 27 | "require-dev": { 28 | "larastan/larastan": "^3.0", 29 | "laravel/pint": "^1.1", 30 | "laravel/scout": "^10.0", 31 | "orchestra/testbench": "^9.0 || ^10.0", 32 | "phpstan/phpstan": "^2.0", 33 | "phpunit/phpunit": "^11.0", 34 | "tomasvotruba/type-coverage": "^2.0" 35 | }, 36 | "suggest": { 37 | "aaronfrancis/fast-paginate": "Improves jsonApiPaginate method performance (SQL database query pagination)", 38 | "laravel/scout": "Integrate search with JsonApiResponse using its search method" 39 | }, 40 | "minimum-stability": "dev", 41 | "prefer-stable": true, 42 | "autoload": { 43 | "psr-4": { 44 | "OpenSoutheners\\LaravelApiable\\": "src" 45 | }, 46 | "files": [ 47 | "./helpers.php" 48 | ] 49 | }, 50 | "autoload-dev": { 51 | "psr-4": { 52 | "OpenSoutheners\\LaravelApiable\\Tests\\": "tests" 53 | } 54 | }, 55 | "config": { 56 | "sort-packages": true 57 | }, 58 | "extra": { 59 | "laravel": { 60 | "providers": [ 61 | "OpenSoutheners\\LaravelApiable\\ServiceProvider" 62 | ] 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Http/ApplyFulltextSearchToQuery.php: -------------------------------------------------------------------------------- 1 | query->getModel(); 21 | $userSearchQuery = $request->searchQuery(); 22 | 23 | if ( 24 | ! $request->isSearchAllowed() 25 | || ! class_use($queryModel, 'Laravel\Scout\Searchable') 26 | || ! method_exists($queryModel, 'search') 27 | || empty($userSearchQuery) 28 | ) { 29 | return $next($request); 30 | } 31 | 32 | $scoutBuilder = $queryModel::search($userSearchQuery); 33 | 34 | $this->applyFilters($scoutBuilder, $request->userAllowedSearchFilters()); 35 | 36 | $request->query->whereKey($scoutBuilder->keys()->toArray()); 37 | 38 | return $next($request); 39 | } 40 | 41 | /** 42 | * Apply filters to search query (Scout). 43 | * 44 | * @param \Laravel\Scout\Builder $query 45 | * @param array $searchFilters 46 | * @return void 47 | */ 48 | protected function applyFilters($query, array $searchFilters) 49 | { 50 | if (empty($searchFilters)) { 51 | return; 52 | } 53 | 54 | foreach ($searchFilters as $attribute => $values) { 55 | if (count($values) > 1) { 56 | $query->whereIn($attribute, $values); 57 | 58 | continue; 59 | } 60 | 61 | $query->where($attribute, head($values)); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | 9 | strategy: 10 | fail-fast: true 11 | matrix: 12 | os: [ubuntu-latest] 13 | php: [8.2, 8.3, 8.4] 14 | stability: [prefer-stable] 15 | laravel: [11.*, 12.*] 16 | include: 17 | - laravel: 11.* 18 | testbench: 9.* 19 | 20 | - laravel: 12.* 21 | testbench: 10.* 22 | 23 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} 24 | 25 | steps: 26 | - name: 🏗 Checkout code 27 | uses: actions/checkout@v4 28 | 29 | - name: 🏗 Setup PHP 30 | uses: shivammathur/setup-php@v2 31 | with: 32 | php-version: ${{ matrix.php }} 33 | coverage: pcov 34 | 35 | - name: 🏗 Get composer cache directory 36 | id: composer-cache 37 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 38 | 39 | - name: 🏗 Cache dependencies 40 | uses: actions/cache@v4 41 | with: 42 | path: ${{ steps.composer-cache.outputs.dir }} 43 | key: dependencies-composer-laravel-${{ matrix.laravel }}-${{ hashFiles('**/composer.lock') }} 44 | restore-keys: dependencies-composer-laravel-${{ matrix.laravel }}- 45 | 46 | - name: 📦 Install dependencies 47 | run: | 48 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 49 | composer update --prefer-dist --no-interaction --no-progress --${{ matrix.stability }} 50 | 51 | - name: 🧪 Execute tests 52 | run: vendor/bin/phpunit -c phpunit.coverage.dist.xml 53 | 54 | - name: 🚀 Upload coverage reports to Codecov 55 | uses: codecov/codecov-action@v5 56 | with: 57 | token: ${{ secrets.CODECOV_TOKEN }} 58 | flags: php${{ matrix.php }}-laravel${{ matrix.laravel }} 59 | files: ./clover.xml 60 | fail_ci_if_error: true 61 | # verbose: true 62 | -------------------------------------------------------------------------------- /docs/comparison.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Comparison 4 | category: Digging deeper 5 | --- 6 | 7 | # Comparison 8 | 9 | Of course there are lot more libraries out there for Laravel and even more generic (for PHP) that we can also use instead of this one. 10 | 11 | So here we'll explain the differences between them and this one. 12 | 13 | ## skore-labs/laravel-json-api 14 | 15 | **[Link to repository](https://github.com/laravel-json-api/laravel)** 16 | 17 | Its our own! But now in a different organisation + renewed with more stuff on top of it (like a built-in query builder, JSON:API error handling, etc). 18 | 19 | We recommend you to update to this one if you feel ready to jump to an almost similar experience, **but keep in mind this one requires Laravel 9+ and PHP 8.0+**. 20 | 21 | ## laravel-json-api/laravel 22 | 23 | **[Link to repository](https://github.com/laravel-json-api/laravel)** 24 | 25 | This is very similar to this new Laravel Apiable. Only problem thought is this package seems to achieve the same by an odd way and requires to add more "layers" on top of the ones that Laravel's already provides (API resources, etc). 26 | 27 | Also it comes licensed as Apache 2.0, while our is reusing the same license as Laravel does: MIT. 28 | 29 | ## spatie/laravel-query-builder 30 | 31 | **[Link to repository](https://github.com/spatie/laravel-query-builder)** 32 | 33 | Disadvantages compared to this: 34 | 35 | - Doesn't integrate well with [GeneaLabs/laravel-model-caching](https://github.com/GeneaLabs/laravel-model-caching). 36 | - Doesn't provide filter methods out-of-the-box for scopes (you need to create all by your own). 37 | - Doesn't provide ability to use appended attributes in filters, sorts & appends (Laravel accessors). 38 | - Doesn't do JSON:API response formatting. 39 | 40 | ## Fractal 41 | 42 | **[Link to repository](https://github.com/thephpleague/fractal)** 43 | 44 | Much simpler than the one above, but still adds a new layer (as **it is not a Laravel package**). 45 | 46 | So it's much of the same, doesn't take advantage of the tools that the framework already provides like the API resources. Still a very good option thought as its one of the official _The PHP League_ packages, so you'll expect a very good support. -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | withoutExceptionHandling(); 23 | } 24 | 25 | /** 26 | * Get package providers. 27 | * 28 | * @param \Illuminate\Foundation\Application $app 29 | * @return array 30 | */ 31 | protected function getPackageProviders($app) 32 | { 33 | return [ 34 | ScoutServiceProvider::class, 35 | ServiceProvider::class, 36 | ]; 37 | } 38 | 39 | /** 40 | * Define environment setup. 41 | * 42 | * @param \Illuminate\Foundation\Application $app 43 | * @return void 44 | */ 45 | protected function defineEnvironment($app) 46 | { 47 | // Setup default database to use sqlite :memory: 48 | $app['config']->set('database.default', 'testing'); 49 | $app['config']->set('database.connections.testing', [ 50 | 'driver' => 'sqlite', 51 | 'database' => ':memory:', 52 | 'prefix' => '', 53 | ]); 54 | 55 | // Setup package own config (statuses) 56 | $app['config']->set('apiable', include __DIR__.'/../config/apiable.php'); 57 | $app['config']->set('apiable.resource_type_map', [ 58 | Plan::class => 'plan', 59 | Post::class => 'post', 60 | Tag::class => 'label', 61 | User::class => 'client', 62 | ]); 63 | } 64 | 65 | /** 66 | * Define database migrations. 67 | * 68 | * @return void 69 | */ 70 | protected function defineDatabaseMigrations() 71 | { 72 | $this->loadLaravelMigrations(); 73 | $this->loadMigrationsFrom(__DIR__.'/database'); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Testing/Concerns/HasAttributes.php: -------------------------------------------------------------------------------- 1 | |string $value 22 | * @return $this 23 | */ 24 | public function hasAttribute($name, $value = null) 25 | { 26 | PHPUnit::assertArrayHasKey($name, $this->attributes, sprintf('JSON:API response does not have an attribute named "%s"', $name)); 27 | 28 | if ($value) { 29 | PHPUnit::assertContains($value, $this->attributes, sprintf('JSON:API response does not have an attribute named "%s" with value "%s"', $name, json_encode($value))); 30 | } 31 | 32 | return $this; 33 | } 34 | 35 | /** 36 | * Assert that a resource does not has an attribute with name and value (optional). 37 | * 38 | * @param int|string $name 39 | * @param array|string $value 40 | * @return $this 41 | */ 42 | public function hasNotAttribute($name, $value = null) 43 | { 44 | PHPUnit::assertArrayNotHasKey($name, $this->attributes, sprintf('JSON:API response does not have an attribute named "%s"', $name)); 45 | 46 | if ($value) { 47 | PHPUnit::assertNotContains($value, $this->attributes, sprintf('JSON:API response does not have an attribute named "%s" with value "%s"', $name, json_encode($value))); 48 | } 49 | 50 | return $this; 51 | } 52 | 53 | /** 54 | * Assert that a resource has an array of attributes with names and values (optional). 55 | * 56 | * @param mixed $attributes 57 | * @return $this 58 | */ 59 | public function hasAttributes($attributes) 60 | { 61 | foreach ($attributes as $name => $value) { 62 | $this->hasAttribute($name, $value); 63 | } 64 | 65 | return $this; 66 | } 67 | 68 | /** 69 | * Assert that a resource does not has an array of attributes with names and values (optional). 70 | * 71 | * @param mixed $attributes 72 | * @return $this 73 | */ 74 | public function hasNotAttributes($attributes) 75 | { 76 | foreach ($attributes as $name => $value) { 77 | $this->hasNotAttribute($name, $value); 78 | } 79 | 80 | return $this; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Http/Concerns/AllowsFields.php: -------------------------------------------------------------------------------- 1 | > 17 | */ 18 | protected $allowedFields = []; 19 | 20 | /** 21 | * Get all fields from request. 22 | * 23 | * @return array 24 | */ 25 | public function fields() 26 | { 27 | $fields = $this->request->get('fields', []); 28 | 29 | foreach ($fields as $type => $columns) { 30 | $fields[$type] = explode(',', $columns); 31 | } 32 | 33 | return array_filter($fields); 34 | } 35 | 36 | /** 37 | * Allow sparse fields (columns or accessors) for a specific resource type. 38 | * 39 | * @param \OpenSoutheners\LaravelApiable\Http\AllowedFields|class-string<\Illuminate\Database\Eloquent\Model>|string $type 40 | * @param array|string|null $attributes 41 | * @return $this 42 | */ 43 | public function allowFields($type, $attributes = null) 44 | { 45 | if ($type instanceof AllowedFields) { 46 | $this->allowedFields = array_merge($this->allowedFields, $type->toArray()); 47 | 48 | return $this; 49 | } 50 | 51 | if (class_exists($type) && is_subclass_of($type, Model::class)) { 52 | $type = Apiable::getResourceType($type); 53 | } 54 | 55 | $this->allowedFields = array_merge($this->allowedFields, [$type => (array) $attributes]); 56 | 57 | return $this; 58 | } 59 | 60 | public function userAllowedFields() 61 | { 62 | return $this->validator($this->fields()) 63 | ->givingRules($this->allowedFields) 64 | ->when( 65 | function ($key, $modifiers, $values, $rules, &$valids) { 66 | $valids = array_intersect($values, $rules); 67 | 68 | return empty(array_diff($values, $rules)); 69 | }, 70 | fn ($key, $values) => throw new Exception(sprintf('"%s" fields for resource type "%s" cannot be sparsed', implode(', ', $values), $key)) 71 | ) 72 | ->validate(); 73 | } 74 | 75 | /** 76 | * Get list of allowed fields per resource type. 77 | * 78 | * @return array> 79 | */ 80 | public function getAllowedFields() 81 | { 82 | return $this->allowedFields; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Http/Concerns/AllowsAppends.php: -------------------------------------------------------------------------------- 1 | > 17 | */ 18 | protected $allowedAppends = []; 19 | 20 | /** 21 | * Get user append attributes from request. 22 | */ 23 | public function appends(): array 24 | { 25 | $appends = $this->request->get('appends', []); 26 | 27 | foreach ($appends as $type => $attributes) { 28 | $appends[$type] = explode(',', $attributes); 29 | } 30 | 31 | return array_filter($appends); 32 | } 33 | 34 | /** 35 | * Allow the include of model accessors (attributes). 36 | * 37 | * @param \OpenSoutheners\LaravelApiable\Http\AllowedAppends|class-string<\Illuminate\Database\Eloquent\Model>|string $type 38 | */ 39 | public function allowAppends(AllowedAppends|string $type, ?array $attributes = null): self 40 | { 41 | if ($type instanceof AllowedAppends) { 42 | $this->allowedAppends = array_merge($this->allowedAppends, $type->toArray()); 43 | 44 | return $this; 45 | } 46 | 47 | if (class_exists($type) && is_subclass_of($type, Model::class)) { 48 | $type = Apiable::getResourceType($type); 49 | } 50 | 51 | $this->allowedAppends = array_merge($this->allowedAppends, [$type => (array) $attributes]); 52 | 53 | return $this; 54 | } 55 | 56 | /** 57 | * Get appends that passed the validation. 58 | */ 59 | public function userAllowedAppends(): array 60 | { 61 | return $this->validator($this->appends()) 62 | ->givingRules($this->allowedAppends) 63 | ->when( 64 | function ($key, $modifiers, $values, $rules, &$valids) { 65 | $valids = array_intersect($values, $rules); 66 | 67 | return empty(array_diff($values, $rules)); 68 | }, 69 | fn ($key, $values) => throw new Exception(sprintf('"%s" fields for resource type "%s" cannot be sparsed', implode(', ', $values), $key)) 70 | ) 71 | ->validate(); 72 | } 73 | 74 | /** 75 | * Get list of allowed appends per resource type. 76 | * 77 | * @return array> 78 | */ 79 | public function getAllowedAppends(): array 80 | { 81 | return $this->allowedAppends; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/Fixtures/Post.php: -------------------------------------------------------------------------------- 1 | $this->id, 39 | 'title' => $this->title, 40 | 'content' => $this->content, 41 | ]; 42 | } 43 | 44 | /** 45 | * Get its parent post. 46 | * 47 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 48 | */ 49 | public function parent() 50 | { 51 | return $this->belongsTo(self::class); 52 | } 53 | 54 | /** 55 | * Get its author user. 56 | * 57 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 58 | */ 59 | public function author() 60 | { 61 | return $this->belongsTo(User::class, 'author_id'); 62 | } 63 | 64 | /** 65 | * Get its tags. 66 | * 67 | * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany 68 | */ 69 | public function tags() 70 | { 71 | return $this->belongsToMany(Tag::class); 72 | } 73 | 74 | /** 75 | * Return whether the post is published. 76 | * 77 | * @return bool 78 | */ 79 | public function getIsPublishedAttribute() 80 | { 81 | return true; 82 | } 83 | 84 | /** 85 | * Query posts by active status. 86 | * 87 | * @param string $value 88 | * @return void 89 | */ 90 | public function scopeStatus(Builder $query, $value) 91 | { 92 | $query->where('status', $value); 93 | } 94 | 95 | /** 96 | * Query posts by active status. 97 | * 98 | * @return void 99 | */ 100 | public function scopeActive(Builder $query) 101 | { 102 | $query->where('status', 'Active'); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Builder.php: -------------------------------------------------------------------------------- 1 | $columns 21 | * @return \OpenSoutheners\LaravelApiable\Http\Resources\JsonApiCollection 22 | */ 23 | return function (null|int|string $pageSize = null, array $columns = ['*'], string $pageName = 'page.number', ?int $page = null) { 24 | $page ??= request($pageName, 1); 25 | $pageSize ??= $this->getModel()->getPerPage(); 26 | $requestedPageSize = (int) request('page.size', Apiable::config('responses.pagination.default_size')); 27 | 28 | if ($requestedPageSize && (! $pageSize || $requestedPageSize !== $pageSize)) { 29 | $pageSize = $requestedPageSize; 30 | } 31 | 32 | /** 33 | * FIXME: This is needed as Laravel is very inconsistent, request get is using dots 34 | * while paginator doesn't represent them... 35 | */ 36 | $pageNumberParamName = rawurldecode(Str::beforeLast(Arr::query(Arr::undot([$pageName => ''])), '=')); 37 | 38 | // @codeCoverageIgnoreStart 39 | if (class_exists("Hammerstone\FastPaginate\FastPaginate") || class_exists("AaronFrancis\FastPaginate\FastPaginate")) { 40 | return Apiable::toJsonApi( 41 | $this->fastPaginate($pageSize, $columns, $pageNumberParamName, $page) 42 | ); 43 | } 44 | // @codeCoverageIgnoreEnd 45 | 46 | $results = ($total = $this->toBase()->getCountForPagination()) 47 | ? $this->forPage($page, $pageSize)->get($columns) 48 | : $this->getModel()->newCollection(); 49 | 50 | return Apiable::toJsonApi($this->paginator($results, $total, $pageSize, $page, [ 51 | 'path' => Paginator::resolveCurrentPath(), 52 | 'pageName' => $pageNumberParamName, 53 | ])); 54 | }; 55 | } 56 | 57 | public function hasJoin() 58 | { 59 | /** 60 | * Check whether join is already on the query instance. 61 | * 62 | * @param string $joinTable 63 | * @return bool 64 | */ 65 | return function ($joinTable) { 66 | $joins = $this->getQuery()->joins; 67 | 68 | if ($joins === null) { 69 | return false; 70 | } 71 | 72 | foreach ($joins as $join) { 73 | if ($join->table === $joinTable) { 74 | return true; 75 | } 76 | } 77 | 78 | return false; 79 | }; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Http/ApplyFieldsToQuery.php: -------------------------------------------------------------------------------- 1 | query->toBase()->columns ?: []) === 0) { 21 | $request->query->select($request->query->qualifyColumn('*')); 22 | } 23 | 24 | if (count($request->fields()) === 0 || count($request->getAllowedFields()) === 0) { 25 | return $next($request); 26 | } 27 | 28 | $this->applyFields($request->query, $request->userAllowedFields()); 29 | 30 | return $next($request); 31 | } 32 | 33 | /** 34 | * Apply array of fields to the query. 35 | * 36 | * @return \Illuminate\Database\Eloquent\Builder 37 | */ 38 | protected function applyFields(Builder $query, array $fields) 39 | { 40 | /** @var \OpenSoutheners\LaravelApiable\Contracts\JsonApiable|\Illuminate\Database\Eloquent\Model $mainQueryModel */ 41 | $mainQueryModel = $query->getModel(); 42 | $mainQueryResourceType = Apiable::getResourceType($mainQueryModel); 43 | $queryEagerLoaded = $query->getEagerLoads(); 44 | 45 | // TODO: Move this to some class methods 46 | foreach ($fields as $type => $columns) { 47 | $typeModel = Apiable::getModelFromResourceType($type); 48 | 49 | $matchedFn = match (true) { 50 | $mainQueryResourceType === $type => function () use ($query, $mainQueryModel, $columns) { 51 | if (! in_array($mainQueryModel->getKeyName(), $columns)) { 52 | $columns[] = $mainQueryModel->getQualifiedKeyName(); 53 | } 54 | 55 | $query->select($mainQueryModel->qualifyColumns($columns)); 56 | }, 57 | in_array($typeModel, $queryEagerLoaded) => fn () => $query->with($type, function (Builder $query) use ($queryEagerLoaded, $type, $columns) { 58 | $relatedModel = $query->getModel(); 59 | 60 | if (! in_array($relatedModel->getKeyName(), $columns)) { 61 | $columns[] = $relatedModel->getKeyName(); 62 | } 63 | 64 | $queryEagerLoaded[$type]($query->select($relatedModel->qualifyColumns($columns))); 65 | }), 66 | default => fn () => null, 67 | }; 68 | 69 | $matchedFn(); 70 | } 71 | 72 | return $query; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Http/Resources/CollectsResources.php: -------------------------------------------------------------------------------- 1 | $resource 19 | * @return mixed 20 | */ 21 | protected function collectResource($resource) 22 | { 23 | $collects = $this->collects(); 24 | 25 | $this->collection = $collects && ! $resource->first() instanceof $collects 26 | ? $this->getFiltered($resource, $collects) 27 | : $resource->toBase(); 28 | 29 | return $resource instanceof AbstractPaginator 30 | ? $resource->setCollection($this->collection) 31 | : $this->collection; 32 | } 33 | 34 | /** 35 | * Get resource collection filtered by authorisation. 36 | * 37 | * @param \Illuminate\Pagination\AbstractPaginator<\OpenSoutheners\LaravelApiable\Contracts\JsonApiable>|\Illuminate\Support\Collection<\OpenSoutheners\LaravelApiable\Contracts\JsonApiable> $resource 38 | * @param class-string<\OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource> $collects 39 | * @return \Illuminate\Support\Collection 40 | */ 41 | protected function getFiltered($resource, $collects) 42 | { 43 | if ($resource instanceof AbstractPaginator) { 44 | $resource = $resource->getCollection(); 45 | } 46 | 47 | return $resource->mapInto($collects); 48 | } 49 | 50 | /** 51 | * Get the resource that this resource collects. 52 | * 53 | * @return string|null 54 | */ 55 | protected function collects() 56 | { 57 | if ($this->collects) { 58 | return $this->collects; 59 | } 60 | 61 | if (Str::endsWith(class_basename($this), 'Collection') && 62 | class_exists($class = Str::replaceLast('Collection', '', get_class($this)))) { 63 | return $class; 64 | } 65 | } 66 | 67 | /** 68 | * Get the JSON serialization options that should be applied to the resource response. 69 | * 70 | * @return int 71 | */ 72 | public function jsonOptions() 73 | { 74 | $collects = $this->collects(); 75 | 76 | if (! $collects) { 77 | return 0; 78 | } 79 | 80 | return (new ReflectionClass($collects)) 81 | ->newInstanceWithoutConstructor() 82 | ->jsonOptions(); 83 | } 84 | 85 | /** 86 | * Get an iterator for the resource collection. 87 | */ 88 | public function getIterator(): Traversable 89 | { 90 | return $this->collection->getIterator(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Installing and configuring Laravel Apiable into your Laravel application. 3 | --- 4 | 5 | # Introduction 6 | 7 | Install with the following command: 8 | 9 | ```bash 10 | composer require open-southeners/laravel-apiable 11 | ``` 12 | 13 | ## Getting started 14 | 15 | First publish the config file once installed like this: 16 | 17 | ```bash 18 | php artisan vendor:publish --provider="OpenSoutheners\LaravelApiable\ServiceProvider" 19 | ``` 20 | 21 | Then edit the `resource_type_map` part including all your models like this: 22 | 23 | ```php 24 | /** 25 | * Resource type model map. 26 | * 27 | * @see https://docs.opensoutheners.com/laravel-apiable/guide/#getting-started 28 | */ 29 | 'resource_type_map' => [ 30 | App\Models\Film::class => 'film', 31 | App\Models\Review::class => 'review', 32 | ], 33 | ``` 34 | 35 | **If you see, this is same as Laravel's** [**`Relation::enforceMorphMap()`**](https://laravel.com/docs/master/eloquent-relationships#custom-polymorphic-types) **but reversed.** 36 | 37 | ### Setup your models 38 | 39 | {% hint style="info" %} 40 | For more information about how to customise this check out Responses section. 41 | {% endhint %} 42 | 43 | This is a bit of manual work, but you need to setup your models in order for them to be JSON:API serializable entities: 44 | 45 | ```php 46 | use Illuminate\Database\Eloquent\Model; 47 | use OpenSoutheners\LaravelApiable\Contracts\JsonApiable; 48 | use OpenSoutheners\LaravelApiable\Concerns\HasJsonApi; 49 | 50 | class Film extends Model implements JsonApiable 51 | { 52 | use HasJsonApi; 53 | 54 | // rest of your model 55 | } 56 | ``` 57 | 58 | You need to add that `implements JsonApiable` to your class importing this class and the `jsonApiableOptions` method. 59 | 60 | ### Basic transformation usage 61 | 62 | And, finally, use as simple as importing the class `OpenSoutheners\LaravelApiable\Http\Resources\JsonApiCollection` for collections or `OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource` for resources. 63 | 64 | ```php 65 | use OpenSoutheners\LaravelApiable\Http\Resources\JsonApiCollection; 66 | use App\Models\Film; 67 | 68 | class FilmController extends Controller 69 | { 70 | /** 71 | * Display a listing of the resource. 72 | * 73 | * @return \OpenSoutheners\LaravelApiable\Http\Resources\JsonApiCollection<\App\Models\Film> 74 | */ 75 | public function index() 76 | { 77 | return new JsonApiCollection(Film::all()); 78 | } 79 | } 80 | ``` 81 | 82 | ### Error handling 83 | 84 | When your application returns errors and your frontend only understand JSON:API, then these needs to be transform. So we've you cover, set them up by simply doing the following on your `app/Exceptions/Handler.php` 85 | 86 | ```php 87 | /** 88 | * Register the exception handling callbacks for the application. 89 | * 90 | * @return void 91 | */ 92 | public function register() 93 | { 94 | $this->renderable(function (Throwable $e, $request) { 95 | if ($request->is('api/*') && app()->bound('apiable')) { 96 | return apiable()->jsonApiRenderable($e, $request); 97 | } 98 | }); 99 | 100 | // Rest of the register method... 101 | } 102 | ``` 103 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Install with the following command: 4 | 5 | 6 | 7 | 8 | ```bash:no-line-numbers 9 | composer require open-southeners/laravel-apiable 10 | ``` 11 | 12 | 13 | 14 | 15 | ## Getting started 16 | 17 | First publish the config file once installed like this: 18 | 19 | ```bash:no-line-numbers 20 | php artisan vendor:publish --provider="OpenSoutheners\LaravelApiable\ServiceProvider" 21 | ``` 22 | 23 | Then edit the `resource_type_map` part including all your models like this: 24 | 25 | ```php 26 | /** 27 | * Resource type model map. 28 | * 29 | * @see https://docs.opensoutheners.com/laravel-apiable/guide/#getting-started 30 | */ 31 | 'resource_type_map' => [ 32 | App\Models\Film::class => 'film', 33 | App\Models\Review::class => 'review', 34 | ], 35 | ``` 36 | 37 | **If you see, this is same as Laravel's [`Relation::enforceMorphMap()`](https://laravel.com/docs/master/eloquent-relationships#custom-polymorphic-types) but reversed.** 38 | 39 | ### Setup your models 40 | 41 | ::: tip 42 | For more information about how to customise this [check out Responses section](responses.md). 43 | ::: 44 | 45 | This is a bit of manual work, but you need to setup your models in order for them to be JSON:API serializable entities: 46 | 47 | ```php 48 | use Illuminate\Database\Eloquent\Model; 49 | use OpenSoutheners\LaravelApiable\Contracts\JsonApiable; 50 | use OpenSoutheners\LaravelApiable\Concerns\HasJsonApi; 51 | 52 | class Film extends Model implements JsonApiable 53 | { 54 | use HasJsonApi; 55 | 56 | // rest of your model 57 | } 58 | ``` 59 | 60 | You need to add that `implements JsonApiable` to your class importing this class and the `jsonApiableOptions` method. 61 | 62 | ### Basic transformation usage 63 | 64 | And, finally, use as simple as importing the class `OpenSoutheners\LaravelApiable\Http\Resources\JsonApiCollection` for collections or `OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource` for resources. 65 | 66 | ```php 67 | use OpenSoutheners\LaravelApiable\Http\Resources\JsonApiCollection; 68 | use App\Models\Film; 69 | 70 | class FilmController extends Controller 71 | { 72 | /** 73 | * Display a listing of the resource. 74 | * 75 | * @return \OpenSoutheners\LaravelApiable\Http\Resources\JsonApiCollection<\App\Models\Film> 76 | */ 77 | public function index() 78 | { 79 | return new JsonApiCollection(Film::all()); 80 | } 81 | } 82 | ``` 83 | 84 | ### Error handling 85 | 86 | When your application returns errors and your frontend only understand JSON:API, then these needs to be transform. So we've you cover, set them up by simply doing the following on your `app/Exceptions/Handler.php` 87 | 88 | ```php 89 | /** 90 | * Register the exception handling callbacks for the application. 91 | * 92 | * @return void 93 | */ 94 | public function register() 95 | { 96 | $this->renderable(function (Throwable $e, $request) { 97 | if ($request->is('api/*') && app()->bound('apiable')) { 98 | return apiable()->jsonApiRenderable($e, $request); 99 | } 100 | }); 101 | 102 | // Rest of the register method... 103 | } 104 | ``` 105 | -------------------------------------------------------------------------------- /src/Http/Concerns/AllowsSorts.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | protected array $allowedSorts = []; 18 | 19 | /** 20 | * @var array 21 | */ 22 | protected array $defaultSorts = []; 23 | 24 | /** 25 | * Get user sorts from request. 26 | */ 27 | public function sorts(): array 28 | { 29 | $sortsSourceArr = array_filter(explode(',', $this->request->get('sort', ''))); 30 | $sortsArr = []; 31 | 32 | while ($sort = array_pop($sortsSourceArr)) { 33 | $attribute = $sort; 34 | $direction = $sort[0] === '-' ? 'desc' : 'asc'; 35 | 36 | if ($direction === 'desc') { 37 | $attribute = ltrim($attribute, '-'); 38 | } 39 | 40 | $sortsArr[$attribute] = $direction; 41 | } 42 | 43 | return $sortsArr; 44 | } 45 | 46 | /** 47 | * Allow sorting by the following attribute and direction. 48 | * 49 | * @param \OpenSoutheners\LaravelApiable\Http\AllowedSort|array|string $attribute 50 | * @param int|null $direction 51 | */ 52 | public function allowSort($attribute, $direction = null): static 53 | { 54 | $this->allowedSorts = array_merge( 55 | $this->allowedSorts, 56 | $attribute instanceof AllowedSort 57 | ? $attribute->toArray() 58 | : (new AllowedSort($attribute, $direction))->toArray(), 59 | 60 | ); 61 | 62 | return $this; 63 | } 64 | 65 | /** 66 | * Default sort by the following attribute and direction when no user sorts are being applied. 67 | * 68 | * @param \OpenSoutheners\LaravelApiable\Http\DefaultSort|array|string $attribute 69 | * @param int|null $direction 70 | */ 71 | public function applyDefaultSort($attribute, $direction = null): static 72 | { 73 | $this->defaultSorts = array_merge( 74 | $this->defaultSorts, 75 | $attribute instanceof DefaultSort 76 | ? $attribute->toArray() 77 | : (new DefaultSort($attribute, $direction))->toArray(), 78 | 79 | ); 80 | 81 | return $this; 82 | } 83 | 84 | /** 85 | * Get allowed user sorts. 86 | */ 87 | public function userAllowedSorts(): array 88 | { 89 | return $this->validator($this->sorts()) 90 | ->givingRules($this->allowedSorts) 91 | ->when(function ($key, $modifiers, $values, $rules) { 92 | if ($rules === '*') { 93 | return true; 94 | } 95 | 96 | return $values === $rules; 97 | }, fn ($key) => new Exception(sprintf('"%s" is not sortable', $key))) 98 | ->validate(); 99 | } 100 | 101 | /** 102 | * Get list of allowed sorts. 103 | * 104 | * @return array 105 | */ 106 | public function getAllowedSorts(): array 107 | { 108 | return $this->allowedSorts; 109 | } 110 | 111 | /** 112 | * Get list of default sorts with their directions. 113 | * 114 | * @return array 115 | */ 116 | public function getDefaultSorts(): array 117 | { 118 | return $this->defaultSorts; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing guidelines 2 | 3 | Thank you very much for your contributions! 4 | 5 | Remember that we are an open source organisation that will always accept any external contribution and/or help (if it follows this guidelines). 6 | 7 | ## Table of Contents 8 | 9 | - [Hacktoberfest](#hacktoberfest) 10 | - [How to Contribute](#how-to-contribute) 11 | - [Development Workflow](#development-workflow) 12 | - [Git Guidelines](#git-guidelines) 13 | - [Release Process (internal team only)](#release-process-internal-team-only) 14 | 15 | ## Hacktoberfest 16 | 17 | This is our first [Hacktoberfest](https://hacktoberfest.com)!!! 🌶️ 🔥 18 | 19 | We appreciate ALL contributions very much with a big thank you to any contributor that is looking to help us! 20 | 21 | 1. We will follow the quality standards set by the organizers of Hacktoberfest (see detail on their [website](https://hacktoberfest.com/participation/#spam)). We **WILL NOT** consider any PR that doesn’t match that standard. 22 | 2. PRs might be reviewed at any time (some weekends included) as we're an open source organisation working for multiple projects (commercial and non-commercial), we are based at EU so our timezone is based in CEST. 23 | 3. We won't assign tasks labeled as hacktoberfest, so whoever make it first and right will be merged. 24 | 25 | ## How to Contribute 26 | 27 | 1. We must first see the idea you had behind, **if you found [already an issue](https://github.com/open-southeners/laravel-apiable/issues) go ahead**, otherwise **comunicate first** via [Github issue](https://github.com/open-southeners/laravel-apiable/issues/new/choose) or [Discord](https://discord.gg/tyMUxvMnvh). 28 | 2. Once approved the idea (and opened the issue), [fork this repository](https://github.com/open-southeners/laravel-apiable/fork). 29 | 3. Read and make sure that the [Development Workflow](#development-workflow) is applied properly. 30 | 4. [Submit the branch as a Pull Request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) pointing to the `main` branch of this repository. 31 | 5. All done! Now wait until we review the changes of your Pull Request. 32 | 33 | ## Development workflow 34 | 35 | Share an idea, voting that idea then, finally, implement it: Code it, test it. That's our methodology. 36 | 37 | ### Code style 38 | 39 | We enforce to use [Laravel Pint](https://laravel.com/docs/9.x/pint) with laravel as preset, please remember this before sending your contributions, otherwise send them fixed later 💅. 40 | 41 | If you're using VSCode you can also check our own extension integrating Laravel Pint: https://marketplace.visualstudio.com/items?itemName=open-southeners.laravel-pint. 42 | 43 | ### Testing 44 | 45 | **All tests must pass** and we might consider writing some more tests if the contribution requires. 46 | 47 | **Any aditional test adding more coverage will be more than welcome!** 48 | 49 | ## Git Guidelines 50 | 51 | We do not enforce many rules on 52 | 53 | ### Using branches 54 | 55 | **We do not enforce this**, but its recommended. Otherwise **make sure you are contributing from your own forked** version of this repository. 56 | 57 | We do not enforce any branch naming style, but please use something descriptive of your changes. 58 | 59 | ### Descriptive commit messages 60 | 61 | We do not enforce any rule (commitlint) or anything to this repository. 62 | 63 | But being descriptive in the commit messages **is a must**. 64 | 65 | ## Release Process (internal team only) 66 | 67 | This is only for us, you should not perform neither take care of any of this. 68 | 69 | ### Changelog generation 70 | 71 | We do this manually by writing down carefully all the parts added, removed, fixed, changed, etc... 72 | 73 | Just take a look at the standard: https://keepachangelog.com/en/1.0.0/ 74 | -------------------------------------------------------------------------------- /src/Http/QueryParamsValidator.php: -------------------------------------------------------------------------------- 1 | rules = $rules; 30 | 31 | return $this; 32 | } 33 | 34 | /** 35 | * Validate when patterns are matched (using rules instead). 36 | * 37 | * @param callable|\Throwable $exception 38 | */ 39 | public function whenPatternMatches($exception, array $patterns = []): self 40 | { 41 | return $this->when(function ($key, $modifiers, $values, $rules, &$valids) use ($patterns) { 42 | $paramPattern = $patterns[$key]['values'] ?? $rules['values']; 43 | 44 | if ($paramPattern === '*') { 45 | return true; 46 | } 47 | 48 | $values = (array) $values; 49 | 50 | $valids = array_filter($values, fn ($value) => Str::is( 51 | $paramPattern, 52 | is_array($value) ? head($value) : $value) 53 | ); 54 | 55 | return count($values) === count($valids); 56 | }, $exception); 57 | } 58 | 59 | /** 60 | * Validate when condition function passes, throws exception otherwise. 61 | * 62 | * @param callable|\Throwable $exception 63 | */ 64 | public function when(Closure $condition, $exception): self 65 | { 66 | $this->validationCallbacks[] = [$condition, $exception]; 67 | 68 | return $this; 69 | } 70 | 71 | /** 72 | * Performs validation running all conditions on each query parameter. 73 | */ 74 | public function validate(): array 75 | { 76 | $filteredResults = []; 77 | 78 | foreach ($this->params as $key => $values) { 79 | foreach ($this->validationCallbacks as $callback) { 80 | [$condition, $exception] = $callback; 81 | 82 | $valids = []; 83 | $rulesForKey = $this->rules === false ? $this->rules : ($this->rules[$key] ?? null); 84 | $queryParamValues = Arr::isAssoc((array) $values) ? array_values($values) : $values; 85 | $queryParamModifiers = Arr::isAssoc((array) $values) ? array_keys($values) : []; 86 | 87 | if (is_string($queryParamValues) && Str::contains($queryParamValues, ',')) { 88 | $queryParamValues = explode(',', $values); 89 | } 90 | 91 | $conditionResult = is_null($rulesForKey) 92 | ? false 93 | : $condition($key, $queryParamModifiers, $queryParamValues, $rulesForKey, $valids); 94 | 95 | if (! $conditionResult && $this->enforceValidation) { 96 | is_callable($exception) ? $exception($key, $queryParamValues) : throw $exception; 97 | } 98 | 99 | if ($conditionResult || count($valids) > 0) { 100 | $filteredResults[$key] = count($valids) > 0 ? $valids : $values; 101 | } 102 | } 103 | } 104 | 105 | return $filteredResults; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Http/Concerns/AllowsSearch.php: -------------------------------------------------------------------------------- 1 | queryParameters()->get('q', $this->queryParameters()->get('search', [])), 32 | fn ($item) => is_string($item) 33 | )); 34 | } 35 | 36 | /** 37 | * Get user search query filters from request. 38 | * 39 | * @return string[] 40 | */ 41 | public function searchFilters() 42 | { 43 | return array_reduce(array_filter( 44 | $this->queryParameters()->get('q', $this->queryParameters()->get('search', [])), 45 | fn ($item) => is_array($item) && head(array_keys($item)) === 'filter' 46 | ), function ($result, $item) { 47 | $filterFromItem = head(array_values($item)); 48 | 49 | $result[head(array_keys($filterFromItem))] = [ 50 | 'values' => head(array_values($filterFromItem)), 51 | ]; 52 | 53 | return $result; 54 | }); 55 | } 56 | 57 | /** 58 | * Allow fulltext search to be performed. 59 | * 60 | * @return $this 61 | */ 62 | public function allowSearch(bool $value = true) 63 | { 64 | $this->allowedSearch = $value; 65 | 66 | return $this; 67 | } 68 | 69 | /** 70 | * Allow filter search by attribute and pattern of value(s). 71 | * 72 | * @param \OpenSoutheners\LaravelApiable\Http\AllowedSearchFilter|string $attribute 73 | * @param array|string $values 74 | * @return $this 75 | */ 76 | public function allowSearchFilter($attribute, $values = ['*']) 77 | { 78 | $this->allowedSearchFilters = array_merge_recursive( 79 | $this->allowedSearchFilters, 80 | $attribute instanceof AllowedSearchFilter 81 | ? $attribute->toArray() 82 | : (new AllowedSearchFilter($attribute, $values))->toArray() 83 | ); 84 | 85 | return $this; 86 | } 87 | 88 | /** 89 | * Check if fulltext search is allowed. 90 | * 91 | * @return bool 92 | */ 93 | public function isSearchAllowed() 94 | { 95 | return $this->allowedSearch; 96 | } 97 | 98 | /** 99 | * Get user requested search filters filtered by allowed ones. 100 | * 101 | * @return array 102 | */ 103 | public function userAllowedSearchFilters() 104 | { 105 | $searchFilters = $this->searchFilters(); 106 | 107 | if (empty($searchFilters)) { 108 | return []; 109 | } 110 | 111 | return $this->validator($this->searchFilters()) 112 | ->givingRules($this->allowedSearchFilters) 113 | ->whenPatternMatches(fn ($key) => throw new Exception(sprintf('"%s" is not filterable on search or contains invalid values', $key))) 114 | ->validate(); 115 | } 116 | 117 | /** 118 | * Get list of allowed search filters. 119 | * 120 | * @return array 121 | */ 122 | public function getAllowedSearchFilters() 123 | { 124 | return $this->allowedSearchFilters; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Http/ApplySortsToQuery.php: -------------------------------------------------------------------------------- 1 | userAllowedSorts() ?: $request->getDefaultSorts(); 21 | 22 | $this->applySorts($request->query, $userSorts); 23 | 24 | return $next($request); 25 | } 26 | 27 | /** 28 | * Apply array of sorts to the query. 29 | */ 30 | protected function applySorts(Builder $query, array $sorts): void 31 | { 32 | foreach ($sorts as $attribute => $direction) { 33 | $query->orderBy($this->getQualifiedAttribute($query, $attribute, $direction), $direction); 34 | } 35 | } 36 | 37 | /** 38 | * Get attribute adding a join when sorting by relationship or a column sort. 39 | * 40 | * @return string|\Closure|\Illuminate\Database\Eloquent\Builder 41 | */ 42 | protected function getQualifiedAttribute(Builder $query, string $attribute, string $direction) 43 | { 44 | $queryModel = $query->getModel(); 45 | 46 | if (! str_contains($attribute, '.')) { 47 | return $queryModel->qualifyColumn($attribute); 48 | } 49 | 50 | [$relationship, $column] = explode('.', $attribute); 51 | 52 | if (! method_exists($queryModel, $relationship)) { 53 | return $queryModel->qualifyColumn($column); 54 | } 55 | 56 | /** @var \Illuminate\Database\Eloquent\Relations\HasOneOrMany|\Illuminate\Database\Eloquent\Relations\BelongsTo|\Illuminate\Database\Eloquent\Relations\BelongsToMany $relationshipMethod */ 57 | $relationshipMethod = call_user_func([$queryModel, $relationship]); 58 | $relationshipModel = $relationshipMethod->getRelated(); 59 | 60 | if (is_a($relationshipMethod, BelongsToMany::class)) { 61 | return $relationshipModel->newQuery() 62 | ->select($column) 63 | ->join($relationshipMethod->getTable(), $relationshipMethod->getRelatedPivotKeyName(), $relationshipModel->getQualifiedKeyName()) 64 | ->whereColumn($relationshipMethod->getQualifiedForeignPivotKeyName(), $queryModel->getQualifiedKeyName()) 65 | ->orderBy($column, $direction) 66 | ->limit(1); 67 | } 68 | 69 | $relationshipTable = $relationshipModel->getTable(); 70 | $joinAsRelationshipTable = $relationshipTable; 71 | 72 | if ($relationshipTable === $queryModel->getTable()) { 73 | $joinAsRelationshipTable = "{$relationship}_{$relationshipTable}"; 74 | } 75 | 76 | $joinName = $relationshipTable.($joinAsRelationshipTable !== $relationshipTable ? " as {$joinAsRelationshipTable}" : ''); 77 | 78 | $query->select($queryModel->qualifyColumn('*')); 79 | 80 | $query->when( 81 | ! $query->hasJoin($joinName), 82 | fn (Builder $query) => $query->join( 83 | $joinName, 84 | "{$joinAsRelationshipTable}.{$relationshipMethod->getOwnerKeyName()}", 85 | '=', 86 | $relationshipMethod->getQualifiedForeignKeyName() 87 | ) 88 | ); 89 | 90 | return "{$joinAsRelationshipTable}.{$column}"; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Testing/AssertableJsonApi.php: -------------------------------------------------------------------------------- 1 | id = $id; 30 | $this->type = $type; 31 | 32 | $this->attributes = $attributes; 33 | $this->relationships = $relationships; 34 | $this->includeds = $includeds; 35 | 36 | $this->collection = $collection; 37 | } 38 | 39 | public static function fromTestResponse($response) 40 | { 41 | try { 42 | $content = json_decode($response->getContent(), true); 43 | PHPUnit::assertArrayHasKey('data', $content); 44 | $data = $content['data']; 45 | $collection = []; 46 | 47 | if (static::responseContainsCollection($data)) { 48 | $collection = $data; 49 | $data = head($data); 50 | } 51 | 52 | PHPUnit::assertIsArray($data); 53 | PHPUnit::assertArrayHasKey('id', $data); 54 | PHPUnit::assertArrayHasKey('type', $data); 55 | PHPUnit::assertArrayHasKey('attributes', $data); 56 | PHPUnit::assertIsArray($data['attributes']); 57 | } catch (AssertionFailedError $e) { 58 | PHPUnit::fail('Not a valid JSON:API response or response data is empty.'); 59 | } 60 | 61 | return new self($data['id'], $data['type'], $data['attributes'], $data['relationships'] ?? [], $content['included'] ?? [], $collection); 62 | } 63 | 64 | /** 65 | * Get the instance as an array. 66 | * 67 | * @return array 68 | */ 69 | public function toArray() 70 | { 71 | return $this->attributes; 72 | } 73 | 74 | /** 75 | * Check if data contains a collection of resources. 76 | * 77 | * @return bool 78 | */ 79 | public static function responseContainsCollection(array $data = []) 80 | { 81 | return ! array_key_exists('attributes', $data); 82 | } 83 | 84 | /** 85 | * Assert that actual response is a resource 86 | * 87 | * @return $this 88 | */ 89 | public function isResource() 90 | { 91 | PHPUnit::assertEmpty($this->collection, 'Failed asserting that response is a resource'); 92 | 93 | return $this; 94 | } 95 | 96 | /** 97 | * Get the identifier in a pretty printable message by id and type. 98 | * 99 | * @param mixed $id 100 | * @return string 101 | */ 102 | protected function getIdentifierMessageFor($id = null, ?string $type = null) 103 | { 104 | $messagePrefix = '{ id: %s, type: "%s" }'; 105 | 106 | if (! $id && ! $type) { 107 | return sprintf($messagePrefix.' at position %d', (string) $this->id, $this->type, $this->atPosition); 108 | } 109 | 110 | return sprintf($messagePrefix, (string) $id, $type); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Http/Concerns/ResolvesFromRouteAction.php: -------------------------------------------------------------------------------- 1 | resolveAttributesFrom(new ReflectionClass($controller)); 42 | 43 | if ($method = array_shift($routeAction)) { 44 | $this->resolveAttributesFrom(new ReflectionMethod($controller, $method)); 45 | } 46 | } 47 | } 48 | 49 | /** 50 | * Get PHP query param attributes from reflected class or method. 51 | * 52 | * @param \ReflectionClass|\ReflectionMethod $reflected 53 | * @return void 54 | */ 55 | protected function resolveAttributesFrom($reflected) 56 | { 57 | $allowedQueryParams = array_filter($reflected->getAttributes(), function (ReflectionAttribute $attribute) { 58 | return is_subclass_of($attribute->getName(), QueryParam::class) 59 | || in_array($attribute->getName(), [ApplyDefaultFilter::class, ApplyDefaultSort::class]); 60 | }); 61 | 62 | foreach ($allowedQueryParams as $allowedQueryParam) { 63 | $attributeInstance = $allowedQueryParam->newInstance(); 64 | 65 | match (true) { 66 | $attributeInstance instanceof SearchQueryParam => $this->allowSearch($attributeInstance->allowSearch), 67 | $attributeInstance instanceof SearchFilterQueryParam => $this->allowSearchFilter($attributeInstance->attribute, $attributeInstance->values), 68 | $attributeInstance instanceof FilterQueryParam => $this->allowFilter($attributeInstance->attribute, $attributeInstance->type, $attributeInstance->values), 69 | $attributeInstance instanceof SortQueryParam => $this->allowSort($attributeInstance->attribute, $attributeInstance->direction), 70 | $attributeInstance instanceof IncludeQueryParam => $this->allowInclude($attributeInstance->relationships), 71 | $attributeInstance instanceof FieldsQueryParam => $this->allowFields($attributeInstance->type, $attributeInstance->fields), 72 | $attributeInstance instanceof AppendsQueryParam => $this->allowAppends($attributeInstance->type, $attributeInstance->attributes), 73 | $attributeInstance instanceof ApplyDefaultSort => $this->applyDefaultSort($attributeInstance->attribute, $attributeInstance->direction), 74 | $attributeInstance instanceof ApplyDefaultFilter => $this->applyDefaultFilter($attributeInstance->attribute, $attributeInstance->operator, $attributeInstance->values), 75 | default => null, 76 | }; 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Http/Resources/JsonApiResource.php: -------------------------------------------------------------------------------- 1 | resource = $resource; 32 | 33 | $this->attachModelRelations(); 34 | } 35 | 36 | /** 37 | * Transform the resource into an array. 38 | * 39 | * @param \Illuminate\Http\Request $request 40 | * @return array 41 | */ 42 | public function toArray($request) 43 | { 44 | if (! $this->evaluateResponse()) { 45 | return $this->resource; 46 | } 47 | 48 | $responseArray = $this->getResourceIdentifier(); 49 | 50 | return array_merge($responseArray, [ 51 | 'attributes' => $this->getAttributes(), 52 | 'relationships' => $this->when(! empty($this->relationships), $this->relationships), 53 | ]); 54 | } 55 | 56 | /** 57 | * Test response if valid for formatting. 58 | * 59 | * @return bool 60 | */ 61 | protected function evaluateResponse() 62 | { 63 | return ! is_array($this->resource) 64 | && $this->resource !== null 65 | && ! $this->resource instanceof MissingValue; 66 | } 67 | 68 | /** 69 | * Get object identifier "id" and "type". 70 | * 71 | * @return array 72 | */ 73 | public function getResourceIdentifier() 74 | { 75 | return [ 76 | $this->resource->getKeyName() => (string) $this->resource->getKey(), 77 | 'type' => Apiable::getResourceType($this->resource), 78 | ]; 79 | } 80 | 81 | /** 82 | * Get filtered attributes excluding all the ids. 83 | * 84 | * @return array 85 | */ 86 | protected function getAttributes() 87 | { 88 | return static::filterAttributes( 89 | $this->resource, 90 | array_merge($this->resource->attributesToArray(), $this->withAttributes()) 91 | ); 92 | } 93 | 94 | /** 95 | * Attach additional attributes data. 96 | * 97 | * @return array 98 | */ 99 | protected function withAttributes() 100 | { 101 | return []; 102 | } 103 | 104 | /** 105 | * Customize the response for a request. 106 | * 107 | * @param \Illuminate\Http\Request $request 108 | * @param \Illuminate\Http\JsonResponse $response 109 | * @return void 110 | */ 111 | public function withResponse($request, $response) 112 | { 113 | $response->header('Content-Type', 'application/vnd.api+json'); 114 | } 115 | 116 | /** 117 | * Filter attributes of a resource. 118 | */ 119 | public static function filterAttributes($model, array $attributes): array 120 | { 121 | return array_filter( 122 | $attributes, 123 | function ($value, $key) use ($model) { 124 | $result = $key !== $model->getKeyName() && $value !== null; 125 | 126 | if (! $result) { 127 | return false; 128 | } 129 | 130 | if (! (Apiable::config('responses.include_ids_on_attributes') ?? false)) { 131 | return last(explode('_id', $key)) !== ''; 132 | } 133 | 134 | return true; 135 | }, 136 | ARRAY_FILTER_USE_BOTH 137 | ); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Http/Resources/Json/ResourceCollection.php: -------------------------------------------------------------------------------- 1 | |\Illuminate\Pagination\AbstractPaginator|\Illuminate\Pagination\AbstractCursorPaginator> 18 | */ 19 | class ResourceCollection extends JsonApiResource implements Countable, IteratorAggregate 20 | { 21 | use CollectsResources; 22 | 23 | /** 24 | * The resource that this resource collects. 25 | * 26 | * @var class-string<\OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource> 27 | */ 28 | public $collects; 29 | 30 | /** 31 | * The mapped collection instance. 32 | * 33 | * @var \Illuminate\Support\Collection 34 | */ 35 | public $collection; 36 | 37 | /** 38 | * Indicates if all existing request query parameters should be added to pagination links. 39 | */ 40 | protected bool $preserveQueryParameters = false; 41 | 42 | /** 43 | * The query parameters that should be added to the pagination links. 44 | * 45 | * @var array|null 46 | */ 47 | protected $queryParameters; 48 | 49 | /** 50 | * Create a new resource instance. 51 | */ 52 | public function __construct(mixed $resource) 53 | { 54 | $this->resource = $this->collectResource($resource); 55 | } 56 | 57 | /** 58 | * Indicate that all current query parameters should be appended to pagination links. 59 | */ 60 | public function preserveQuery(): self 61 | { 62 | $this->preserveQueryParameters = true; 63 | 64 | return $this; 65 | } 66 | 67 | /** 68 | * Specify the query string parameters that should be present on pagination links. 69 | */ 70 | public function withQuery(array $query): self 71 | { 72 | $this->preserveQueryParameters = false; 73 | 74 | $this->queryParameters = $query; 75 | 76 | return $this; 77 | } 78 | 79 | /** 80 | * Return the count of items in the resource collection. 81 | */ 82 | public function count(): int 83 | { 84 | return $this->collection->count(); 85 | } 86 | 87 | /** 88 | * Transform the resource into a JSON array. 89 | * 90 | * @param \Illuminate\Http\Request $request 91 | */ 92 | public function toArray($request): array 93 | { 94 | /** @var \Illuminate\Support\Collection $collectionArray */ 95 | $collectionArray = $this->collection->map->toArray($request); 96 | 97 | return $collectionArray->toArray(); 98 | } 99 | 100 | /** 101 | * Create an HTTP response that represents the object. 102 | * 103 | * @param \Illuminate\Http\Request $request 104 | */ 105 | public function toResponse($request): JsonResponse 106 | { 107 | if ($this->resource instanceof AbstractPaginator || $this->resource instanceof AbstractCursorPaginator) { 108 | return $this->preparePaginatedResponse($request); 109 | } 110 | 111 | return parent::toResponse($request); 112 | } 113 | 114 | /** 115 | * Create a paginate-aware HTTP response. 116 | * 117 | * @param \Illuminate\Http\Request $request 118 | */ 119 | protected function preparePaginatedResponse($request): JsonResponse 120 | { 121 | if ($this->preserveQueryParameters) { 122 | $this->resource->appends($request->query()); 123 | } elseif ($this->queryParameters !== null) { 124 | $this->resource->appends($this->queryParameters); 125 | } 126 | 127 | return (new PaginatedResourceResponse($this))->toResponse($request); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Handler.php: -------------------------------------------------------------------------------- 1 | jsonApiException = new JsonApiException(); 28 | } 29 | 30 | /** 31 | * Check whether include error trace. 32 | */ 33 | protected function includesTrace(): bool 34 | { 35 | return (bool) ($this->withTrace ?? env('APP_DEBUG')); 36 | } 37 | 38 | /** 39 | * Create an HTTP response that represents the object. 40 | * 41 | * @param \Illuminate\Http\Request $request 42 | */ 43 | public function toResponse($request): JsonResponse 44 | { 45 | match (true) { 46 | $this->exception instanceof ValidationException => $this->handleValidation($request), 47 | default => $this->handleException($request), 48 | }; 49 | 50 | return new JsonResponse( 51 | $this->jsonApiException->toArray(), 52 | max(array_column($this->jsonApiException->getErrors(), 'status')), 53 | array_merge( 54 | $this->exception instanceof HttpExceptionInterface ? $this->exception->getHeaders() : [], 55 | $this->headers 56 | ) 57 | ); 58 | } 59 | 60 | /** 61 | * Add header to the resulting response. 62 | */ 63 | public function withHeader(string $key, string $value): self 64 | { 65 | $this->headers[$key] = $value; 66 | 67 | return $this; 68 | } 69 | 70 | /** 71 | * Handle any other type of exception. 72 | * 73 | * @param \Illuminate\Http\Request $request 74 | */ 75 | protected function handleException($request): void 76 | { 77 | $code = null; 78 | $statusCode = Response::HTTP_INTERNAL_SERVER_ERROR; 79 | $message = $this->exception->getMessage(); 80 | $trace = $this->exception->getTrace(); 81 | 82 | if ( 83 | $this->exception instanceof HttpExceptionInterface 84 | || method_exists($this->exception, 'getStatusCode') 85 | ) { 86 | $statusCode = $this->exception->getStatusCode(); 87 | } 88 | 89 | /** 90 | * When authentication exception need to return proper error code as Laravel framework 91 | * is completely inconsistent with its exceptions... 92 | */ 93 | if ($this->exception instanceof AuthenticationException) { 94 | $statusCode = Response::HTTP_UNAUTHORIZED; 95 | } 96 | 97 | if (! $this->includesTrace()) { 98 | $message = 'Internal server error.'; 99 | $trace = []; 100 | } 101 | 102 | if ($this->exception instanceof QueryException && $this->includesTrace()) { 103 | $code = $this->exception->getCode(); 104 | } 105 | 106 | $this->jsonApiException->addError(title: $message, status: $statusCode, code: $code, trace: $trace); 107 | } 108 | 109 | /** 110 | * Handle validation exception. 111 | * 112 | * @param \Illuminate\Http\Request $request 113 | */ 114 | protected function handleValidation($request): void 115 | { 116 | $status = $this->exception->getCode() ?: Response::HTTP_UNPROCESSABLE_ENTITY; 117 | 118 | foreach ($this->exception->errors() as $errorSource => $errors) { 119 | foreach ($errors as $error) { 120 | $this->jsonApiException->addError(title: $error, source: $errorSource, status: $status); 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Http/RequestQueryObject.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | public $query; 26 | 27 | /** 28 | * @var \Illuminate\Support\Collection<(int|string), array>|null 29 | */ 30 | protected ?Collection $queryParameters = null; 31 | 32 | /** 33 | * Construct the request query object. 34 | */ 35 | public function __construct(protected Request $request) 36 | { 37 | // 38 | } 39 | 40 | /** 41 | * Set query for this request query object. 42 | * 43 | * @param \Illuminate\Database\Eloquent\Builder $query 44 | */ 45 | public function setQuery($query): self 46 | { 47 | $this->query = $query; 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * Get request query parameters as array. 54 | * 55 | * @return \Illuminate\Support\Collection 56 | */ 57 | public function queryParameters(): Collection 58 | { 59 | if (! $this->queryParameters) { 60 | $this->queryParameters = Collection::make( 61 | array_map( 62 | [HeaderUtils::class, 'parseQuery'], 63 | explode('&', $this->request->server('QUERY_STRING', '')) 64 | ) 65 | )->groupBy(fn ($item, $key) => head(array_keys($item)), true) 66 | ->map(fn (Collection $collection) => $collection->flatten(1)->all()); 67 | } 68 | 69 | return $this->queryParameters; 70 | } 71 | 72 | /** 73 | * Get the underlying request object. 74 | */ 75 | public function getRequest(): Request 76 | { 77 | return $this->request; 78 | } 79 | 80 | /** 81 | * Allows the following user operations. 82 | */ 83 | public function allows( 84 | array $sorts = [], 85 | array $filters = [], 86 | array $includes = [], 87 | array $fields = [], 88 | array $appends = [] 89 | ): self { 90 | /** @var array $allowedArr */ 91 | $allowedArr = compact('sorts', 'filters', 'includes', 'fields', 'appends'); 92 | 93 | foreach ($allowedArr as $allowedKey => $alloweds) { 94 | foreach ($alloweds as $allowedItem) { 95 | $allowedItemAsArg = (array) $allowedItem; 96 | 97 | match ($allowedKey) { 98 | 'sorts' => $this->allowSort(...$allowedItemAsArg), 99 | 'filters' => $this->allowFilter(...$allowedItemAsArg), 100 | 'includes' => $this->allowInclude(...$allowedItemAsArg), 101 | 'fields' => $this->allowFields(...$allowedItemAsArg), 102 | 'appends' => $this->allowAppends(...$allowedItemAsArg), 103 | default => null, 104 | }; 105 | } 106 | } 107 | 108 | return $this; 109 | } 110 | 111 | /** 112 | * Process query object allowing the following user operations. 113 | */ 114 | public function allowing(array $alloweds): self 115 | { 116 | foreach ($alloweds as $allowed) { 117 | match (get_class($allowed)) { 118 | AllowedSort::class => $this->allowSort($allowed), 119 | AllowedFilter::class => $this->allowFilter($allowed), 120 | AllowedInclude::class => $this->allowInclude($allowed), 121 | AllowedFields::class => $this->allowFields($allowed), 122 | AllowedAppends::class => $this->allowAppends($allowed), 123 | AllowedSearchFilter::class => $this->allowSearchFilter($allowed), 124 | default => null, 125 | }; 126 | } 127 | 128 | return $this; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tests/JsonApiRelationshipsTest.php: -------------------------------------------------------------------------------- 1 | authoredPost = new Post([ 39 | 'id' => 5, 40 | 'status' => 'Published', 41 | 'title' => 'Test Title', 42 | 'abstract' => 'Test abstract', 43 | ]); 44 | 45 | $this->authoredPost->setRelation('author', new User([ 46 | 'id' => 1, 47 | 'name' => 'Myself', 48 | 'email' => 'me@internet.org', 49 | 'password' => '1234', 50 | ])); 51 | 52 | $this->lonelyPost = new Post([ 53 | 'id' => 6, 54 | 'status' => 'Published', 55 | 'title' => 'Test Title 2', 56 | ]); 57 | 58 | return Apiable::toJsonApi(collect([ 59 | $this->authoredPost, 60 | $this->lonelyPost, 61 | ])); 62 | }); 63 | 64 | $response = $this->get('/', ['Accept' => 'application/json']); 65 | 66 | $response->assertSuccessful(); 67 | 68 | $response->assertJsonApi(function (AssertableJsonApi $jsonApi) { 69 | $jsonApi->hasAnyRelationships('client', true) 70 | ->hasNotAnyRelationships('post', true); 71 | 72 | $jsonApi->at(0)->hasNotRelationshipWith($this->lonelyPost, true); 73 | }); 74 | } 75 | 76 | #[Group('requiresDatabase')] 77 | public function testResourceHasTagsRelationships() 78 | { 79 | // TODO: setRelation method doesn't work with hasMany relationships, so need migrations loaded 80 | $this->loadMigrationsFrom(__DIR__.'/database/migrations'); 81 | 82 | Route::get('/', function () { 83 | $this->authoredPost = Post::create([ 84 | 'status' => 'Published', 85 | 'title' => 'Test Title', 86 | ]); 87 | 88 | $this->lonelyTag = Tag::create([ 89 | 'name' => 'Lifestyle', 90 | 'slug' => 'lifestyle', 91 | ]); 92 | 93 | $this->postTag = Tag::create([ 94 | 'name' => 'News', 95 | 'slug' => 'news', 96 | ]); 97 | 98 | $anotherTag = Tag::create([ 99 | 'name' => 'International', 100 | 'slug' => 'international', 101 | ]); 102 | 103 | $this->authoredPost->tags()->attach([ 104 | $this->postTag->id, 105 | $anotherTag->id, 106 | ]); 107 | 108 | $this->authoredPost->author()->associate( 109 | User::create([ 110 | 'name' => 'Myself', 111 | 'email' => 'me@internet.org', 112 | 'password' => '1234', 113 | ])->id 114 | ); 115 | 116 | $this->authoredPost->save(); 117 | 118 | return Apiable::toJsonApi($this->authoredPost->refresh()->loadMissing('author', 'tags')); 119 | }); 120 | 121 | $response = $this->get('/', ['Accept' => 'application/json']); 122 | 123 | $response->assertSuccessful(); 124 | 125 | $response->assertJsonApi(function (AssertableJsonApi $jsonApi) { 126 | $jsonApi->hasRelationshipWith($this->postTag, true) 127 | ->hasNotRelationshipWith($this->lonelyTag, true); 128 | }); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tests/Helpers/GeneratesPredictableTestData.php: -------------------------------------------------------------------------------- 1 | 'Aysha', 21 | 'email' => 'aysha@example.com', 22 | 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 23 | ], 24 | [ 25 | 'name' => 'Ruben', 26 | 'email' => 'd8vjork@example.com', 27 | 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 28 | ], 29 | [ 30 | 'name' => 'Coco', 31 | 'email' => 'coco@example.com', 32 | 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 33 | ], 34 | [ 35 | 'name' => 'Perla', 36 | 'email' => 'perla@example.com', 37 | 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 38 | ], 39 | [ 40 | 'name' => 'Ruben', 41 | 'email' => 'ruben_robles@example.com', 42 | 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 43 | ], 44 | ]); 45 | 46 | Tag::insert([ 47 | [ 48 | 'name' => 'Recipes', 49 | 'slug' => 'recipes', 50 | 'created_by' => 1, 51 | ], 52 | [ 53 | 'name' => 'Traveling', 54 | 'slug' => 'traveling', 55 | 'created_by' => 1, 56 | ], 57 | [ 58 | 'name' => 'Programming', 59 | 'slug' => 'programming', 60 | 'created_by' => 2, 61 | ], 62 | [ 63 | 'name' => 'Lifestyle', 64 | 'slug' => 'lifestyle', 65 | 'created_by' => 2, 66 | ], 67 | [ 68 | 'name' => 'Tips', 69 | 'slug' => 'tips', 70 | 'created_by' => 2, 71 | ], 72 | [ 73 | 'name' => 'Internet', 74 | 'slug' => 'internet', 75 | 'created_by' => 2, 76 | ], 77 | [ 78 | 'name' => 'Games', 79 | 'slug' => 'games', 80 | 'created_by' => 2, 81 | ], 82 | [ 83 | 'name' => 'Computers', 84 | 'slug' => 'computers', 85 | 'created_by' => 3, 86 | ], 87 | [ 88 | 'name' => 'Pets', 89 | 'slug' => 'pets', 90 | 'created_by' => 4, 91 | ], 92 | [ 93 | 'name' => 'Clothing', 94 | 'slug' => 'clothing', 95 | 'created_by' => 4, 96 | ], 97 | ]); 98 | 99 | Post::insert([ 100 | [ 101 | 'title' => 'My first test', 102 | 'content' => 'Hello this is my first test', 103 | 'status' => 'Active', 104 | 'author_id' => 1, 105 | ], 106 | [ 107 | 'title' => 'Hello world', 108 | 'content' => 'A classic in programming...', 109 | 'status' => 'Archived', 110 | 'author_id' => 2, 111 | ], 112 | [ 113 | 'title' => 'Y esto en español', 114 | 'content' => 'Porque si', 115 | 'status' => 'Active', 116 | 'author_id' => 3, 117 | ], 118 | [ 119 | 'title' => 'Hola mundo', 120 | 'content' => 'Lorem ipsum', 121 | 'status' => 'Inactive', 122 | 'author_id' => 3, 123 | ], 124 | ]); 125 | 126 | Post::find(1)->tags()->attach([1, 3, 4]); 127 | Post::find(2)->tags()->attach([1, 3, 4, 5]); 128 | Post::find(3)->tags()->attach(5); 129 | 130 | return $this; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Http/Resources/RelationshipsWithIncludes.php: -------------------------------------------------------------------------------- 1 | resource->getRelations(); 28 | 29 | foreach ($relations as $relation => $relationObj) { 30 | if (! $relationObj || $relationObj instanceof Pivot) { 31 | continue; 32 | } 33 | 34 | if (Apiable::config('responses.normalize_relations') ?? false) { 35 | $relation = Str::snake($relation); 36 | } 37 | 38 | if ($relationObj instanceof DatabaseCollection) { 39 | $this->relationships[$relation]['data'] = []; 40 | 41 | /** @var \Illuminate\Database\Eloquent\Model $relationModel */ 42 | foreach ($relationObj->all() as $relationModel) { 43 | $this->processModelRelation($relation, $relationModel); 44 | } 45 | } 46 | 47 | if ($relationObj instanceof Model) { 48 | $this->relationships[$relation]['data'] = null; 49 | 50 | $this->processModelRelation($relation, $relationObj); 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * Process a model relation attaching to its model additional attributes. 57 | * 58 | * @param \OpenSoutheners\LaravelApiable\Contracts\JsonApiable|\Illuminate\Database\Eloquent\Model $model 59 | */ 60 | protected function processModelRelation(string $relation, $model): void 61 | { 62 | /** @var \OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource $modelResource */ 63 | $modelResource = new self($model); 64 | $modelIdentifier = $modelResource->getResourceIdentifier(); 65 | 66 | if (empty($modelIdentifier[$model->getKeyName()] ?? null)) { 67 | return; 68 | } 69 | 70 | $resourceRelationshipData = []; 71 | 72 | $resourceRelationshipData = $modelIdentifier; 73 | 74 | $pivotRelations = array_filter($model->getRelations(), fn ($relation) => $relation instanceof Pivot); 75 | 76 | foreach ($pivotRelations as $pivotRelation => $pivotRelationObj) { 77 | $resourceRelationshipDataMeta = static::filterAttributes($pivotRelationObj, $pivotRelationObj->getAttributes()); 78 | 79 | array_walk($resourceRelationshipDataMeta, fn ($value, $key) => ["{$pivotRelation}_{$key}" => $value]); 80 | 81 | $resourceRelationshipData['meta'] = $resourceRelationshipDataMeta; 82 | } 83 | 84 | if (is_array($this->relationships[$relation]['data'])) { 85 | $this->relationships[$relation]['data'][] = array_filter($resourceRelationshipData); 86 | } else { 87 | $this->relationships[$relation]['data'] = array_filter($resourceRelationshipData); 88 | } 89 | 90 | $this->addIncluded($modelResource); 91 | } 92 | 93 | /** 94 | * Set included data to resource's with. 95 | */ 96 | protected function addIncluded(JsonApiResource $resource): void 97 | { 98 | $includesCol = Collection::make([ 99 | $resource, 100 | array_values($resource->getIncluded()), 101 | ])->flatten(); 102 | 103 | $includesArr = $this->checkUniqueness($includesCol)->values()->all(); 104 | 105 | if (! empty($includesArr)) { 106 | $this->with = array_merge_recursive($this->with, ['included' => $includesArr]); 107 | } 108 | } 109 | 110 | /** 111 | * Get resource included relationships. 112 | */ 113 | public function getIncluded(): array 114 | { 115 | return $this->with['included'] ?? []; 116 | } 117 | 118 | /** 119 | * Check and return unique resources on a collection. 120 | */ 121 | protected function checkUniqueness(Collection $collection): Collection 122 | { 123 | return $collection->unique(static function ($resource): string { 124 | return implode('', $resource->getResourceIdentifier()); 125 | }); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Http/Concerns/IteratesResultsAfterQuery.php: -------------------------------------------------------------------------------- 1 | addAppendsToResult($result); 29 | 30 | $includeAllowed = is_null($this->includeAllowedToResponse) 31 | ? Apiable::config('responses.include_allowed') 32 | : $this->includeAllowedToResponse; 33 | 34 | if ($includeAllowed) { 35 | $result->additional(['meta' => array_filter([ 36 | 'allowed_filters' => $this->request->getAllowedFilters(), 37 | 'allowed_sorts' => $this->request->getAllowedSorts(), 38 | ])]); 39 | } 40 | 41 | if ($result instanceof JsonApiCollection) { 42 | $result->withQuery( 43 | array_filter( 44 | $this->getRequest()->query->all(), 45 | fn ($queryParam) => $queryParam !== 'page', 46 | ARRAY_FILTER_USE_KEY 47 | ) 48 | ); 49 | } 50 | 51 | return $result; 52 | } 53 | 54 | /** 55 | * Add allowed user appends to result. 56 | * 57 | * @param \OpenSoutheners\LaravelApiable\Http\Resources\JsonApiCollection|\OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource $result 58 | * @return void 59 | */ 60 | protected function addAppendsToResult($result) 61 | { 62 | $filteredUserAppends = (new QueryParamsValidator( 63 | $this->request->appends(), 64 | $this->request->enforcesValidation(), 65 | $this->request->getAllowedAppends() 66 | ))->when( 67 | function ($key, $modifiers, $values, $rules, &$valids) { 68 | $valids = array_intersect($values, $rules); 69 | 70 | return empty(array_diff($values, $rules)); 71 | }, 72 | fn ($key, $values) => throw new Exception(sprintf('"%s" fields for resource type "%s" cannot be appended', implode(', ', $values), $key)) 73 | )->validate(); 74 | 75 | // This are forced by the application owner / developer... 76 | // So the values are bypassing allowed appends 77 | if (! empty($this->forceAppends)) { 78 | $filteredUserAppends = array_merge_recursive($filteredUserAppends, $this->forceAppends); 79 | } 80 | 81 | if (! empty($filteredUserAppends)) { 82 | // TODO: Not really optimised, need to think of a better solution... 83 | // TODO: Or refactor old "transformers" classes with a "plain tree" of resources 84 | $result instanceof JsonApiCollection 85 | ? $result->collection->each(fn (JsonApiResource $item) => $this->appendToApiResource($item, $filteredUserAppends)) 86 | : $this->appendToApiResource($result, $filteredUserAppends); 87 | } 88 | } 89 | 90 | /** 91 | * Append array of attributes to the resulted JSON:API resource. 92 | * 93 | * @param \OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource|mixed $resource 94 | * @return void 95 | */ 96 | protected function appendToApiResource(mixed $resource, array $appends) 97 | { 98 | if (! ($resource instanceof JsonApiResource)) { 99 | return; 100 | } 101 | 102 | /** @var array<\OpenSoutheners\LaravelApiable\Http\Resources\JsonApiResource> $resourceIncluded */ 103 | $resourceIncluded = $resource->with['included'] ?? []; 104 | $resourceType = Apiable::getResourceType($resource->resource); 105 | 106 | if ($appendsArr = $appends[$resourceType] ?? null) { 107 | $resource->resource->makeVisible($appendsArr)->append($appendsArr); 108 | } 109 | 110 | foreach ($resourceIncluded as $included) { 111 | $includedResourceType = Apiable::getResourceType($included->resource); 112 | 113 | if ($appendsArr = $appends[$includedResourceType] ?? null) { 114 | $included->resource->makeVisible($appendsArr)->append($appendsArr); 115 | } 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /tests/Http/Resources/JsonApiCollectionTest.php: -------------------------------------------------------------------------------- 1 | 5, 26 | 'status' => 'Published', 27 | 'title' => 'Test Title', 28 | 'abstract' => 'Test abstract', 29 | ]), 30 | new Post([ 31 | 'id' => 6, 32 | 'status' => 'Published', 33 | 'title' => 'Test Title 2', 34 | ]), 35 | ])); 36 | }); 37 | } 38 | 39 | public function testCollectionsMayBeConvertedToJsonApi() 40 | { 41 | $response = $this->get('/', ['Accept' => 'application/json']); 42 | 43 | $response->assertStatus(200); 44 | 45 | $response->assertJson([ 46 | 'data' => [ 47 | [ 48 | 'id' => '5', 49 | 'type' => 'post', 50 | 'attributes' => [ 51 | 'title' => 'Test Title', 52 | 'abstract' => 'Test abstract', 53 | ], 54 | ], 55 | [ 56 | 'id' => '6', 57 | 'type' => 'post', 58 | 'attributes' => [ 59 | 'title' => 'Test Title 2', 60 | ], 61 | ], 62 | ], 63 | ], true); 64 | } 65 | 66 | public function testCollectionsAtHasAttribute() 67 | { 68 | $this->get('/', ['Accept' => 'application/json'])->assertJsonApi(function (AssertableJsonApi $jsonApi) { 69 | $jsonApi->at(0)->hasAttribute('title', 'Test Title'); 70 | 71 | $jsonApi->at(1)->hasAttribute('title', 'Test Title 2'); 72 | }); 73 | } 74 | 75 | public function testCollectionsTakeByDefaultFirstItem() 76 | { 77 | $this->get('/', ['Accept' => 'application/json'])->assertJsonApi(function (AssertableJsonApi $jsonApi) { 78 | $jsonApi->hasAttribute('title', 'Test Title'); 79 | }); 80 | } 81 | 82 | public function testCollectionsHasSize() 83 | { 84 | $this->get('/', ['Accept' => 'application/json'])->assertJsonApi(function (AssertableJsonApi $jsonApi) { 85 | $jsonApi->hasSize(2); 86 | }); 87 | } 88 | 89 | public function testCollectionsAtUnreachablePosition() 90 | { 91 | $this->expectException(AssertionFailedError::class); 92 | 93 | $this->get('/', ['Accept' => 'application/json'])->assertJsonApi(function (AssertableJsonApi $jsonApi) { 94 | $jsonApi->at(10); 95 | }); 96 | } 97 | 98 | public function testCollectionsToArrayReturnsArray() 99 | { 100 | $this->get('/', ['Accept' => 'application/json'])->assertJsonApi(function (AssertableJsonApi $jsonApi) { 101 | $responseArray = $jsonApi->toArray(); 102 | 103 | $this->assertIsArray($responseArray); 104 | $this->assertFalse(empty($responseArray), 'toArray() should not be empty'); 105 | }); 106 | } 107 | 108 | public function testCollectionsWithPreserveQueryWillReturnPaginationLinksWithSimilarParams() 109 | { 110 | Route::get('/posts', function () { 111 | $postsCollection = collect([ 112 | new Post([ 113 | 'id' => 5, 114 | 'status' => 'Published', 115 | 'title' => 'Test Title', 116 | 'abstract' => 'Test abstract', 117 | ]), 118 | new Post([ 119 | 'id' => 6, 120 | 'status' => 'Published', 121 | 'title' => 'Test Title 2', 122 | ]), 123 | ]); 124 | 125 | return Apiable::toJsonApi( 126 | new LengthAwarePaginator($postsCollection, $postsCollection->count(), 1) 127 | )->preserveQuery(); 128 | }); 129 | 130 | $response = $this->get('/posts?filter[title]=test', ['Accept' => 'application/json']); 131 | 132 | $response->assertJsonFragment(['url' => '/?filter%5Btitle%5D=test&page=2']); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /tests/JsonApiPaginationTest.php: -------------------------------------------------------------------------------- 1 | 'Published', 'title' => 'Test Title']); 25 | Post::create(['status' => 'Published', 'title' => 'Test Title 2']); 26 | Post::create(['status' => 'Published', 'title' => 'Test Title 3']); 27 | Post::create(['status' => 'Published', 'title' => 'Test Title 4']); 28 | 29 | return Post::query()->jsonApiPaginate(); 30 | }); 31 | } 32 | 33 | public function testJsonApiPaginationWithPageSize() 34 | { 35 | $response = $this->getJson('/posts?page[size]=2'); 36 | 37 | $response->assertJsonApi(function (AssertableJsonApi $jsonApi) { 38 | $jsonApi->hasSize(2); 39 | }); 40 | 41 | $response->assertJsonFragment([ 42 | "links" => [ 43 | "first" => url("/posts?page%5Bnumber%5D=1"), 44 | "last" => url("/posts?page%5Bnumber%5D=2"), 45 | "prev" => null, 46 | "next" => url("/posts?page%5Bnumber%5D=2") 47 | ], 48 | "meta" => [ 49 | "current_page" => 1, 50 | "from" => 1, 51 | "last_page" => 2, 52 | "links" => [ 53 | [ 54 | "url" => null, 55 | "label" => "« Previous", 56 | "page" => null, 57 | "active" => false 58 | ], 59 | [ 60 | "url" => url("/posts?page%5Bnumber%5D=2"), 61 | "label" => "2", 62 | "page" => 2, 63 | "active" => false 64 | ], 65 | [ 66 | "url" => url("/posts?page%5Bnumber%5D=2"), 67 | "label" => "Next »", 68 | "page" => 2, 69 | "active" => false 70 | ], 71 | [ 72 | "url" => url("/posts?page%5Bnumber%5D=1"), 73 | "label" => "1", 74 | "page" => 1, 75 | "active" => true 76 | ], 77 | ], 78 | "path" => url("/posts"), 79 | "per_page" => 2, 80 | "to" => 2, 81 | "total" => 4, 82 | ], 83 | ]); 84 | 85 | $response->assertStatus(200); 86 | } 87 | 88 | public function testJsonApiPaginationWithPageSizeAndLastPage() 89 | { 90 | $response = $this->getJson('/posts?page[size]=2&page[number]=2'); 91 | 92 | $response->assertJsonApi(function (AssertableJsonApi $jsonApi) { 93 | $jsonApi->hasSize(2); 94 | }); 95 | 96 | $response->assertJsonFragment([ 97 | "links" => [ 98 | "first" => url("/posts?page%5Bnumber%5D=1"), 99 | "last" => url("/posts?page%5Bnumber%5D=2"), 100 | "prev" => url("/posts?page%5Bnumber%5D=1"), 101 | "next" => null 102 | ], 103 | "meta" => [ 104 | "current_page" => 2, 105 | "from" => 3, 106 | "last_page" => 2, 107 | "links" => [ 108 | [ 109 | "url" => url("/posts?page%5Bnumber%5D=1"), 110 | "label" => "« Previous", 111 | "page" => 1, 112 | "active" => false 113 | ], 114 | [ 115 | "url" => url("/posts?page%5Bnumber%5D=2"), 116 | "label" => "2", 117 | "page" => 2, 118 | "active" => true 119 | ], 120 | [ 121 | "url" => null, 122 | "label" => "Next »", 123 | "page" => null, 124 | "active" => false 125 | ], 126 | [ 127 | "url" => url("/posts?page%5Bnumber%5D=1"), 128 | "label" => "1", 129 | "page" => 1, 130 | "active" => false 131 | ], 132 | ], 133 | // TODO: Fix current URL on tests context? 134 | // "path" => url("/posts?page%5Bnumber%5D=2"), 135 | "path" => url("/posts"), 136 | "per_page" => 2, 137 | "to" => 4, 138 | "total" => 4, 139 | ], 140 | ]); 141 | 142 | $response->assertStatus(200); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Support/Apiable.php: -------------------------------------------------------------------------------- 1 | , string> 23 | */ 24 | protected static $modelResourceTypeMap = []; 25 | 26 | /** 27 | * Get package prefixed config by key. 28 | */ 29 | public static function config(string $key): mixed 30 | { 31 | return config("apiable.$key"); 32 | } 33 | 34 | /** 35 | * Format model or collection of models to JSON:API, false otherwise if not valid resource. 36 | */ 37 | public static function toJsonApi(mixed $resource): JsonApiResource|JsonApiCollection 38 | { 39 | return match (true) { 40 | $resource instanceof Builder => $resource->jsonApiPaginate(), 41 | $resource instanceof AbstractPaginator, $resource instanceof Collection => new JsonApiCollection($resource), 42 | $resource instanceof Model, $resource instanceof MissingValue => new JsonApiResource($resource), 43 | default => new JsonApiCollection(Collection::make([])), 44 | }; 45 | } 46 | 47 | /** 48 | * Determine default resource type from giving model. 49 | * 50 | * @param \Illuminate\Database\Eloquent\Model|class-string<\Illuminate\Database\Eloquent\Model> $model 51 | */ 52 | public static function resourceTypeForModel(Model|string $model): string 53 | { 54 | return Str::snake(class_basename(is_string($model) ? $model : get_class($model))); 55 | } 56 | 57 | /** 58 | * Get resource type from a model. 59 | * 60 | * @param \Illuminate\Database\Eloquent\Model|class-string<\Illuminate\Database\Eloquent\Model> $model 61 | */ 62 | public static function getResourceType(Model|string $model): string 63 | { 64 | return static::$modelResourceTypeMap[is_string($model) ? $model : get_class($model)] 65 | ?? static::resourceTypeForModel($model); 66 | } 67 | 68 | /** 69 | * Transforms error rendering to a JSON:API complaint error response. 70 | */ 71 | public static function jsonApiRenderable(Throwable $e, ?bool $withTrace = null): Handler 72 | { 73 | return new Handler($e, $withTrace); 74 | } 75 | 76 | /** 77 | * Prepare response allowing user requests from query. 78 | * 79 | * @template T of \Illuminate\Database\Eloquent\Model 80 | * 81 | * @param \Illuminate\Database\Eloquent\Builder|T|class-string $query 82 | * @return \OpenSoutheners\LaravelApiable\Http\JsonApiResponse 83 | */ 84 | public static function response($query, array $alloweds = []): JsonApiResponse 85 | { 86 | $response = JsonApiResponse::from($query); 87 | 88 | if (! empty($alloweds)) { 89 | $response->allowing($alloweds); 90 | } 91 | 92 | return $response; 93 | } 94 | 95 | /** 96 | * Add models to JSON:API types mapping to the application. 97 | * 98 | * @param array>|array, string> $models 99 | * @return void 100 | */ 101 | public static function modelResourceTypeMap(array $models = []) 102 | { 103 | if (! Arr::isAssoc($models)) { 104 | $models = array_map(fn ($model) => static::resourceTypeForModel($model), $models); 105 | } 106 | 107 | static::$modelResourceTypeMap = $models; 108 | } 109 | 110 | /** 111 | * Get models to JSON:API types mapping. 112 | * 113 | * @return array, string> 114 | */ 115 | public static function getModelResourceTypeMap() 116 | { 117 | return static::$modelResourceTypeMap; 118 | } 119 | 120 | /** 121 | * Get model class from given resource type. 122 | * 123 | * @return \Illuminate\Database\Eloquent\Model|false 124 | */ 125 | public static function getModelFromResourceType(string $type) 126 | { 127 | return array_flip(static::$modelResourceTypeMap)[$type] ?? false; 128 | } 129 | 130 | /** 131 | * Add suffix to filter attribute/scope name. 132 | * 133 | * @return string 134 | */ 135 | public static function scopedFilterSuffix(string $value) 136 | { 137 | return "{$value}_scoped"; 138 | } 139 | 140 | /** 141 | * Force responses to be formatted in a specific format type. 142 | * 143 | * @return void 144 | */ 145 | public static function forceResponseFormatting(?string $format = null) 146 | { 147 | config(['apiable.responses.formatting.force' => true]); 148 | 149 | if ($format) { 150 | config(['apiable.responses.formatting.type' => $format]); 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: All the deep details of this library goes here. 3 | --- 4 | 5 | # API 6 | 7 | ## Illuminate\Database\Eloquent\Builder 8 | 9 | {% hint style="info" %} 10 | Please, note the **difference between Collection and Builder coming from an Eloquent model**, because that conditions the accesibility of these and other methods. 11 | {% endhint %} 12 | 13 | Extending the framework `Illuminate\Database\Eloquent\Builder`. 14 | 15 | **Source:** [**OpenSoutheners\LaravelApiable\Builder**](https://github.com/open-southeners/laravel-apiable/blob/7caaa1dbf4925c53ff181630eec46d9b7df2c277/src/Builder.php) 16 | 17 | ### jsonApiPaginate 18 | 19 | Transforms collection of query results of valid `JsonApiable` resources to a paginated JSON:API collection (`JsonApiCollection`). 20 | 21 | **Parameters:** 22 | 23 | | Name | Default | 24 | | -------- | ------- | 25 | | pageSize | `null` | 26 | | columns | `['*']` | 27 | | page | `null` | 28 | 29 | **Example:** 30 | 31 | ```php 32 | App\Models\Post::where('title', 'my filter')->jsonApiPaginate(); 33 | ``` 34 | 35 | ## Illuminate\Support\Collection 36 | 37 | Extending the framework `Illuminate\Support\Collection`. 38 | 39 | **Source:** [**OpenSoutheners\LaravelApiable\Collection**](https://github.com/open-southeners/laravel-apiable/blob/7caaa1dbf4925c53ff181630eec46d9b7df2c277/src/Collection.php) 40 | 41 | ### toJsonApi 42 | 43 | Transforms collection of valid `JsonApiable` resources to a JSON:API collection (`JsonApiCollection`). 44 | 45 | **Note: This method doesn't paginate, for pagination take a look to the Builder::jsonApiPaginate.** 46 | 47 | **Parameters:** 48 | 49 | _None..._ 50 | 51 | **Example:** 52 | 53 | ```php 54 | App\Models\Post::where('title', 'my filter')->get()->toJsonApi(); 55 | 56 | // or 57 | 58 | collect([Post::first(), Post::latest()->first()])->toJsonApi(); 59 | ``` 60 | 61 | ## OpenSoutheners\LaravelApiable\Contracts\JsonApiable 62 | 63 | Model contract. 64 | 65 | ### toJsonApi 66 | 67 | If the model below implements `OpenSoutheners\LaravelApiable\Contracts\JsonApiable` and uses the trait `OpenSoutheners\LaravelApiable\Concerns\HasJsonApi`, you could do the following to transform the model to JSON:API valid response: 68 | 69 | ```php 70 | $post = App\Models\Post::first(); 71 | 72 | $post->toJsonApi(); 73 | ``` 74 | 75 | ## OpenSoutheners\LaravelApiable\Support\Apiable 76 | 77 | These methods are available as global helpers functions (see examples). 78 | 79 | ### config 80 | 81 | Method used to get user config parameters for this specific package. 82 | 83 | **Example:** 84 | 85 | ```php 86 | Apiable::config('filters.default_operator', 'default value here'); 87 | ``` 88 | 89 | ```php 90 | apiable()->config('filters.default_operator', 'default value here'); 91 | ``` 92 | 93 | ### toJsonApi 94 | 95 | Transform passed value (can be instance of different types: Builder, Model, Collection, etc...). 96 | 97 | **Example:** 98 | 99 | ```php 100 | $post = Post::first(); 101 | 102 | Apiable::toJsonApi($post); 103 | 104 | // or 105 | 106 | $posts = Post::get(); 107 | 108 | Apiable::toJsonApi($posts); 109 | ``` 110 | 111 | ```php 112 | $post = Post::first(); 113 | 114 | apiable()->toJsonApi($post); 115 | 116 | // or 117 | 118 | $posts = Post::get(); 119 | 120 | apiable()->toJsonApi($post); 121 | ``` 122 | 123 | ### resourceTypeForModel 124 | 125 | Guess resource type from model class or instance. 126 | 127 | **Example:** 128 | 129 | ```php 130 | $post = Post::first(); 131 | 132 | Apiable::resourceTypeForModel($post); 133 | 134 | // or 135 | 136 | Apiable::resourceTypeForModel(Post::class); 137 | ``` 138 | 139 | ```php 140 | $post = Post::first(); 141 | 142 | apiable()->resourceTypeForModel($post); 143 | 144 | // or 145 | 146 | apiable()->resourceTypeForModel(Post::class); 147 | ``` 148 | 149 | ### getResourceType 150 | 151 | Get resource type from model class or instance (if one specified, otherwise guess it using `resourceTypeForModel` method). 152 | 153 | **Example:** 154 | 155 | ```php 156 | $post = Post::first(); 157 | 158 | Apiable::getResourceType($post); 159 | 160 | // or 161 | 162 | Apiable::getResourceType(Post::class); 163 | ``` 164 | 165 | ```php 166 | $post = Post::first(); 167 | 168 | apiable()->getResourceType($post); 169 | 170 | // or 171 | 172 | apiable()->getResourceType(Post::class); 173 | ``` 174 | 175 | ### jsonApiRenderable 176 | 177 | Render errors in a JSON:API way. **Check documentation on how to integrate this in your project.** 178 | 179 | **Example:** 180 | 181 | ```php 182 | try { 183 | // Code that might fails here... 184 | } catch (\Throwable $e) { 185 | Apiable::jsonApiRenderable($e, request()); 186 | } 187 | ``` 188 | 189 | ```php 190 | try { 191 | // Code that might fails here... 192 | } catch (\Throwable $e) { 193 | apiable()->jsonApiRenderable($e, request()); 194 | } 195 | ``` 196 | 197 | ### response 198 | 199 | Render content as a JSON:API serialised response. **Check documentation on how to customise these reponses.** 200 | 201 | **Example:** 202 | 203 | ```php 204 | Apiable::response(Film::all())->allowing([ 205 | // list of allowed user request params... 206 | ])->list(); 207 | 208 | // or 209 | 210 | Apiable::response(Film::all(), [ 211 | // list of allowed user request params... 212 | ]); 213 | ``` 214 | 215 | ```php 216 | apiable()->response(Film::all())->allowing([ 217 | // list of allowed user request params... 218 | ])->list(); 219 | 220 | // or 221 | 222 | apiable()->response(Film::all(), [ 223 | // list of allowed user request params... 224 | ]); 225 | ``` 226 | -------------------------------------------------------------------------------- /src/Http/Concerns/AllowsFilters.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | protected array $allowedFilters = []; 20 | 21 | /** 22 | * @var array 23 | */ 24 | protected array $defaultFilters = []; 25 | 26 | /** 27 | * Get user filters from request. 28 | */ 29 | public function filters(): array 30 | { 31 | $queryStringArr = explode('&', $this->request->server('QUERY_STRING', '')); 32 | $filters = []; 33 | 34 | foreach ($queryStringArr as $param) { 35 | $filterQueryParam = HeaderUtils::parseQuery($param); 36 | 37 | if (! is_array(head($filterQueryParam))) { 38 | continue; 39 | } 40 | 41 | $filterQueryParamAttribute = head(array_keys($filterQueryParam)); 42 | 43 | if ($filterQueryParamAttribute !== 'filter') { 44 | continue; 45 | } 46 | 47 | $filterQueryParam = head($filterQueryParam); 48 | $filterQueryParamAttribute = head(array_keys($filterQueryParam)); 49 | $filterQueryParamValue = head(array_values($filterQueryParam)); 50 | 51 | if (! isset($filters[$filterQueryParamAttribute])) { 52 | $filters[$filterQueryParamAttribute] = [$filterQueryParamValue]; 53 | 54 | continue; 55 | } 56 | 57 | $filters[$filterQueryParamAttribute][] = $filterQueryParamValue; 58 | } 59 | 60 | return $filters; 61 | } 62 | 63 | /** 64 | * Allow filter by attribute and pattern of value(s). 65 | * 66 | * @param \OpenSoutheners\LaravelApiable\Http\AllowedFilter|string $attribute 67 | * @param array|string|int $operator 68 | * @param array|string $values 69 | */ 70 | public function allowFilter($attribute, $operator = ['*'], $values = ['*']): static 71 | { 72 | if ($values === ['*'] && (is_array($operator) || is_string($operator))) { 73 | $values = $operator; 74 | 75 | $operator = null; 76 | } 77 | 78 | $this->allowedFilters = array_merge_recursive( 79 | $this->allowedFilters, 80 | $attribute instanceof AllowedFilter 81 | ? $attribute->toArray() 82 | : (new AllowedFilter($attribute, $operator, $values))->toArray() 83 | ); 84 | 85 | return $this; 86 | } 87 | 88 | /** 89 | * Default filter by the following attribute and direction when no user filters are being applied. 90 | * 91 | * @param \OpenSoutheners\LaravelApiable\Http\DefaultFilter|string $attribute 92 | * @param array|string|int $operator 93 | * @param array|string $values 94 | */ 95 | public function applyDefaultFilter($attribute, $operator = ['*'], $values = ['*']): static 96 | { 97 | if ($values === ['*'] && (is_array($operator) || is_string($operator))) { 98 | $values = $operator; 99 | 100 | $operator = null; 101 | } 102 | 103 | $this->defaultFilters = array_merge_recursive( 104 | $this->defaultFilters, 105 | $attribute instanceof DefaultFilter 106 | ? $attribute->toArray() 107 | : (new DefaultFilter($attribute, $operator, $values))->toArray() 108 | ); 109 | 110 | return $this; 111 | } 112 | 113 | /** 114 | * Allow filter by scope and pattern of value(s). 115 | * 116 | * @param array|string $value 117 | */ 118 | public function allowScopedFilter(string $attribute, array|string $value = '*'): static 119 | { 120 | $this->allowedFilters = array_merge_recursive( 121 | $this->allowedFilters, 122 | AllowedFilter::scoped($attribute, $value)->toArray() 123 | ); 124 | 125 | return $this; 126 | } 127 | 128 | /** 129 | * Get user requested filters filtered by allowed ones. 130 | */ 131 | public function userAllowedFilters(): array 132 | { 133 | $defaultFilterOperator = Apiable::config('requests.filters.default_operator'); 134 | $throwOnValidationError = fn ($key) => throw new Exception(sprintf('"%s" is not filterable or contains invalid values', $key)); 135 | 136 | return $this->validator($this->filters()) 137 | ->givingRules($this->allowedFilters) 138 | ->whenPatternMatches($throwOnValidationError) 139 | ->when(function ($key, $modifiers, $values, $rules) use ($defaultFilterOperator): bool { 140 | $allowedOperators = (array) ($rules['operator'] ?? $defaultFilterOperator); 141 | 142 | return ! empty(array_intersect($modifiers, $allowedOperators)); 143 | }, $throwOnValidationError) 144 | ->validate(); 145 | } 146 | 147 | /** 148 | * Get list of allowed filters. 149 | * 150 | * @return array 151 | */ 152 | public function getAllowedFilters(): array 153 | { 154 | return $this->allowedFilters; 155 | } 156 | 157 | /** 158 | * Get list of default filters. 159 | * 160 | * @return array 161 | */ 162 | public function getDefaultFilters(): array 163 | { 164 | return $this->defaultFilters; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Http/AllowedFilter.php: -------------------------------------------------------------------------------- 1 | |null $operator 43 | * @return void 44 | */ 45 | public function __construct(string $attribute, int|array|null $operator = null, array|string $values = '*') 46 | { 47 | if (! is_null($operator) && ! $this->isValidOperator($operator)) { 48 | throw new \Exception( 49 | sprintf('Operator value "%s" for filtered attribute "%s" is not valid', $operator, $attribute) 50 | ); 51 | } 52 | 53 | $this->attribute = $attribute; 54 | $this->operator = $operator ?? Apiable::config('requests.filters.default_operator') ?? static::SIMILAR; 55 | $this->values = $values; 56 | } 57 | 58 | /** 59 | * Allow default filter by attribute 60 | * 61 | * @param string $attribute 62 | * @param string|array $values 63 | */ 64 | public static function make($attribute, $values = '*'): self 65 | { 66 | return new self($attribute, null, $values); 67 | } 68 | 69 | /** 70 | * Allow exact attribute-value(s) filter. 71 | * 72 | * @param string $attribute 73 | * @param string|array $values 74 | */ 75 | public static function exact($attribute, $values = '*'): self 76 | { 77 | return new self($attribute, static::EXACT, $values); 78 | } 79 | 80 | /** 81 | * Allow similar attribute-value(s) filter. 82 | * 83 | * @param string $attribute 84 | * @param string|array|null $values 85 | */ 86 | public static function similar($attribute, $values = '*'): self 87 | { 88 | return new self($attribute, static::SIMILAR, $values); 89 | } 90 | 91 | /** 92 | * Allow greater than attribute-value(s) filter. 93 | * 94 | * @param string $attribute 95 | * @param string|array|null $values 96 | */ 97 | public static function greaterThan($attribute, $values = '*'): self 98 | { 99 | return new self($attribute, static::GREATER_THAN, $values); 100 | } 101 | 102 | /** 103 | * Allow greater or equal than attribute-value(s) filter. 104 | * 105 | * @param string $attribute 106 | * @param string|array|null $values 107 | */ 108 | public static function greaterOrEqualThan($attribute, $values = '*'): self 109 | { 110 | return new self($attribute, static::GREATER_OR_EQUAL_THAN, $values); 111 | } 112 | 113 | /** 114 | * Allow lower than attribute-value(s) filter. 115 | * 116 | * @param string $attribute 117 | * @param string|array|null $values 118 | */ 119 | public static function lowerThan($attribute, $values = '*'): self 120 | { 121 | return new self($attribute, static::LOWER_THAN, $values); 122 | } 123 | 124 | /** 125 | * Allow lower or equal than attribute-value(s) filter. 126 | * 127 | * @param string $attribute 128 | * @param string|array|null $values 129 | */ 130 | public static function lowerOrEqualThan($attribute, $values = '*'): self 131 | { 132 | return new self($attribute, static::LOWER_OR_EQUAL_THAN, $values); 133 | } 134 | 135 | /** 136 | * Allow similar attribute-value(s) filter. 137 | * 138 | * @param string $attribute 139 | * @param string|array $values 140 | */ 141 | public static function scoped($attribute, $values = '1'): self 142 | { 143 | return new self( 144 | Apiable::config('requests.filters.enforce_scoped_names') ? Apiable::scopedFilterSuffix($attribute) : $attribute, 145 | static::SCOPE, 146 | $values 147 | ); 148 | } 149 | 150 | /** 151 | * Check if passed operators are valid. 152 | * 153 | * @param int|array $value 154 | */ 155 | protected function isValidOperator($value): bool 156 | { 157 | $valuesArr = (array) $value; 158 | 159 | return count(array_intersect($valuesArr, [ 160 | static::SIMILAR, 161 | static::EXACT, 162 | static::SCOPE, 163 | static::LOWER_THAN, 164 | static::GREATER_THAN, 165 | static::LOWER_OR_EQUAL_THAN, 166 | static::GREATER_OR_EQUAL_THAN, 167 | ])) === count($valuesArr); 168 | } 169 | 170 | /** 171 | * Get the instance as an array. 172 | * 173 | * @return array> 174 | */ 175 | public function toArray(): array 176 | { 177 | $operators = []; 178 | 179 | foreach ((array) $this->operator as $operator) { 180 | $operators[] = match ($operator) { 181 | static::EXACT => 'equal', 182 | static::SCOPE => 'scope', 183 | static::SIMILAR => 'like', 184 | static::LOWER_THAN => 'lt', 185 | static::GREATER_THAN => 'gt', 186 | static::LOWER_OR_EQUAL_THAN => 'lte', 187 | static::GREATER_OR_EQUAL_THAN => 'gte', 188 | default => 'like', 189 | }; 190 | } 191 | 192 | return [ 193 | $this->attribute => [ 194 | 'operator' => count($operators) === 1 ? $operators[0] : $operators, 195 | 'values' => $this->values, 196 | ], 197 | ]; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/Testing/Concerns/HasRelationships.php: -------------------------------------------------------------------------------- 1 | includeds, function ($included) use ($model) { 32 | return $included['type'] === Apiable::getResourceType($model) && $included['id'] == $model->getKey(); 33 | })); 34 | 35 | return new self($item['id'], $item['type'], $item['attributes'], $item['relationships'] ?? [], $this->includeds); 36 | } 37 | 38 | /** 39 | * Assert that a resource has any relationship and included (optional) by type. 40 | * 41 | * @param mixed $name 42 | * @param bool $withIncluded 43 | * @return $this 44 | */ 45 | public function hasAnyRelationships($name, $withIncluded = false) 46 | { 47 | $type = Apiable::getResourceType($name); 48 | 49 | PHPUnit::assertTrue( 50 | count($this->filterResources($this->relationships, $type)) > 0, 51 | sprintf('There is not any relationship with type "%s"', $type) 52 | ); 53 | 54 | if ($withIncluded) { 55 | PHPUnit::assertTrue( 56 | count($this->filterResources($this->includeds, $type)) > 0, 57 | sprintf('There is not any relationship with type "%s"', $type) 58 | ); 59 | } 60 | 61 | return $this; 62 | } 63 | 64 | /** 65 | * Assert that a resource does not have any relationship and included (optional) by type. 66 | * 67 | * @param mixed $name 68 | * @param bool $withIncluded 69 | * @return $this 70 | */ 71 | public function hasNotAnyRelationships($name, $withIncluded = false) 72 | { 73 | $type = Apiable::getResourceType($name); 74 | 75 | PHPUnit::assertFalse( 76 | count($this->filterResources($this->relationships, $type)) > 0, 77 | sprintf('There is a relationship with type "%s" for resource "%s"', $type, $this->getIdentifierMessageFor()) 78 | ); 79 | 80 | if ($withIncluded) { 81 | PHPUnit::assertFalse( 82 | count($this->filterResources($this->includeds, $type)) > 0, 83 | sprintf('There is a included relationship with type "%s"', $type) 84 | ); 85 | } 86 | 87 | return $this; 88 | } 89 | 90 | /** 91 | * Assert that a resource has any relationship and included (optional) by model instance. 92 | * 93 | * @param bool $withIncluded 94 | * @return $this 95 | */ 96 | public function hasRelationshipWith(Model $model, $withIncluded = false) 97 | { 98 | $type = Apiable::getResourceType($model); 99 | 100 | PHPUnit::assertTrue( 101 | count($this->filterResources($this->relationships, $type, $model->getKey())) > 0, 102 | sprintf('There is no relationship "%s" for resource "%s"', $this->getIdentifierMessageFor($model->getKey(), $type), $this->getIdentifierMessageFor()) 103 | ); 104 | 105 | if ($withIncluded) { 106 | PHPUnit::assertTrue( 107 | count($this->filterResources($this->includeds, $type, $model->getKey())) > 0, 108 | sprintf('There is no included relationship "%s"', $this->getIdentifierMessageFor($model->getKey(), $type)) 109 | ); 110 | } 111 | 112 | return $this; 113 | } 114 | 115 | /** 116 | * Assert that a resource does not have any relationship and included (optional) by model instance. 117 | * 118 | * @param bool $withIncluded 119 | * @return $this 120 | */ 121 | public function hasNotRelationshipWith(Model $model, $withIncluded = false) 122 | { 123 | $type = Apiable::getResourceType($model); 124 | 125 | PHPUnit::assertFalse( 126 | count($this->filterResources($this->relationships, $type, $model->getKey())) > 0, 127 | sprintf('There is a relationship "%s" for resource "%s"', $this->getIdentifierMessageFor($model->getKey(), $type), $this->getIdentifierMessageFor()) 128 | ); 129 | 130 | if ($withIncluded) { 131 | PHPUnit::assertFalse( 132 | count($this->filterResources($this->includeds, $type, $model->getKey())) > 0, 133 | sprintf('There is a included relationship "%s"', $this->getIdentifierMessageFor($model->getKey(), $type)) 134 | ); 135 | } 136 | 137 | return $this; 138 | } 139 | 140 | /** 141 | * Filter array of resources by a provided identifier. 142 | * 143 | * @param mixed $id 144 | * @return array 145 | */ 146 | protected function filterResources(array $resources, string $type, $id = null) 147 | { 148 | return array_filter($resources, function ($resource) use ($type, $id) { 149 | return $this->filterResourceWithIdentifier($resource, $type, $id); 150 | }); 151 | } 152 | 153 | /** 154 | * Filter provided resource with given identifier. 155 | * 156 | * @param mixed $id 157 | * @return bool 158 | */ 159 | protected function filterResourceWithIdentifier(array $resource, string $type, $id = null) 160 | { 161 | if (is_array($resource) && ! isset($resource['type'])) { 162 | return count($this->filterResources($resource, $type, $id)) > 0; 163 | } 164 | 165 | $condition = $resource['type'] === $type; 166 | 167 | if ($id) { 168 | $condition &= $resource['id'] == $id; 169 | } 170 | 171 | return (bool) $condition; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /tests/ApiableTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($this->app->bound('apiable')); 22 | $this->assertInstanceOf(Apiable::class, $this->app->make('apiable')); 23 | } 24 | 25 | public function testApiableHelperReturnsSupportFromContainer() 26 | { 27 | $this->assertTrue(function_exists('apiable')); 28 | $this->assertInstanceOf(Apiable::class, apiable()); 29 | } 30 | 31 | public function testToJsonApiReturnsEmptyJsonApiCollectionWhenInvalidInput() 32 | { 33 | $this->assertEquals(new JsonApiCollection(Collection::make([])), Apiable::toJsonApi(new \stdClass)); 34 | $this->assertEquals(new JsonApiCollection(Collection::make([])), Apiable::toJsonApi('test')); 35 | } 36 | 37 | public function testToJsonApiReturnsFormattedJsonWhenValidInput() 38 | { 39 | $firstPost = new Post(['id' => 1, 'title' => 'foo', 'content' => 'bar', 'status' => 'Published']); 40 | $secondPost = new Post(['id' => 2, 'title' => 'hello', 'content' => 'world', 'status' => 'Published']); 41 | 42 | $this->assertTrue(Apiable::toJsonApi(new Plan) instanceof JsonApiResource); 43 | $this->assertTrue(Apiable::toJsonApi($firstPost) instanceof JsonApiResource); 44 | $this->assertTrue(Apiable::toJsonApi(collect([$firstPost, $secondPost])) instanceof JsonApiCollection); 45 | $this->assertTrue(Apiable::toJsonApi(Post::query()) instanceof JsonApiCollection); 46 | $this->assertTrue(Apiable::toJsonApi(Post::paginate()) instanceof JsonApiCollection); 47 | } 48 | 49 | public function testResponseReturnsTrueWhenValidInput() 50 | { 51 | $this->assertTrue(Apiable::response(Post::query()) instanceof JsonApiResponse); 52 | $this->assertTrue( 53 | Apiable::response(Post::query(), [ 54 | AllowedAppends::make('post', ['abstract']), 55 | ]) instanceof JsonApiResponse 56 | ); 57 | 58 | $this->assertCount( 59 | 1, 60 | Apiable::response(Post::query())->allowing([ 61 | AllowedAppends::make('post', ['abstract']), 62 | ])->getAllowedAppends() 63 | ); 64 | } 65 | 66 | public function testGetModelResourceTypeMapGetsNonEmptyArray() 67 | { 68 | $this->assertIsArray(Apiable::getModelResourceTypeMap()); 69 | $this->assertNotEmpty(Apiable::getModelResourceTypeMap()); 70 | } 71 | 72 | public function testModelResourceTypeMapSetsReplacingPreviousArray() 73 | { 74 | $this->assertNotEmpty(Apiable::getModelResourceTypeMap()); 75 | Apiable::modelResourceTypeMap([]); 76 | $this->assertEmpty(Apiable::getModelResourceTypeMap()); 77 | } 78 | 79 | public function testModelResourceTypeMapSetsArrayOfModels() 80 | { 81 | Apiable::modelResourceTypeMap([Post::class]); 82 | $this->assertNotEmpty(Apiable::getModelResourceTypeMap()); 83 | } 84 | 85 | public function testJsonApiRenderableReturnsExceptionAsFormatted500ErrorJson() 86 | { 87 | $handler = Apiable::jsonApiRenderable(new \Exception('My error'), true); 88 | 89 | $this->assertTrue($handler instanceof Responsable); 90 | 91 | $exceptionAsJson = $handler->toResponse(request()); 92 | 93 | $this->assertTrue($exceptionAsJson instanceof JsonResponse); 94 | 95 | $exceptionAsJsonString = $exceptionAsJson->__toString(); 96 | 97 | $this->assertStringContainsString('"status":"500"', $exceptionAsJsonString); 98 | $this->assertStringContainsString('"title":"My error"', $exceptionAsJsonString); 99 | } 100 | 101 | public function testJsonApiRenderableReturnsExceptionAsFormatted500ErrorJsonWithHiddenDetailsWhenDebugFalse() 102 | { 103 | $handler = Apiable::jsonApiRenderable(new \Exception('My error'), false); 104 | 105 | $this->assertTrue($handler instanceof Responsable); 106 | 107 | $exceptionAsJson = $handler->toResponse(request()); 108 | 109 | $this->assertTrue($exceptionAsJson instanceof JsonResponse); 110 | 111 | $exceptionAsJsonString = $exceptionAsJson->__toString(); 112 | 113 | $this->assertStringContainsString('"status":"500"', $exceptionAsJsonString); 114 | $this->assertStringContainsString('"title":"Internal server error."', $exceptionAsJsonString); 115 | } 116 | 117 | public function testJsonApiRenderableReturnsValidationExceptionAsFormatted422ErrorJson() 118 | { 119 | $handler = Apiable::jsonApiRenderable(ValidationException::withMessages([ 120 | 'email' => ['The email is incorrectly formatted.'], 121 | 'password' => ['The password should have 6 characters or more.'], 122 | ])); 123 | 124 | $this->assertTrue($handler instanceof Responsable); 125 | 126 | $exceptionAsJson = $handler->toResponse(request()); 127 | 128 | $this->assertTrue($exceptionAsJson instanceof JsonResponse); 129 | 130 | $exceptionAsJsonString = $exceptionAsJson->__toString(); 131 | 132 | $this->assertStringContainsString('"status":"422"', $exceptionAsJsonString); 133 | $this->assertStringContainsString('"title":"The email is incorrectly formatted."', $exceptionAsJsonString); 134 | $this->assertStringContainsString('"source":{"pointer":"email"}', $exceptionAsJsonString); 135 | 136 | $this->assertStringContainsString('"title":"The password should have 6 characters or more."', $exceptionAsJsonString); 137 | $this->assertStringContainsString('"source":{"pointer":"password"}', $exceptionAsJsonString); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: >- 3 | This package also have some testing utilities built on top of PHPUnit and 4 | Laravel's framework assertions. 5 | --- 6 | 7 | # Testing 8 | 9 | ## Assertions 10 | 11 | Simple assert that your API route is returning a proper JSON:API response: 12 | 13 | ```php 14 | $response = $this->getJson('/posts'); 15 | 16 | $response->assertJsonApi(); 17 | ``` 18 | 19 | ### at 20 | 21 | Assert the resource at position of the collection starting by 0. 22 | 23 | ```php 24 | use OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi; 25 | 26 | $response = $this->getJson('/posts'); 27 | 28 | $response->assertJsonApi(function (AssertableJsonApi $assert) { 29 | $assert->at(0)->hasAttribute('title', 'Hello world'); 30 | }); 31 | ``` 32 | 33 | ### atRelation 34 | 35 | Assert the related model. 36 | 37 | ```php 38 | use OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi; 39 | 40 | $response = $this->getJson('/posts'); 41 | 42 | $relatedComment = Comment::find(4); 43 | 44 | $response->assertJsonApi(function (AssertableJsonApi $assert) use ($relatedComment) { 45 | $assert->at(0)->atRelation($relatedComment)->hasAttribute('content', 'Foo bar'); 46 | }); 47 | ``` 48 | 49 | ### hasAttribute 50 | 51 | Assert the resource has the specified attribute key and value. 52 | 53 | ```php 54 | use OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi; 55 | 56 | $response = $this->getJson('/posts/1'); 57 | 58 | $response->assertJsonApi(function (AssertableJsonApi $assert) { 59 | $assert->hasAttribute('title', 'Hello world'); 60 | }); 61 | ``` 62 | 63 | ### hasNotAttribute 64 | 65 | Assert the resource does not has the specified attribute key and value. 66 | 67 | ```php 68 | use OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi; 69 | 70 | $response = $this->getJson('/posts/1'); 71 | 72 | $response->assertJsonApi(function (AssertableJsonApi $assert) { 73 | $assert->hasNotAttribute('title', 'Hello world'); 74 | }); 75 | ``` 76 | 77 | ### hasAttributes 78 | 79 | Assert the resource has the specified attributes keys and values. 80 | 81 | ```php 82 | use OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi; 83 | 84 | $response = $this->getJson('/posts/1'); 85 | 86 | $response->assertJsonApi(function (AssertableJsonApi $assert) { 87 | $assert->hasAttributes([ 88 | 'title' => 'Hello world' 89 | 'slug' => 'hello-world' 90 | ]); 91 | }); 92 | ``` 93 | 94 | ### hasNotAttributes 95 | 96 | Assert the resource does not has the specified attributes keys and values. 97 | 98 | ```php 99 | use OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi; 100 | 101 | $response = $this->getJson('/posts/1'); 102 | 103 | $response->assertJsonApi(function (AssertableJsonApi $assert) { 104 | $assert->hasNotAttributes([ 105 | 'title' => 'Hello world' 106 | 'slug' => 'hello-world' 107 | ]); 108 | }); 109 | ``` 110 | 111 | ### hasId 112 | 113 | Assert the resource has the specified ID (or model key). 114 | 115 | ```php 116 | use OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi; 117 | 118 | $response = $this->getJson('/posts/1'); 119 | 120 | $response->assertJsonApi(function (AssertableJsonApi $assert) { 121 | $assert->hasId(1); 122 | }); 123 | ``` 124 | 125 | ### hasType 126 | 127 | Assert the resource has the specified type. 128 | 129 | ```php 130 | use OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi; 131 | 132 | $response = $this->getJson('/posts/1'); 133 | 134 | $response->assertJsonApi(function (AssertableJsonApi $assert) { 135 | $assert->hasType('post'); 136 | }); 137 | ``` 138 | 139 | ### hasAnyRelationships 140 | 141 | Assert that the resource **has any** relationships with the specified **resource type**. 142 | 143 | Second parameter is for assert that the response **includes** the relationship data at the `included`. 144 | 145 | ```php 146 | use OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi; 147 | 148 | $response = $this->getJson('/posts/1'); 149 | 150 | $response->assertJsonApi(function (AssertableJsonApi $assert) { 151 | $assert->hasAnyRelationships('comment', true); 152 | }); 153 | ``` 154 | 155 | ### hasNotAnyRelationships 156 | 157 | Assert that the resource **doesn't have any** relationships with the specified **resource type**. 158 | 159 | Second parameter is for assert that the response **doesn't includes** the relationship data at the `included`. 160 | 161 | ```php 162 | use OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi; 163 | 164 | $response = $this->getJson('/posts/2'); 165 | 166 | $response->assertJsonApi(function (AssertableJsonApi $assert) { 167 | $assert->hasNotAnyRelationships('comment', true); 168 | }); 169 | ``` 170 | 171 | ### hasRelationshipWith 172 | 173 | Assert that the specific model resource **is a** relationship with the parent resource. 174 | 175 | Second parameter is for assert that the response **includes** the relationship data at the `included`. 176 | 177 | ```php 178 | use OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi; 179 | 180 | $response = $this->getJson('/posts/1'); 181 | 182 | $relatedComment = Comment::find(4); 183 | 184 | $response->assertJsonApi(function (AssertableJsonApi $assert) use ($relatedComment) { 185 | $assert->hasRelationshipWith($relatedComment, true); 186 | }); 187 | ``` 188 | 189 | ### hasNotRelationshipWith 190 | 191 | Assert that the specific model resource **is not** a relationship with the parent resource. 192 | 193 | Second parameter is for assert that the response **doesn't includes** the relationship data at the `included`. 194 | 195 | ```php 196 | use OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi; 197 | 198 | $response = $this->getJson('/posts/1'); 199 | 200 | $relatedComment = Comment::find(4); 201 | 202 | $response->assertJsonApi(function (AssertableJsonApi $assert) use ($relatedComment) { 203 | $assert->hasRelationshipWith($relatedComment, true); 204 | }); 205 | ``` 206 | 207 | ### isCollection 208 | 209 | Assert that the response is a collection (list of resources). 210 | 211 | ```php 212 | use OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi; 213 | 214 | $response = $this->getJson('/posts'); 215 | 216 | $response->assertJsonApi(function (AssertableJsonApi $assert) { 217 | $assert->isCollection(); 218 | }); 219 | ``` 220 | 221 | ### isResource 222 | 223 | ```php 224 | use OpenSoutheners\LaravelApiable\Testing\AssertableJsonApi; 225 | 226 | $response = $this->getJson('/posts/1'); 227 | 228 | $response->assertJsonApi(function (AssertableJsonApi $assert) { 229 | $assert->isResource(); 230 | }); 231 | ``` 232 | -------------------------------------------------------------------------------- /docs/responses.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: >- 3 | For your API controllers responses you've multiple ways to transform your 4 | models or collection of models to JSON:API, here we list all of them. 5 | --- 6 | 7 | # Responses 8 | 9 | To start using the JSON:API serialisation (responses) you can just use the Apiable facade to do so: 10 | 11 | ```php 12 | Apiable::toJsonApi(Film::all()); 13 | ``` 14 | 15 | {% hint style="info" %} 16 | Take in mind that this `toJsonApi` method doesn't do anything more than serialisation, if you want filters, sorts and more flexible queries for users on your API head to [Requests section](requests.md). 17 | {% endhint %} 18 | 19 | ## Custom resource type 20 | 21 | To customise the resource type, the one that you see as the `type: "post"` (in case of a Post model), **this is very important for your frontend** to identify the resource. If you want to customise this you will need the `config/apiable.php` file on your Laravel app: 22 | 23 | ``` 24 | php artisan vendor:publish --provider="OpenSoutheners\\LaravelApiable\\ServiceProvider" 25 | ``` 26 | 27 | Then add items to the `resource_type_map` option: 28 | 29 | ```php 30 | [ 35 | \App\Models\Film::class => 'film', 36 | \App\Models\User::class => 'client', 37 | ], 38 | 39 | ``` 40 | 41 | {% hint style="info" %} 42 | Just remember to check the allowed types in [the oficial JSON:API spec](https://jsonapi.org/format/#document-member-names). 43 | {% endhint %} 44 | 45 | ## Using JsonApiResponse 46 | 47 | JsonApiResponse is a class helper that will abstract everything for you: 48 | 49 | 1. Handle users request query parameters sent (filters, sorts, includes, appends, etc). 50 | 2. Transform all request parameters to a Eloquent query then apply conditional viewable and pagination if needed. 51 | 3. Serialisation depending on application needs (JSON:API, raw JSON, etc). 52 | 53 | ### List of resources 54 | 55 | {% hint style="info" %} 56 | This will get a paginated response. In case you've install [hammerstone/fast-paginate](https://github.com/hammerstonedev/fast-paginate) it will use fastPaginate method to make it faster. 57 | {% endhint %} 58 | 59 | To get a list (wrapped in a `JsonApiCollection`) of your resources query you should do the following: 60 | 61 | ```php 62 | JsonApiResponse::from(Film::where('title', 'LIKE', 'The%')); 63 | ``` 64 | 65 | #### Conditionally viewable resources 66 | 67 | By default this is enabled but it can be disabled from the config file. 68 | 69 | When listing resources normally through API you want to exclude some of the ones that your users doesn't have access to, and we got covered here, simply adding a query scope in your model: 70 | 71 | ```php 72 | whereHas('author', fn (Builder $query) => $query->whereKey($user->getKey())); 91 | } 92 | } 93 | ``` 94 | 95 | In case your models are using custom query builders you can use this feature as well on them: 96 | 97 | ```php 98 | 109 | */ 110 | class FilmBuilder extends Builder implements ViewableBuilder 111 | { 112 | /** 113 | * Scope applied to the query for show/hide items. 114 | * 115 | * @param \Illuminate\Contracts\Auth\Authenticatable|null $user 116 | * @return \Illuminate\Database\Eloquent\Builder 117 | */ 118 | public function viewable(?Authenticatable $user = null) 119 | { 120 | $this->whereHas('author', fn (Builder $query) => $query->whereKey($user->getKey())); 121 | 122 | return $this; 123 | } 124 | } 125 | ``` 126 | 127 | #### Disable viewable per request 128 | 129 | If the **viewable is implemented at the model or query builder** level **this will get called** whenever you use Apiable, you can disable it per request using the following method: 130 | 131 | ```php 132 | JsonApiResponse::from(Film::where('title', 'LIKE', 'The%')) 133 | ->conditionallyLoadResults(false); 134 | ``` 135 | 136 | #### Customise pagination method 137 | 138 | In case you want to customise the pagination used you can actually use the `paginateUsing` method: 139 | 140 | ```php 141 | JsonApiResponse::from(Film::class) 142 | ->paginateUsing(fn ($query) => $query->simplePaginate()); 143 | ``` 144 | 145 | ### One resource from the list or query 146 | 147 | You can still use apiable responses to get one result: 148 | 149 | ```php 150 | JsonApiResponse::from(Film::whereKey($id))->gettingOne(); 151 | ``` 152 | 153 | This will get a JsonApiResource response with just that one resource queried. 154 | 155 | ## Responses formatting 156 | 157 | {% hint style="info" %} 158 | Custom formatting is coming soon on v4. 159 | {% endhint %} 160 | 161 | Serialisation is something this package was limited into back when it was dedicated just to JSON:API, nowadays and in the future it is capable of more than that so you can still use the powerful requests query parameters with the responses your frontend or clients requires. 162 | 163 | So in case they want normal JSON they should send the following header: 164 | 165 | ``` 166 | Accept: application/json 167 | ``` 168 | 169 | Or in case you want JSON:API: 170 | 171 | ``` 172 | Accept: application/vnd.api+json 173 | ``` 174 | 175 | In case you want to force any formatting in your application you can use the following: 176 | 177 | ```php 178 | JsonApiResponse::from(Film::class)->forceFormatting(); 179 | ``` 180 | 181 | The previous will force formatting to the default format which is in the config file generated by this package (on `config/apiable.php`), in case you want to enforce other format you only need to specify its RFC language string: 182 | 183 | ```php 184 | JsonApiResponse::from(Film::class)->forceFormatting('application/json'); 185 | ``` 186 | 187 | {% hint style="warning" %} 188 | In case an unsupported type is sent via the `Accept` header or this `forceFormatting` method the application will return a 406 not acceptable HTTP exception. 189 | {% endhint %} 190 | -------------------------------------------------------------------------------- /tests/Http/Resources/JsonApiResourceTest.php: -------------------------------------------------------------------------------- 1 | 5, 18 | 'status' => 'Published', 19 | 'title' => 'Test Title', 20 | 'abstract' => 'Test abstract', 21 | ]))->toJsonApi(); 22 | }); 23 | 24 | $response = $this->get('/', ['Accept' => 'application/json']); 25 | 26 | $response->assertStatus(200); 27 | 28 | $response->assertJson([ 29 | 'data' => [ 30 | 'id' => '5', 31 | 'type' => 'post', 32 | 'attributes' => [ 33 | 'title' => 'Test Title', 34 | 'abstract' => 'Test abstract', 35 | ], 36 | ], 37 | ], true); 38 | } 39 | 40 | public function testResourcesHasIdentifier() 41 | { 42 | Route::get('/', function () { 43 | return Apiable::toJsonApi(new Post([ 44 | 'id' => 5, 45 | 'status' => 'Published', 46 | 'title' => 'Test Title', 47 | 'abstract' => 'Test abstract', 48 | ])); 49 | }); 50 | 51 | $this->get('/', ['Accept' => 'application/json'])->assertJsonApi(function (AssertableJsonApi $jsonApi) { 52 | $jsonApi->hasId(5)->hasType('post'); 53 | }); 54 | } 55 | 56 | public function testResourcesHasAttribute() 57 | { 58 | Route::get('/', function () { 59 | return Apiable::toJsonApi(new Post([ 60 | 'id' => 5, 61 | 'status' => 'Published', 62 | 'title' => 'Test Title', 63 | 'abstract' => 'Test abstract', 64 | ])); 65 | }); 66 | 67 | $this->get('/', ['Accept' => 'application/json'])->assertJsonApi(function (AssertableJsonApi $jsonApi) { 68 | $jsonApi->hasAttribute('title', 'Test Title'); 69 | }); 70 | } 71 | 72 | public function testResourcesHasAttributes() 73 | { 74 | Route::get('/', function () { 75 | return Apiable::toJsonApi(new Post([ 76 | 'id' => 5, 77 | 'status' => 'Published', 78 | 'title' => 'Test Title', 79 | 'abstract' => 'Test abstract', 80 | ])); 81 | }); 82 | 83 | $this->get('/', ['Accept' => 'application/json'])->assertJsonApi(function (AssertableJsonApi $jsonApi) { 84 | $jsonApi->hasAttributes([ 85 | 'title' => 'Test Title', 86 | 'abstract' => 'Test abstract', 87 | ]); 88 | }); 89 | } 90 | 91 | public function testResourcesMayBeConvertedToJsonApiWithToJsonMethod() 92 | { 93 | $resource = Apiable::toJsonApi(new Post([ 94 | 'id' => 5, 95 | 'title' => 'Test Title', 96 | 'abstract' => 'Test abstract', 97 | ]), true); 98 | 99 | $this->assertSame('{"id":"5","type":"post","attributes":{"title":"Test Title","abstract":"Test abstract"}}', $resource->toJson()); 100 | } 101 | 102 | public function testResourcesWithRelationshipsMayBeConvertedToJsonApi() 103 | { 104 | Route::get('/', function () { 105 | $post = new Post([ 106 | 'id' => 5, 107 | 'status' => 'Published', 108 | 'title' => 'Test Title', 109 | 'abstract' => 'Test abstract', 110 | ]); 111 | 112 | $post->setRelation('parent', new Post([ 113 | 'id' => 4, 114 | 'title' => 'Test Parent Title', 115 | ])); 116 | 117 | return Apiable::toJsonApi($post); 118 | }); 119 | 120 | $response = $this->get('/', ['Accept' => 'application/json']); 121 | 122 | $response->assertStatus(200); 123 | 124 | $response->assertJson([ 125 | 'data' => [ 126 | 'id' => '5', 127 | 'type' => 'post', 128 | 'attributes' => [ 129 | 'title' => 'Test Title', 130 | 'abstract' => 'Test abstract', 131 | ], 132 | 'relationships' => [ 133 | 'parent' => [ 134 | 'data' => [ 135 | 'id' => '4', 136 | 'type' => 'post', 137 | ], 138 | ], 139 | ], 140 | ], 141 | 'included' => [ 142 | [ 143 | 'id' => '4', 144 | 'type' => 'post', 145 | 'attributes' => [ 146 | 'title' => 'Test Parent Title', 147 | ], 148 | ], 149 | ], 150 | ], true); 151 | } 152 | 153 | public function testResourcesHasRelationshipWith() 154 | { 155 | Route::get('/', function () { 156 | $post = new Post([ 157 | 'id' => 5, 158 | 'status' => 'Published', 159 | 'title' => 'Test Title', 160 | 'abstract' => 'Test abstract', 161 | ]); 162 | 163 | $post->setRelation('parent', new Post([ 164 | 'id' => 4, 165 | 'status' => 'Published', 166 | 'title' => 'Test Parent Title', 167 | ])); 168 | 169 | return Apiable::toJsonApi($post); 170 | }); 171 | 172 | $this->get('/', ['Accept' => 'application/json'])->assertJsonApi(function (AssertableJsonApi $jsonApi) { 173 | $jsonApi->hasRelationshipWith(new Post([ 174 | 'id' => 4, 175 | 'title' => 'Test Parent Title', 176 | ]), true); 177 | }); 178 | } 179 | 180 | public function testResourcesAtRelationHasAttribute() 181 | { 182 | Route::get('/', function () { 183 | $post = new Post([ 184 | 'id' => 5, 185 | 'status' => 'Published', 186 | 'title' => 'Test Title', 187 | 'abstract' => 'Test abstract', 188 | ]); 189 | 190 | $post->setRelation('parent', new Post([ 191 | 'id' => 4, 192 | 'status' => 'Published', 193 | 'title' => 'Test Parent Title', 194 | ])); 195 | 196 | return Apiable::toJsonApi($post); 197 | }); 198 | 199 | $this->get('/', ['Accept' => 'application/json'])->assertJsonApi(function (AssertableJsonApi $jsonApi) { 200 | $jsonApi->atRelation(new Post([ 201 | 'id' => 4, 202 | 'status' => 'Published', 203 | 'title' => 'Test Parent Title', 204 | ]))->hasAttribute('title', 'Test Parent Title'); 205 | }); 206 | } 207 | } 208 | --------------------------------------------------------------------------------