Skip to content

Commit fe98cdc

Browse files
committed
Add typed filter value normalization
Normalize filter query values through schema types before applying filters, including nested arrays, objects, operator payloads, and comma-separated arrays. Expose filter type/operator metadata in OpenAPI, update Laravel filters to share the normalization path, and preserve precise JSON:API source parameters for nested filter errors.
1 parent 559b3ab commit fe98cdc

44 files changed

Lines changed: 1992 additions & 529 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/errors.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,16 @@ error object from the context in which it is thrown:
9393

9494
```php
9595
throw (new UnknownFieldException('email'))
96-
->source(['pointer' => '/data/attributes/email'])
96+
->prependSourcePointer('/data/attributes/email')
9797
->meta(['suggestion' => 'Did you mean "emailAddress"?'])
9898
->links(['about' => 'https://example.com/docs/fields']);
9999
```
100100

101+
Use `prependSourcePointer()` for request body locations and
102+
`prependSourceParameter()` for query parameters. If an error comes from nested validation, use
103+
`prependSourcePath()` to accumulate relative path segments before anchoring it to
104+
a pointer or parameter.
105+
101106
## Multiple Errors
102107

103108
When multiple validation errors occur (e.g., multiple field validation
@@ -111,7 +116,7 @@ use Tobyz\JsonApiServer\Exception\InvalidFieldValueException;
111116
throw new JsonApiErrorsException([
112117
new RequiredFieldException(),
113118
new InvalidFieldValueException('Must be a valid email address'),
114-
])->prependSource(['pointer' => '/data/attributes/email']);
119+
])->prependSourcePointer('/data/attributes/email');
115120
```
116121

117122
This will return a JSON:API error response with multiple error objects:

docs/filtering.md

Lines changed: 110 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ provided to make it easy to implement filtering on your resource.
2929

3030
The easiest way to define a filter is to use the `CustomFilter` class, which
3131
accepts the name of the filter parameter and a callback to apply the filter to
32-
the query. The value received by a filter can be a string or an array, so you
33-
will need to handle both:
32+
the query. Without a declared type, the value received by a filter is the raw
33+
query string value: either a string or an array, depending on how the filter was
34+
used in the URL:
3435

3536
```php
37+
use Tobyz\JsonApiServer\Context;
3638
use Tobyz\JsonApiServer\Schema\CustomFilter;
3739

