Skip to content

Commit 3cbef6a

Browse files
authored
docs: filtering system and url search parameters (#6244)
1 parent 31d24ac commit 3cbef6a

File tree

1 file changed

+309
-0
lines changed

1 file changed

+309
-0
lines changed
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
# Filtering system and query parameters
2+
3+
* Deciders: @dunglas, @soyuka
4+
* Consulted: @aegypius, @mrossard, @metaclass-nl, @helyakin
5+
* Informed: @jdeniau, @bendavies
6+
7+
## Context and Problem Statement
8+
9+
Over the year we collected lots of issues and behaviors around filter composition, query parameters documentation and validation. A [Github issue][pull/2400] tracks these problems or enhancements. Today, an API Filter is defined by this interface:
10+
11+
```php
12+
/**
13+
* Filters applicable on a resource.
14+
*
15+
* @author Kévin Dunglas <dunglas@gmail.com>
16+
*/
17+
interface FilterInterface
18+
{
19+
/**
20+
* Gets the description of this filter for the given resource.
21+
*
22+
* Returns an array with the filter parameter names as keys and array with the following data as values:
23+
* - property: the property where the filter is applied
24+
* - type: the type of the filter
25+
* - required: if this filter is required
26+
* - strategy (optional): the used strategy
27+
* - is_collection (optional): if this filter is for collection
28+
* - swagger (optional): additional parameters for the path operation,
29+
* e.g. 'swagger' => [
30+
* 'description' => 'My Description',
31+
* 'name' => 'My Name',
32+
* 'type' => 'integer',
33+
* ]
34+
* - openapi (optional): additional parameters for the path operation in the version 3 spec,
35+
* e.g. 'openapi' => [
36+
* 'description' => 'My Description',
37+
* 'name' => 'My Name',
38+
* 'schema' => [
39+
* 'type' => 'integer',
40+
* ]
41+
* ]
42+
* - schema (optional): schema definition,
43+
* e.g. 'schema' => [
44+
* 'type' => 'string',
45+
* 'enum' => ['value_1', 'value_2'],
46+
* ]
47+
* The description can contain additional data specific to a filter.
48+
*
49+
* @see \ApiPlatform\OpenApi\Factory\OpenApiFactory::getFiltersParameters
50+
*/
51+
public function getDescription(string $resourceClass): array;
52+
}
53+
```
54+
55+
The idea of this ADR is to find a way to introduce more functionalities to API Platform filters such as:
56+
57+
- document query parameters for hydra, JSON Schema (OpenAPI being an extension of JSON Schema).
58+
- pilot the query parameter validation (current QueryParameterValidator bases itself on the given documentation schema) this is good but lacks flexibility when you need custom validation (created by @jdeniau)
59+
- compose with filters, which will naturally help creating an or/and filter
60+
- reduce the strong link between a query parameter and a property (they may have different names [#5980][pull/5980]), different types, a query parameter can have no link with a property (order filter). We still keep that link as inspired by [Hydra property search][hydra].
61+
- provide a way to implement different query parameter syntaxes without changing the Filter implementation behind it
62+
63+
We will keep a BC layer with the current doctrine system as it shouldn't change much.
64+
65+
### Filter composition
66+
67+
For this to work, we need to consider a 4 year old bug on searching with UIDs. Our SearchFilter allows to search by `propertyName` or by relation, using either a scalar or an IRI:
68+
69+
```
70+
/books?author.id=1
71+
/books?author.id=/author/1
72+
```
73+
74+
Many attempts to fix these behaviors on API Platform have lead to bugs and to be reverted. The proposal is to change how filters are applied to provide filters with less logic, that are easier to maintain and that do one thing good.
75+
76+
For the following example we will use an UUID to represent the stored identifier of an Author resource.
77+
78+
We know `author` is a property of `Book`, that represents a Resource. So it can be filtered by:
79+
80+
- IRI
81+
- uid
82+
83+
We should therefore call both of these filters for each query parameter matched:
84+
85+
- IriFilter (will do nothing if the value is not an IRI)
86+
- UuidFilter
87+
88+
With that in mind, an `or` filter would call a bunch of sorted filters specifying the logic operation to execute.
89+
90+
### Query parameter
91+
92+
The above shows that a query parameter **key**, which is a `string`, may lead to multiple filters being called. The same can represent one or multiple values, and for a same **key** we can handle multiple types of data.
93+
Also, if someone wants to implement the [loopback API][loopback] `?filter[fields][vin]=false` the link between the query parameter, the filter and the value gets more complex.
94+
95+
We need a way to instruct the program to parse query parameters and produce a link between filters, values and some context (property, logical operation, type etc.). The same system could be used to determine the **type** a **filter** must have to pilot query parameter validation and the JSON Schema.
96+
97+
## Considered Options
98+
99+
Let's define a new Attribute `Parameter` that holds informations (filters, context, schema) tight to a parameter `key`.
100+
101+
```php
102+
namespace ApiPlatform\Metadata;
103+
104+
use ApiPlatform\OpenApi;
105+
106+
final class Parameter {
107+
/**
108+
* @param array{type?: string}|null $schema
109+
* @param array<string, mixed> $extraProperties
110+
* @param ProviderInterface|callable|string|null $provider
111+
* @param FilterInterface|string|null $filter
112+
*/
113+
public function __construct(
114+
protected ?string $key = null,
115+
protected ?array $schema = null,
116+
protected ?OpenApi\Model\Parameter $openApi = null,
117+
protected mixed $provider = null,
118+
protected mixed $filter = null,
119+
protected ?string $property = null,
120+
protected ?string $description = null,
121+
protected ?bool $required = null,
122+
protected ?int $priority = null,
123+
protected ?array $extraProperties = [],
124+
) {
125+
}
126+
}
127+
```
128+
129+
By default applied to a class, the `Parameter` would apply on every operations, or it could be specified on a single operation:
130+
131+
```php
132+
use ApiPlatform\Metadata\GetCollection;
133+
use ApiPlatform\Metadata\QueryParameter;
134+
135+
#[GetCollection(parameters: ['and' => new QueryParameter])]
136+
#[QueryParameter('and')]
137+
class Book {}
138+
```
139+
140+
The `property` field is useful to link filters or to document the `hydra:search` context.
141+
142+
```php
143+
#[QueryParameter(key: 'q', property: 'hydra:freetextQuery', required: true)]
144+
class Dummy {}
145+
```
146+
147+
Will declare the following `hydra:search` context:
148+
149+
```json
150+
{
151+
"hydra:template": "/dummy{?q}",
152+
"hydra:mapping": [
153+
["@type": "IriTemplateMapping", "variable": "q", "property": "hydra:freetextQuery", "required": true],
154+
]
155+
}
156+
```
157+
158+
API Platform will continue to provide parsed query parameters and set an `_api_query_parameters` Request attribute, in the end the filter may or may not use it:
159+
160+
```php
161+
$queryString = RequestParser::getQueryString($request);
162+
$request->attributes->set('_api_query_parameters', $queryString ? RequestParser::parseRequestParams($queryString) : []);
163+
```
164+
165+
On top of that we will provide an additional `_api_header_parameters` as we would like to introduce a `QueryParameter` and a `HeaderParameter`. Graphql uses the `args` context.
166+
167+
### Property parameter substitution (`:property`)
168+
169+
The `:property` [url pattern][urlpattern] is allowed as a `Parameter` key and will get substitution from the specified `Filter`.
170+
171+
```php
172+
#[GetCollection(
173+
uriTemplate: 'search_filter_parameter{._format}',
174+
parameters: [
175+
'searchOnTextAndDate[:property]' => new QueryParameter(filter: 'app_filter_date_and_search'),
176+
]
177+
)]
178+
179+
#[ApiFilter(SearchTextAndDateFilter::class, alias: 'app_filter_date_and_search', properties: ['foo', 'createdAt'], arguments: ['dateFilterProperties' => ['createdAt' => 'exclude_null'], 'searchFilterProperties' => ['foo' => 'exact']])]
180+
class Foo {}
181+
```
182+
183+
This allows to search on `searchOnTextAndDate[foo]` or `searchOnTextAndDate[createdAt][after]`.
184+
185+
### Parameter Provider
186+
187+
During the `Provider` phase (`RequestEvent::REQUEST`), we could use a `ParameterProvider`:
188+
189+
```php
190+
/**
191+
* Optionnaly transforms request parameters and provides modification to the current Operation.
192+
*/
193+
interface ParameterProviderInterface
194+
{
195+
/**
196+
* @param array<string, mixed> $parameters
197+
* @param array<string, mixed>|array{request?: Request, resource_class?: string, operation: HttpOperation} $context
198+
*/
199+
public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?HttpOperation;
200+
}
201+
```
202+
203+
This provider can:
204+
205+
1. alter the HTTP Operation to provide additional context:
206+
207+
```php
208+
class GroupsParameterProvider implements ParameterProviderInterface {
209+
public function provider(Parameter $parameter, array $uriVariables = [], array $context = []): HttpOperation
210+
{
211+
$request = $context['request'];
212+
return $context['operation']->withNormalizationContext(['groups' => $request->query->all('groups')]);
213+
}
214+
}
215+
```
216+
217+
2. alter the parameter context:
218+
219+
```php
220+
class UuidParameter implements ParameterProviderInterface {
221+
public function provider(Parameter $parameter, array $uriVariables = [], array $context = []): HttpOperation
222+
{
223+
$request = $context['request'];
224+
$operation = $context['operation'];
225+
$parameters = $request->attributes->get('_api_query_parameters');
226+
foreach ($parameters as $key => $value) {
227+
$parameter = $operation->getParameter($key);
228+
if (!$parameter) {
229+
continue;
230+
}
231+
232+
if (!in_array('uuid', $parameter->getSchema()['type'])) {
233+
continue;
234+
}
235+
236+
// TODO: should handle array values
237+
try {
238+
$parameters[$key] = Uuid::fromString($value);
239+
} catch (\Exception $e) {}
240+
241+
if ($parameter->getFilter() === SearchFilter::class) {
242+
// Additionnaly, we are now sure we want an uuid filter so we could change it:
243+
$operation->withParameter($key, $parameter->withFilter(UuidFilter::class));
244+
}
245+
}
246+
247+
return $operation;
248+
}
249+
}
250+
```
251+
252+
3. Validate parameters through the ParameterValidator.
253+
254+
### Filters
255+
256+
Filters should remain mostly unchanged, the current informations about the `property` to filter should also be specified inside a `Parameter`.
257+
They alter the Doctrine/Elasticsearch Query, therefore we need one interface per persistence layer supported. The current logic within API Platform is:
258+
259+
```php
260+
// src/Doctrine/Orm/Extension/FilterExtension.php
261+
foreach ($operation->getFilters() ?? [] as $filterId) {
262+
$filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null;
263+
if ($filter instanceof FilterInterface) {
264+
// Apply the OrderFilter after every other filter to avoid an edge case where OrderFilter would do a LEFT JOIN instead of an INNER JOIN
265+
if ($filter instanceof OrderFilter) {
266+
$orderFilters[] = $filter;
267+
continue;
268+
}
269+
270+
$context['filters'] ??= [];
271+
$filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
272+
}
273+
}
274+
```
275+
276+
As we want a parameter to have some filters, we'd add the same logic based on the parameters `filter` information, for example:
277+
278+
```php
279+
// src/Doctrine/Orm/Extension/ParameterExtension.php
280+
$values = $request->attributes->get('_api_query_parameters');
281+
foreach ($operation->getParameters() as $key => $parameter) {
282+
if (!array_key_exists($key, $values) || !($filterId = $parameter->getFilter())) {
283+
continue;
284+
}
285+
286+
$filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null;
287+
288+
if ($filter instanceof FilterInterface) {
289+
$context['parameter'] = $parameter;
290+
$context['value'] = $values[$key];
291+
$filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
292+
}
293+
}
294+
```
295+
296+
- A `Parameter` doesn't necessary have a filter.
297+
- Any logic regarding order of filters needs to be handled by the callee (just as above).
298+
- For filter composition we may introduce an `OrFilter` or `AndFilter` on an `or` or `and` parameter that would be exposed for users to use.
299+
300+
## Links
301+
302+
* [Filter composition][pull/2400]
303+
* [Hydra property search](hydra)
304+
305+
[pull/5980]: https://github.com/api-platform/core/pull/5980 "ApiFilter does not respect SerializerName"
306+
[pull/2400]: https://github.com/api-platform/core/pull/2400 "Filter composition"
307+
[hydra]: http://www.hydra-cg.com/spec/latest/core/#supported-property-data-source "Hydra property data source"
308+
[urlpattern]: https://urlpattern.spec.whatwg.org/ "URL Pattern"
309+
[loopback]: https://loopback.io/doc/en/lb2/Fields-filter.html "Loopback filtering API"

0 commit comments

Comments
 (0)