diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..349086d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +### Testing +- `vendor/bin/phpunit` - Run all tests +- `vendor/bin/phpunit --coverage-clover coverage.xml` - Run tests with coverage +- `vendor/bin/phpunit --configuration phpunit.php8.1.xml.dist` - Run tests for specific PHP version +- `vendor/bin/phpunit tests/Unit` - Run only unit tests +- `vendor/bin/phpunit tests/Feature` - Run only feature tests + +### Static Analysis +- `vendor/bin/phpstan analyze` - Run PHPStan static analysis (level 6) + +### Dependencies +- `composer install` - Install dependencies +- `composer require laravel/framework ^9.0` - Install specific Laravel version for testing + +## Architecture + +This is a Laravel package that provides JSON:API compliant resource serialization. The core architecture follows these patterns: + +### Resource System +- **JsonApiResource**: Main abstract class extending Laravel's JsonResource, implements JSON:API specification +- **JsonApiCollection**: Handles collections of resources with proper JSON:API formatting +- **Resourceable**: Interface defining resource contracts + +### Key Components + +#### Descriptors +- **Values** (`src/Descriptors/Values`): Type descriptors for attributes (string, integer, float, date, enum, etc.) +- **Relations** (`src/Descriptors/Relations`): Relationship descriptors (one, many) + +#### Resource Concerns +- **Attributes**: Handles attribute serialization with field filtering +- **Relationships**: Manages relationship loading and serialization with include support +- **ConditionallyLoadsAttributes**: Laravel-style conditional attribute support +- **Identifier**: Resource ID and type handling +- **Links**: JSON:API links support +- **Meta**: Meta information handling +- **Schema**: Resource schema generation for validation +- **ToResponse**: Response formatting + +#### Request Validation +- **Rules/Includes**: Validates `include` parameter against resource schema +- **Rules/Fields**: Validates `fields` parameter for sparse fieldsets + +### Key Features +- **Include Support**: Dynamic relationship loading via `?include=` parameter +- **Sparse Fieldsets**: Attribute filtering via `?fields[type]=` parameter +- **Described Notation**: Fluent API for defining attributes and relationships with type casting +- **Laravel Compatibility**: Supports Laravel 9-12 and PHP 8.1-8.4 + +### Configuration +The package includes a config file (`config/jsonapi.php`) with settings for: +- Nullable value handling +- Date formatting +- Float precision +- Automatic whenHas conditions +- Relationship loading behavior + +### Testing Structure +- **Unit Tests**: Test individual components in isolation +- **Feature Tests**: Test complete JSON:API response formatting +- Uses Orchestra Testbench for Laravel package testing +- SQLite in-memory database for testing \ No newline at end of file diff --git a/src/Descriptors/Relations/Relation.php b/src/Descriptors/Relations/Relation.php index dc2f5b5..5e05ce4 100644 --- a/src/Descriptors/Relations/Relation.php +++ b/src/Descriptors/Relations/Relation.php @@ -3,10 +3,12 @@ namespace Ark4ne\JsonApi\Descriptors\Relations; use Ark4ne\JsonApi\Descriptors\Describer; +use Ark4ne\JsonApi\Filters\Filters; use Ark4ne\JsonApi\Resources\Relationship; use Ark4ne\JsonApi\Support\Includes; use Ark4ne\JsonApi\Traits\HasRelationLoad; use Closure; +use Illuminate\Contracts\Auth\Access\Gate; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Http\Resources\MissingValue; @@ -23,6 +25,7 @@ abstract class Relation extends Describer protected ?Closure $links = null; protected ?Closure $meta = null; protected ?bool $whenIncluded = null; + protected ?Filters $filters = null; /** * @param class-string<\Ark4ne\JsonApi\Resources\JsonApiResource|\Ark4ne\JsonApi\Resources\JsonApiCollection> $related @@ -63,6 +66,37 @@ public function meta(Closure $meta): static return $this; } + /** + * Set filters for the relationship + * + * @param Closure(Filters): Filters $filters Callback that receives (Filters $filters) and configures the filters + * @return static + */ + public function filters(Closure $filters): static + { + $this->filters = $filters(new Filters); + return $this; + } + + /** + * @param iterable|string $abilities Abilities to check + * @param array $arguments Arguments to pass to the policy method, the model is always the first argument + * @param string $gateClass Gate class to use, defaults to the default Gate implementation + * @param string|null $guard Guard to use, defaults to the default guard + * @return static + */ + public function can(iterable|string $abilities, array $arguments = [], string $gateClass = Gate::class, ?string $guard = null): static + { + return $this->when(fn( + Request $request, + Model $model, + string $attribute + ) => app($gateClass) + ->forUser($request->user($guard)) + ->allows($abilities, [$model, ...$arguments]) + ); + } + /** * @param bool|null $whenIncluded * @return static @@ -139,6 +173,10 @@ public function resolveFor(Request $request, mixed $model, string $attribute): R $relation->whenIncluded($this->whenIncluded); } + if ($this->filters !== null) { + $relation->withFilters($this->filters); + } + return $relation; } diff --git a/src/Filters/CallbackFilterRule.php b/src/Filters/CallbackFilterRule.php new file mode 100644 index 0000000..ba4d9e1 --- /dev/null +++ b/src/Filters/CallbackFilterRule.php @@ -0,0 +1,32 @@ + + */ +class CallbackFilterRule implements FilterRule +{ + /** + * @param Closure(Request, Resource): bool $callback + */ + public function __construct( + protected Closure $callback + ) { + } + + /** + * @param Request $request + * @param Resource $model + * @return bool + */ + public function passes(Request $request, mixed $model): bool + { + return (bool) ($this->callback)($request, $model); + } +} \ No newline at end of file diff --git a/src/Filters/FilterRule.php b/src/Filters/FilterRule.php new file mode 100644 index 0000000..1a1c6a3 --- /dev/null +++ b/src/Filters/FilterRule.php @@ -0,0 +1,20 @@ +> */ + protected array $rules = []; + + /** + * Add a policy-based filter + * + * @param iterable|string $abilities Abilities to check + * @param array $arguments Arguments to pass to the policy method, the model is always the first argument + * @param string $gateClass Gate class to use, defaults to the default Gate implementation + * @param string|null $guard Guard to use, defaults to the default guard + * @return static + */ + public function can(iterable|string $abilities, array $arguments = [], string $gateClass = Gate::class, ?string $guard = null): static + { + $this->rules[] = new PolicyFilterRule($abilities, $arguments, $gateClass, $guard); + return $this; + } + + /** + * Add a custom filter rule + * + * @param Closure $callback Callback that receives (Request $request, Model $model) and returns bool + * @return static + */ + public function when(Closure $callback): static + { + $this->rules[] = new CallbackFilterRule($callback); + return $this; + } + + /** + * Apply all filters to the given data + * + * @param mixed $data + * @return mixed + */ + public function apply(Request $request, mixed $data): mixed + { + if ($data instanceof MissingValue || $data === null) { + return $data; + } + + // If it's a collection/array, filter each item + if (is_iterable($data)) { + $filtered = []; + foreach ($data as $key => $item) { + if ($this->shouldInclude($request, $item)) { + $filtered[$key] = $item; + } + } + + // Preserve the original collection type + if ($data instanceof Collection) { + return new Collection($filtered); + } + + return $filtered; + } + + // Single model - check if it should be included + return $this->shouldInclude($request, $data) ? $data : new MissingValue(); + } + + /** + * Check if a model should be included based on all filter rules + * + * @param mixed $model + * @return bool + */ + protected function shouldInclude(Request $request, mixed $model): bool + { + // All rules must pass + foreach ($this->rules as $rule) { + if (!$rule->passes($request, $model)) { + return false; + } + } + + return true; + } +} \ No newline at end of file diff --git a/src/Filters/PolicyFilterRule.php b/src/Filters/PolicyFilterRule.php new file mode 100644 index 0000000..eefd2a9 --- /dev/null +++ b/src/Filters/PolicyFilterRule.php @@ -0,0 +1,38 @@ + + */ +class PolicyFilterRule implements FilterRule +{ + /** + * @param iterable|string $abilities + * @param array $arguments + */ + public function __construct( + protected iterable|string $abilities, + protected array $arguments = [], + protected string $gateClass = Gate::class, + protected ?string $guard = null + ) { + } + + /** + * @param Request $request + * @param Resource $model + * @return bool + */ + public function passes(Request $request, mixed $model): bool + { + return app($this->gateClass) + ->forUser($request->user($this->guard)) + ->allows($this->abilities, [$model, ...$this->arguments]); + } +} \ No newline at end of file diff --git a/src/Resources/Relationship.php b/src/Resources/Relationship.php index c8a64c9..7f20734 100644 --- a/src/Resources/Relationship.php +++ b/src/Resources/Relationship.php @@ -2,6 +2,7 @@ namespace Ark4ne\JsonApi\Resources; +use Ark4ne\JsonApi\Filters\Filters; use Ark4ne\JsonApi\Support\Values; use Ark4ne\JsonApi\Traits\HasRelationLoad; use Closure; @@ -25,6 +26,8 @@ class Relationship implements Resourceable protected ?bool $whenIncluded = null; + protected ?Filters $filters = null; + /** * @param class-string $resource * @param Closure $value @@ -111,6 +114,19 @@ public function whenIncluded(null|bool $whenIncluded = null): static return $this; } + /** + * Set filters for the relationship + * + * @param Filters $filters + * @return $this + */ + public function withFilters(Filters $filters): static + { + $this->filters = $filters; + + return $this; + } + /** * Return class-string of resource * @@ -148,6 +164,11 @@ public function toArray(mixed $request, bool $included = true): array : value($this->value); $value ??= new MissingValue; + // Apply filters if they are defined and we have data + if ($this->filters !== null && !Values::isMissing($value)) { + $value = $this->filters->apply($request, $value); + } + if ($this->asCollection && !is_subclass_of($this->resource, ResourceCollection::class)) { $resource = $this->resource::collection($value); } else { diff --git a/tests/Feature/RelationshipFiltersTest.php b/tests/Feature/RelationshipFiltersTest.php new file mode 100644 index 0000000..0fe1c5c --- /dev/null +++ b/tests/Feature/RelationshipFiltersTest.php @@ -0,0 +1,122 @@ +user = User::factory()->create(); + Post::factory([ + 'user_id' => $this->user->id, + 'is_public' => true, + ])->create(); + Post::factory([ + 'user_id' => $this->user->id, + 'is_public' => false, + ])->create(); + + $this->postPublic = $this->user->posts()->where('is_public', true)->get(); + $this->postPrivate = $this->user->posts()->where('is_public', false)->get(); + } + + public function test_can_filter_relationship_with_policy() + { + // Create a resource with policy filter + $resource = new class($this->user) extends JsonApiResource { + protected function toRelationships(Request $request): iterable + { + return [ + 'posts' => $this->many(PostResource::class) + ->filters(fn(Filters $filters) => $filters->can('view')), + ]; + } + }; + + // Mock the gate to only allow public posts + $this->mock(Gate::class, function ($mock) { + $mock->shouldReceive('forUser')->andReturnSelf(); + $mock->shouldReceive('allows') + ->with('view', [$this->postPublic->first()]) + ->andReturn(true); + $mock->shouldReceive('allows') + ->with('view', [$this->postPrivate->first()]) + ->andReturn(false); + }); + + $result = $resource->toArray($this->createRequest()); + + // Should only contain the public post + $this->assertCount($this->postPublic->count(), $result['relationships']['posts']['data']); + $this->assertEquals($this->postPublic->first()->id, $result['relationships']['posts']['data'][0]['id']); + } + + public function test_can_filter_relationship_with_custom_callback() + { + $resource = new class($this->user) extends JsonApiResource { + protected function toRelationships(Request $request): iterable + { + return [ + 'posts' => $this->many(PostResource::class) + ->filters(fn(Filters $filters) => $filters->when( + fn($request, $post) => $post->is_public + )) + ]; + } + }; + + $result = $resource->toArray($this->createRequest()); + + // Should only contain the public post + $this->assertCount($this->postPublic->count(), $result['relationships']['posts']['data']); + $this->assertEquals($this->postPublic->first()->id, $result['relationships']['posts']['data'][0]['id']); + } + + public function test_can_combine_multiple_filters() + { + $resource = new class($this->user) extends JsonApiResource { + protected function toRelationships(Request $request): iterable + { + return [ + 'posts' => $this->many(PostResource::class) + ->filters(fn(Filters $filters) => $filters + ->when(fn($request, $post) => $post->is_public) + ->when(fn($request, $post) => $post->id > 0) + ) + ]; + } + }; + + $result = $resource->toArray($this->createRequest()); + + // Should only contain the public post that passes both filters + $this->assertCount($this->postPublic->count(), $result['relationships']['posts']['data']); + $this->assertEquals($this->postPublic->first()->id, $result['relationships']['posts']['data'][0]['id']); + } + + protected function createRequest(): Request + { + return Request::create('/test', 'GET'); + } +} + +class PostResource extends JsonApiResource +{ + protected function toAttributes(Request $request): iterable + { + return [ + 'title' => $this->title, + 'is_public' => $this->is_public, + ]; + } +} \ No newline at end of file diff --git a/tests/app/Factories/CommentFactory.php b/tests/app/Factories/CommentFactory.php index d0b900f..4fb8f50 100644 --- a/tests/app/Factories/CommentFactory.php +++ b/tests/app/Factories/CommentFactory.php @@ -4,6 +4,8 @@ use Illuminate\Database\Eloquent\Factories\Factory; use Test\app\Models\Comment; +use Test\app\Models\Post; +use Test\app\Models\User; class CommentFactory extends Factory { @@ -12,6 +14,8 @@ class CommentFactory extends Factory public function definition() { return [ + 'user_id' => User::factory(), + 'post_id' => Post::factory(), 'content' => $this->faker->text() ]; } diff --git a/tests/app/Factories/PostFactory.php b/tests/app/Factories/PostFactory.php index 0d620fc..4a35b77 100644 --- a/tests/app/Factories/PostFactory.php +++ b/tests/app/Factories/PostFactory.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\Factory; use Test\app\Models\Post; +use Test\app\Models\User; class PostFactory extends Factory { @@ -12,8 +13,10 @@ class PostFactory extends Factory public function definition() { return [ + 'user_id' => User::factory(), 'title' => $this->faker->title(), 'content' => $this->faker->text(), + 'is_public' => $this->faker->boolean(), ]; } } diff --git a/tests/app/migrations.php b/tests/app/migrations.php index c17a94b..f6b0e95 100644 --- a/tests/app/migrations.php +++ b/tests/app/migrations.php @@ -23,6 +23,7 @@ public function tables() ->comment('0: draft, 1: published'); $table->string('title'); $table->string('content'); + $table->boolean('is_public'); $table->timestamps(); $table->foreignId('user_id')->constrained('users'); },