Skip to content

Commit f86b8fa

Browse files
authored
v1.5 relationships filters (#31)
* feat: add filters support for relationships * feat: add CLAUDE.md for project guidance and testing instructions * fix: update parameter type hint for abilities in Relation.php * break: drop support of laravel 9.0 * Revert "break: drop support of laravel 9.0" This reverts commit 84ffb42. * chore: fixup for backward compatibility * fix: improve type hints and simplify data filtering in Filters.php
1 parent 16c9ad5 commit f86b8fa

File tree

11 files changed

+448
-0
lines changed

11 files changed

+448
-0
lines changed

CLAUDE.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Commands
6+
7+
### Testing
8+
- `vendor/bin/phpunit` - Run all tests
9+
- `vendor/bin/phpunit --coverage-clover coverage.xml` - Run tests with coverage
10+
- `vendor/bin/phpunit --configuration phpunit.php8.1.xml.dist` - Run tests for specific PHP version
11+
- `vendor/bin/phpunit tests/Unit` - Run only unit tests
12+
- `vendor/bin/phpunit tests/Feature` - Run only feature tests
13+
14+
### Static Analysis
15+
- `vendor/bin/phpstan analyze` - Run PHPStan static analysis (level 6)
16+
17+
### Dependencies
18+
- `composer install` - Install dependencies
19+
- `composer require laravel/framework ^9.0` - Install specific Laravel version for testing
20+
21+
## Architecture
22+
23+
This is a Laravel package that provides JSON:API compliant resource serialization. The core architecture follows these patterns:
24+
25+
### Resource System
26+
- **JsonApiResource**: Main abstract class extending Laravel's JsonResource, implements JSON:API specification
27+
- **JsonApiCollection**: Handles collections of resources with proper JSON:API formatting
28+
- **Resourceable**: Interface defining resource contracts
29+
30+
### Key Components
31+
32+
#### Descriptors
33+
- **Values** (`src/Descriptors/Values`): Type descriptors for attributes (string, integer, float, date, enum, etc.)
34+
- **Relations** (`src/Descriptors/Relations`): Relationship descriptors (one, many)
35+
36+
#### Resource Concerns
37+
- **Attributes**: Handles attribute serialization with field filtering
38+
- **Relationships**: Manages relationship loading and serialization with include support
39+
- **ConditionallyLoadsAttributes**: Laravel-style conditional attribute support
40+
- **Identifier**: Resource ID and type handling
41+
- **Links**: JSON:API links support
42+
- **Meta**: Meta information handling
43+
- **Schema**: Resource schema generation for validation
44+
- **ToResponse**: Response formatting
45+
46+
#### Request Validation
47+
- **Rules/Includes**: Validates `include` parameter against resource schema
48+
- **Rules/Fields**: Validates `fields` parameter for sparse fieldsets
49+
50+
### Key Features
51+
- **Include Support**: Dynamic relationship loading via `?include=` parameter
52+
- **Sparse Fieldsets**: Attribute filtering via `?fields[type]=` parameter
53+
- **Described Notation**: Fluent API for defining attributes and relationships with type casting
54+
- **Laravel Compatibility**: Supports Laravel 9-12 and PHP 8.1-8.4
55+
56+
### Configuration
57+
The package includes a config file (`config/jsonapi.php`) with settings for:
58+
- Nullable value handling
59+
- Date formatting
60+
- Float precision
61+
- Automatic whenHas conditions
62+
- Relationship loading behavior
63+
64+
### Testing Structure
65+
- **Unit Tests**: Test individual components in isolation
66+
- **Feature Tests**: Test complete JSON:API response formatting
67+
- Uses Orchestra Testbench for Laravel package testing
68+
- SQLite in-memory database for testing

src/Descriptors/Relations/Relation.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
namespace Ark4ne\JsonApi\Descriptors\Relations;
44

55
use Ark4ne\JsonApi\Descriptors\Describer;
6+
use Ark4ne\JsonApi\Filters\Filters;
67
use Ark4ne\JsonApi\Resources\Relationship;
78
use Ark4ne\JsonApi\Support\Includes;
89
use Ark4ne\JsonApi\Traits\HasRelationLoad;
910
use Closure;
11+
use Illuminate\Contracts\Auth\Access\Gate;
1012
use Illuminate\Database\Eloquent\Model;
1113
use Illuminate\Http\Request;
1214
use Illuminate\Http\Resources\MissingValue;
@@ -23,6 +25,7 @@ abstract class Relation extends Describer
2325
protected ?Closure $links = null;
2426
protected ?Closure $meta = null;
2527
protected ?bool $whenIncluded = null;
28+
protected ?Filters $filters = null;
2629

2730
/**
2831
* @param class-string<\Ark4ne\JsonApi\Resources\JsonApiResource|\Ark4ne\JsonApi\Resources\JsonApiCollection> $related
@@ -63,6 +66,37 @@ public function meta(Closure $meta): static
6366
return $this;
6467
}
6568

69+
/**
70+
* Set filters for the relationship
71+
*
72+
* @param Closure(Filters): Filters $filters Callback that receives (Filters $filters) and configures the filters
73+
* @return static
74+
*/
75+
public function filters(Closure $filters): static
76+
{
77+
$this->filters = $filters(new Filters);
78+
return $this;
79+
}
80+
81+
/**
82+
* @param iterable<mixed>|string $abilities Abilities to check
83+
* @param array<mixed> $arguments Arguments to pass to the policy method, the model is always the first argument
84+
* @param string $gateClass Gate class to use, defaults to the default Gate implementation
85+
* @param string|null $guard Guard to use, defaults to the default guard
86+
* @return static
87+
*/
88+
public function can(iterable|string $abilities, array $arguments = [], string $gateClass = Gate::class, ?string $guard = null): static
89+
{
90+
return $this->when(fn(
91+
Request $request,
92+
Model $model,
93+
string $attribute
94+
) => app($gateClass)
95+
->forUser($request->user($guard))
96+
->allows($abilities, [$model, ...$arguments])
97+
);
98+
}
99+
66100
/**
67101
* @param bool|null $whenIncluded
68102
* @return static
@@ -139,6 +173,10 @@ public function resolveFor(Request $request, mixed $model, string $attribute): R
139173
$relation->whenIncluded($this->whenIncluded);
140174
}
141175

176+
if ($this->filters !== null) {
177+
$relation->withFilters($this->filters);
178+
}
179+
142180
return $relation;
143181
}
144182

src/Filters/CallbackFilterRule.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace Ark4ne\JsonApi\Filters;
4+
5+
use Closure;
6+
use Illuminate\Http\Request;
7+
8+
/**
9+
* @template Resource
10+
*
11+
* @implements FilterRule<Resource>
12+
*/
13+
class CallbackFilterRule implements FilterRule
14+
{
15+
/**
16+
* @param Closure(Request, Resource): bool $callback
17+
*/
18+
public function __construct(
19+
protected Closure $callback
20+
) {
21+
}
22+
23+
/**
24+
* @param Request $request
25+
* @param Resource $model
26+
* @return bool
27+
*/
28+
public function passes(Request $request, mixed $model): bool
29+
{
30+
return (bool) ($this->callback)($request, $model);
31+
}
32+
}

src/Filters/FilterRule.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace Ark4ne\JsonApi\Filters;
4+
5+
use Illuminate\Http\Request;
6+
7+
/**
8+
* @template Resource
9+
*/
10+
interface FilterRule
11+
{
12+
/**
13+
* Determine if the filter rule passes for the given model
14+
*
15+
* @param Request $request
16+
* @param Resource $model The model being filtered
17+
* @return bool
18+
*/
19+
public function passes(Request $request, mixed $model): bool;
20+
}

src/Filters/Filters.php

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
namespace Ark4ne\JsonApi\Filters;
4+
5+
use Ark4ne\JsonApi\Support\Values;
6+
use Closure;
7+
use Illuminate\Contracts\Auth\Access\Gate;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Http\Resources\MissingValue;
10+
use Illuminate\Http\Resources\PotentiallyMissing;
11+
use Illuminate\Support\Collection;
12+
13+
/**
14+
* @template Resource
15+
*/
16+
class Filters
17+
{
18+
/** @var array<FilterRule<Resource>> */
19+
protected array $rules = [];
20+
21+
/**
22+
* Add a policy-based filter
23+
*
24+
* @param iterable<string>|string $abilities Abilities to check
25+
* @param array<mixed> $arguments Arguments to pass to the policy method, the model is always the first argument
26+
* @param string $gateClass Gate class to use, defaults to the default Gate implementation
27+
* @param string|null $guard Guard to use, defaults to the default guard
28+
* @return static
29+
*/
30+
public function can(iterable|string $abilities, array $arguments = [], string $gateClass = Gate::class, ?string $guard = null): static
31+
{
32+
$this->rules[] = new PolicyFilterRule($abilities, $arguments, $gateClass, $guard);
33+
return $this;
34+
}
35+
36+
/**
37+
* Add a custom filter rule
38+
*
39+
* @param Closure(Request, Resource): bool $callback Callback that receives (Request $request, Model $model) and returns bool
40+
* @return static
41+
*/
42+
public function when(Closure $callback): static
43+
{
44+
$this->rules[] = new CallbackFilterRule($callback);
45+
return $this;
46+
}
47+
48+
/**
49+
* Apply all filters to the given data
50+
*
51+
* @param Request $request
52+
* @param null|PotentiallyMissing|Resource|iterable<array-key, Resource> $data
53+
* @return mixed
54+
*/
55+
public function apply(Request $request, mixed $data): mixed
56+
{
57+
if ($data === null) {
58+
return $data;
59+
}
60+
if (Values::isMissing($data)) {
61+
return $data;
62+
}
63+
64+
// If it's a collection/array, filter each item
65+
if (is_iterable($data)) {
66+
$filtered = (new Collection($data))
67+
->filter(fn($item) => $this->shouldInclude($request, $item));
68+
69+
// Preserve the original collection type
70+
if ($data instanceof Collection) {
71+
return $filtered;
72+
}
73+
74+
return $filtered->all();
75+
}
76+
77+
// Single model - check if it should be included
78+
return $this->shouldInclude($request, $data)
79+
? $data
80+
: new MissingValue();
81+
}
82+
83+
/**
84+
* Check if a model should be included based on all filter rules
85+
*
86+
* @param Request $request
87+
* @param Resource $model
88+
* @return bool
89+
*/
90+
protected function shouldInclude(Request $request, mixed $model): bool
91+
{
92+
// All rules must pass
93+
foreach ($this->rules as $rule) {
94+
if (!$rule->passes($request, $model)) {
95+
return false;
96+
}
97+
}
98+
99+
return true;
100+
}
101+
}

src/Filters/PolicyFilterRule.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Ark4ne\JsonApi\Filters;
4+
5+
use Illuminate\Contracts\Auth\Access\Gate;
6+
use Illuminate\Http\Request;
7+
8+
/**
9+
* @template Resource
10+
*
11+
* @implements FilterRule<Resource>
12+
*/
13+
class PolicyFilterRule implements FilterRule
14+
{
15+
/**
16+
* @param iterable<string>|string $abilities
17+
* @param array<mixed> $arguments
18+
*/
19+
public function __construct(
20+
protected iterable|string $abilities,
21+
protected array $arguments = [],
22+
protected string $gateClass = Gate::class,
23+
protected ?string $guard = null
24+
) {
25+
}
26+
27+
/**
28+
* @param Request $request
29+
* @param Resource $model
30+
* @return bool
31+
*/
32+
public function passes(Request $request, mixed $model): bool
33+
{
34+
return app($this->gateClass)
35+
->forUser($request->user($this->guard))
36+
->allows($this->abilities, [$model, ...$this->arguments]);
37+
}
38+
}

src/Resources/Relationship.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Ark4ne\JsonApi\Resources;
44

5+
use Ark4ne\JsonApi\Filters\Filters;
56
use Ark4ne\JsonApi\Support\Values;
67
use Ark4ne\JsonApi\Traits\HasRelationLoad;
78
use Closure;
@@ -25,6 +26,8 @@ class Relationship implements Resourceable
2526

2627
protected ?bool $whenIncluded = null;
2728

29+
protected ?Filters $filters = null;
30+
2831
/**
2932
* @param class-string<T> $resource
3033
* @param Closure $value
@@ -111,6 +114,19 @@ public function whenIncluded(null|bool $whenIncluded = null): static
111114
return $this;
112115
}
113116

117+
/**
118+
* Set filters for the relationship
119+
*
120+
* @param Filters $filters
121+
* @return $this
122+
*/
123+
public function withFilters(Filters $filters): static
124+
{
125+
$this->filters = $filters;
126+
127+
return $this;
128+
}
129+
114130
/**
115131
* Return class-string of resource
116132
*
@@ -148,6 +164,11 @@ public function toArray(mixed $request, bool $included = true): array
148164
: value($this->value);
149165
$value ??= new MissingValue;
150166

167+
// Apply filters if they are defined and we have data
168+
if ($this->filters !== null && !Values::isMissing($value)) {
169+
$value = $this->filters->apply($request, $value);
170+
}
171+
151172
if ($this->asCollection && !is_subclass_of($this->resource, ResourceCollection::class)) {
152173
$resource = $this->resource::collection($value);
153174
} else {

0 commit comments

Comments
 (0)