├── 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 [](https://www.php.net/supported-versions.php) [](https://codecov.io/gh/open-southeners/laravel-apiable) [](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 | [](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 |
--------------------------------------------------------------------------------