├── .github ├── FUNDING.yml └── workflows │ ├── style.yml │ └── test.yml ├── .gitignore ├── .php_cs.php ├── LICENSE ├── README.md ├── composer.json ├── config └── resource-abilities.php ├── phpunit.xml.dist ├── src ├── Abilities.php ├── AbilityContainer.php ├── AbilityResource.php ├── AbilityTypes │ ├── AbilityType.php │ ├── GateAbilityType.php │ └── PolicyAbilityType.php ├── AddsAbilities.php ├── AnonymousResourceCollection.php ├── Builder.php ├── Collection.php ├── HasAbilities.php ├── HasRelationships.php ├── JsonResource │ └── ProcessesAbilities.php ├── PaginatedResourceResponse.php ├── ResourceAbilitiesServiceProvider.php ├── ResourceCollection.php ├── ResourceCollection │ └── ProcessesAbilities.php ├── ResourceResponse.php └── Serializers │ ├── AbilitySerializer.php │ ├── ExtendedAbilitySerializer.php │ └── Serializer.php └── tests ├── AbilitiesTest.php ├── AddsAbilitiesTest.php ├── CollectionTest.php ├── Fakes ├── TestModel.php ├── TestPolicy.php ├── TestRouter.php ├── User.php └── UserResource.php ├── HasAbilitiesTest.php ├── HasRelationshipsTest.php ├── JsonResource └── ProcessesAbilitiesTest.php ├── ResourceCollection └── ProcessesAbilitiesTest.php └── TestCase.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [lexdewilligen] 4 | patreon: # 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/style.yml: -------------------------------------------------------------------------------- 1 | name: Check & fix styling 2 | 3 | on: [ push ] 4 | 5 | jobs: 6 | php-cs-fixer: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | with: 13 | ref: ${{ github.head_ref }} 14 | 15 | - name: Run PHP CS Fixer 16 | uses: docker://oskarstark/php-cs-fixer-ga 17 | with: 18 | args: --config=.php_cs.php --allow-risky=yes 19 | 20 | - name: Commit changes 21 | uses: stefanzweifel/git-auto-commit-action@v4 22 | with: 23 | commit_message: Fix styling -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | os: [ubuntu-latest] 12 | php: [8.2, 8.1, 8.0] 13 | laravel: [9.*] 14 | dependency-version: [prefer-stable] 15 | include: 16 | - laravel: 9.* 17 | testbench: 7.* 18 | 19 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v2 24 | 25 | - name: Cache dependencies 26 | uses: actions/cache@v1 27 | with: 28 | path: ~/.composer/cache/files 29 | key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 30 | 31 | - name: Setup PHP 32 | uses: shivammathur/setup-php@v2 33 | with: 34 | php-version: ${{ matrix.php }} 35 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 36 | coverage: none 37 | 38 | - name: Install dependencies 39 | run: | 40 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 41 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest 42 | - name: Execute tests 43 | run: vendor/bin/phpunit -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | vendor 3 | .phpunit.result.cache 4 | .php-cs-fixer.cache 5 | composer.lock 6 | coverage -------------------------------------------------------------------------------- /.php_cs.php: -------------------------------------------------------------------------------- 1 | notPath('bootstrap/*') 5 | ->notPath('storage/*') 6 | ->notPath('resources/view/mail/*') 7 | ->in([ 8 | __DIR__ . '/src', 9 | __DIR__ . '/tests', 10 | ]) 11 | ->name('*.php') 12 | ->notName('*.blade.php') 13 | ->ignoreDotFiles(true) 14 | ->ignoreVCS(true); 15 | 16 | return (new PhpCsFixer\Config()) 17 | ->setRules([ 18 | '@PSR12' => true, 19 | 'array_syntax' => ['syntax' => 'short'], 20 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 21 | 'no_unused_imports' => true, 22 | 'not_operator_with_successor_space' => true, 23 | 'trailing_comma_in_multiline' => true, 24 | 'phpdoc_scalar' => true, 25 | 'unary_operator_spaces' => true, 26 | 'binary_operator_spaces' => true, 27 | 'blank_line_before_statement' => [ 28 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 29 | ], 30 | 'phpdoc_single_line_var_spacing' => true, 31 | 'phpdoc_var_without_name' => true, 32 | 'method_argument_space' => [ 33 | 'on_multiline' => 'ensure_fully_multiline', 34 | 'keep_multiple_spaces_after_comma' => true, 35 | ], 36 | 'single_trait_insert_per_statement' => true, 37 | ]) 38 | ->setFinder($finder); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 AgilePixels 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Add abilities to Laravel API resources 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/agilepixels/laravel-resource-abilities.svg?style=flat)](https://packagist.org/packages/agilepixels/laravel-resource-abilities) 4 | ![Test](https://github.com/agilepixels/laravel-resource-abilities/workflows/Test/badge.svg) 5 | ![Check & fix styling](https://github.com/agilepixels/laravel-resource-abilities/workflows/Check%20&%20fix%20styling/badge.svg) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/agilepixels/laravel-resource-abilities.svg?style=flat)](https://packagist.org/packages/agilepixels/laravel-resource-abilities) 7 | 8 | If you build a web application with a separate frontend and backend, all kinds of information has to be transferred between 9 | these two parts. Part of this are your routes, but how do you share the authorized actions in the most convenient way? 10 | One quick note: from now on, we'll call these authorized actions "abilities". To share abilities, we use API resources. That 11 | way we can see the abilities the current user has for that resource. 12 | 13 | Please read the full introduction and all documentation about this package in our [GitHub Wiki](https://github.com/agilepixels/laravel-resource-abilities/wiki). 14 | 15 | ## Support us 16 | 17 | Your support is most welcome! Feel free to send in any pull requests to improve this package. If you wish to contribute 18 | in any other way, do check out the "sponsor this package" to the right. We'd love to receive your support! 19 | 20 | ## Testing 21 | 22 | ``` bash 23 | composer test 24 | ``` 25 | 26 | ## Credits 27 | 28 | - [Lex de Willigen](https://github.com/lexdewilligen) 29 | - [All contributors](https://github.com/agilepixels/laravel-resource-abilities/contributors) 30 | 31 | ## License 32 | 33 | The MIT License (MIT). Please see [License File](https://github.com/agilepixels/laravel-resource-abilities/blob/master/LICENSE.md) for more information. -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "agilepixels/laravel-resource-abilities", 3 | "description" : "Add abilities to Laravel API resources", 4 | "keywords" : [ 5 | "laravel", 6 | "php" 7 | ], 8 | "homepage" : "https://github.com/agilepixels/laravel-resource-abilities", 9 | "license" : "MIT", 10 | "authors" : [ 11 | { 12 | "name" : "Lex de Willigen", 13 | "email" : "lex@agilepixels.com", 14 | "homepage" : "https://agilepixels.com", 15 | "role" : "Developer" 16 | } 17 | ], 18 | "require" : { 19 | "php" : "^8.0|^8.1|^8.2", 20 | "illuminate/database" : "^9.0", 21 | "illuminate/routing" : "^9.0" 22 | }, 23 | "require-dev" : { 24 | "orchestra/testbench" : "^7.0", 25 | "phpunit/phpunit" : "^8.0|^9.0" 26 | }, 27 | "autoload" : { 28 | "psr-4" : { 29 | "AgilePixels\\ResourceAbilities\\" : "src" 30 | } 31 | }, 32 | "autoload-dev" : { 33 | "psr-4" : { 34 | "AgilePixels\\ResourceAbilities\\Tests\\" : "tests" 35 | } 36 | }, 37 | "scripts" : { 38 | "test" : "vendor/bin/phpunit", 39 | "test-coverage" : "vendor/bin/phpunit --coverage-html coverage" 40 | }, 41 | "config" : { 42 | "sort-packages" : true 43 | }, 44 | "extra" : { 45 | "laravel" : { 46 | "providers" : [ 47 | "AgilePixels\\ResourceAbilities\\ResourceAbilitiesServiceProvider" 48 | ] 49 | } 50 | }, 51 | "minimum-stability": "dev", 52 | "prefer-stable": true 53 | } -------------------------------------------------------------------------------- /config/resource-abilities.php: -------------------------------------------------------------------------------- 1 | AgilePixels\ResourceAbilities\Serializers\AbilitySerializer::class, 17 | ]; 18 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | src/ 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Abilities.php: -------------------------------------------------------------------------------- 1 | abilityTypes = new Collection(); 17 | } 18 | 19 | public function gate(string $ability, Model | string $model): GateAbilityType 20 | { 21 | $gateAbilityType = GateAbilityType::make($ability, $model); 22 | 23 | $this->abilityTypes[] = $gateAbilityType; 24 | 25 | return $gateAbilityType; 26 | } 27 | 28 | public function policy(string $policy, Model | string $model): PolicyAbilityType 29 | { 30 | $policyAbilityType = PolicyAbilityType::make($policy, $model); 31 | 32 | $this->abilityTypes[] = $policyAbilityType; 33 | 34 | return $policyAbilityType; 35 | } 36 | 37 | public function abilities(Abilities $abilities): void 38 | { 39 | $this->abilityTypes = $this->abilityTypes->merge( 40 | $abilities->getAbilityTypes() 41 | ); 42 | } 43 | 44 | public function getAbilityTypes(): Collection 45 | { 46 | return $this->abilityTypes; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/AbilityContainer.php: -------------------------------------------------------------------------------- 1 | abilitiesGroup = new Abilities(); 24 | $this->abilities = $abilities; 25 | $this->withAllAbilities = $withAllAbilities; 26 | } 27 | 28 | public static function create(Model | string $model, array $abilities, bool $withAllAbilities): static 29 | { 30 | return new static($model, $abilities, $withAllAbilities); 31 | } 32 | 33 | public function add(string $ability, array $parameters = [], string $serializer = null): AbilityResource 34 | { 35 | /** 36 | * Handle Policy check 37 | */ 38 | if (Str::endsWith($ability, 'Policy')) { 39 | $this->abilitiesGroup 40 | ->policy($ability, $this->resource) 41 | ->parameters($parameters) 42 | ->serializer($serializer); 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * Handle Gate check 49 | */ 50 | $this->abilitiesGroup 51 | ->gate($ability, $this->resource) 52 | ->parameters($parameters) 53 | ->serializer($serializer); 54 | 55 | return $this; 56 | } 57 | 58 | public function toArray($request): array 59 | { 60 | return $this->abilitiesGroup 61 | ->getAbilityTypes() 62 | ->mapWithKeys(function (AbilityType $abilityType) { 63 | return $abilityType->getAbilities($this->abilities, $this->withAllAbilities); 64 | }) 65 | ->toArray(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/AbilityTypes/AbilityType.php: -------------------------------------------------------------------------------- 1 | parameters = $parameters; 29 | 30 | return $this; 31 | } 32 | 33 | /** 34 | * The serializer to format the output of abilities. By default, a config value 35 | * in resource-abilities.serializer will be used. 36 | * 37 | * @param string|null $serializer 38 | * 39 | * @return $this 40 | */ 41 | public function serializer(?string $serializer): self 42 | { 43 | $this->serializer = $serializer; 44 | 45 | return $this; 46 | } 47 | 48 | protected function resolveSerializer(): Serializer 49 | { 50 | $serializer = is_null($this->serializer) 51 | ? config('resource-abilities.serializer') 52 | : $this->serializer; 53 | 54 | return new $serializer(); 55 | } 56 | 57 | /** 58 | * @param bool $withAllAbilities 59 | * @param array $abilities An array of abilities that should be loaded for the resource 60 | * 61 | * @return array 62 | */ 63 | abstract public function getAbilities(array $abilities, bool $withAllAbilities): array; 64 | } 65 | -------------------------------------------------------------------------------- /src/AbilityTypes/GateAbilityType.php: -------------------------------------------------------------------------------- 1 | ability, $abilities, true) && ! $withAllAbilities) { 27 | return []; 28 | } 29 | 30 | $abilityContainer = AbilityContainer::make( 31 | $this->ability, 32 | Gate::check($this->ability, [$this->model, ...$this->parameters]), 33 | ); 34 | 35 | return $this->resolveSerializer()->format($abilityContainer); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/AbilityTypes/PolicyAbilityType.php: -------------------------------------------------------------------------------- 1 | policy = $policy; 24 | $this->abilities = get_class_methods($policy); 25 | } 26 | 27 | #[Pure] 28 | public static function make(string $policy, Model | string $model): static 29 | { 30 | return new static($policy, $model); 31 | } 32 | 33 | public function getAbilities(array $abilities, bool $withAllAbilities): array 34 | { 35 | return collect($this->abilities) 36 | 37 | /** 38 | * Filter out helper methods 39 | */ 40 | ->reject(fn (string $ability) => in_array($ability, ['denyWithStatus', 'denyAsNotFound'])) 41 | 42 | /** 43 | * Filter out the non-specified abilities 44 | */ 45 | ->when( 46 | ! $withAllAbilities, 47 | fn (Collection $collection) => $collection->filter(fn (string $ability) => in_array($ability, $abilities, true)) 48 | ) 49 | 50 | /** 51 | * Filter out the methods that require a model if no model is available 52 | */ 53 | ->when( 54 | ! $this->model instanceof Model, 55 | fn (Collection $collection) => $collection->filter(fn (string $ability) => ! $this->requiresModelInstance($this->policy, $ability)) 56 | ) 57 | 58 | /** 59 | * Authorize all abilities that are left against $this->model 60 | */ 61 | ->map( 62 | fn ($ability) => is_string($this->model) || $this->requiresModelInstance($this->policy, $ability) 63 | ? AbilityContainer::make($ability, Gate::check($ability, [$this->model, ...$this->parameters])) 64 | : AbilityContainer::make($ability, Gate::check($ability, [$this->model::class, ...$this->parameters])) 65 | ) 66 | 67 | /** 68 | * Format the resulting set of abilities using the given serializer 69 | */ 70 | ->flatMap(fn ($abilityContainer) => $this->resolveSerializer()->format($abilityContainer)) 71 | ->toArray(); 72 | } 73 | 74 | protected function getParameters(string $policy, string $ability): Collection 75 | { 76 | return collect((new ReflectionMethod($policy, $ability))->getParameters()); 77 | } 78 | 79 | protected function requiresModelInstance(string $policy, string $ability): bool 80 | { 81 | return $this->getParameters($policy, $ability)->skip(1)->first()?->getType()?->getName() === (is_string($this->model) ? $this->model : $this->model::class); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/AddsAbilities.php: -------------------------------------------------------------------------------- 1 | withAllAbilities; 14 | } 15 | 16 | public function getAbilities(): array 17 | { 18 | return $this->abilities; 19 | } 20 | 21 | public function checkAbility(string | array $ability): static 22 | { 23 | $this->mergeAbilities( 24 | is_string($ability) ? func_get_args() : $ability, 25 | false 26 | ); 27 | 28 | return $this; 29 | } 30 | 31 | public function mergeAbilities(array $abilities, bool $withAllAbilities): static 32 | { 33 | $this->abilities = array_merge($this->abilities, $abilities); 34 | $this->withAllAbilities = $withAllAbilities; 35 | 36 | return $this; 37 | } 38 | 39 | public function withAllAbilities(bool $withAllAbilities = true): static 40 | { 41 | $this->withAllAbilities = $withAllAbilities; 42 | 43 | return $this; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/AnonymousResourceCollection.php: -------------------------------------------------------------------------------- 1 | mergeAbilities($this->abilities, $this->withAllAbilities); 17 | 18 | return $model; 19 | } 20 | 21 | public function newCollection(array $models = []): Collection 22 | { 23 | return new Collection($models); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/HasRelationships.php: -------------------------------------------------------------------------------- 1 | whenLoaded($relationship)); 12 | } 13 | 14 | public static function makeWhenLoaded(string $relationship, JsonResource $resource): static 15 | { 16 | return static::make($resource->whenLoaded($relationship)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/JsonResource/ProcessesAbilities.php: -------------------------------------------------------------------------------- 1 | resource, 21 | $this->resource->getAbilities(), 22 | $this->resource->getWithAllAbilities() 23 | )->add($ability, $parameters, $serializer); 24 | } 25 | 26 | public static function collectionAbilities(Collection | MissingValue $resource, string $ability, string $model, array $parameters = [], string $serializer = null): AbilityResource 27 | { 28 | return AbilityResource::create( 29 | $model, 30 | $resource instanceof Collection ? $resource->getAbilities() : [], 31 | $resource instanceof Collection ? $resource->getWithAllAbilities() : true, 32 | )->add($ability, $parameters, $serializer); 33 | } 34 | 35 | /** 36 | * Create a new anonymous resource collection. 37 | * 38 | * @param mixed $resource 39 | * @return AnonymousResourceCollection 40 | */ 41 | public static function collection($resource): AnonymousResourceCollection 42 | { 43 | return tap(new AnonymousResourceCollection($resource, static::class), function ($collection) { 44 | if (property_exists(static::class, 'preserveKeys')) { 45 | $collection->preserveKeys = (new static())->preserveKeys === true; 46 | } 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/PaginatedResourceResponse.php: -------------------------------------------------------------------------------- 1 | json( 21 | array_merge_recursive( 22 | $this->resource->resolve($request), 23 | $this->paginationInformation($request), 24 | ), 25 | $this->calculateStatus() 26 | ), function ($response) use ($request) { 27 | $response->original = $this->resource->resource->map(function ($item) { 28 | return is_array($item) ? Arr::get($item, 'resource') : $item->resource; 29 | }); 30 | 31 | $this->resource->withResponse($request, $response); 32 | }); 33 | } 34 | 35 | /** 36 | * Add the pagination information to the response. 37 | * 38 | * @param Request $request 39 | * @return array 40 | */ 41 | protected function paginationInformation(Request $request): array 42 | { 43 | $paginated = $this->resource->resource->toArray(); 44 | 45 | return [ 46 | 'links' => $this->paginationLinks($paginated), 47 | 'meta' => $this->meta($paginated), 48 | ]; 49 | } 50 | 51 | /** 52 | * Get the pagination links for the response. 53 | * 54 | * @param array $paginated 55 | * @return array 56 | */ 57 | protected function paginationLinks(array $paginated): array 58 | { 59 | return [ 60 | 'first' => $paginated['first_page_url'] ?? null, 61 | 'last' => $paginated['last_page_url'] ?? null, 62 | 'prev' => $paginated['prev_page_url'] ?? null, 63 | 'next' => $paginated['next_page_url'] ?? null, 64 | ]; 65 | } 66 | 67 | /** 68 | * Gather the meta data for the response. 69 | * 70 | * @param array $paginated 71 | * @return array 72 | */ 73 | protected function meta(array $paginated): array 74 | { 75 | return Arr::except($paginated, [ 76 | 'data', 77 | 'first_page_url', 78 | 'last_page_url', 79 | 'prev_page_url', 80 | 'next_page_url', 81 | ]); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/ResourceAbilitiesServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 13 | $this->publishes([ 14 | __DIR__ . '/../config/resource-abilities.php' => config_path('resource-abilities.php'), 15 | ], 'config'); 16 | } 17 | } 18 | 19 | public function register() 20 | { 21 | $this->mergeConfigFrom(__DIR__ . '/../config/resource-abilities.php', 'resource-abilities'); 22 | 23 | Builder::macro('checkAbility', function (string $ability) { 24 | $this->model->checkAbility($ability); 25 | 26 | return $this; 27 | }); 28 | 29 | Builder::macro('withAllAbilities', function (bool $withAllAbilities = true) { 30 | $this->model->withAllAbilities($withAllAbilities); 31 | 32 | return $this; 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ResourceCollection.php: -------------------------------------------------------------------------------- 1 | all(); 30 | } 31 | 32 | if ($this->haveDefaultWrapperAndDataIsUnwrapped($data)) { 33 | $data = [$this->wrapper() => $data]; 34 | } elseif ($this->haveAdditionalInformationAndDataIsUnwrapped($data, $with, $additional)) { 35 | $data = [($this->wrapper() ?? 'data') => $data]; 36 | } 37 | 38 | return array_merge_recursive($data, $with, $additional); 39 | } 40 | 41 | /** 42 | * Determine if we have a default wrapper and the given data is unwrapped. 43 | * 44 | * @param array $data 45 | * @return bool 46 | */ 47 | protected function haveDefaultWrapperAndDataIsUnwrapped(array $data): bool 48 | { 49 | return $this->wrapper() && ! array_key_exists($this->wrapper(), $data); 50 | } 51 | 52 | /** 53 | * Determine if "with" data has been added and our data is unwrapped. 54 | * 55 | * @param array $data 56 | * @param array $with 57 | * @param array $additional 58 | * @return bool 59 | */ 60 | protected function haveAdditionalInformationAndDataIsUnwrapped(array $data, array $with, array $additional): bool 61 | { 62 | return (! empty($with) || ! empty($additional)) && 63 | (! $this->wrapper() || 64 | ! array_key_exists($this->wrapper(), $data)); 65 | } 66 | 67 | /** 68 | * Get the default data wrapper for the resource. 69 | * 70 | * @return string 71 | */ 72 | protected function wrapper(): string 73 | { 74 | return static::$wrap; 75 | } 76 | 77 | /** 78 | * Resolve the resource to an array. 79 | * 80 | * @param Request|null $request 81 | * @return array 82 | */ 83 | public function resolve($request = null) 84 | { 85 | $data = static::toArray( 86 | $request = $request ?: Container::getInstance()->make('request') 87 | ); 88 | 89 | if ($data instanceof Arrayable) { 90 | $data = $data->toArray(); 91 | } elseif ($data instanceof JsonSerializable) { 92 | $data = $data->jsonSerialize(); 93 | } 94 | 95 | $data = $this->filter((array) $data); 96 | 97 | return $this->wrapData( 98 | $data, 99 | $this->with($request), 100 | $this->additional 101 | ); 102 | } 103 | 104 | /** 105 | * Create an HTTP response that represents the object. 106 | * 107 | * @param Request $request 108 | * @return JsonResponse 109 | */ 110 | public function toResponse($request): JsonResponse 111 | { 112 | if ($this->resource instanceof AbstractPaginator) { 113 | return $this->preparePaginatedResponse($request); 114 | } 115 | 116 | return (new ResourceResponse($this))->toResponse($request); 117 | } 118 | 119 | /** 120 | * Create a paginate-aware HTTP response. 121 | * 122 | * @param Request $request 123 | * @return JsonResponse 124 | */ 125 | protected function preparePaginatedResponse($request): JsonResponse 126 | { 127 | if ($this->preserveAllQueryParameters) { 128 | $this->resource->appends($request->query()); 129 | } elseif (! is_null($this->queryParameters)) { 130 | $this->resource->appends($this->queryParameters); 131 | } 132 | 133 | return (new PaginatedResourceResponse($this))->toResponse($request); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/ResourceCollection/ProcessesAbilities.php: -------------------------------------------------------------------------------- 1 | resource->getAbilities(), 20 | $this->resource->getWithAllAbilities() 21 | )->add($ability, $parameters, $serializer); 22 | } 23 | 24 | /** 25 | * Map the given collection resource into its individual resources. 26 | * 27 | * @param mixed $resource 28 | * @return mixed 29 | */ 30 | protected function collectResource($resource) 31 | { 32 | if ($resource instanceof MissingValue) { 33 | return $resource; 34 | } 35 | 36 | if (is_array($resource)) { 37 | $resource = new Collection($resource); 38 | } 39 | 40 | $collects = $this->collects(); 41 | 42 | $this->collection = $collects && ! $resource->first() instanceof $collects 43 | ? $resource 44 | ->mapInto($collects) 45 | ->mergeAbilities($this->resource->getAbilities(), $this->resource->getWithAllAbilities()) 46 | : $resource->toBase(); 47 | 48 | return $resource instanceof AbstractPaginator 49 | ? $resource->setCollection($this->collection) 50 | : $this->collection; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/ResourceResponse.php: -------------------------------------------------------------------------------- 1 | json( 21 | $this->resource->resolve($request), 22 | $this->calculateStatus() 23 | ), function ($response) use ($request) { 24 | $response->original = $this->resource->resource; 25 | 26 | $this->resource->withResponse($request, $response); 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Serializers/AbilitySerializer.php: -------------------------------------------------------------------------------- 1 | ability => $abilityContainer->granted, 13 | ]; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Serializers/ExtendedAbilitySerializer.php: -------------------------------------------------------------------------------- 1 | ability => [ 13 | 'ability' => $abilityContainer->ability, 14 | 'granted' => $abilityContainer->granted, 15 | ], 16 | ]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Serializers/Serializer.php: -------------------------------------------------------------------------------- 1 | policy(TestPolicy::class, new TestModel()); 19 | 20 | $this->assertTrue( 21 | $abilities 22 | ->getAbilityTypes() 23 | ->contains(PolicyAbilityType::make(TestPolicy::class, new TestModel())) 24 | ); 25 | } 26 | 27 | public function it_can_add_a_gate() 28 | { 29 | $abilities = new Abilities(); 30 | 31 | $abilities->gate(TestPolicy::CREATE, new TestModel()); 32 | 33 | $this->assertTrue( 34 | $abilities 35 | ->getAbilityTypes() 36 | ->contains(GateAbilityType::make(TestPolicy::CREATE, new TestModel())) 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/AddsAbilitiesTest.php: -------------------------------------------------------------------------------- 1 | assertEmpty($this->testModel->getAbilities()); 11 | } 12 | 13 | /** @test */ 14 | public function it_adds_abilities() 15 | { 16 | $this->testModel->checkAbility('ability'); 17 | 18 | $this->assertContains('ability', $this->testModel->getAbilities()); 19 | } 20 | 21 | /** @test */ 22 | public function it_merges_abilities_to_new_instances() 23 | { 24 | $this->testModel->checkAbility('ability'); 25 | 26 | $newInstance = $this->testModel->newInstance(); 27 | 28 | $this->assertContains('ability', $newInstance->getAbilities()); 29 | } 30 | 31 | /** @test */ 32 | public function it_adds_an_array_of_abilities() 33 | { 34 | $this->testModel->checkAbility(['ability', 'another_ability']); 35 | 36 | $this->assertContains('ability', $this->testModel->getAbilities()); 37 | $this->assertContains('another_ability', $this->testModel->getAbilities()); 38 | } 39 | 40 | /** @test */ 41 | public function it_adds_spreaded_abilities() 42 | { 43 | $this->testModel->checkAbility('ability', 'another_ability'); 44 | 45 | $this->assertContains('ability', $this->testModel->getAbilities()); 46 | $this->assertContains('another_ability', $this->testModel->getAbilities()); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/CollectionTest.php: -------------------------------------------------------------------------------- 1 | testModel = TestModel::create([ 18 | 'id' => 1, 19 | 'name' => 'testModel', 20 | ]); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Fakes/TestModel.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 19 | } 20 | 21 | public function users(): HasMany 22 | { 23 | return $this->hasMany(User::class); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Fakes/TestPolicy.php: -------------------------------------------------------------------------------- 1 | routeCollection = new RouteCollection(); 18 | } 19 | 20 | public static function setup(): TestRouter 21 | { 22 | return new self(app(Router::class)); 23 | } 24 | 25 | public function route($methods, $uri, $action): Route 26 | { 27 | $route = new Route($methods, $uri, $action); 28 | 29 | $this->addRoute($route->middleware('web')); 30 | 31 | return $route; 32 | } 33 | 34 | public function get($uri, $action): Route 35 | { 36 | return $this->route('GET', $uri, $action); 37 | } 38 | 39 | public function post($uri, $action): Route 40 | { 41 | return $this->route('POST', $uri, $action); 42 | } 43 | 44 | public function put($uri, $action): Route 45 | { 46 | return $this->route('PUT', $uri, $action); 47 | } 48 | 49 | private function addRoute(Route $route) 50 | { 51 | $this->routeCollection->add($route); 52 | 53 | $this->router->setRoutes($this->routeCollection); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/Fakes/User.php: -------------------------------------------------------------------------------- 1 | $this->id, 18 | 'name' => $this->name, 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/HasAbilitiesTest.php: -------------------------------------------------------------------------------- 1 | testModel = TestModel::create([ 18 | 'id' => 1, 19 | 'name' => 'testModel', 20 | ]); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/HasRelationshipsTest.php: -------------------------------------------------------------------------------- 1 | testModel = TestModel::create([ 24 | 'id' => 1, 25 | 'name' => 'testModel', 26 | ]); 27 | $this->user = User::create([ 28 | 'id' => 1, 29 | 'name' => 'Test User', 30 | ]); 31 | 32 | Auth::login($this->user); 33 | } 34 | 35 | /** @test */ 36 | public function it_will_drop_a_to_one_relation_when_not_loaded() 37 | { 38 | $testResource = new class (null) extends JsonResource { 39 | use ProcessesAbilities; 40 | use HasRelationships; 41 | 42 | public function toArray($request) 43 | { 44 | return [ 45 | 'user' => UserResource::makeWhenLoaded('user', $this), 46 | ]; 47 | } 48 | }; 49 | 50 | $this->router->get('/resource', fn () => $testResource::make($this->testModel)); 51 | 52 | $this->get('/resource')->assertExactJson([ 53 | 'data' => [], 54 | ]); 55 | } 56 | 57 | /** @test */ 58 | public function it_will_add_a_to_one_relation_when_loaded() 59 | { 60 | $testResource = new class (null) extends JsonResource { 61 | use ProcessesAbilities; 62 | use HasRelationships; 63 | 64 | public function toArray($request) 65 | { 66 | return [ 67 | 'user' => UserResource::makeWhenLoaded('user', $this), 68 | ]; 69 | } 70 | }; 71 | 72 | $this->testModel->setRelation('user', $this->user); 73 | 74 | $this->router->get('/resource', fn () => $testResource::make($this->testModel)); 75 | 76 | $this->get('/resource')->assertExactJson([ 77 | 'data' => [ 78 | 'user' => [ 79 | 'id' => 1, 80 | 'name' => 'Test User', 81 | ], 82 | ], 83 | ]); 84 | } 85 | 86 | /** @test */ 87 | public function it_will_drop_a_to_many_relation_when_not_loaded() 88 | { 89 | $testResource = new class (null) extends JsonResource { 90 | use ProcessesAbilities; 91 | use HasRelationships; 92 | 93 | public function toArray($request) 94 | { 95 | return [ 96 | 'users' => UserResource::collectionWhenLoaded('users', $this), 97 | ]; 98 | } 99 | }; 100 | 101 | $this->router->get('/resource', fn () => $testResource::make($this->testModel)); 102 | 103 | $this->get('/resource')->assertExactJson([ 104 | 'data' => [], 105 | ]); 106 | } 107 | 108 | /** @test */ 109 | public function it_will_add_a_to_many_relation_when_loaded() 110 | { 111 | $testResource = new class (null) extends JsonResource { 112 | use ProcessesAbilities; 113 | use HasRelationships; 114 | 115 | public function toArray($request) 116 | { 117 | return [ 118 | 'users' => UserResource::collectionWhenLoaded('users', $this), 119 | ]; 120 | } 121 | }; 122 | 123 | $this->testModel->setRelation('users', [$this->user]); 124 | 125 | $this->router->get('/resource', fn () => $testResource::make($this->testModel)); 126 | 127 | $this->get('/resource')->assertExactJson([ 128 | 'data' => [ 129 | 'users' => [ 130 | 'data' => [ 131 | [ 132 | 'id' => 1, 133 | 'name' => 'Test User', 134 | ], 135 | ], 136 | ], 137 | ], 138 | ]); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /tests/JsonResource/ProcessesAbilitiesTest.php: -------------------------------------------------------------------------------- 1 | testModel = TestModel::create([ 30 | 'id' => 1, 31 | 'name' => 'testModel', 32 | ]); 33 | $this->user = User::create([ 34 | 'id' => 1, 35 | 'name' => 'Test User', 36 | ]); 37 | 38 | Auth::login($this->user); 39 | } 40 | 41 | /** 42 | * Resource 43 | */ 44 | 45 | /** @test */ 46 | public function it_will_wrap_nested_resources_when_making_a_resource() 47 | { 48 | $testResource = new class (null) extends JsonResource { 49 | use ProcessesAbilities; 50 | 51 | public function toArray($request) 52 | { 53 | return [ 54 | 'users' => UserResource::collectionWhenLoaded('users', $this), 55 | ]; 56 | } 57 | }; 58 | 59 | $this->testModel->setRelation('users', [$this->user]); 60 | 61 | $this->router->get('/resource', fn () => $testResource::make($this->testModel->withAllAbilities(false))); 62 | 63 | $this->get('/resource')->assertExactJson([ 64 | 'data' => [ 65 | 'users' => [ 66 | 'data' => [ 67 | ['id' => 1, 'name' => 'Test User'], 68 | ], 69 | ], 70 | ], 71 | ]); 72 | } 73 | 74 | /** @test */ 75 | public function it_will_generate_abilities_when_making_a_resource() 76 | { 77 | $testResource = new class (null) extends JsonResource { 78 | use ProcessesAbilities; 79 | 80 | public function toArray($request) 81 | { 82 | return [ 83 | 'abilities' => $this->abilities(TestPolicy::class), 84 | ]; 85 | } 86 | }; 87 | 88 | $this->router->get('/resource', fn () => $testResource::make($this->testModel->withAllAbilities(false))); 89 | 90 | $this->get('/resource')->assertExactJson([ 91 | 'data' => [ 92 | 'abilities' => [], 93 | ], 94 | ]); 95 | } 96 | 97 | /** @test */ 98 | public function it_will_check_policy_abilities_when_making_a_resource() 99 | { 100 | $testResource = new class (null) extends JsonResource { 101 | use ProcessesAbilities; 102 | 103 | public function toArray($request) 104 | { 105 | return [ 106 | 'abilities' => $this->abilities(TestPolicy::class), 107 | ]; 108 | } 109 | }; 110 | 111 | $this->testModel 112 | ->checkAbility('view') 113 | ->checkAbility('update'); 114 | 115 | $this->router->get('/resource', fn () => $testResource::make($this->testModel)); 116 | 117 | $this->get('/resource')->assertExactJson([ 118 | 'data' => [ 119 | 'abilities' => [ 120 | 'view' => true, 121 | 'update' => false, 122 | ], 123 | ], 124 | ]); 125 | } 126 | 127 | /** @test */ 128 | public function it_will_check_gate_abilities_when_making_a_resource() 129 | { 130 | $testResource = new class (null) extends JsonResource { 131 | use ProcessesAbilities; 132 | 133 | public function toArray($request) 134 | { 135 | return [ 136 | 'abilities' => $this->abilities('view'), 137 | ]; 138 | } 139 | }; 140 | 141 | $this->testModel 142 | ->checkAbility('view') 143 | ->checkAbility('update'); 144 | 145 | $this->router->get('/resource', fn () => $testResource::make($this->testModel)); 146 | 147 | $this->get('/resource')->assertExactJson([ 148 | 'data' => [ 149 | 'abilities' => [ 150 | 'view' => true, 151 | ], 152 | ], 153 | ]); 154 | } 155 | 156 | /** @test */ 157 | public function it_will_check_multiple_gate_abilities_when_making_a_resource() 158 | { 159 | $testResource = new class (null) extends JsonResource { 160 | use ProcessesAbilities; 161 | 162 | public function toArray($request) 163 | { 164 | return [ 165 | 'abilities' => $this 166 | ->abilities('view') 167 | ->add('update'), 168 | ]; 169 | } 170 | }; 171 | 172 | $this->testModel 173 | ->checkAbility('view') 174 | ->checkAbility('update'); 175 | 176 | $this->router->get('/resource', fn () => $testResource::make($this->testModel)); 177 | 178 | $this->get('/resource')->assertExactJson([ 179 | 'data' => [ 180 | 'abilities' => [ 181 | 'view' => true, 182 | 'update' => false, 183 | ], 184 | ], 185 | ]); 186 | } 187 | 188 | /** @test */ 189 | public function it_will_check_all_policy_abilities_when_making_a_resource() 190 | { 191 | $testResource = new class (null) extends JsonResource { 192 | use ProcessesAbilities; 193 | 194 | public function toArray($request) 195 | { 196 | return [ 197 | 'abilities' => $this->abilities(TestPolicy::class, [true]), 198 | ]; 199 | } 200 | }; 201 | 202 | $this->router->get('/resource', fn () => $testResource::make($this->testModel)); 203 | 204 | $this->get('/resource')->assertExactJson([ 205 | 'data' => [ 206 | 'abilities' => [ 207 | 'viewAny' => true, 208 | 'view' => true, 209 | 'create' => true, 210 | 'update' => false, 211 | 'delete' => true, 212 | 'restore' => true, 213 | 'forceDelete' => false, 214 | ], 215 | ], 216 | ]); 217 | } 218 | 219 | /** @test */ 220 | public function it_will_pass_parameters_when_checking_gates_when_making_a_resource() 221 | { 222 | $testResource = new class (null) extends JsonResource { 223 | use ProcessesAbilities; 224 | 225 | public function toArray($request) 226 | { 227 | return [ 228 | 'abilities' => $this->abilities('restore', [true]), 229 | ]; 230 | } 231 | }; 232 | 233 | $this->testModel 234 | ->checkAbility('restore'); 235 | 236 | $this->router->get('/resource', fn () => $testResource::make($this->testModel)); 237 | 238 | $this->get('/resource')->assertExactJson([ 239 | 'data' => [ 240 | 'abilities' => [ 241 | 'restore' => true, 242 | ], 243 | ], 244 | ]); 245 | } 246 | 247 | /** @test */ 248 | public function it_will_pass_parameters_when_checking_policies_when_making_a_resource() 249 | { 250 | $testResource = new class (null) extends JsonResource { 251 | use ProcessesAbilities; 252 | 253 | public function toArray($request) 254 | { 255 | return [ 256 | 'abilities' => $this->abilities(TestPolicy::class, [true]), 257 | ]; 258 | } 259 | }; 260 | 261 | $this->testModel 262 | ->checkAbility('restore'); 263 | 264 | $this->router->get('/resource', fn () => $testResource::make($this->testModel)); 265 | 266 | $this->get('/resource')->assertExactJson([ 267 | 'data' => [ 268 | 'abilities' => [ 269 | 'restore' => true, 270 | ], 271 | ], 272 | ]); 273 | } 274 | 275 | /** @test */ 276 | public function it_will_use_serializer_when_checking_policies_when_making_a_resource() 277 | { 278 | $testResource = new class (null) extends JsonResource { 279 | use ProcessesAbilities; 280 | 281 | public function toArray($request) 282 | { 283 | return [ 284 | 'abilities' => $this->abilities(TestPolicy::class, serializer: ExtendedAbilitySerializer::class), 285 | ]; 286 | } 287 | }; 288 | 289 | $this->testModel 290 | ->checkAbility('view'); 291 | 292 | $this->router->get('/resource', fn () => $testResource::make($this->testModel)); 293 | 294 | $this->get('/resource')->assertExactJson([ 295 | 'data' => [ 296 | 'abilities' => [ 297 | 'view' => [ 298 | 'ability' => 'view', 299 | 'granted' => true, 300 | ], 301 | ], 302 | ], 303 | ]); 304 | } 305 | 306 | /** @test */ 307 | public function it_will_use_serializer_when_checking_gates_when_making_a_resource() 308 | { 309 | $testResource = new class (null) extends JsonResource { 310 | use ProcessesAbilities; 311 | 312 | public function toArray($request) 313 | { 314 | return [ 315 | 'abilities' => $this->abilities('view', serializer: ExtendedAbilitySerializer::class), 316 | ]; 317 | } 318 | }; 319 | 320 | $this->testModel 321 | ->checkAbility('view'); 322 | 323 | $this->router->get('/resource', fn () => $testResource::make($this->testModel)); 324 | 325 | $this->get('/resource')->assertExactJson([ 326 | 'data' => [ 327 | 'abilities' => [ 328 | 'view' => [ 329 | 'ability' => 'view', 330 | 'granted' => true, 331 | ], 332 | ], 333 | ], 334 | ]); 335 | } 336 | 337 | /** @test */ 338 | public function it_will_use_serializer_per_ability_type_when_making_a_resource() 339 | { 340 | $testResource = new class (null) extends JsonResource { 341 | use ProcessesAbilities; 342 | 343 | public function toArray($request) 344 | { 345 | return [ 346 | 'abilities' => $this->abilities('view')->add('update', serializer: ExtendedAbilitySerializer::class), 347 | ]; 348 | } 349 | }; 350 | 351 | $this->router->get('/resource', fn () => $testResource::make($this->testModel)); 352 | 353 | $this->get('/resource')->assertExactJson([ 354 | 'data' => [ 355 | 'abilities' => [ 356 | 'view' => true, 357 | 'update' => [ 358 | 'ability' => 'update', 359 | 'granted' => false, 360 | ], 361 | ], 362 | ], 363 | ]); 364 | } 365 | 366 | /** 367 | * Collection 368 | */ 369 | 370 | /** @test */ 371 | public function it_will_wrap_data_when_making_a_collection() 372 | { 373 | $testResource = new class (null) extends JsonResource { 374 | use ProcessesAbilities; 375 | 376 | public function toArray($request) 377 | { 378 | return [ 379 | 'id' => $this->id, 380 | ]; 381 | } 382 | }; 383 | 384 | $collection = TestModel::query()->get(); 385 | 386 | $this->router->get('/resources', fn () => $testResource::collection($collection->withAllAbilities(false))); 387 | 388 | $this->get('/resources')->assertExactJson([ 389 | 'data' => [ 390 | ['id' => 1], 391 | ], 392 | ]); 393 | } 394 | 395 | /** @test */ 396 | public function it_will_wrap_nested_resources_when_making_a_collection() 397 | { 398 | $testResource = new class (null) extends JsonResource { 399 | use ProcessesAbilities; 400 | 401 | public function toArray($request) 402 | { 403 | return [ 404 | 'users' => UserResource::collectionWhenLoaded('users', $this), 405 | ]; 406 | } 407 | }; 408 | 409 | $collection = TestModel::query()->get(); 410 | $collection->map(fn (TestModel $testModel) => $testModel->setRelation('users', [$this->user])); 411 | 412 | $this->router->get('/resources', fn () => $testResource::collection($collection->withAllAbilities(false))); 413 | 414 | $this->get('/resources')->assertExactJson([ 415 | 'data' => [ 416 | [ 417 | 'users' => [ 418 | 'data' => [ 419 | ['id' => 1, 'name' => 'Test User'], 420 | ], 421 | ], 422 | ], 423 | ], 424 | ]); 425 | } 426 | 427 | /** @test */ 428 | public function it_will_generate_abilities_when_making_a_collection() 429 | { 430 | $testResource = new class (null) extends JsonResource { 431 | use ProcessesAbilities; 432 | 433 | public function toArray($request) 434 | { 435 | return []; 436 | } 437 | 438 | public static function collection($resource): AnonymousResourceCollection 439 | { 440 | return parent::collection($resource)->additional([ 441 | 'abilities' => self::collectionAbilities($resource, TestPolicy::class, TestModel::class), 442 | ]); 443 | } 444 | }; 445 | 446 | $collection = TestModel::query()->get(); 447 | 448 | $this->router->get('/resources', fn () => $testResource::collection($collection->withAllAbilities(false))); 449 | 450 | $this->get('/resources')->assertExactJson([ 451 | 'data' => [[]], 452 | 'abilities' => [], 453 | ]); 454 | } 455 | 456 | /** @test */ 457 | public function it_will_check_policy_abilities_when_making_a_collection() 458 | { 459 | $testResource = new class (null) extends JsonResource { 460 | use ProcessesAbilities; 461 | 462 | public function toArray($request) 463 | { 464 | return []; 465 | } 466 | 467 | public static function collection($resource): AnonymousResourceCollection 468 | { 469 | return parent::collection($resource)->additional([ 470 | 'abilities' => self::collectionAbilities($resource, TestPolicy::class, TestModel::class), 471 | ]); 472 | } 473 | }; 474 | 475 | $collection = TestModel::query()->get(); 476 | 477 | $this->router->get('/resources', fn () => $testResource::collection($collection)); 478 | 479 | $this->get('/resources')->assertExactJson([ 480 | 'data' => [[]], 481 | 'abilities' => [ 482 | 'viewAny' => true, 483 | 'create' => false, 484 | ], 485 | ]); 486 | } 487 | 488 | /** @test */ 489 | public function it_will_check_gate_abilities_when_making_a_collection() 490 | { 491 | $testResource = new class (null) extends JsonResource { 492 | use ProcessesAbilities; 493 | 494 | public function toArray($request) 495 | { 496 | return []; 497 | } 498 | 499 | public static function collection($resource): AnonymousResourceCollection 500 | { 501 | return parent::collection($resource)->additional([ 502 | 'abilities' => self::collectionAbilities($resource, 'viewAny', TestModel::class), 503 | ]); 504 | } 505 | }; 506 | 507 | $collection = TestModel::query()->get(); 508 | 509 | $this->router->get('/resources', fn () => $testResource::collection($collection)); 510 | 511 | $this->get('/resources')->assertExactJson([ 512 | 'data' => [[]], 513 | 'abilities' => [ 514 | 'viewAny' => true, 515 | ], 516 | ]); 517 | } 518 | 519 | /** @test */ 520 | public function it_will_check_multiple_gate_abilities_when_making_a_collection() 521 | { 522 | $testResource = new class (null) extends JsonResource { 523 | use ProcessesAbilities; 524 | 525 | public function toArray($request) 526 | { 527 | return []; 528 | } 529 | 530 | public static function collection($resource): AnonymousResourceCollection 531 | { 532 | return parent::collection($resource)->additional([ 533 | 'abilities' => self::collectionAbilities($resource, 'viewAny', TestModel::class)->add('create'), 534 | ]); 535 | } 536 | }; 537 | 538 | $collection = TestModel::query()->get(); 539 | 540 | $this->router->get('/resources', fn () => $testResource::collection($collection)); 541 | 542 | $this->get('/resources')->assertExactJson([ 543 | 'data' => [[]], 544 | 'abilities' => [ 545 | 'viewAny' => true, 546 | 'create' => false, 547 | ], 548 | ]); 549 | } 550 | 551 | /** @test */ 552 | public function it_will_check_only_policy_abilities_without_models_when_making_a_collection() 553 | { 554 | $testResource = new class (null) extends JsonResource { 555 | use ProcessesAbilities; 556 | 557 | public function toArray($request) 558 | { 559 | return []; 560 | } 561 | 562 | public static function collection($resource): AnonymousResourceCollection 563 | { 564 | return parent::collection($resource)->additional([ 565 | 'abilities' => self::collectionAbilities($resource, TestPolicy::class, TestModel::class), 566 | ]); 567 | } 568 | }; 569 | 570 | $collection = TestModel::query()->get(); 571 | 572 | $this->router->get('/resources', fn () => $testResource::collection($collection)); 573 | 574 | $this->get('/resources')->assertExactJson([ 575 | 'data' => [[]], 576 | 'abilities' => [ 577 | 'viewAny' => true, 578 | 'create' => false, 579 | ], 580 | ]); 581 | } 582 | 583 | /** @test */ 584 | public function it_will_pass_parameters_when_checking_gates_when_making_a_collection() 585 | { 586 | $testResource = new class (null) extends JsonResource { 587 | use ProcessesAbilities; 588 | 589 | public function toArray($request) 590 | { 591 | return []; 592 | } 593 | 594 | public static function collection($resource): AnonymousResourceCollection 595 | { 596 | return parent::collection($resource)->additional([ 597 | 'abilities' => self::collectionAbilities($resource, 'create', TestModel::class, [true]), 598 | ]); 599 | } 600 | }; 601 | 602 | $collection = TestModel::query()->get(); 603 | 604 | $this->router->get('/resources', fn () => $testResource::collection($collection)); 605 | 606 | $this->get('/resources')->assertExactJson([ 607 | 'data' => [[]], 608 | 'abilities' => [ 609 | 'create' => true, 610 | ], 611 | ]); 612 | } 613 | 614 | /** @test */ 615 | public function it_will_pass_parameters_when_checking_policies_when_making_a_collection() 616 | { 617 | $testResource = new class (null) extends JsonResource { 618 | use ProcessesAbilities; 619 | 620 | public function toArray($request) 621 | { 622 | return []; 623 | } 624 | 625 | public static function collection($resource): AnonymousResourceCollection 626 | { 627 | return parent::collection($resource)->additional([ 628 | 'abilities' => self::collectionAbilities($resource, TestPolicy::class, TestModel::class, [true]), 629 | ]); 630 | } 631 | }; 632 | 633 | $collection = TestModel::query()->get(); 634 | 635 | $this->router->get('/resources', fn () => $testResource::collection($collection)); 636 | 637 | $this->get('/resources')->assertExactJson([ 638 | 'data' => [[]], 639 | 'abilities' => [ 640 | 'viewAny' => true, 641 | 'create' => true, 642 | ], 643 | ]); 644 | } 645 | 646 | /** @test */ 647 | public function it_will_use_serializer_when_checking_policies_when_making_a_collection() 648 | { 649 | $testResource = new class (null) extends JsonResource { 650 | use ProcessesAbilities; 651 | 652 | public function toArray($request) 653 | { 654 | return []; 655 | } 656 | 657 | public static function collection($resource): AnonymousResourceCollection 658 | { 659 | return parent::collection($resource)->additional([ 660 | 'abilities' => self::collectionAbilities($resource, TestPolicy::class, TestModel::class, serializer: ExtendedAbilitySerializer::class), 661 | ]); 662 | } 663 | }; 664 | 665 | $collection = TestModel::query()->get(); 666 | 667 | $this->router->get('/resources', fn () => $testResource::collection($collection)); 668 | 669 | $this->get('/resources')->assertExactJson([ 670 | 'data' => [[]], 671 | 'abilities' => [ 672 | 'viewAny' => [ 673 | 'ability' => 'viewAny', 674 | 'granted' => true, 675 | ], 676 | 'create' => [ 677 | 'ability' => 'create', 678 | 'granted' => false, 679 | ], 680 | ], 681 | ]); 682 | } 683 | 684 | /** @test */ 685 | public function it_will_use_serializer_when_checking_gates_when_making_a_collection() 686 | { 687 | $testResource = new class (null) extends JsonResource { 688 | use ProcessesAbilities; 689 | 690 | public function toArray($request) 691 | { 692 | return []; 693 | } 694 | 695 | public static function collection($resource): AnonymousResourceCollection 696 | { 697 | return parent::collection($resource)->additional([ 698 | 'abilities' => self::collectionAbilities($resource, 'viewAny', TestModel::class, serializer: ExtendedAbilitySerializer::class), 699 | ]); 700 | } 701 | }; 702 | 703 | $collection = TestModel::query()->get(); 704 | 705 | $this->router->get('/resources', fn () => $testResource::collection($collection)); 706 | 707 | $this->get('/resources')->assertExactJson([ 708 | 'data' => [[]], 709 | 'abilities' => [ 710 | 'viewAny' => [ 711 | 'ability' => 'viewAny', 712 | 'granted' => true, 713 | ], 714 | ], 715 | ]); 716 | } 717 | } 718 | -------------------------------------------------------------------------------- /tests/ResourceCollection/ProcessesAbilitiesTest.php: -------------------------------------------------------------------------------- 1 | testResource = new class (null) extends JsonResource { 29 | use ProcessesAbilities; 30 | 31 | public function toArray($request) 32 | { 33 | return []; 34 | } 35 | }; 36 | $this->user = User::create([ 37 | 'id' => 1, 38 | 'name' => 'Test User', 39 | ]); 40 | 41 | Auth::login($this->user); 42 | } 43 | 44 | /** @test */ 45 | public function it_will_generate_abilities() 46 | { 47 | $collection = TestModel::query()->get(); 48 | 49 | $this->router->get('/resources', fn () => new class ($collection) extends ResourceCollection { 50 | use ProcessesAbilities; 51 | 52 | public function toArray($request) 53 | { 54 | return [ 55 | 'data' => $this->collection, 56 | 'abilities' => $this->abilities(TestPolicy::class, TestModel::class), 57 | ]; 58 | } 59 | }); 60 | 61 | $this->get('/resources')->assertExactJson([ 62 | 'data' => [], 63 | 'abilities' => [ 64 | 'viewAny' => true, 65 | 'create' => false, 66 | ], 67 | ]); 68 | } 69 | 70 | /** @test */ 71 | public function it_will_check_policy_abilities() 72 | { 73 | $collection = TestModel::query()->get(); 74 | $collection 75 | ->checkAbility('viewAny') 76 | ->checkAbility('create'); 77 | 78 | $this->router->get('/resources', fn () => new class ($collection) extends ResourceCollection { 79 | use ProcessesAbilities; 80 | 81 | public function toArray($request) 82 | { 83 | return [ 84 | 'data' => $this->collection, 85 | 'abilities' => $this->abilities(TestPolicy::class, TestModel::class), 86 | ]; 87 | } 88 | }); 89 | 90 | $this->get('/resources')->assertExactJson([ 91 | 'data' => [], 92 | 'abilities' => [ 93 | 'viewAny' => true, 94 | 'create' => false, 95 | ], 96 | ]); 97 | } 98 | 99 | /** @test */ 100 | public function it_will_check_gate_abilities() 101 | { 102 | $collection = TestModel::query()->get(); 103 | 104 | $this->router->get('/resources', fn () => new class ($collection) extends ResourceCollection { 105 | use ProcessesAbilities; 106 | 107 | public function toArray($request) 108 | { 109 | return [ 110 | 'data' => $this->collection, 111 | 'abilities' => $this->abilities('viewAny', TestModel::class), 112 | ]; 113 | } 114 | }); 115 | 116 | $this->get('/resources')->assertExactJson([ 117 | 'data' => [], 118 | 'abilities' => [ 119 | 'viewAny' => true, 120 | ], 121 | ]); 122 | } 123 | 124 | /** @test */ 125 | public function it_will_check_multiple_gate_abilities() 126 | { 127 | $collection = TestModel::query()->get(); 128 | 129 | $this->router->get('/resources', fn () => new class ($collection) extends ResourceCollection { 130 | use ProcessesAbilities; 131 | 132 | public function toArray($request) 133 | { 134 | return [ 135 | 'data' => $this->collection, 136 | 'abilities' => $this->abilities('viewAny', TestModel::class)->add('create'), 137 | ]; 138 | } 139 | }); 140 | 141 | $this->get('/resources')->assertExactJson([ 142 | 'data' => [], 143 | 'abilities' => [ 144 | 'viewAny' => true, 145 | 'create' => false, 146 | ], 147 | ]); 148 | } 149 | 150 | /** @test */ 151 | public function it_will_check_only_policy_abilities_without_models() 152 | { 153 | $collection = TestModel::query()->get(); 154 | 155 | $this->router->get('/resources', fn () => new class ($collection) extends ResourceCollection { 156 | use ProcessesAbilities; 157 | 158 | public function toArray($request) 159 | { 160 | return [ 161 | 'data' => $this->collection, 162 | 'abilities' => $this->abilities(TestPolicy::class, TestModel::class), 163 | ]; 164 | } 165 | }); 166 | 167 | $this->get('/resources')->assertExactJson([ 168 | 'data' => [], 169 | 'abilities' => [ 170 | 'viewAny' => true, 171 | 'create' => false, 172 | ], 173 | ]); 174 | } 175 | 176 | /** @test */ 177 | public function it_will_pass_parameters_when_checking_gates() 178 | { 179 | $collection = TestModel::query()->get(); 180 | 181 | $this->router->get('/resources', fn () => new class ($collection) extends ResourceCollection { 182 | use ProcessesAbilities; 183 | 184 | public function toArray($request) 185 | { 186 | return [ 187 | 'data' => $this->collection, 188 | 'abilities' => $this->abilities('create', TestModel::class, [true]), 189 | ]; 190 | } 191 | }); 192 | 193 | $this->get('/resources')->assertExactJson([ 194 | 'data' => [], 195 | 'abilities' => [ 196 | 'create' => true, 197 | ], 198 | ]); 199 | } 200 | 201 | /** @test */ 202 | public function it_will_pass_parameters_when_checking_policies() 203 | { 204 | $collection = TestModel::query()->get(); 205 | 206 | $this->router->get('/resources', fn () => new class ($collection) extends ResourceCollection { 207 | use ProcessesAbilities; 208 | 209 | public function toArray($request) 210 | { 211 | return [ 212 | 'data' => $this->collection, 213 | 'abilities' => $this->abilities(TestPolicy::class, TestModel::class, [true]), 214 | ]; 215 | } 216 | }); 217 | 218 | $this->get('/resources')->assertExactJson([ 219 | 'data' => [], 220 | 'abilities' => [ 221 | 'viewAny' => true, 222 | 'create' => true, 223 | ], 224 | ]); 225 | } 226 | 227 | /** @test */ 228 | public function it_will_use_serializer_when_checking_policies() 229 | { 230 | $collection = TestModel::query()->get(); 231 | 232 | $this->router->get('/resources', fn () => new class ($collection) extends ResourceCollection { 233 | use ProcessesAbilities; 234 | 235 | public function toArray($request) 236 | { 237 | return [ 238 | 'data' => $this->collection, 239 | 'abilities' => $this->abilities(TestPolicy::class, TestModel::class, serializer: ExtendedAbilitySerializer::class), 240 | ]; 241 | } 242 | }); 243 | 244 | $this->get('/resources')->assertExactJson([ 245 | 'data' => [], 246 | 'abilities' => [ 247 | 'viewAny' => [ 248 | 'ability' => 'viewAny', 249 | 'granted' => true, 250 | ], 251 | 'create' => [ 252 | 'ability' => 'create', 253 | 'granted' => false, 254 | ], 255 | ], 256 | ]); 257 | } 258 | 259 | /** @test */ 260 | public function it_will_use_serializer_when_checking_gates() 261 | { 262 | $collection = TestModel::query()->get(); 263 | 264 | $this->router->get('/resources', fn () => new class ($collection) extends ResourceCollection { 265 | use ProcessesAbilities; 266 | 267 | public function toArray($request) 268 | { 269 | return [ 270 | 'data' => $this->collection, 271 | 'abilities' => $this->abilities('viewAny', TestModel::class, serializer: ExtendedAbilitySerializer::class), 272 | ]; 273 | } 274 | }); 275 | 276 | $this->get('/resources')->assertExactJson([ 277 | 'data' => [], 278 | 'abilities' => [ 279 | 'viewAny' => [ 280 | 'ability' => 'viewAny', 281 | 'granted' => true, 282 | ], 283 | ], 284 | ]); 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | setUpEnvironment(); 22 | $this->setUpDatabase(); 23 | 24 | $this->router = TestRouter::setup(); 25 | } 26 | 27 | protected function getPackageProviders($app): array 28 | { 29 | return [ 30 | ResourceAbilitiesServiceProvider::class, 31 | ]; 32 | } 33 | 34 | private function setUpDatabase() 35 | { 36 | Schema::create('users', function (Blueprint $table) { 37 | $table->bigIncrements('id'); 38 | $table->string('name'); 39 | }); 40 | 41 | Schema::create('test_models', function (Blueprint $table) { 42 | $table->bigIncrements('id'); 43 | $table->string('name'); 44 | }); 45 | 46 | Schema::create('second_test_models', function (Blueprint $table) { 47 | $table->bigIncrements('id'); 48 | $table->string('name'); 49 | }); 50 | } 51 | 52 | private function setUpEnvironment(): void 53 | { 54 | config()->set('database.default', 'sqlite'); 55 | config()->set('database.connections.sqlite', [ 56 | 'driver' => 'sqlite', 57 | 'database' => ':memory:', 58 | 'prefix' => '', 59 | ]); 60 | 61 | config()->set('app.key', 'kuFyUdCwrgWJjLWURIbkemJlFLGatcmo'); 62 | 63 | config()->set('resource-links.serializer', AbilitySerializer::class); 64 | 65 | Model::unguard(); 66 | } 67 | } 68 | --------------------------------------------------------------------------------