3840
CustomFilter::make('name', function (
@@ -51,7 +53,110 @@ GET /posts?filter[name]=Toby
5153
GET /posts?filter[name][]=Toby&filter[name][]=Franz
5254
```
5355

54-
## Boolean Filters
56+
### Typed Values
57+
58+
Filters receive raw query string values by default. If you want a filter to
59+
receive a validated value, declare its type using the same type system used by
60+
fields and parameters:
61+
62+
```php
63+
use Tobyz\JsonApiServer\Schema\CustomFilter;
64+
use Tobyz\JsonApiServer\Schema\Type;
65+
66+
CustomFilter::make('published', function ($query, bool $value) {
67+
$query->where('published', $value);
68+
})->type(Type\Boolean::make());
69+
70+
CustomFilter::make('ids', function ($query, array $ids) {
71+
$query->whereKey($ids);
72+
})->type(
73+
Type\Arr::make()->items(Type\Integer::make())
74+
);
75+
```
76+
77+
### Arrays
78+
79+
Array filters accept repeated query parameters, and scalar values are treated as
80+
one-item arrays. If you want a comma-delimited string to be split into array
81+
items, use `commaSeparated()` on the array type:
82+
83+
```php
84+
CustomFilter::make('ids', function ($query, array $ids) {
85+
$query->whereKey($ids);
86+
})->type(
87+
Type\Arr::make()->items(Type\Integer::make())->commaSeparated()
88+
);
89+
```
90+
91+
Now all of these requests pass an integer array to the callback:
92+
93+
```http
94+
GET /posts?filter[ids]=1
95+
GET /posts?filter[ids]=1,2,3
96+
GET /posts?filter[ids][]=1&filter[ids][]=2
97+
```
98+
99+
### Operators
100+
101+
You may also opt into operator syntax:
102+
103+
```php
104+
CustomFilter::make('views', function ($query, array $value) {
105+
foreach ($value as $operator => $views) {
106+
$query->where('views', [
107+
'eq' => '=',
108+
'gt' => '>',
109+
'gte' => '>=',
110+
'lt' => '<',
111+
'lte' => '<=',
112+
][$operator], $views);
113+
}
114+
})
115+
->type(Type\Integer::make())
116+
->operators(['eq', 'gt', 'gte', 'lt', 'lte']);
117+
```
118+
119+
```http
120+
GET /posts?filter[views]=100
121+
GET /posts?filter[views][gt]=100
122+
```
123+
124+
The value passed to the callback is an array keyed by operator. When no operator
125+
is specified, the first configured operator is used.
126+
127+
## Writing Filters
128+
129+
If you need to reuse filter logic across resources, create your own filter
130+
class by extending `Tobyz\JsonApiServer\Schema\Filter` and implementing the
131+
`applyValue` method:
132+
133+
```php
134+
use Tobyz\JsonApiServer\Context;
135+
use Tobyz\JsonApiServer\Schema\Filter;
136+
use Tobyz\JsonApiServer\Schema\Type;
137+
138+
class WhereIn extends Filter
139+
{
140+
public static function make(string $name): static
141+
{
142+
return new static($name);
143+
}
144+
145+
public function __construct(string $name)
146+
{
147+
parent::__construct($name);
148+
149+
$this->type(Type\Arr::make()->items(Type\Str::make()));
150+
}
151+
152+
protected function applyValue(object $query, mixed $value, Context $context): void
153+
{
154+
$query->whereIn($this->name, $value);
155+
}
156+
}
157+
```
158+
159+
## Boolean Groups
55160

56161
By default it is assumed that each filter applied to the query will be combined
57162
with a logical `AND`. When a resource implements
@@ -69,8 +174,8 @@ GET /posts
69174
&filter[and][1][or][1][not][status]=archived
70175
```
71176

72-
In this request every result must be published, and it must also either have
73-
more than 100 views or it is not archived.
177+
In this request every result must be published, and it must either have more
178+
than 100 views or not be archived.
74179

75180
```http
76181
GET /posts
@@ -83,27 +188,6 @@ This request returns drafts, or posts that are published and have comments. The
83188
second example also shows that in certain cases you can omit `[and]` groups and
84189
numeric indices; sibling filters at the same level default to `AND` behaviour.
85190

86-
## Writing Filters
87-
88-
To create your own filter class, extend the `Tobyz\JsonApiServer\Schema\Filter`
89-
class and implement the `apply` method:
90-
91-
```php
92-
use Tobyz\JsonApiServer\Context;
93-
use Tobyz\JsonApiServer\Schema\Filter;
94-
95-
class WhereIn extends Filter
96-
{
97-
public function apply(
98-
object $query,
99-
string|array $value,
100-
Context $context,
101-
): void {
102-
$query->whereIn($this->name, $value);
103-
}
104-
}
105-
```
106-
107191
## Visibility
108192

109193
If you want to restrict the ability to use a filter, use the `visible` or

docs/laravel.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ WhereNull::make('draft')->column('published_at');
180180
WhereNotNull::make('published')->column('published_at');
181181
Scope::make('withTrashed')->asBoolean();
182182
Scope::make('trashed')->scope('onlyTrashed');
183+
Scope::make('ids')->commaSeparated();
183184
```
184185

185186
### Boolean Filters

src/Endpoint/Concerns/MutatesResource.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ private function assertFieldsWritable(Context $context, bool $creating = false):
135135
try {
136136
$this->assertFieldWritable($context, $field, $creating);
137137
} catch (Sourceable $e) {
138-
throw $e->prependSource(['pointer' => '/data' . field_path($field)]);
138+
throw $e->prependSourcePointer('/data' . field_path($field));
139139
}
140140
}
141141
}
@@ -169,7 +169,7 @@ private function deserializeValues(Context $context, bool $creating = false): vo
169169
try {
170170
set_value($context->data, $field, $field->deserializeValue($value, $context));
171171
} catch (Sourceable $e) {
172-
throw $e->prependSource(['pointer' => '/data' . field_path($field)]);
172+
throw $e->prependSourcePointer('/data' . field_path($field));
173173
}
174174
}
175175
}
@@ -218,7 +218,7 @@ private function validateField(Context $context, Field|Id $field, mixed $value):
218218
);
219219
}
220220

221-
$errors[] = $error->source(['pointer' => '/data' . field_path($field)]);
221+
$errors[] = $error->prependSourcePointer('/data' . field_path($field));
222222
};
223223

224224
$field->validateValue($value, $fail, $context->withField($field));

src/Endpoint/Concerns/ResolvesList.php

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,26 @@ protected function listParameters(
2727
$params = [];
2828

2929
if ($filters = $collection->filters()) {
30-
$params[] = Parameter::make('filter')->type(Type\Obj::make());
30+
$filterProperties = [];
3131

32-
// TODO: properties of above?
3332
foreach ($filters as $filter) {
34-
$params[] = Parameter::make("filter[{$filter->name}]")->type(Type\Any::make());
33+
$filterProperties[$filter->name] = $filter->getSchema();
3534
}
35+
36+
$params[] = Parameter::make('filter')
37+
->type(Type\Obj::make())
38+
->schema([
39+
'style' => 'deepObject',
40+
'explode' => true,
41+
'schema' => [
42+
'type' => 'object',
43+
'additionalProperties' => true,
44+
'properties' => $filterProperties,
45+
],
46+
]);
3647
}
3748

38-
if ($sorts = $collection->sorts()) {
49+
if ($collection->sorts()) {
3950
$params[] = Parameter::make('sort')
4051
->type(Type\Str::make())
4152
->default($defaultSort ?? $collection->defaultSort());
@@ -115,7 +126,7 @@ private function applyListFilters(
115126
try {
116127
apply_filters($query, $filters, $collection, $context);
117128
} catch (Sourceable $e) {
118-
throw $e->prependSource(['parameter' => 'filter']);
129+
throw $e->prependSourceParameter('filter');
119130
}
120131
}
121132
}

src/Exception/Concerns/JsonApiError.php

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
trait JsonApiError
88
{
99
public array $error = [];
10+
private array $sourcePath = [];
1011

1112
public function __construct(array|string $message = '')
1213
{
@@ -20,15 +21,66 @@ public function __construct(array|string $message = '')
2021

2122
public function source(array $source): static
2223
{
24+
$this->sourcePath = [];
2325
$this->error['source'] = $source;
2426

2527
return $this;
2628
}
2729

30+
public function prependSourceParameter(string $parameter): static
31+
{
32+
if ($this->sourcePath) {
33+
$parameter .= implode(
34+
'',
35+
array_map(fn(int|string $segment) => '[' . $segment . ']', $this->sourcePath),
36+
);
37+
38+
$this->sourcePath = [];
39+
}
40+
41+
$this->error['source']['parameter'] =
42+
$parameter . ($this->error['source']['parameter'] ?? '');
43+
44+
return $this;
45+
}
46+
47+
public function prependSourcePointer(string $pointer): static
48+
{
49+
if ($this->sourcePath) {
50+
$pointer .= '/' . implode(
51+
'/',
52+
array_map(
53+
fn(int|string $segment) => strtr((string) $segment, ['~' => '~0', '/' => '~1']),
54+
$this->sourcePath,
55+
),
56+
);
57+
58+
$this->sourcePath = [];
59+
}
60+
61+
$this->error['source']['pointer'] = $pointer . ($this->error['source']['pointer'] ?? '');
62+
63+
return $this;
64+
}
65+
66+
public function prependSourcePath(int|string ...$path): static
67+
{
68+
$this->sourcePath = [...$path, ...$this->sourcePath];
69+
70+
return $this;
71+
}
72+
73+
/** @deprecated Use prependSourcePath() and prependSourceParameter() or prependSourcePointer(). */
2874
public function prependSource(array $source): static
2975
{
3076
foreach ($source as $k => $v) {
31-
$this->error['source'][$k] = $v . ($this->error['source'][$k] ?? '');
77+
if ($k === 'parameter') {
78+
$this->prependSourceParameter($v);
79+
} elseif ($k === 'pointer') {
80+
$this->prependSourcePointer($v);
81+
} else {
82+
$this->error['source'][$k] = $v . ($this->error['source'][$k] ?? '');
83+
}
3284
}
3385

3486
return $this;

src/Exception/JsonApiErrorsException.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,32 @@ public function __construct(public array $errors)
1717
}
1818
}
1919

20+
public function prependSourcePath(int|string ...$path): static
21+
{
22+
return $this->eachSourceable(fn(Sourceable $error) => $error->prependSourcePath(...$path));
23+
}
24+
25+
public function prependSourceParameter(string $parameter): static
26+
{
27+
return $this->eachSourceable(fn(Sourceable $error) => $error->prependSourceParameter($parameter));
28+
}
29+
30+
public function prependSourcePointer(string $pointer): static
31+
{
32+
return $this->eachSourceable(fn(Sourceable $error) => $error->prependSourcePointer($pointer));
33+
}
34+
35+
/** @deprecated Use prependSourcePath() and prependSourceParameter() or prependSourcePointer(). */
2036
public function prependSource(array $source): static
37+
{
38+
return $this->eachSourceable(fn(Sourceable $error) => $error->prependSource($source));
39+
}
40+
41+
private function eachSourceable(callable $callback): static
2142
{
2243
foreach ($this->errors as $error) {
2344
if ($error instanceof Sourceable) {
24-
$error->prependSource($source);
45+
$callback($error);
2546
}
2647
}
2748

src/Exception/Sourceable.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,12 @@
44

55
interface Sourceable
66
{
7+
public function prependSourcePath(int|string ...$path): static;
8+
9+
public function prependSourceParameter(string $parameter): static;
10+
11+
public function prependSourcePointer(string $pointer): static;
12+
13+
/** @deprecated Use prependSourcePath() and prependSourceParameter() or prependSourcePointer(). */
714
public function prependSource(array $source): static;
815
}

src/Extension/Atomic/Atomic.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ public function handle(Context $context): ?Response
6464
default => throw new InvalidAtomicOperationException($operation['op'] ?? null),
6565
};
6666
} catch (Sourceable $e) {
67-
throw $e->prependSource(['pointer' => "/atomic:operations/$i"]);
67+
throw $e->prependSourcePointer("/atomic:operations/$i");
6868
}
6969

7070
$results[] = json_decode($response->getBody(), true);

0 commit comments

Comments
 (0)