From 6468e5c0c640ceb18b9f240cc96b96767a53b7f8 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 24 Nov 2023 19:16:37 +0100 Subject: [PATCH 01/20] docs: filters adr --- docs/adr/0006-filters.md | 253 +++++++++++++++++++++++ src/Metadata/ApiResource.php | 2 + src/Metadata/Get.php | 2 + src/Metadata/GetCollection.php | 2 + src/Metadata/GraphQl/Operation.php | 2 + src/Metadata/GraphQl/Query.php | 2 + src/Metadata/GraphQl/QueryCollection.php | 2 + src/Metadata/GraphQl/Subscription.php | 4 +- src/Metadata/HttpOperation.php | 2 + src/Metadata/Metadata.php | 29 ++- src/Metadata/Operation.php | 3 + src/Metadata/Parameter.php | 16 ++ src/Metadata/Patch.php | 2 + src/Metadata/Post.php | 1 + src/Metadata/Put.php | 2 + 15 files changed, 316 insertions(+), 8 deletions(-) create mode 100644 docs/adr/0006-filters.md create mode 100644 src/Metadata/Parameter.php diff --git a/docs/adr/0006-filters.md b/docs/adr/0006-filters.md new file mode 100644 index 00000000000..c5e7e4882b9 --- /dev/null +++ b/docs/adr/0006-filters.md @@ -0,0 +1,253 @@ +# Filtering system and query parameters + +* Deciders: @dunglas, @soyuka +* Consulted: @aegypius, @mrossard, @metaclass-nl, @helyakin +* Informed: @jdeniau, @bendavies + +## Context and Problem Statement + +Over the year we collected lots of issues and behaviors around filter composition, query parameters documentation and validation. A [Github issue](https://github.com/api-platform/core/issues/2400) tracks these problems or enhancements. Today, an API Filter is defined by this interface: + +```php +/** + * Filters applicable on a resource. + * + * @author Kévin Dunglas + */ +interface FilterInterface +{ + /** + * Gets the description of this filter for the given resource. + * + * Returns an array with the filter parameter names as keys and array with the following data as values: + * - property: the property where the filter is applied + * - type: the type of the filter + * - required: if this filter is required + * - strategy (optional): the used strategy + * - is_collection (optional): if this filter is for collection + * - swagger (optional): additional parameters for the path operation, + * e.g. 'swagger' => [ + * 'description' => 'My Description', + * 'name' => 'My Name', + * 'type' => 'integer', + * ] + * - openapi (optional): additional parameters for the path operation in the version 3 spec, + * e.g. 'openapi' => [ + * 'description' => 'My Description', + * 'name' => 'My Name', + * 'schema' => [ + * 'type' => 'integer', + * ] + * ] + * - schema (optional): schema definition, + * e.g. 'schema' => [ + * 'type' => 'string', + * 'enum' => ['value_1', 'value_2'], + * ] + * The description can contain additional data specific to a filter. + * + * @see \ApiPlatform\OpenApi\Factory\OpenApiFactory::getFiltersParameters + */ + public function getDescription(string $resourceClass): array; +} +``` + +The idea of this ADR is to find a way to introduce more functionalities to API Platform filters such as: + +- document query parameters for hydra, JSON Schema (OpenAPI being an extension of JSON Schema). +- 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) +- compose with filters, which will naturally help creating an or/and filter +- remove the relation 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) +- provide a way to implement different query parameter syntaxes without changing the Filter implementation behind it + +We will keep a BC layer with the current doctrine system as it shouldn't change much. + +### Filter composition + +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: + +``` +/books?author.id=1 +/books?author.id=/author/1 +``` + +Many attempts to fix these behavior on API Platform have lead to bugs and to be reverted. My 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. + +For the following example we will use an UUID to represent the stored identifier of an Author resource. + +We know `author` is a property of `Book`, that represents a Resource. So it can be filtered by: + +- IRI +- uid + +We should therefore call both of these filters for each query parameter matched: + +- IriFilter (will do nothing if the value is not an IRI) +- UuidFilter + +With that in mind, an `or` filter would call a bunch of filters specifying the logic operation to execute. + +### Query parameter + +The above shows that a query parameter **key**, which is a `string` may lead to multiple filters being called. This same can represent one or multiple values, and for a same **key** we can handle multiple types of data. +Also, if someone wants to implement the [loopback API](https://loopback.io/doc/en/lb2/Fields-filter.html) `?filter[fields][vin]=false` the link between the query parameter, the filter and the value gets more complex. + +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. + +## Considered Options + +Let's define a new Attribute `Parameter` that holds informations (filters, context, schema) tight to a parameter `key`. + +```php +namespace ApiPlatform\Metadata; + +use ApiPlatform\OpenApi; + +final class Parameter { + public string $key; + public \ArrayObject schema; + public array $context; + public OpenApi\Parameter $openApi; + public string|callable provider(): Operation; + // filter service id + public string $filter; +} +``` + +By default applied to a class, the `Parameter` would apply on every operations, or it could be specified on a single operation: + +```php +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Doctrine\Common\AndParameter; + +#[GetCollection(parameters: ['and' => new AndParameter])] +#[AndParameter('and')] +class Book {} +``` + +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: + +```php +$queryString = RequestParser::getQueryString($request); +$request->attributes->set('_api_query_parameters', $queryString ? RequestParser::parseRequestParams($queryString) : []); +``` + +On top of that we will provide an additional `_api_header_parameters` as we would like to introduce a `QueryParameter` and an `HeaderParameter`. + +### Parameter Provider + +During the `Provider` phase (`RequestEvent::REQUEST`), we could use a `ParameterProvider`: + +```php +/** + * Optionnaly transforms request parameters and provides modification to the current Operation. + * + * @implements ProviderInterface + */ +interface ParameterProvider extends ProviderInterface { + public function provider(Operation $operation, array $uriVariables = [], array $context = []): HttpOperation; +} +``` + +This provider can: + +1. alter the HTTP Operation to provide additional context: + +```php +class GroupsParameterProvider implements ProviderInterface { + public function provider(Operation $operation, array $uriVariables = [], array $context = []): HttpOperation + { + $request = $context['request']; + return $operation->withNormalizationContext(['groups' => $request->query->all('groups')]); + } +} +``` + +2. alter the parameter context: + +```php +class UuidParameter implements ProviderInterface { + public function provider(Operation $operation, array $uriVariables = [], array $context = []): HttpOperation + { + $request = $context['request']; + $parameters = $request->attributes->get('_api_query_parameters'); + foreach ($parameters as $key => $value) { + $parameter = $operation->getParameter($key); + if (!$parameter) { + continue; + } + + if (!in_array('uuid', $parameter->getSchema()['type'])) { + continue; + } + + // TODO: should handle array values + try { + $parameters[$key] = Uuid::fromString($value); + } catch (\Exception $e) {} + + if ($parameter->getFilter() === SearchFilter::class) { + // Additionnaly, we are now sure we want an uuid filter so we could change it: + $operation->withParameter($key, $parameter->withFilter(UuidFilter::class)); + } + } + + return $operation; + } +} +``` + +3. Validate parameters through the ParameterValidator. + +### Filters + +Filters should remain mostly unchanged, the current informations about the `property` to filter should be specified inside a `Parameter`'s `context`. +They alter the Doctrine/Elasticsearch Query, therefore we need one interface per persistence layer supported. The current logic within API Platform is: + +```php +// src/Doctrine/Orm/Extension/FilterExtension.php +foreach ($operation->getFilters() ?? [] as $filterId) { + $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + if ($filter instanceof FilterInterface) { + // Apply the OrderFilter after every other filter to avoid an edge case where OrderFilter would do a LEFT JOIN instead of an INNER JOIN + if ($filter instanceof OrderFilter) { + $orderFilters[] = $filter; + continue; + } + + $context['filters'] ??= []; + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } +} +``` + +As we want a parameter to have some filters, we'd add the same logic based on the parameters `filter` information, for example: + +```php +// src/Doctrine/Orm/Extension/ParameterExtension.php +$values = $request->attributes->get('_api_query_parameters'); +foreach ($operation->getParameters() as $key => $parameter) { + if (!array_key_exists($key, $values) || !($filterId = $parameter->getFilter())) { + continue; + } + + $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + + if ($filter instanceof FilterInterface) { + $context['parameter'] = $parameter; + $context['value'] = $values[$key]; + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } +} +``` + +- A `Parameter` doesn't necessary have a filter. +- Any logic regarding order of filters needs to be handled by the callee (just as above). +- For filter composition we may introduce an `OrFilter` or `AndFilter` on an `or` or `and` parameter that would be exposed for users to use. + +## Links + +* [Filter composition][pull/2400] + +[pull/5980]: https://github.com/api-platform/core/pull/5980 "ApiFilter does not respect SerializerName" +[pull/2400]: https://github.com/api-platform/core/pull/2400 "Filter composition" diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index 7cc6e2f2a19..e6da8e53290 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -960,6 +960,7 @@ public function __construct( $provider = null, $processor = null, protected ?OptionsInterface $stateOptions = null, + protected ?array $parameters = null, protected array $extraProperties = [], ) { parent::__construct( @@ -1000,6 +1001,7 @@ class: $class, provider: $provider, processor: $processor, stateOptions: $stateOptions, + parameters: $parameters, extraProperties: $extraProperties ); diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index fe098a2002f..7ba70eb3b8a 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -94,6 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + ?array $parameters = null, array $extraProperties = [], ) { parent::__construct( @@ -169,6 +170,7 @@ class: $class, provider: $provider, processor: $processor, stateOptions: $stateOptions, + parameters: $parameters, extraProperties: $extraProperties, ); } diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index f4e85be5017..608d499630a 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -94,6 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + ?array $parameters = null, array $extraProperties = [], private ?string $itemUriTemplate = null, ) { @@ -169,6 +170,7 @@ class: $class, name: $name, provider: $provider, processor: $processor, + parameters: $parameters, extraProperties: $extraProperties, stateOptions: $stateOptions, ); diff --git a/src/Metadata/GraphQl/Operation.php b/src/Metadata/GraphQl/Operation.php index cd2802c0e33..d714caa9dd4 100644 --- a/src/Metadata/GraphQl/Operation.php +++ b/src/Metadata/GraphQl/Operation.php @@ -84,6 +84,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + ?array $parameters = null, array $extraProperties = [] ) { parent::__construct( @@ -131,6 +132,7 @@ class: $class, provider: $provider, processor: $processor, stateOptions: $stateOptions, + parameters: $parameters, extraProperties: $extraProperties ); } diff --git a/src/Metadata/GraphQl/Query.php b/src/Metadata/GraphQl/Query.php index 2ce0d9250df..150ca28d3da 100644 --- a/src/Metadata/GraphQl/Query.php +++ b/src/Metadata/GraphQl/Query.php @@ -68,6 +68,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + ?array $parameters = null, array $extraProperties = [], protected ?bool $nested = null, @@ -121,6 +122,7 @@ class: $class, provider: $provider, processor: $processor, stateOptions: $stateOptions, + parameters: $parameters, extraProperties: $extraProperties ); } diff --git a/src/Metadata/GraphQl/QueryCollection.php b/src/Metadata/GraphQl/QueryCollection.php index 1427c024142..ddb68a87d3e 100644 --- a/src/Metadata/GraphQl/QueryCollection.php +++ b/src/Metadata/GraphQl/QueryCollection.php @@ -69,6 +69,7 @@ public function __construct( $provider = null, $processor = null, protected ?OptionsInterface $stateOptions = null, + ?array $parameters = null, array $extraProperties = [], ?bool $nested = null, @@ -121,6 +122,7 @@ class: $class, name: $name ?: 'collection_query', provider: $provider, processor: $processor, + parameters: $parameters, extraProperties: $extraProperties, nested: $nested, ); diff --git a/src/Metadata/GraphQl/Subscription.php b/src/Metadata/GraphQl/Subscription.php index 3cc90c8fda1..eff6724721f 100644 --- a/src/Metadata/GraphQl/Subscription.php +++ b/src/Metadata/GraphQl/Subscription.php @@ -17,7 +17,7 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class Subscription extends Operation -{ + public function __construct( ?string $resolver = null, ?array $args = null, @@ -68,6 +68,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + ?array $parameters = null, array $extraProperties = [], ) { parent::__construct( @@ -119,6 +120,7 @@ class: $class, provider: $provider, processor: $processor, stateOptions: $stateOptions, + parameters: $parameters, extraProperties: $extraProperties, ); } diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index dfbc84abdf0..9c717dfd483 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -200,6 +200,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + ?array $parameters = null, array $extraProperties = [], ) { parent::__construct( @@ -247,6 +248,7 @@ class: $class, provider: $provider, processor: $processor, stateOptions: $stateOptions, + parameters: $parameters, extraProperties: $extraProperties ); } diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index 4f2715aa55d..390bf02a599 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -21,15 +21,16 @@ abstract class Metadata { /** - * @param string|null $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties + * @param string|null $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties * @param string|\Stringable|null $security https://api-platform.com/docs/core/security * @param string|\Stringable|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization - * @param mixed|null $mercure - * @param mixed|null $messenger - * @param mixed|null $input - * @param mixed|null $output - * @param mixed|null $provider - * @param mixed|null $processor + * @param mixed|null $mercure + * @param mixed|null $messenger + * @param mixed|null $input + * @param mixed|null $output + * @param mixed|null $provider + * @param mixed|null $processor + * @param array $parameters */ public function __construct( protected ?string $shortName = null, @@ -69,6 +70,7 @@ public function __construct( protected $provider = null, protected $processor = null, protected ?OptionsInterface $stateOptions = null, + protected ?array $parameters = [], protected array $extraProperties = [] ) { } @@ -566,6 +568,19 @@ public function withStateOptions(?OptionsInterface $stateOptions): static return $self; } + public function getParameters(): ?array + { + return $this->parameters; + } + + public function withParameters(array $parameters = []): static + { + $self = clone $this; + $self->parameters = $parameters; + + return $self; + } + public function getExtraProperties(): ?array { return $this->extraProperties; diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php index 3992cefab98..63ddaf921bd 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -59,6 +59,7 @@ abstract class Operation extends Metadata * @param bool|null $forceEager {@see https://api-platform.com/docs/core/performance/#force-eager} * @param string|callable|null $provider {@see https://api-platform.com/docs/core/state-providers/#state-providers} * @param string|callable|null $processor {@see https://api-platform.com/docs/core/state-processors/#state-processors} + * @param array $parameters */ public function __construct( protected ?string $shortName = null, @@ -805,6 +806,7 @@ public function __construct( protected $provider = null, protected $processor = null, protected ?OptionsInterface $stateOptions = null, + protected ?array $parameters = [], protected array $extraProperties = [], ) { parent::__construct( @@ -845,6 +847,7 @@ class: $class, provider: $provider, processor: $processor, stateOptions: $stateOptions, + parameters: $parameters, extraProperties: $extraProperties, ); } diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php new file mode 100644 index 00000000000..9ed708918b4 --- /dev/null +++ b/src/Metadata/Parameter.php @@ -0,0 +1,16 @@ + Date: Mon, 4 Mar 2024 15:59:22 +0100 Subject: [PATCH 02/20] feat: parameter implementation --- ...> 0006-filtering-system-and-parameters.md} | 2 +- docs/adr/filter-notes | 61 +++++++++ src/Metadata/GraphQl/Subscription.php | 2 +- src/Metadata/HeaderParameter.php | 18 +++ src/Metadata/HeaderParameterInterface.php | 18 +++ src/Metadata/Metadata.php | 18 +-- src/Metadata/Operation.php | 24 ++-- src/Metadata/Parameter.php | 117 ++++++++++++++++-- src/Metadata/Post.php | 1 + src/Metadata/QueryParameter.php | 18 +++ src/Metadata/QueryParameterInterface.php | 18 +++ ...meterResourceMetadataCollectionFactory.php | 103 +++++++++++++++ src/Serializer/Parameter/GroupParameter.php | 0 .../SerializerFilterParameterProvider.php | 61 +++++++++ src/State/ParameterProviderInterface.php | 31 +++++ src/State/Provider/ParameterProvider.php | 79 ++++++++++++ src/Symfony/Bundle/Resources/config/api.xml | 7 ++ .../Resources/config/metadata/resource.xml | 5 + .../Resources/config/state/provider.xml | 5 + .../TestBundle/ApiResource/WithParameter.php | 58 +++++++++ .../CustomGroupParameterProvider.php | 26 ++++ tests/Fixtures/app/config/config_common.yml | 5 + tests/Parameter/ParameterTests.php | 51 ++++++++ 23 files changed, 698 insertions(+), 30 deletions(-) rename docs/adr/{0006-filters.md => 0006-filtering-system-and-parameters.md} (98%) create mode 100644 docs/adr/filter-notes create mode 100644 src/Metadata/HeaderParameter.php create mode 100644 src/Metadata/HeaderParameterInterface.php create mode 100644 src/Metadata/QueryParameter.php create mode 100644 src/Metadata/QueryParameterInterface.php create mode 100644 src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php create mode 100644 src/Serializer/Parameter/GroupParameter.php create mode 100644 src/Serializer/Parameter/SerializerFilterParameterProvider.php create mode 100644 src/State/ParameterProviderInterface.php create mode 100644 src/State/Provider/ParameterProvider.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/WithParameter.php create mode 100644 tests/Fixtures/TestBundle/Parameter/CustomGroupParameterProvider.php create mode 100644 tests/Parameter/ParameterTests.php diff --git a/docs/adr/0006-filters.md b/docs/adr/0006-filtering-system-and-parameters.md similarity index 98% rename from docs/adr/0006-filters.md rename to docs/adr/0006-filtering-system-and-parameters.md index c5e7e4882b9..7b37456126d 100644 --- a/docs/adr/0006-filters.md +++ b/docs/adr/0006-filtering-system-and-parameters.md @@ -71,7 +71,7 @@ For this to work, we need to consider a 4 year old bug on searching with UIDs. O /books?author.id=/author/1 ``` -Many attempts to fix these behavior on API Platform have lead to bugs and to be reverted. My 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. +Many attempts to fix these behavior 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. For the following example we will use an UUID to represent the stored identifier of an Author resource. diff --git a/docs/adr/filter-notes b/docs/adr/filter-notes new file mode 100644 index 00000000000..8b700fbe383 --- /dev/null +++ b/docs/adr/filter-notes @@ -0,0 +1,61 @@ +```php +// how to give uidfilter the parameters it should declare? +// is it automatic if we find a property having the uid type? +#[Get(filters: [new SearchFilter(), new UidFilter()]) +#[Parameter('key', schema: ['type' => 'string'])] // add transform + validate extension points +class Book { + +} + +final class Parameter { + mixed $value; + ?string $property; + ?string $class; + array $attributes; +} + +class FilterInterface {} + +class UidFilter { + public function __construct(private readonly string $class) {} + + public function parseQueryParameter(array $queryParameters = []): Parameter[] { + return [ + new Parameter(value: '', attributes: ['operation' => 'and']) + ]; + } + + // Query parameter type + public function getSchema(): array { + return ['type' => 'string']; + } + + public function getOpenApiParameter(): OpenApi\Parameter { + return ...; + } +} + +public function process(Operation $operation) { + $request = $context['request']; + + foreach($operation->getFilters() as $filter) { + foreach ($filter->parseQueryParameter($request->query, $context) as $parameter) { + $this->queryParameterValidator->validate($filter, $parameter, $context); + $filter->execute($filter, $parameter, $context); + } + } +} +``` + + +TODO: +see SerializerFilterContextBuilder: public function apply(Request $request, bool $normalization, array $attributes, array &$context): void; +maybe something like: + +``` +class SerializerFilterInterface { + public function getNormalizationContext(...); + public function getDenormalizationContext(...); +} +``` + diff --git a/src/Metadata/GraphQl/Subscription.php b/src/Metadata/GraphQl/Subscription.php index eff6724721f..8eb53c386b5 100644 --- a/src/Metadata/GraphQl/Subscription.php +++ b/src/Metadata/GraphQl/Subscription.php @@ -17,7 +17,7 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class Subscription extends Operation - +{ public function __construct( ?string $resolver = null, ?array $args = null, diff --git a/src/Metadata/HeaderParameter.php b/src/Metadata/HeaderParameter.php new file mode 100644 index 00000000000..b12be70e0cb --- /dev/null +++ b/src/Metadata/HeaderParameter.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +class HeaderParameter extends Parameter implements HeaderParameterInterface +{ +} diff --git a/src/Metadata/HeaderParameterInterface.php b/src/Metadata/HeaderParameterInterface.php new file mode 100644 index 00000000000..4ecc8404b29 --- /dev/null +++ b/src/Metadata/HeaderParameterInterface.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +interface HeaderParameterInterface +{ +} diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index 390bf02a599..79bc387aaec 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -21,15 +21,15 @@ abstract class Metadata { /** - * @param string|null $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties - * @param string|\Stringable|null $security https://api-platform.com/docs/core/security - * @param string|\Stringable|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization - * @param mixed|null $mercure - * @param mixed|null $messenger - * @param mixed|null $input - * @param mixed|null $output - * @param mixed|null $provider - * @param mixed|null $processor + * @param string|null $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties + * @param string|\Stringable|null $security https://api-platform.com/docs/core/security + * @param string|\Stringable|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization + * @param mixed|null $mercure + * @param mixed|null $messenger + * @param mixed|null $input + * @param mixed|null $output + * @param mixed|null $provider + * @param mixed|null $processor * @param array $parameters */ public function __construct( diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php index 63ddaf921bd..fb6f0f53afb 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -47,18 +47,18 @@ abstract class Operation extends Metadata * class?: string|null, * name?: string, * }|string|false|null $output {@see https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation} - * @param string|array|bool|null $mercure {@see https://api-platform.com/docs/core/mercure} - * @param string|bool|null $messenger {@see https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus} - * @param bool|null $elasticsearch {@see https://api-platform.com/docs/core/elasticsearch/} - * @param bool|null $read {@see https://api-platform.com/docs/core/events/#the-event-system} - * @param bool|null $deserialize {@see https://api-platform.com/docs/core/events/#the-event-system} - * @param bool|null $validate {@see https://api-platform.com/docs/core/events/#the-event-system} - * @param bool|null $write {@see https://api-platform.com/docs/core/events/#the-event-system} - * @param bool|null $serialize {@see https://api-platform.com/docs/core/events/#the-event-system} - * @param bool|null $fetchPartial {@see https://api-platform.com/docs/core/performance/#fetch-partial} - * @param bool|null $forceEager {@see https://api-platform.com/docs/core/performance/#force-eager} - * @param string|callable|null $provider {@see https://api-platform.com/docs/core/state-providers/#state-providers} - * @param string|callable|null $processor {@see https://api-platform.com/docs/core/state-processors/#state-processors} + * @param string|array|bool|null $mercure {@see https://api-platform.com/docs/core/mercure} + * @param string|bool|null $messenger {@see https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus} + * @param bool|null $elasticsearch {@see https://api-platform.com/docs/core/elasticsearch/} + * @param bool|null $read {@see https://api-platform.com/docs/core/events/#the-event-system} + * @param bool|null $deserialize {@see https://api-platform.com/docs/core/events/#the-event-system} + * @param bool|null $validate {@see https://api-platform.com/docs/core/events/#the-event-system} + * @param bool|null $write {@see https://api-platform.com/docs/core/events/#the-event-system} + * @param bool|null $serialize {@see https://api-platform.com/docs/core/events/#the-event-system} + * @param bool|null $fetchPartial {@see https://api-platform.com/docs/core/performance/#fetch-partial} + * @param bool|null $forceEager {@see https://api-platform.com/docs/core/performance/#force-eager} + * @param string|callable|null $provider {@see https://api-platform.com/docs/core/state-providers/#state-providers} + * @param string|callable|null $processor {@see https://api-platform.com/docs/core/state-processors/#state-processors} * @param array $parameters */ public function __construct( diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index 9ed708918b4..67938d26ca5 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -1,16 +1,119 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + namespace ApiPlatform\Metadata; use ApiPlatform\OpenApi; +use ApiPlatform\State\ProviderInterface; + +abstract class Parameter +{ + /** + * @param array $extraProperties + * @param ProviderInterface|string|null $provider + * @param FilterInterface|string|null $filter + */ + public function __construct( + protected ?string $key = null, + protected ?\ArrayObject $schema = null, + protected ?OpenApi\Model\Parameter $openApi = null, + protected mixed $provider = null, + protected mixed $filter = null, + protected array $extraProperties = [], + ) { + } + + public function getKey(): ?string + { + return $this->key; + } + + public function getSchema(): ?\ArrayObject + { + return $this->schema; + } + + public function getOpenApi(): ?OpenApi\Model\Parameter + { + return $this->openApi; + } + + public function getProvider(): mixed + { + return $this->provider; + } + + public function getFilter(): mixed + { + return $this->filter; + } + + public function getExtraProperties(): ?array + { + return $this->extraProperties; + } + + public function withKey(?string $key): static + { + $self = clone $this; + $self->key = $key; + + return $self; + } + + /** + * @param \ArrayObject $schema + */ + public function withSchema(\ArrayObject $schema): static + { + $self = clone $this; + $self->schema = $schema; + + return $self; + } + + public function withOpenApi(OpenApi\Model\Parameter $openApi): static + { + $self = clone $this; + $self->openApi = $openApi; + + return $self; + } + + public function withProvider(mixed $provider): static + { + $self = clone $this; + $self->provider = $provider; + + return $self; + } + + public function withFilter(mixed $filter): static + { + $self = clone $this; + $self->filter = $filter; + + return $self; + } -final class Parameter { - public string $key; - public \ArrayObject $schema; - public array $context; - public ?OpenApi\Model\Parameter $openApi; /** - * @param fn(mixed $value, Parameter $parameter, array $context)|string|null + * @param array $extraProperties */ - public mixed $provider; + public function withExtraProperties(array $extraProperties = []): static + { + $self = clone $this; + $self->extraProperties = $extraProperties; + + return $self; + } } diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index b07ceb8378f..5c9ec3e6732 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -172,6 +172,7 @@ class: $class, provider: $provider, processor: $processor, stateOptions: $stateOptions, + parameters: $parameters, extraProperties: $extraProperties ); } diff --git a/src/Metadata/QueryParameter.php b/src/Metadata/QueryParameter.php new file mode 100644 index 00000000000..036f4c244ef --- /dev/null +++ b/src/Metadata/QueryParameter.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +class QueryParameter extends Parameter implements QueryParameterInterface +{ +} diff --git a/src/Metadata/QueryParameterInterface.php b/src/Metadata/QueryParameterInterface.php new file mode 100644 index 00000000000..3f32c078fe8 --- /dev/null +++ b/src/Metadata/QueryParameterInterface.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +interface QueryParameterInterface +{ +} diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php new file mode 100644 index 00000000000..fb49906e330 --- /dev/null +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\HeaderParameterInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\OpenApi; +use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; +use Psr\Container\ContainerInterface; + +final class ParameterResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + public function __construct(private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, private readonly ?ContainerInterface $filterLocator = null) + { + } + + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = $this->decorated?->create($resourceClass) ?? new ResourceMetadataCollection($resourceClass); + + foreach ($resourceMetadataCollection as $i => $resource) { + $operations = $resource->getOperations(); + + foreach ($operations as $operationName => $operation) { + $parameters = []; + foreach ($operation->getParameters() ?? [] as $key => $parameter) { + if (null === $parameter->getKey()) { + $parameter = $parameter->withKey($key); + } + + $filter = $parameter->getFilter(); + if (\is_string($filter) && $this->filterLocator->has($filter)) { + $filter = $this->filterLocator->get($filter); + } + + if (!$filter instanceof FilterInterface) { + $parameters[$key] = $parameter; + continue; + } + + if ($filter instanceof SerializerFilterInterface && null === $parameter->getProvider()) { + $parameter = $parameter->withProvider('api_platform.serializer.filter_parameter_provider'); + } + + // Read filter description to populate the Parameter + $description = $filter->getDescription($resourceClass); + if (($schema = $description['schema'] ?? []) && null === $parameter->getSchema()) { + $parameter = $parameter->withSchema($schema); + } + + if (!($openApi = $description['openapi'] ?? null) && null === $parameter->getOpenApi()) { + $parameters[$key] = $parameter; + continue; + } + + if ($openApi instanceof OpenApi\Model\Parameter) { + $parameter = $parameter->withOpenApi($openApi); + $parameters[$key] = $parameter; + continue; + } + + if (\is_array($openApi)) { + $parameters[] = new OpenApi\Model\Parameter( + $key, + $parameter instanceof HeaderParameterInterface ? 'header' : 'query', + $description['description'] ?? '', + $description['required'] ?? $openApi['required'] ?? false, + $openApi['deprecated'] ?? false, + $openApi['allowEmptyValue'] ?? true, + $schema, + $openApi['style'] ?? null, + $openApi['explode'] ?? ('array' === ($schema['type'] ?? null)), + $openApi['allowReserved'] ?? false, + $openApi['example'] ?? null, + isset($openApi['examples'] + ) ? new \ArrayObject($openApi['examples']) : null + ); + } + + $parameters[$key] = $parameter; + } + + $operations->add($operationName, $operation->withParameters($parameters)); + } + + $resourceMetadataCollection[$i] = $resource->withOperations($operations->sort()); + } + + return $resourceMetadataCollection; + } +} diff --git a/src/Serializer/Parameter/GroupParameter.php b/src/Serializer/Parameter/GroupParameter.php new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Serializer/Parameter/SerializerFilterParameterProvider.php b/src/Serializer/Parameter/SerializerFilterParameterProvider.php new file mode 100644 index 00000000000..df174e8760a --- /dev/null +++ b/src/Serializer/Parameter/SerializerFilterParameterProvider.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Parameter; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Serializer\Filter\FilterInterface; +use ApiPlatform\State\ParameterProviderInterface; +use ApiPlatform\State\Util\RequestAttributesExtractor; +use Psr\Container\ContainerInterface; + +final class SerializerFilterParameterProvider implements ParameterProviderInterface +{ + public function __construct(private readonly ?ContainerInterface $filterLocator) + { + } + + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?HttpOperation + { + if (null === ($request = $context['request'] ?? null) || null === ($operation = $context['operation'] ?? null)) { + return null; + } + + $filter = $parameter->getFilter(); + if (\is_string($filter)) { + $filter = $this->getFilter($filter); + } + + if ($filter instanceof FilterInterface) { + $context = $operation->getNormalizationContext(); + $filter->apply($request, true, RequestAttributesExtractor::extractAttributes($request), $context); + + return $operation->withNormalizationContext($context); + } + + return null; + } + + /** + * Gets a filter with a backward compatibility. + */ + private function getFilter(string $filterId): ?FilterInterface + { + if ($this->filterLocator && $this->filterLocator->has($filterId)) { + return $this->filterLocator->get($filterId); + } + + return null; + } +} diff --git a/src/State/ParameterProviderInterface.php b/src/State/ParameterProviderInterface.php new file mode 100644 index 00000000000..5c105c7be83 --- /dev/null +++ b/src/State/ParameterProviderInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Parameter; + +/** + * Optionnaly transforms request parameters and provides modification to the current Operation. + * + * @experimental + */ +interface ParameterProviderInterface +{ + /** + * @param array $parameters + * @param array|array{request?: Request, resource_class?: string, operation: HttpOperation} $context + */ + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?HttpOperation; +} diff --git a/src/State/Provider/ParameterProvider.php b/src/State/Provider/ParameterProvider.php new file mode 100644 index 00000000000..b99f97e2247 --- /dev/null +++ b/src/State/Provider/ParameterProvider.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Provider; + +use ApiPlatform\Metadata\HeaderParameterInterface; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Exception\ProviderNotFoundException; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\Util\RequestParser; +use Psr\Container\ContainerInterface; + +class ParameterProvider implements ProviderInterface +{ + public function __construct(private readonly ?ProviderInterface $decorated = null, private readonly ?ContainerInterface $locator = null) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + if (null === ($request = $context['request'])) { + return $this->decorated?->provide($operation, $uriVariables, $context); + } + + if (null === $request->attributes->get('_api_query_parameters')) { + $queryString = RequestParser::getQueryString($request); + $request->attributes->set('_api_query_parameters', $queryString ? RequestParser::parseRequestParams($queryString) : []); + } + + if (null === $request->attributes->get('_api_header_parameters')) { + $request->attributes->set('_api_header_parameters', $request->headers->all()); + } + + $context = ['operation' => $operation] + $context; + + foreach ($operation->getParameters() ?? [] as $key => $parameter) { + if (null === ($provider = $parameter->getProvider())) { + continue; + } + + $parameters = $parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters'); + if (!isset($parameters[$key])) { + continue; + } + + if (\is_callable($provider) && (($op = $provider($parameter, $parameters, $context)) instanceof HttpOperation)) { + $operation = $op; + $request->attributes->set('_api_operation', $operation); + $context['operation'] = $operation; + continue; + } + + if (!\is_string($provider) || !$this->locator->has($provider)) { + throw new ProviderNotFoundException(sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); + } + + /** @var ProviderInterface $providerInstance */ + $providerInstance = $this->locator->get($provider); + if (($op = $providerInstance->provide($parameter, $parameters, $context)) instanceof HttpOperation) { + $operation = $op; + $request->attributes->set('_api_operation', $operation); + $context['operation'] = $operation; + } + } + + return $this->decorated?->provide($operation, $uriVariables, $context); + } +} diff --git a/src/Symfony/Bundle/Resources/config/api.xml b/src/Symfony/Bundle/Resources/config/api.xml index 7efa4318d10..01474ec58b1 100644 --- a/src/Symfony/Bundle/Resources/config/api.xml +++ b/src/Symfony/Bundle/Resources/config/api.xml @@ -36,6 +36,13 @@ + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index 55fc4c58a7a..04d8fa2016f 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -74,6 +74,11 @@ + + + + + diff --git a/src/Symfony/Bundle/Resources/config/state/provider.xml b/src/Symfony/Bundle/Resources/config/state/provider.xml index 56a42f70f87..d849872a182 100644 --- a/src/Symfony/Bundle/Resources/config/state/provider.xml +++ b/src/Symfony/Bundle/Resources/config/state/provider.xml @@ -25,6 +25,11 @@ + + + + + api_platform.symfony.main_controller diff --git a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php new file mode 100644 index 00000000000..d0a7a3f0da0 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\HeaderParameter; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Serializer\Filter\GroupFilter; +use ApiPlatform\Tests\Fixtures\TestBundle\Parameter\CustomGroupParameterProvider; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Serializer\Attribute\Groups; + +#[Get( + parameters: [ + 'groups' => new QueryParameter(filter: new GroupFilter(parameterName: 'groups', overrideDefaultGroups: false)), + 'group' => new QueryParameter(provider: [self::class, 'provideGroup']), + 'properties' => new QueryParameter(filter: 'my_dummy.property'), + 'service' => new QueryParameter(provider: CustomGroupParameterProvider::class), + 'auth' => new HeaderParameter(provider: [self::class, 'restrictAccess']), + ], + provider: [WithParameter::class, 'provide'] +)] +class WithParameter +{ + #[Groups(['a'])] + public $a = 'foo'; + #[Groups(['b', 'custom'])] + public $b = 'bar'; + + public static function provide() + { + return new self(); + } + + public static function provideGroup(Parameter $parameter, array $parameters = [], array $context = []) + { + $operation = $context['operation']; + + return $operation->withNormalizationContext(['groups' => $parameters['group']]); + } + + public static function restrictAccess(): void + { + throw new AccessDeniedHttpException(); + } +} diff --git a/tests/Fixtures/TestBundle/Parameter/CustomGroupParameterProvider.php b/tests/Fixtures/TestBundle/Parameter/CustomGroupParameterProvider.php new file mode 100644 index 00000000000..35b0f88817b --- /dev/null +++ b/tests/Fixtures/TestBundle/Parameter/CustomGroupParameterProvider.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Parameter; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\State\ParameterProviderInterface; + +final class CustomGroupParameterProvider implements ParameterProviderInterface +{ + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?HttpOperation + { + return $context['operation']->withNormalizationContext(['groups' => 'custom']); + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 9fe7d402553..7bd2b6b82a5 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -445,3 +445,8 @@ services: api_platform.http_cache.tag_collector: class: ApiPlatform\Tests\Fixtures\TestBundle\HttpCache\TagCollectorDefault public: true + + ApiPlatform\Tests\Fixtures\TestBundle\Parameter\CustomGroupParameterProvider: + tags: + - name: 'api_platform.parameter_provider' + key: 'ApiPlatform\Tests\Fixtures\TestBundle\Parameter\CustomGroupParameterProvider' diff --git a/tests/Parameter/ParameterTests.php b/tests/Parameter/ParameterTests.php new file mode 100644 index 00000000000..dc8213cd5e4 --- /dev/null +++ b/tests/Parameter/ParameterTests.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Parameter; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + +final class ParameterTests extends ApiTestCase +{ + public function testWithGroupFilter(): void + { + $response = self::createClient()->request('GET', 'with_parameters?groups[]=b'); + $this->assertArraySubset(['b' => 'bar'], $response->toArray()); + $response = self::createClient()->request('GET', 'with_parameters?groups[]=b&groups[]=a'); + $this->assertArraySubset(['a' => 'foo', 'b' => 'bar'], $response->toArray()); + } + + public function testWithGroupProvider(): void + { + $response = self::createClient()->request('GET', 'with_parameters?group[]=b&group[]=a'); + $this->assertArraySubset(['a' => 'foo', 'b' => 'bar'], $response->toArray()); + } + + public function testWithServiceFilter(): void + { + $response = self::createClient()->request('GET', 'with_parameters?properties[]=a'); + $this->assertArraySubset(['a' => 'foo'], $response->toArray()); + } + + public function testWithServiceProvider(): void + { + $response = self::createClient()->request('GET', 'with_parameters?service=blabla'); + $this->assertArrayNotHasKey('a', $response->toArray()); + } + + public function testWithHeader(): void + { + $response = self::createClient()->request('GET', 'with_parameters?service=blabla', ['headers' => ['auth' => 'foo']]); + $this->assertResponseStatusCodeSame(401); + } +} From 8dd88ff8ff96867a943f70b2fc925580ee32de01 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 4 Mar 2024 16:05:21 +0100 Subject: [PATCH 03/20] remove notes --- docs/adr/filter-notes | 61 ------------------------------------------- 1 file changed, 61 deletions(-) delete mode 100644 docs/adr/filter-notes diff --git a/docs/adr/filter-notes b/docs/adr/filter-notes deleted file mode 100644 index 8b700fbe383..00000000000 --- a/docs/adr/filter-notes +++ /dev/null @@ -1,61 +0,0 @@ -```php -// how to give uidfilter the parameters it should declare? -// is it automatic if we find a property having the uid type? -#[Get(filters: [new SearchFilter(), new UidFilter()]) -#[Parameter('key', schema: ['type' => 'string'])] // add transform + validate extension points -class Book { - -} - -final class Parameter { - mixed $value; - ?string $property; - ?string $class; - array $attributes; -} - -class FilterInterface {} - -class UidFilter { - public function __construct(private readonly string $class) {} - - public function parseQueryParameter(array $queryParameters = []): Parameter[] { - return [ - new Parameter(value: '', attributes: ['operation' => 'and']) - ]; - } - - // Query parameter type - public function getSchema(): array { - return ['type' => 'string']; - } - - public function getOpenApiParameter(): OpenApi\Parameter { - return ...; - } -} - -public function process(Operation $operation) { - $request = $context['request']; - - foreach($operation->getFilters() as $filter) { - foreach ($filter->parseQueryParameter($request->query, $context) as $parameter) { - $this->queryParameterValidator->validate($filter, $parameter, $context); - $filter->execute($filter, $parameter, $context); - } - } -} -``` - - -TODO: -see SerializerFilterContextBuilder: public function apply(Request $request, bool $normalization, array $attributes, array &$context): void; -maybe something like: - -``` -class SerializerFilterInterface { - public function getNormalizationContext(...); - public function getDenormalizationContext(...); -} -``` - From 2ab7b2df5288afa4e15b9e8ce22c314ee69f99e5 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 7 Mar 2024 15:16:23 +0100 Subject: [PATCH 04/20] review suggestions Co-authored-by: Vincent <407859+vincentchalamon@users.noreply.github.com> --- docs/adr/0006-filtering-system-and-parameters.md | 8 ++++---- src/Metadata/Parameter.php | 9 ++++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/adr/0006-filtering-system-and-parameters.md b/docs/adr/0006-filtering-system-and-parameters.md index 7b37456126d..58741df2b0e 100644 --- a/docs/adr/0006-filtering-system-and-parameters.md +++ b/docs/adr/0006-filtering-system-and-parameters.md @@ -71,7 +71,7 @@ For this to work, we need to consider a 4 year old bug on searching with UIDs. O /books?author.id=/author/1 ``` -Many attempts to fix these behavior 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. +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. For the following example we will use an UUID to represent the stored identifier of an Author resource. @@ -89,7 +89,7 @@ With that in mind, an `or` filter would call a bunch of filters specifying the l ### Query parameter -The above shows that a query parameter **key**, which is a `string` may lead to multiple filters being called. This same can represent one or multiple values, and for a same **key** we can handle multiple types of data. +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. Also, if someone wants to implement the [loopback API](https://loopback.io/doc/en/lb2/Fields-filter.html) `?filter[fields][vin]=false` the link between the query parameter, the filter and the value gets more complex. 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. @@ -132,7 +132,7 @@ $queryString = RequestParser::getQueryString($request); $request->attributes->set('_api_query_parameters', $queryString ? RequestParser::parseRequestParams($queryString) : []); ``` -On top of that we will provide an additional `_api_header_parameters` as we would like to introduce a `QueryParameter` and an `HeaderParameter`. +On top of that we will provide an additional `_api_header_parameters` as we would like to introduce a `QueryParameter` and a `HeaderParameter`. ### Parameter Provider @@ -145,7 +145,7 @@ During the `Provider` phase (`RequestEvent::REQUEST`), we could use a `Parameter * @implements ProviderInterface */ interface ParameterProvider extends ProviderInterface { - public function provider(Operation $operation, array $uriVariables = [], array $context = []): HttpOperation; + public function provider(HttpOperation $operation, array $uriVariables = [], array $context = []): HttpOperation; } ``` diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index 67938d26ca5..04e541912e6 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -19,6 +19,7 @@ abstract class Parameter { /** + * @param \ArrayObject|null $schema * @param array $extraProperties * @param ProviderInterface|string|null $provider * @param FilterInterface|string|null $filter @@ -90,6 +91,9 @@ public function withOpenApi(OpenApi\Model\Parameter $openApi): static return $self; } + /** + * @param ProviderInterface|string $provider + */ public function withProvider(mixed $provider): static { $self = clone $this; @@ -98,6 +102,9 @@ public function withProvider(mixed $provider): static return $self; } + /** + * @param FilterInterface|string $filter + */ public function withFilter(mixed $filter): static { $self = clone $this; @@ -109,7 +116,7 @@ public function withFilter(mixed $filter): static /** * @param array $extraProperties */ - public function withExtraProperties(array $extraProperties = []): static + public function withExtraProperties(array $extraProperties): static { $self = clone $this; $self->extraProperties = $extraProperties; From 5ea486f08893ccd221ba9f40f392101191fbdd35 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 7 Mar 2024 15:16:35 +0100 Subject: [PATCH 05/20] experimental everywhere --- src/Metadata/HeaderParameter.php | 3 +++ src/Metadata/HeaderParameterInterface.php | 3 +++ src/Metadata/Metadata.php | 3 +++ src/Metadata/Parameter.php | 19 ++++++++++++++++++- src/Metadata/QueryParameter.php | 3 +++ src/Metadata/QueryParameterInterface.php | 3 +++ ...meterResourceMetadataCollectionFactory.php | 3 +++ src/Serializer/Parameter/GroupParameter.php | 0 .../SerializerFilterParameterProvider.php | 3 +++ src/State/Provider/ParameterProvider.php | 5 ++++- 10 files changed, 43 insertions(+), 2 deletions(-) delete mode 100644 src/Serializer/Parameter/GroupParameter.php diff --git a/src/Metadata/HeaderParameter.php b/src/Metadata/HeaderParameter.php index b12be70e0cb..be63df9dfd2 100644 --- a/src/Metadata/HeaderParameter.php +++ b/src/Metadata/HeaderParameter.php @@ -13,6 +13,9 @@ namespace ApiPlatform\Metadata; +/** + * @experimental + */ class HeaderParameter extends Parameter implements HeaderParameterInterface { } diff --git a/src/Metadata/HeaderParameterInterface.php b/src/Metadata/HeaderParameterInterface.php index 4ecc8404b29..b54a943579d 100644 --- a/src/Metadata/HeaderParameterInterface.php +++ b/src/Metadata/HeaderParameterInterface.php @@ -13,6 +13,9 @@ namespace ApiPlatform\Metadata; +/** + * @experimental + */ interface HeaderParameterInterface { } diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index 79bc387aaec..42bb8906084 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -70,6 +70,9 @@ public function __construct( protected $provider = null, protected $processor = null, protected ?OptionsInterface $stateOptions = null, + /** + * @experimental + */ protected ?array $parameters = [], protected array $extraProperties = [] ) { diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index 04e541912e6..5079b21a68a 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -16,6 +16,9 @@ use ApiPlatform\OpenApi; use ApiPlatform\State\ProviderInterface; +/** + * @experimental + */ abstract class Parameter { /** @@ -30,6 +33,7 @@ public function __construct( protected ?OpenApi\Model\Parameter $openApi = null, protected mixed $provider = null, protected mixed $filter = null, + protected string $property = null, protected array $extraProperties = [], ) { } @@ -54,6 +58,11 @@ public function getProvider(): mixed return $this->provider; } + public function getProperty(): ?string + { + return $this->property; + } + public function getFilter(): mixed { return $this->filter; @@ -64,7 +73,7 @@ public function getExtraProperties(): ?array return $this->extraProperties; } - public function withKey(?string $key): static + public function withKey(string $key): static { $self = clone $this; $self->key = $key; @@ -113,6 +122,14 @@ public function withFilter(mixed $filter): static return $self; } + public function withProperty(string $property): static + { + $self = clone $this; + $self->property = $property; + + return $self; + } + /** * @param array $extraProperties */ diff --git a/src/Metadata/QueryParameter.php b/src/Metadata/QueryParameter.php index 036f4c244ef..776fd7f70d2 100644 --- a/src/Metadata/QueryParameter.php +++ b/src/Metadata/QueryParameter.php @@ -13,6 +13,9 @@ namespace ApiPlatform\Metadata; +/** + * @experimental + */ class QueryParameter extends Parameter implements QueryParameterInterface { } diff --git a/src/Metadata/QueryParameterInterface.php b/src/Metadata/QueryParameterInterface.php index 3f32c078fe8..3315b96d84c 100644 --- a/src/Metadata/QueryParameterInterface.php +++ b/src/Metadata/QueryParameterInterface.php @@ -13,6 +13,9 @@ namespace ApiPlatform\Metadata; +/** + * @experimental + */ interface QueryParameterInterface { } diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index fb49906e330..6559f0d69dd 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -20,6 +20,9 @@ use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; use Psr\Container\ContainerInterface; +/** + * @experimental + */ final class ParameterResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface { public function __construct(private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, private readonly ?ContainerInterface $filterLocator = null) diff --git a/src/Serializer/Parameter/GroupParameter.php b/src/Serializer/Parameter/GroupParameter.php deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/Serializer/Parameter/SerializerFilterParameterProvider.php b/src/Serializer/Parameter/SerializerFilterParameterProvider.php index df174e8760a..027936014ea 100644 --- a/src/Serializer/Parameter/SerializerFilterParameterProvider.php +++ b/src/Serializer/Parameter/SerializerFilterParameterProvider.php @@ -20,6 +20,9 @@ use ApiPlatform\State\Util\RequestAttributesExtractor; use Psr\Container\ContainerInterface; +/** + * @experimental + */ final class SerializerFilterParameterProvider implements ParameterProviderInterface { public function __construct(private readonly ?ContainerInterface $filterLocator) diff --git a/src/State/Provider/ParameterProvider.php b/src/State/Provider/ParameterProvider.php index b99f97e2247..0347e48a849 100644 --- a/src/State/Provider/ParameterProvider.php +++ b/src/State/Provider/ParameterProvider.php @@ -21,7 +21,10 @@ use ApiPlatform\State\Util\RequestParser; use Psr\Container\ContainerInterface; -class ParameterProvider implements ProviderInterface +/** + * @experimental + */ +final class ParameterProvider implements ProviderInterface { public function __construct(private readonly ?ProviderInterface $decorated = null, private readonly ?ContainerInterface $locator = null) { From 86d9a126c527006523fc046283138c8133d97094 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 7 Mar 2024 15:21:57 +0100 Subject: [PATCH 06/20] docs: fix suggestions --- docs/adr/0006-filtering-system-and-parameters.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/adr/0006-filtering-system-and-parameters.md b/docs/adr/0006-filtering-system-and-parameters.md index 58741df2b0e..ce01d112187 100644 --- a/docs/adr/0006-filtering-system-and-parameters.md +++ b/docs/adr/0006-filtering-system-and-parameters.md @@ -57,7 +57,7 @@ The idea of this ADR is to find a way to introduce more functionalities to API P - document query parameters for hydra, JSON Schema (OpenAPI being an extension of JSON Schema). - 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) - compose with filters, which will naturally help creating an or/and filter -- remove the relation 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) +- 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] - provide a way to implement different query parameter syntaxes without changing the Filter implementation behind it We will keep a BC layer with the current doctrine system as it shouldn't change much. @@ -201,7 +201,7 @@ class UuidParameter implements ProviderInterface { ### Filters -Filters should remain mostly unchanged, the current informations about the `property` to filter should be specified inside a `Parameter`'s `context`. +Filters should remain mostly unchanged, the current informations about the `property` to filter should also be specified inside a `Parameter`. They alter the Doctrine/Elasticsearch Query, therefore we need one interface per persistence layer supported. The current logic within API Platform is: ```php @@ -248,6 +248,8 @@ foreach ($operation->getParameters() as $key => $parameter) { ## Links * [Filter composition][pull/2400] +* [Hydra property search](hydra) [pull/5980]: https://github.com/api-platform/core/pull/5980 "ApiFilter does not respect SerializerName" [pull/2400]: https://github.com/api-platform/core/pull/2400 "Filter composition" +[hydra]: http://www.hydra-cg.com/spec/latest/core/#supported-property-data-source "Hydra property data source" From f64234766bd76a0aa48ede9a3430b02f6dc20380 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 7 Mar 2024 15:22:55 +0100 Subject: [PATCH 07/20] fix extraproperties --- src/Metadata/Parameter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index 5079b21a68a..f48af439413 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -68,7 +68,7 @@ public function getFilter(): mixed return $this->filter; } - public function getExtraProperties(): ?array + public function getExtraProperties(): array { return $this->extraProperties; } From 4eb3bc1acb872f866a694dae71ab179a874e0379 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 7 Mar 2024 15:28:52 +0100 Subject: [PATCH 08/20] fix default parameters wither --- src/Metadata/Metadata.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index 42bb8906084..1d69c4eefed 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -576,7 +576,7 @@ public function getParameters(): ?array return $this->parameters; } - public function withParameters(array $parameters = []): static + public function withParameters(array $parameters): static { $self = clone $this; $self->parameters = $parameters; From e9aba340d113baf2d1088d8f11d124101840553b Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 7 Mar 2024 15:40:16 +0100 Subject: [PATCH 09/20] fix httpoperation => operation --- .../0006-filtering-system-and-parameters.md | 22 +++++++++++-------- src/Metadata/Parameter.php | 8 +++---- src/State/ParameterProviderInterface.php | 8 +++---- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/docs/adr/0006-filtering-system-and-parameters.md b/docs/adr/0006-filtering-system-and-parameters.md index ce01d112187..9f87fcbd115 100644 --- a/docs/adr/0006-filtering-system-and-parameters.md +++ b/docs/adr/0006-filtering-system-and-parameters.md @@ -141,11 +141,14 @@ During the `Provider` phase (`RequestEvent::REQUEST`), we could use a `Parameter ```php /** * Optionnaly transforms request parameters and provides modification to the current Operation. - * - * @implements ProviderInterface */ -interface ParameterProvider extends ProviderInterface { - public function provider(HttpOperation $operation, array $uriVariables = [], array $context = []): HttpOperation; +interface ParameterProviderInterface +{ + /** + * @param array $parameters + * @param array|array{request?: Request, resource_class?: string, operation: HttpOperation} $context + */ + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?HttpOperation; } ``` @@ -154,11 +157,11 @@ This provider can: 1. alter the HTTP Operation to provide additional context: ```php -class GroupsParameterProvider implements ProviderInterface { - public function provider(Operation $operation, array $uriVariables = [], array $context = []): HttpOperation +class GroupsParameterProvider implements ParameterProviderInterface { + public function provider(Parameter $parameter, array $uriVariables = [], array $context = []): HttpOperation { $request = $context['request']; - return $operation->withNormalizationContext(['groups' => $request->query->all('groups')]); + return $context['operation']->withNormalizationContext(['groups' => $request->query->all('groups')]); } } ``` @@ -166,10 +169,11 @@ class GroupsParameterProvider implements ProviderInterface { 2. alter the parameter context: ```php -class UuidParameter implements ProviderInterface { - public function provider(Operation $operation, array $uriVariables = [], array $context = []): HttpOperation +class UuidParameter implements ParameterProviderInterface { + public function provider(Parameter $parameter, array $uriVariables = [], array $context = []): HttpOperation { $request = $context['request']; + $operation = $context['operation']; $parameters = $request->attributes->get('_api_query_parameters'); foreach ($parameters as $key => $value) { $parameter = $operation->getParameter($key); diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index f48af439413..845380c6d5c 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -23,9 +23,9 @@ abstract class Parameter { /** * @param \ArrayObject|null $schema - * @param array $extraProperties - * @param ProviderInterface|string|null $provider - * @param FilterInterface|string|null $filter + * @param array $extraProperties + * @param ProviderInterface|string|null $provider + * @param FilterInterface|string|null $filter */ public function __construct( protected ?string $key = null, @@ -33,7 +33,7 @@ public function __construct( protected ?OpenApi\Model\Parameter $openApi = null, protected mixed $provider = null, protected mixed $filter = null, - protected string $property = null, + protected ?string $property = null, protected array $extraProperties = [], ) { } diff --git a/src/State/ParameterProviderInterface.php b/src/State/ParameterProviderInterface.php index 5c105c7be83..da8d6ef1f99 100644 --- a/src/State/ParameterProviderInterface.php +++ b/src/State/ParameterProviderInterface.php @@ -13,7 +13,7 @@ namespace ApiPlatform\State; -use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; /** @@ -24,8 +24,8 @@ interface ParameterProviderInterface { /** - * @param array $parameters - * @param array|array{request?: Request, resource_class?: string, operation: HttpOperation} $context + * @param array $parameters + * @param array|array{request?: Request, resource_class?: string, operation: Operation} $context */ - public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?HttpOperation; + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation; } From 463322a9550469beb03547520b70019fcab87a3f Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 8 Mar 2024 16:48:54 +0100 Subject: [PATCH 10/20] Hydra, openapi parameters --- .../CollectionFiltersNormalizer.php | 27 ++++++- src/Metadata/Extractor/schema/resources.xsd | 23 ++++++ src/Metadata/IdentifiersExtractor.php | 4 +- src/Metadata/Link.php | 41 +++++++++- src/Metadata/Metadata.php | 3 + src/Metadata/Parameter.php | 50 ++++++++++-- ...plateResourceMetadataCollectionFactory.php | 3 +- .../Tests/Extractor/Adapter/resources.yaml | 1 + .../ResourceMetadataCompatibilityTest.php | 34 ++++++++ src/Metadata/Tests/Extractor/xml/valid.xml | 17 ++++ src/OpenApi/Factory/OpenApiFactory.php | 78 +++++++++++++++++-- .../Tests/Factory/OpenApiFactoryTest.php | 29 ++++++- .../SerializerFilterParameterProvider.php | 4 +- src/State/Provider/ParameterProvider.php | 3 +- .../TestBundle/ApiResource/WithParameter.php | 22 +++++- tests/Parameter/ParameterTests.php | 25 ++++-- 16 files changed, 325 insertions(+), 39 deletions(-) diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index 1ccc0203222..f404e9b731e 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -18,6 +18,8 @@ use ApiPlatform\Doctrine\Odm\State\Options as ODMOptions; use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Serializer\CacheableSupportsMethodInterface; @@ -97,8 +99,10 @@ public function normalize(mixed $object, ?string $format = null, array $context } $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class']); $operation = $context['operation'] ?? $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($context['operation_name'] ?? null); + + $parameters = $operation->getParameters(); $resourceFilters = $operation->getFilters(); - if (!$resourceFilters) { + if (!$resourceFilters && !$parameters) { return $data; } @@ -123,8 +127,8 @@ public function normalize(mixed $object, ?string $format = null, array $context } } - if ($currentFilters) { - $data['hydra:search'] = $this->getSearch($resourceClass, $requestParts, $currentFilters); + if ($currentFilters || $parameters) { + $data['hydra:search'] = $this->getSearch($resourceClass, $requestParts, $currentFilters, $parameters); } return $data; @@ -144,8 +148,9 @@ public function setNormalizer(NormalizerInterface $normalizer): void * Returns the content of the Hydra search property. * * @param LegacyFilterInterface[]|FilterInterface[] $filters + * @param array $parameters */ - private function getSearch(string $resourceClass, array $parts, array $filters): array + private function getSearch(string $resourceClass, array $parts, array $filters, ?array $parameters): array { $variables = []; $mapping = []; @@ -156,6 +161,20 @@ private function getSearch(string $resourceClass, array $parts, array $filters): } } + foreach ($parameters ?? [] as $key => $parameter) { + // Each IriTemplateMapping maps a variable used in the template to a property + if (!$parameter instanceof QueryParameterInterface || !($property = $parameter->getProperty())) { + continue; + } + + $variables[] = $key; + $m = ['@type' => 'IriTemplateMapping', 'variable' => $key, 'property' => $property]; + if (null !== ($required = $parameter->getRequired())) { + $m['required'] = $required; + } + $mapping[] = $m; + } + return ['@type' => 'hydra:IriTemplate', 'hydra:template' => sprintf('%s{?%s}', $parts['path'], implode(',', $variables)), 'hydra:variableRepresentation' => 'BasicRepresentation', 'hydra:mapping' => $mapping]; } diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index dbc818d5fe8..37ea7ff784b 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -433,6 +433,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -444,6 +466,7 @@ + diff --git a/src/Metadata/IdentifiersExtractor.php b/src/Metadata/IdentifiersExtractor.php index 3ad989848c3..0b2f595cac9 100644 --- a/src/Metadata/IdentifiersExtractor.php +++ b/src/Metadata/IdentifiersExtractor.php @@ -75,7 +75,7 @@ private function getIdentifiersFromOperation(object $item, Operation $operation, } $identifiers = []; - foreach ($links ?? [] as $link) { + foreach ($links ?? [] as $k => $link) { if (1 < (is_countable($link->getIdentifiers()) ? \count($link->getIdentifiers()) : 0)) { $compositeIdentifiers = []; foreach ($link->getIdentifiers() as $identifier) { @@ -87,7 +87,7 @@ private function getIdentifiersFromOperation(object $item, Operation $operation, } $parameterName = $link->getParameterName(); - $identifiers[$parameterName] = $this->getIdentifierValue($item, $link->getFromClass() ?? $operation->getClass(), $link->getIdentifiers()[0], $parameterName, $link->getToProperty()); + $identifiers[$parameterName] = $this->getIdentifierValue($item, $link->getFromClass() ?? $operation->getClass(), $link->getIdentifiers()[0] ?? $k, $parameterName, $link->getToProperty()); } return $identifiers; diff --git a/src/Metadata/Link.php b/src/Metadata/Link.php index 75e6260d4b1..c540785f14c 100644 --- a/src/Metadata/Link.php +++ b/src/Metadata/Link.php @@ -13,15 +13,50 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi; + #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER)] -final class Link +final class Link extends Parameter { - public function __construct(private ?string $parameterName = null, private ?string $fromProperty = null, private ?string $toProperty = null, private ?string $fromClass = null, private ?string $toClass = null, private ?array $identifiers = null, private ?bool $compositeIdentifier = null, private ?string $expandedValue = null, private ?string $security = null, private ?string $securityMessage = null, private ?string $securityObjectName = null) - { + public function __construct( + private ?string $parameterName = null, + private ?string $fromProperty = null, + private ?string $toProperty = null, + private ?string $fromClass = null, + private ?string $toClass = null, + private ?array $identifiers = null, + private ?bool $compositeIdentifier = null, + private ?string $expandedValue = null, + private ?string $security = null, + private ?string $securityMessage = null, + private ?string $securityObjectName = null, + + ?string $key = null, + ?array $schema = null, + ?OpenApi\Model\Parameter $openApi = null, + mixed $provider = null, + mixed $filter = null, + ?string $property = null, + ?string $description = null, + ?bool $required = null, + array $extraProperties = [], + ) { // For the inverse property shortcut if ($this->parameterName && class_exists($this->parameterName)) { $this->fromClass = $this->parameterName; } + + parent::__construct( + key: $key, + schema: $schema, + openApi: $openApi, + provider: $provider, + filter: $filter, + property: $property, + description: $description, + required: $required, + extraProperties: $extraProperties + ); } public function getParameterName(): ?string diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index 1d69c4eefed..1b1f8e8e759 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -571,6 +571,9 @@ public function withStateOptions(?OptionsInterface $stateOptions): static return $self; } + /** + * @return array + */ public function getParameters(): ?array { return $this->parameters; diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index 845380c6d5c..011bb4b2131 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -22,18 +22,20 @@ abstract class Parameter { /** - * @param \ArrayObject|null $schema - * @param array $extraProperties - * @param ProviderInterface|string|null $provider - * @param FilterInterface|string|null $filter + * @param array{type?: string}|null $schema + * @param array $extraProperties + * @param ProviderInterface|callable|string|null $provider + * @param FilterInterface|string|null $filter */ public function __construct( protected ?string $key = null, - protected ?\ArrayObject $schema = null, + protected ?array $schema = null, protected ?OpenApi\Model\Parameter $openApi = null, protected mixed $provider = null, protected mixed $filter = null, protected ?string $property = null, + protected ?string $description = null, + protected ?bool $required = null, protected array $extraProperties = [], ) { } @@ -43,7 +45,10 @@ public function getKey(): ?string return $this->key; } - public function getSchema(): ?\ArrayObject + /** + * @return array{type?: string}|null $schema + */ + public function getSchema(): ?array { return $this->schema; } @@ -68,6 +73,19 @@ public function getFilter(): mixed return $this->filter; } + public function getDescription(): ?string + { + return $this->description; + } + + public function getRequired(): ?bool + { + return $this->required; + } + + /** + * @return array + */ public function getExtraProperties(): array { return $this->extraProperties; @@ -82,9 +100,9 @@ public function withKey(string $key): static } /** - * @param \ArrayObject $schema + * @param array{type?: string} $schema */ - public function withSchema(\ArrayObject $schema): static + public function withSchema(array $schema): static { $self = clone $this; $self->schema = $schema; @@ -130,6 +148,22 @@ public function withProperty(string $property): static return $self; } + public function withDescription(string $description): static + { + $self = clone $this; + $self->description = $description; + + return $self; + } + + public function withRequired(bool $required): static + { + $self = clone $this; + $self->required = $required; + + return $self; + } + /** * @param array $extraProperties */ diff --git a/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php index f75e3ef2962..67db22b49cc 100644 --- a/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php @@ -155,7 +155,8 @@ private function configureUriVariables(ApiResource|HttpOperation $operation): Ap return $operation; } - foreach ($uriVariables = $operation->getUriVariables() as $parameterName => $link) { + foreach ($uriVariables = $operation->getUriVariables() as $parameterName => $l) { + $link = null === $l->getFromClass() ? $l->withFromClass($operation->getClass()) : $l; $uriVariables[$parameterName] = $this->linkFactory->completeLink($link); } $operation = $operation->withUriVariables($uriVariables); diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml index 698b6213871..534be1d57b5 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -330,6 +330,7 @@ resources: elasticsearchOptions: index: foo_index type: foo_type + parameters: null extraProperties: custom_property: 'Lorem ipsum dolor sit amet' another_custom_property: diff --git a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php index 0e20d227e8d..e63de397f07 100644 --- a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php +++ b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php @@ -749,4 +749,38 @@ private function withLinks(array $values): ?array return [new Link($values[0]['rel'] ?? null, $values[0]['href'] ?? null)]; } + + private function withParameters(array $values): array + { + // $uriVariables = []; + // foreach ($values as $parameterName => $value) { + // if (\is_string($value)) { + // $uriVariables[$value] = $value; + // continue; + // } + // + // if (isset($value['fromClass']) || isset($value[0])) { + // $uriVariables[$parameterName]['from_class'] = $value['fromClass'] ?? $value[0]; + // } + // if (isset($value['fromProperty']) || isset($value[1])) { + // $uriVariables[$parameterName]['from_property'] = $value['fromProperty'] ?? $value[1]; + // } + // if (isset($value['toClass'])) { + // $uriVariables[$parameterName]['to_class'] = $value['toClass']; + // } + // if (isset($value['toProperty'])) { + // $uriVariables[$parameterName]['to_property'] = $value['toProperty']; + // } + // if (isset($value['identifiers'])) { + // $uriVariables[$parameterName]['identifiers'] = $value['identifiers']; + // } + // if (isset($value['compositeIdentifier'])) { + // $uriVariables[$parameterName]['composite_identifier'] = $value['compositeIdentifier']; + // } + // } + + dd($values); + + return $values; + } } diff --git a/src/Metadata/Tests/Extractor/xml/valid.xml b/src/Metadata/Tests/Extractor/xml/valid.xml index 52276015291..c045d78f9ce 100644 --- a/src/Metadata/Tests/Extractor/xml/valid.xml +++ b/src/Metadata/Tests/Extractor/xml/valid.xml @@ -114,5 +114,22 @@ + + + + + + string + + + + + bar + true + + + + + diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index d2a19220d9c..b059d9c183d 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -20,6 +20,7 @@ use ApiPlatform\JsonSchema\TypeFactoryInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\HeaderParameterInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -274,22 +275,31 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } // Set up parameters + $openapiParameters = $openapiOperation->getParameters(); foreach ($operation->getUriVariables() ?? [] as $parameterName => $uriVariable) { if ($uriVariable->getExpandedValue() ?? false) { continue; } - $parameter = new Parameter($parameterName, 'path', "$resourceShortName identifier", true, false, false, ['type' => 'string']); - if ($this->hasParameter($openapiOperation, $parameter)) { + $parameter = new Parameter($parameterName, 'path', $uriVariable->getDescription() ?? "$resourceShortName identifier", $uriVariable->getRequired() ?? true, false, false, $uriVariable->getSchema() ?? ['type' => 'string']); + + if ($linkParameter = $uriVariable->getOpenApi()) { + $parameter = $this->mergeParameter($parameter, $linkParameter); + } + + if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) { + $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter); continue; } - $openapiOperation = $openapiOperation->withParameter($parameter); + $openapiParameters[] = $parameter; } + $openapiOperation = $openapiOperation->withParameters($openapiParameters); + if ($operation instanceof CollectionOperationInterface && 'POST' !== $method) { foreach (array_merge($this->getPaginationParameters($operation), $this->getFiltersParameters($operation)) as $parameter) { - if ($this->hasParameter($openapiOperation, $parameter)) { + if ($operationParameter = $this->hasParameter($openapiOperation, $parameter)) { continue; } @@ -297,6 +307,25 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } + $openapiParameters = $openapiOperation->getParameters(); + foreach ($operation->getParameters() ?? [] as $key => $p) { + $in = $p instanceof HeaderParameterInterface ? 'header' : 'query'; + $parameter = new Parameter($key, $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); + + if ($linkParameter = $p->getOpenApi()) { + $parameter = $this->mergeParameter($parameter, $linkParameter); + } + + if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) { + $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter); + continue; + } + + $openapiParameters[] = $parameter; + } + + $openapiOperation = $openapiOperation->withParameters($openapiParameters); + $existingResponses = $openapiOperation?->getResponses() ?: []; $overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses(); if ($overrideResponses || !$existingResponses) { @@ -712,14 +741,47 @@ private function appendSchemaDefinitions(\ArrayObject $schemas, \ArrayObject $de } } - private function hasParameter(Model\Operation $operation, Parameter $parameter): bool + /** + * @return array{0: int, 1: Parameter}|null + */ + private function hasParameter(Model\Operation $operation, Parameter $parameter): ?array { - foreach ($operation->getParameters() as $existingParameter) { + foreach ($operation->getParameters() as $key => $existingParameter) { if ($existingParameter->getName() === $parameter->getName() && $existingParameter->getIn() === $parameter->getIn()) { - return true; + return [$key, $existingParameter]; + } + } + + return null; + } + + private function mergeParameter(Parameter $actual, Parameter $defined): Parameter + { + foreach ([ + 'name', + 'in', + 'description', + 'required', + 'deprecated', + 'allowEmptyValue', + 'style', + 'explode', + 'allowReserved', + 'example', + ] as $method) { + $newValue = $defined->{"get$method"}(); + if (null !== $newValue && $actual->{"get$method"}() !== $newValue) { + $actual = $actual->{"with$method"}($newValue); + } + } + + foreach (['examples', 'content', 'schema'] as $method) { + $newValue = $defined->{"get$method"}(); + if ($newValue && \count($newValue) > 0 && $actual->{"get$method"}() !== $newValue) { + $actual = $actual->{"with$method"}($newValue); } } - return false; + return $actual; } } diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index 8a0271b95db..d6c2fb31311 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HeaderParameter; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\NotExposed; @@ -57,6 +58,7 @@ use ApiPlatform\OpenApi\Tests\Fixtures\DummyFilter; use ApiPlatform\OpenApi\Tests\Fixtures\OutputDto; use ApiPlatform\State\Pagination\PaginationOptions; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithParameter; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -252,11 +254,20 @@ public function testInvoke(): void ]) ); + $baseOperation = (new HttpOperation())->withTypes(['http://schema.example.com/Dummy'])->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats'])->withClass(Dummy::class)->withShortName('Parameter')->withDescription('This is a dummy'); + $parameterResource = (new ApiResource())->withOperations(new Operations([ + 'uriVariableSchema' => (new Get(uriTemplate: '/uri_variable_uuid', uriVariables: ['id' => new Link(schema: ['type' => 'string', 'format' => 'uuid'], description: 'hello', required: true, openApi: new Parameter('id', 'path', allowEmptyValue: true))]))->withOperation($baseOperation), + 'parameters' => (new Put(uriTemplate: '/parameters', parameters: [ + 'foo' => new HeaderParameter(description: 'hi', schema: ['type' => 'string', 'format' => 'uuid']), + ]))->withOperation($baseOperation), + ])); + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); - $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class, WithParameter::class])); $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [$dummyResource, $dummyResourceWebhook])); + $resourceCollectionMetadataFactoryProphecy->create(WithParameter::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(WithParameter::class, [$parameterResource])); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); @@ -504,7 +515,12 @@ public function testInvoke(): void $components = $openApi->getComponents(); $this->assertInstanceOf(Components::class, $components); - $this->assertEquals($components->getSchemas(), new \ArrayObject(['Dummy' => $dummySchema->getDefinitions(), 'Dummy.OutputDto' => $dummySchema->getDefinitions()])); + $parameterSchema = $dummySchema->getDefinitions(); + $this->assertEquals($components->getSchemas(), new \ArrayObject([ + 'Dummy' => $dummySchema->getDefinitions(), + 'Dummy.OutputDto' => $dummySchema->getDefinitions(), + 'Parameter' => $parameterSchema, + ])); $this->assertEquals($components->getSecuritySchemes(), new \ArrayObject([ 'oauth' => new SecurityScheme('oauth2', 'OAuth 2.0 authorization code Grant', null, null, null, null, new OAuthFlows(null, null, null, new OAuthFlow('/oauth/v2/auth', '/oauth/v2/token', '/oauth/v2/refresh', new \ArrayObject(['scope param'])))), @@ -970,5 +986,14 @@ public function testInvoke(): void [], null ), $emptyRequestBodyPath->getPost()); + + $parameter = $paths->getPath('/uri_variable_uuid')->getGet()->getParameters()[0]; + $this->assertTrue($parameter->getAllowEmptyValue()); + $this->assertEquals(['type' => 'string', 'format' => 'uuid'], $parameter->getSchema()); + + $parameter = $paths->getPath('/parameters')->getPut()->getParameters()[0]; + $this->assertEquals(['type' => 'string', 'format' => 'uuid'], $parameter->getSchema()); + $this->assertEquals('header', $parameter->getIn()); + $this->assertEquals('hello', $parameter->getDescription()); } } diff --git a/src/Serializer/Parameter/SerializerFilterParameterProvider.php b/src/Serializer/Parameter/SerializerFilterParameterProvider.php index 027936014ea..2be9600e533 100644 --- a/src/Serializer/Parameter/SerializerFilterParameterProvider.php +++ b/src/Serializer/Parameter/SerializerFilterParameterProvider.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Serializer\Parameter; -use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; use ApiPlatform\Serializer\Filter\FilterInterface; use ApiPlatform\State\ParameterProviderInterface; @@ -29,7 +29,7 @@ public function __construct(private readonly ?ContainerInterface $filterLocator) { } - public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?HttpOperation + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation { if (null === ($request = $context['request'] ?? null) || null === ($operation = $context['operation'] ?? null)) { return null; diff --git a/src/State/Provider/ParameterProvider.php b/src/State/Provider/ParameterProvider.php index 0347e48a849..a4681d77539 100644 --- a/src/State/Provider/ParameterProvider.php +++ b/src/State/Provider/ParameterProvider.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\Exception\ProviderNotFoundException; +use ApiPlatform\State\ParameterProviderInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\RequestParser; use Psr\Container\ContainerInterface; @@ -68,7 +69,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c throw new ProviderNotFoundException(sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); } - /** @var ProviderInterface $providerInstance */ + /** @var ParameterProviderInterface $providerInstance */ $providerInstance = $this->locator->get($provider); if (($op = $providerInstance->provide($parameter, $parameters, $context)) instanceof HttpOperation) { $operation = $op; diff --git a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php index d0a7a3f0da0..93534d8ba07 100644 --- a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -14,7 +14,9 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HeaderParameter; +use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Serializer\Filter\GroupFilter; @@ -23,6 +25,10 @@ use Symfony\Component\Serializer\Attribute\Groups; #[Get( + uriTemplate: 'with_parameters/{id}', + uriVariables: [ + 'id' => new Link(schema: ['type' => 'uuid'], property: 'id'), + ], parameters: [ 'groups' => new QueryParameter(filter: new GroupFilter(parameterName: 'groups', overrideDefaultGroups: false)), 'group' => new QueryParameter(provider: [self::class, 'provideGroup']), @@ -30,15 +36,29 @@ 'service' => new QueryParameter(provider: CustomGroupParameterProvider::class), 'auth' => new HeaderParameter(provider: [self::class, 'restrictAccess']), ], - provider: [WithParameter::class, 'provide'] + provider: [self::class, 'provide'] +)] +#[GetCollection( + uriTemplate: 'with_parameters_collection', + parameters: [ + 'hydra' => new QueryParameter(property: 'a', required: true), + ], + provider: [self::class, 'collectionProvider'] )] class WithParameter { + public int $id = 1; + #[Groups(['a'])] public $a = 'foo'; #[Groups(['b', 'custom'])] public $b = 'bar'; + public static function collectionProvider() + { + return [new self()]; + } + public static function provide() { return new self(); diff --git a/tests/Parameter/ParameterTests.php b/tests/Parameter/ParameterTests.php index dc8213cd5e4..e6b0e4deb63 100644 --- a/tests/Parameter/ParameterTests.php +++ b/tests/Parameter/ParameterTests.php @@ -19,33 +19,44 @@ final class ParameterTests extends ApiTestCase { public function testWithGroupFilter(): void { - $response = self::createClient()->request('GET', 'with_parameters?groups[]=b'); + $response = self::createClient()->request('GET', 'with_parameters/1?groups[]=b'); $this->assertArraySubset(['b' => 'bar'], $response->toArray()); - $response = self::createClient()->request('GET', 'with_parameters?groups[]=b&groups[]=a'); + $response = self::createClient()->request('GET', 'with_parameters/1?groups[]=b&groups[]=a'); $this->assertArraySubset(['a' => 'foo', 'b' => 'bar'], $response->toArray()); } public function testWithGroupProvider(): void { - $response = self::createClient()->request('GET', 'with_parameters?group[]=b&group[]=a'); + $response = self::createClient()->request('GET', 'with_parameters/1?group[]=b&group[]=a'); $this->assertArraySubset(['a' => 'foo', 'b' => 'bar'], $response->toArray()); } public function testWithServiceFilter(): void { - $response = self::createClient()->request('GET', 'with_parameters?properties[]=a'); + $response = self::createClient()->request('GET', 'with_parameters/1?properties[]=a'); $this->assertArraySubset(['a' => 'foo'], $response->toArray()); } public function testWithServiceProvider(): void { - $response = self::createClient()->request('GET', 'with_parameters?service=blabla'); + $response = self::createClient()->request('GET', 'with_parameters/1?service=blabla'); $this->assertArrayNotHasKey('a', $response->toArray()); } public function testWithHeader(): void { - $response = self::createClient()->request('GET', 'with_parameters?service=blabla', ['headers' => ['auth' => 'foo']]); - $this->assertResponseStatusCodeSame(401); + self::createClient()->request('GET', 'with_parameters/1?service=blabla', ['headers' => ['auth' => 'foo']]); + $this->assertResponseStatusCodeSame(403); + } + + public function testHydraTemplate(): void + { + $response = self::createClient()->request('GET', 'with_parameters_collection'); + $this->assertArraySubset(['hydra:search' => [ + 'hydra:template' => '/with_parameters_collection{?hydra}', + 'hydra:mapping' => [ + ['@type' => 'IriTemplateMapping', 'variable' => 'hydra', 'property' => 'a', 'required' => true], + ], + ]], $response->toArray()); } } From 3c9d10e4ebe1c58979c472012b5404d8dd1c0295 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 12 Mar 2024 14:56:31 +0100 Subject: [PATCH 11/20] xml/yaml support --- .../Extractor/XmlResourceExtractor.php | 30 +++++++++- .../Extractor/YamlResourceExtractor.php | 25 +++++++++ src/Metadata/Extractor/schema/resources.xsd | 3 +- .../Extractor/Adapter/XmlResourceAdapter.php | 17 ++++++ .../Tests/Extractor/Adapter/resources.xml | 2 +- .../Tests/Extractor/Adapter/resources.yaml | 5 ++ .../ResourceMetadataCompatibilityTest.php | 55 ++++++------------- .../Tests/Extractor/XmlExtractorTest.php | 14 +++++ .../Tests/Extractor/YamlExtractorTest.php | 7 +++ src/Metadata/Tests/Extractor/xml/valid.xml | 32 +++++------ src/Metadata/Tests/Extractor/yaml/valid.yaml | 7 +++ .../Tests/Factory/OpenApiFactoryTest.php | 2 +- 12 files changed, 141 insertions(+), 58 deletions(-) diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index e676fcbe929..cb29ccdcaa1 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -15,11 +15,13 @@ use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HeaderParameter; use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Metadata\Tests\Fixtures\StateOptions; use ApiPlatform\OpenApi\Model\ExternalDocumentation; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; -use ApiPlatform\OpenApi\Model\Parameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use ApiPlatform\OpenApi\Model\RequestBody; use ApiPlatform\State\OptionsInterface; use Symfony\Component\Config\Util\XmlUtils; @@ -97,6 +99,7 @@ private function buildExtendedBase(\SimpleXMLElement $resource): array 'stateOptions' => $this->buildStateOptions($resource), 'links' => $this->buildLinks($resource), 'headers' => $this->buildHeaders($resource), + 'parameters' => $this->buildParameters($resource), ]); } @@ -200,7 +203,7 @@ private function buildOpenapi(\SimpleXMLElement $resource): bool|OpenApiOperatio if (isset($openapi->parameters->parameter)) { foreach ($openapi->parameters->parameter as $parameter) { - $data['parameters'][(string) $parameter->attributes()->name] = new Parameter( + $data['parameters'][(string) $parameter->attributes()->name] = new OpenApiParameter( name: $this->phpize($parameter, 'name', 'string'), in: $this->phpize($parameter, 'in', 'string'), description: $this->phpize($parameter, 'description', 'string'), @@ -494,4 +497,27 @@ private function buildHeaders(\SimpleXMLElement $resource): ?array return $headers; } + + /** + * @return array + */ + private function buildParameters(\SimpleXMLElement $resource): ?array + { + if (!$resource->parameters) { + return null; + } + + $parameters = []; + foreach ($resource->parameters->parameter as $parameter) { + $key = (string) $parameter->attributes()->key; + $cl = ('header' === (string) $parameter->attributes()->in) ? HeaderParameter::class : QueryParameter::class; + $parameters[$key] = new $cl( + key: $key, + required: $this->phpize($parameter, 'required', 'bool'), + schema: isset($parameter->schema->values) ? $this->buildValues($parameter->schema->values) : null, + ); + } + + return $parameters; + } } diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index 2858752cd24..3efc8beadd4 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -15,7 +15,9 @@ use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HeaderParameter; use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Metadata\Tests\Fixtures\StateOptions; use ApiPlatform\OpenApi\Model\ExternalDocumentation; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; @@ -124,6 +126,7 @@ private function buildExtendedBase(array $resource): array 'stateOptions' => $this->buildStateOptions($resource), 'links' => $this->buildLinks($resource), 'headers' => $this->buildHeaders($resource), + 'parameters' => $this->buildParameters($resource), ]); } @@ -450,4 +453,26 @@ private function buildHeaders(array $resource): ?array return $headers; } + + /** + * @return array + */ + private function buildParameters(array $resource): ?array + { + if (!isset($resource['parameters']) || !\is_array($resource['parameters'])) { + return null; + } + + $parameters = []; + foreach ($resource['parameters'] as $key => $parameter) { + $cl = ($parameter['in'] ?? 'query') === 'header' ? HeaderParameter::class : QueryParameter::class; + $parameters[$key] = new $cl( + key: $key, + required: $this->phpize($parameter, 'required', 'bool'), + schema: $parameter['schema'] + ); + } + + return $parameters; + } } diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index 37ea7ff784b..6121622d758 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -440,7 +440,8 @@ - + + diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php index d703caf238e..2d5af4154a7 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -65,6 +65,7 @@ final class XmlResourceAdapter implements ResourceAdapterInterface 'stateOptions', 'collectDenormalizationErrors', 'links', + 'parameters', ]; /** @@ -521,6 +522,22 @@ private function buildHeaders(\SimpleXMLElement $resource, ?array $values = null } } + private function buildParameters(\SimpleXMLElement $resource, ?array $values = null): void + { + if (!$values) { + return; + } + + $node = $resource->addChild('parameters'); + foreach ($values as $key => $value) { + $childNode = $node->addChild('parameter'); + $childNode->addAttribute('in', 'query'); + $childNode->addAttribute('key', $key); + $childNode->addAttribute('required', $this->parse($value['required'])); + $this->buildValues($childNode->addChild('schema'), $value['schema']); + } + } + private function parse($value): ?string { if (null === $value) { diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.xml b/src/Metadata/Tests/Extractor/Adapter/resources.xml index 6b751472cbc..d33570d48e3 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.xml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.xml @@ -1,3 +1,3 @@ -someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazbazbazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazbazbazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet +someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazbazbazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazbazbazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml index 534be1d57b5..0b4332a4752 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -119,6 +119,11 @@ resources: - rel: 'http://www.w3.org/ns/json-ld#error' href: 'http://www.w3.org/ns/hydra/error' + parameters: + author: + key: author + required: true + schema: { type: string } - uriTemplate: '/users/{userId}/comments/{commentId}{._format}' class: ApiPlatform\Metadata\Get diff --git a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php index e63de397f07..7d79549ade9 100644 --- a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php +++ b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php @@ -29,6 +29,7 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Metadata\Resource\Factory\ExtractorResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\OperationDefaultsTrait; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -421,6 +422,9 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'links' => [ ['rel' => 'http://www.w3.org/ns/json-ld#error', 'href' => 'http://www.w3.org/ns/hydra/error'], ], + 'parameters' => [ + 'author' => ['key' => 'author', 'required' => true, 'schema' => ['type' => 'string']], + ], ], [ 'uriTemplate' => '/users/{userId}/comments/{commentId}{._format}', @@ -508,6 +512,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'stateOptions', 'links', 'headers', + 'parameters', ]; /** @@ -528,12 +533,8 @@ public function testValidMetadata(string $extractorClass, ResourceAdapterInterfa throw new AssertionFailedError('Failed asserting that the schema is valid according to '.ApiResource::class, 0, $exception); } - $a = new ResourceMetadataCollection(self::RESOURCE_CLASS, $this->buildApiResources()); - $b = $collection; - - $this->assertEquals($a[0], $b[0]); - - $this->assertEquals(new ResourceMetadataCollection(self::RESOURCE_CLASS, $this->buildApiResources()), $collection); + $resources = $this->buildApiResources(); + $this->assertEquals(new ResourceMetadataCollection(self::RESOURCE_CLASS, $resources), $collection); } public static function getExtractors(): array @@ -750,37 +751,17 @@ private function withLinks(array $values): ?array return [new Link($values[0]['rel'] ?? null, $values[0]['href'] ?? null)]; } - private function withParameters(array $values): array + private function withParameters(array $values): ?array { - // $uriVariables = []; - // foreach ($values as $parameterName => $value) { - // if (\is_string($value)) { - // $uriVariables[$value] = $value; - // continue; - // } - // - // if (isset($value['fromClass']) || isset($value[0])) { - // $uriVariables[$parameterName]['from_class'] = $value['fromClass'] ?? $value[0]; - // } - // if (isset($value['fromProperty']) || isset($value[1])) { - // $uriVariables[$parameterName]['from_property'] = $value['fromProperty'] ?? $value[1]; - // } - // if (isset($value['toClass'])) { - // $uriVariables[$parameterName]['to_class'] = $value['toClass']; - // } - // if (isset($value['toProperty'])) { - // $uriVariables[$parameterName]['to_property'] = $value['toProperty']; - // } - // if (isset($value['identifiers'])) { - // $uriVariables[$parameterName]['identifiers'] = $value['identifiers']; - // } - // if (isset($value['compositeIdentifier'])) { - // $uriVariables[$parameterName]['composite_identifier'] = $value['compositeIdentifier']; - // } - // } - - dd($values); - - return $values; + if (!$values) { + return null; + } + + $parameters = []; + foreach ($values as $k => $value) { + $parameters[$k] = new QueryParameter(key: $value['key'], required: $value['required'], schema: $value['schema']); + } + + return $parameters; } } diff --git a/src/Metadata/Tests/Extractor/XmlExtractorTest.php b/src/Metadata/Tests/Extractor/XmlExtractorTest.php index a041e495ec3..c38dbd802c7 100644 --- a/src/Metadata/Tests/Extractor/XmlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/XmlExtractorTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\Extractor\XmlResourceExtractor; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\Comment; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\User; use PHPUnit\Framework\TestCase; @@ -102,6 +103,7 @@ public function testValidXML(): void 'stateOptions' => null, 'links' => null, 'headers' => null, + 'parameters' => null, ], [ 'uriTemplate' => '/users/{author}/comments{._format}', @@ -275,6 +277,7 @@ public function testValidXML(): void 'stateOptions' => null, 'links' => null, 'headers' => ['hello' => 'world'], + 'parameters' => null, ], [ 'name' => null, @@ -376,6 +379,16 @@ public function testValidXML(): void 'stateOptions' => null, 'links' => null, 'headers' => ['hello' => 'world'], + 'parameters' => [ + 'author' => new QueryParameter( + key: 'author', + required: true, + schema: [ + 'type' => 'string', + ], + extraProperties: [] + ), + ], ], ], 'graphQlOperations' => null, @@ -387,6 +400,7 @@ public function testValidXML(): void 'stateOptions' => null, 'links' => null, 'headers' => ['hello' => 'world'], + 'parameters' => null, ], ], ], $extractor->getResources()); diff --git a/src/Metadata/Tests/Extractor/YamlExtractorTest.php b/src/Metadata/Tests/Extractor/YamlExtractorTest.php index 1b56bf8c0d0..06fa95940b0 100644 --- a/src/Metadata/Tests/Extractor/YamlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/YamlExtractorTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\Extractor\YamlResourceExtractor; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\FlexConfig; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\Program; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\SingleFileConfigDummy; @@ -102,6 +103,7 @@ public function testValidYaml(): void 'stateOptions' => null, 'links' => null, 'headers' => null, + 'parameters' => null, ], ], Program::class => [ @@ -174,6 +176,7 @@ public function testValidYaml(): void 'stateOptions' => null, 'links' => null, 'headers' => null, + 'parameters' => null, ], [ 'uriTemplate' => '/users/{author}/programs{._format}', @@ -317,6 +320,7 @@ public function testValidYaml(): void 'stateOptions' => null, 'links' => null, 'headers' => ['hello' => 'world'], + 'parameters' => null, ], [ 'name' => null, @@ -401,6 +405,7 @@ public function testValidYaml(): void 'stateOptions' => null, 'links' => null, 'headers' => ['hello' => 'world'], + 'parameters' => ['author' => new QueryParameter(schema: ['type' => 'string'], required: true, key: 'author')], ], ], 'graphQlOperations' => null, @@ -411,6 +416,7 @@ public function testValidYaml(): void 'stateOptions' => null, 'links' => null, 'headers' => ['hello' => 'world'], + 'parameters' => null, ], ], SingleFileConfigDummy::class => [ @@ -483,6 +489,7 @@ public function testValidYaml(): void 'stateOptions' => null, 'links' => null, 'headers' => null, + 'parameters' => null, ], ], ], $extractor->getResources()); diff --git a/src/Metadata/Tests/Extractor/xml/valid.xml b/src/Metadata/Tests/Extractor/xml/valid.xml index c045d78f9ce..a725e652894 100644 --- a/src/Metadata/Tests/Extractor/xml/valid.xml +++ b/src/Metadata/Tests/Extractor/xml/valid.xml @@ -112,24 +112,24 @@ true + + + + + + string + + + + + bar + + + + + - - - - - string - - - - - bar - true - - - - - diff --git a/src/Metadata/Tests/Extractor/yaml/valid.yaml b/src/Metadata/Tests/Extractor/yaml/valid.yaml index 7ed798ccfa6..0b627d96f3e 100644 --- a/src/Metadata/Tests/Extractor/yaml/valid.yaml +++ b/src/Metadata/Tests/Extractor/yaml/valid.yaml @@ -23,6 +23,13 @@ resources: extraProperties: foo: 'bar' boolean: true + parameters: + author: + description: 'hello' + required: true + in: 'query' + schema: + type: 'string' ApiPlatform\Metadata\Tests\Fixtures\ApiResource\SingleFileConfigDummy: shortName: single_file_config diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index d6c2fb31311..4780a582e25 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -994,6 +994,6 @@ public function testInvoke(): void $parameter = $paths->getPath('/parameters')->getPut()->getParameters()[0]; $this->assertEquals(['type' => 'string', 'format' => 'uuid'], $parameter->getSchema()); $this->assertEquals('header', $parameter->getIn()); - $this->assertEquals('hello', $parameter->getDescription()); + $this->assertEquals('hi', $parameter->getDescription()); } } From 31daf7cffcac3ead450cbf8a431e6ca10cd397e1 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 18 Mar 2024 16:00:30 +0100 Subject: [PATCH 12/20] xml, yaml, priority parameters --- .../Orm/Extension/ParameterExtension.php | 76 ++++++++++++++ .../CollectionFiltersNormalizer.php | 3 +- src/Metadata/ApiResource.php | 2 +- src/Metadata/Delete.php | 2 + .../Extractor/XmlResourceExtractor.php | 21 ++++ .../Extractor/YamlResourceExtractor.php | 23 ++++- src/Metadata/Get.php | 2 +- src/Metadata/GetCollection.php | 2 +- src/Metadata/GraphQl/Operation.php | 3 +- src/Metadata/GraphQl/Query.php | 3 +- src/Metadata/GraphQl/QueryCollection.php | 3 +- src/Metadata/GraphQl/Subscription.php | 3 +- src/Metadata/HttpOperation.php | 2 +- src/Metadata/Metadata.php | 8 +- src/Metadata/Operation.php | 2 +- src/Metadata/Parameter.php | 14 +++ src/Metadata/Parameters.php | 98 +++++++++++++++++++ src/Metadata/Patch.php | 2 +- src/Metadata/Post.php | 2 +- src/Metadata/Put.php | 2 +- ...meterResourceMetadataCollectionFactory.php | 87 +++++++++------- src/OpenApi/Factory/OpenApiFactory.php | 4 +- src/State/Provider/ParameterProvider.php | 4 +- .../Bundle/Resources/config/doctrine_orm.xml | 7 ++ .../TestBundle/ApiResource/WithParameter.php | 16 ++- .../Entity/SearchFilterParameter.php | 42 ++++++++ tests/Fixtures/app/config/config_common.yml | 5 + tests/Parameter/ParameterTests.php | 44 +++++++++ 28 files changed, 423 insertions(+), 59 deletions(-) create mode 100644 src/Doctrine/Orm/Extension/ParameterExtension.php create mode 100644 src/Metadata/Parameters.php create mode 100644 tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php new file mode 100644 index 00000000000..ba39b4a7765 --- /dev/null +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Orm\Extension; + +use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\HeaderParameterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; +use Psr\Container\ContainerInterface; + +/** + * @author Antoine Bluchet + */ +final class ParameterExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface +{ + public function __construct(private readonly ContainerInterface $filterLocator) + { + } + + private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []) { + if (!($request = $context['request'] ?? null)) { + return; + } + + if (null === $resourceClass) { + throw new InvalidArgumentException('The "$resourceClass" parameter must not be null'); + } + + foreach ($operation->getParameters() ?? [] as $parameter) { + $key = $parameter->getKey(); + if (null === ($filterId = $parameter->getFilter())) { + continue; + } + + $parameters = $parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters'); + if (!isset($parameters[$key])) { + continue; + } + + $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + if ($filter instanceof FilterInterface) { + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => [$parameter->getProperty() ?? $key => $parameters[$key]]] + $context); + } + } + } + + /** + * {@inheritdoc} + */ + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void + { + $this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } + + /** + * {@inheritdoc} + */ + public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void + { + $this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } +} diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index f404e9b731e..54f6cbcd93c 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -19,6 +19,7 @@ use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\QueryParameterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; @@ -150,7 +151,7 @@ public function setNormalizer(NormalizerInterface $normalizer): void * @param LegacyFilterInterface[]|FilterInterface[] $filters * @param array $parameters */ - private function getSearch(string $resourceClass, array $parts, array $filters, ?array $parameters): array + private function getSearch(string $resourceClass, array $parts, array $filters, null|array|Parameters $parameters): array { $variables = []; $mapping = []; diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index e6da8e53290..e519ddf1c60 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -960,7 +960,7 @@ public function __construct( $provider = null, $processor = null, protected ?OptionsInterface $stateOptions = null, - protected ?array $parameters = null, + protected null|array|Parameters $parameters = null, protected array $extraProperties = [], ) { parent::__construct( diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index cfaa3d0f03f..47b9367e182 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -94,6 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + null|array|Parameters $parameters = null, array $extraProperties = [], ) { parent::__construct( @@ -170,6 +171,7 @@ class: $class, processor: $processor, extraProperties: $extraProperties, collectDenormalizationErrors: $collectDenormalizationErrors, + parameters: $parameters, stateOptions: $stateOptions, ); } diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index cb29ccdcaa1..48c71dde349 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -515,6 +515,27 @@ private function buildParameters(\SimpleXMLElement $resource): ?array key: $key, required: $this->phpize($parameter, 'required', 'bool'), schema: isset($parameter->schema->values) ? $this->buildValues($parameter->schema->values) : null, + openApi: isset($parameter->openapi) ? new OpenApiParameter( + name: $this->phpize($parameter->openapi, 'name', 'string'), + in: $this->phpize($parameter->openapi, 'in', 'string'), + description: $this->phpize($parameter->openapi, 'description', 'string'), + required: $this->phpize($parameter->openapi, 'required', 'bool'), + deprecated: $this->phpize($parameter->openapi, 'deprecated', 'bool'), + allowEmptyValue: $this->phpize($parameter->openapi, 'allowEmptyValue', 'bool'), + schema: isset($parameter->openapi->schema->values) ? $this->buildValues($parameter->openapi->schema->values) : null, + style: $this->phpize($parameter->openapi, 'style', 'string'), + explode: $this->phpize($parameter->openapi, 'explode', 'bool'), + allowReserved: $this->phpize($parameter->openapi, 'allowReserved', 'bool'), + example: $this->phpize($parameter->openapi, 'example', 'string'), + examples: isset($parameter->openapi->examples->values) ? new \ArrayObject($this->buildValues($parameter->openapi->examples->values)) : null, + content: isset($parameter->openapi->content->values) ? new \ArrayObject($this->buildValues($parameter->openapi->content->values)) : null, + ) : null, + provider: $this->phpize($parameter, 'provider', 'string'), + filter: $this->phpize($parameter, 'filter', 'string'), + property: $this->phpize($parameter, 'property', 'string'), + description: $this->phpize($parameter, 'description', 'string'), + priority: $this->phpize($parameter, 'priority', 'integer'), + extraProperties: $this->buildExtraProperties($parameter, 'extraProperties'), ); } diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index 3efc8beadd4..089b0f643e2 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -469,7 +469,28 @@ private function buildParameters(array $resource): ?array $parameters[$key] = new $cl( key: $key, required: $this->phpize($parameter, 'required', 'bool'), - schema: $parameter['schema'] + schema: $parameter['schema'], + openapi: ($parameter['openapi'] ?? null) ? new Parameter( + name: $parameter['openapi']['name'], + in: $parameter['in'] ?? 'query', + description: $parameter['openapi']['description'] ?? '', + required: $parameter['openapi']['required'] ?? $parameter['required'] ?? false, + deprecated: $parameter['openapi']['deprecated'] ?? false, + allowEmptyValue: $parameter['openapi']['allowEmptyValue'] ?? false, + schema: $parameter['openapi']['schema'] ?? $parameter['schema'] ?? [], + style: $parameter['openapi']['style'] ?? null, + explode: $parameter['openapi']['explode'] ?? false, + allowReserved: $parameter['openapi']['allowReserved '] ?? false, + example: $parameter['openapi']['example'] ?? null, + examples: isset($parameter['openapi']['examples']) ? new \ArrayObject($parameter['openapi']['examples']) : null, + content: isset($parameter['openapi']['content']) ? new \ArrayObject($parameter['openapi']['content']) : null + ) : null, + provider: $this->phpize($parameter, 'provider', 'string'), + filter: $this->phpize($parameter, 'filter', 'string'), + property: $this->phpize($parameter, 'property', 'string'), + description: $this->phpize($parameter, 'description', 'string'), + priority: $this->phpize($parameter, 'priority', 'integer'), + extraProperties: $this->buildArrayValue($resource, 'extraProperties'), ); } diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 7ba70eb3b8a..22e343f6524 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -94,7 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - ?array $parameters = null, + null|array|Parameters $parameters = null, array $extraProperties = [], ) { parent::__construct( diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 608d499630a..1f139a3a568 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -94,7 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - ?array $parameters = null, + null|array|Parameters $parameters = null, array $extraProperties = [], private ?string $itemUriTemplate = null, ) { diff --git a/src/Metadata/GraphQl/Operation.php b/src/Metadata/GraphQl/Operation.php index d714caa9dd4..a8db6a989fa 100644 --- a/src/Metadata/GraphQl/Operation.php +++ b/src/Metadata/GraphQl/Operation.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operation as AbstractOperation; +use ApiPlatform\Metadata\Parameters; use ApiPlatform\State\OptionsInterface; class Operation extends AbstractOperation @@ -84,7 +85,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - ?array $parameters = null, + null|array|Parameters $parameters = null, array $extraProperties = [] ) { parent::__construct( diff --git a/src/Metadata/GraphQl/Query.php b/src/Metadata/GraphQl/Query.php index 150ca28d3da..1cf453aa007 100644 --- a/src/Metadata/GraphQl/Query.php +++ b/src/Metadata/GraphQl/Query.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata\GraphQl; +use ApiPlatform\Metadata\Parameters; use ApiPlatform\State\OptionsInterface; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] @@ -68,7 +69,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - ?array $parameters = null, + null|array|Parameters $parameters = null, array $extraProperties = [], protected ?bool $nested = null, diff --git a/src/Metadata/GraphQl/QueryCollection.php b/src/Metadata/GraphQl/QueryCollection.php index ddb68a87d3e..82a4d6933d2 100644 --- a/src/Metadata/GraphQl/QueryCollection.php +++ b/src/Metadata/GraphQl/QueryCollection.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Metadata\GraphQl; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Parameters; use ApiPlatform\State\OptionsInterface; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] @@ -69,7 +70,7 @@ public function __construct( $provider = null, $processor = null, protected ?OptionsInterface $stateOptions = null, - ?array $parameters = null, + null|array|Parameters $parameters = null, array $extraProperties = [], ?bool $nested = null, diff --git a/src/Metadata/GraphQl/Subscription.php b/src/Metadata/GraphQl/Subscription.php index 8eb53c386b5..708dfe9099c 100644 --- a/src/Metadata/GraphQl/Subscription.php +++ b/src/Metadata/GraphQl/Subscription.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata\GraphQl; +use ApiPlatform\Metadata\Parameters; use ApiPlatform\State\OptionsInterface; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] @@ -68,7 +69,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - ?array $parameters = null, + null|array|Parameters $parameters = null, array $extraProperties = [], ) { parent::__construct( diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 9c717dfd483..9ba8b2e0f68 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -200,7 +200,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - ?array $parameters = null, + null|array|Parameters $parameters = null, array $extraProperties = [], ) { parent::__construct( diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index 1b1f8e8e759..4cbe4dee2aa 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -30,7 +30,7 @@ abstract class Metadata * @param mixed|null $output * @param mixed|null $provider * @param mixed|null $processor - * @param array $parameters + * @param Parameters|array $parameters */ public function __construct( protected ?string $shortName = null, @@ -73,7 +73,7 @@ public function __construct( /** * @experimental */ - protected ?array $parameters = [], + protected null|array|Parameters $parameters = [], protected array $extraProperties = [] ) { } @@ -574,12 +574,12 @@ public function withStateOptions(?OptionsInterface $stateOptions): static /** * @return array */ - public function getParameters(): ?array + public function getParameters(): null|array|Parameters { return $this->parameters; } - public function withParameters(array $parameters): static + public function withParameters(array|Parameters $parameters): static { $self = clone $this; $self->parameters = $parameters; diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php index fb6f0f53afb..29622429d15 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -806,7 +806,7 @@ public function __construct( protected $provider = null, protected $processor = null, protected ?OptionsInterface $stateOptions = null, - protected ?array $parameters = [], + protected null|array|Parameters $parameters = [], protected array $extraProperties = [], ) { parent::__construct( diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index 011bb4b2131..832f840a83f 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -36,6 +36,7 @@ public function __construct( protected ?string $property = null, protected ?string $description = null, protected ?bool $required = null, + protected ?int $priority = null, protected array $extraProperties = [], ) { } @@ -83,6 +84,11 @@ public function getRequired(): ?bool return $this->required; } + public function getPriority(): ?int + { + return $this->priority; + } + /** * @return array */ @@ -99,6 +105,14 @@ public function withKey(string $key): static return $self; } + public function withPriority(int $priority): static + { + $self = clone $this; + $self->priority = $priority; + + return $self; + } + /** * @param array{type?: string} $schema */ diff --git a/src/Metadata/Parameters.php b/src/Metadata/Parameters.php new file mode 100644 index 00000000000..f0b6afdbf8d --- /dev/null +++ b/src/Metadata/Parameters.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +/** + * An parameter dictionnary. + */ +final class Parameters implements \IteratorAggregate, \Countable +{ + private array $parameters = []; + + /** + * @param array $parameters + */ + public function __construct(array $parameters = []) + { + foreach ($parameters as $parameterName => $parameter) { + if ($parameter->getKey()) { + $parameterName = $parameter->getKey(); + } + + $this->parameters[] = [$parameterName, $parameter]; + } + + $this->sort(); + } + + public function getIterator(): \Traversable + { + return (function (): \Generator { + foreach ($this->parameters as [$parameterName, $parameter]) { + yield $parameterName => $parameter; + } + })(); + } + + public function add(string $key, Parameter $value): self + { + foreach ($this->parameters as $i => [$parameterName, $parameter]) { + if ($parameterName === $key) { + $this->parameters[$i] = [$key, $value]; + + return $this; + } + } + + $this->parameters[] = [$key, $value]; + + return $this; + } + + public function remove(string $key): self + { + foreach ($this->parameters as $i => [$parameterName, $parameter]) { + if ($parameterName === $key) { + unset($this->parameters[$i]); + + return $this; + } + } + + throw new \RuntimeException(sprintf('Could not remove parameter "%s".', $key)); + } + + public function has(string $key): bool + { + foreach ($this->parameters as $i => [$parameterName, $parameter]) { + if ($parameterName === $key) { + return true; + } + } + + return false; + } + + public function count(): int + { + return \count($this->parameters); + } + + public function sort(): self + { + usort($this->parameters, fn ($a, $b): int|float => $b[1]->getPriority() - $a[1]->getPriority()); + + return $this; + } +} diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index 288ce47c136..08f652bdc88 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -94,7 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - ?array $parameters = null, + null|array|Parameters $parameters = null, array $extraProperties = [], ) { parent::__construct( diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index 5c9ec3e6732..9067169909c 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -94,7 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - ?array $parameters = null, + null|array|Parameters $parameters = null, array $extraProperties = [], private ?string $itemUriTemplate = null ) { diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index b9cc5201222..f3b49734fc1 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -94,7 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - ?array $parameters = null, + null|array|Parameters $parameters = null, array $extraProperties = [], private ?bool $allowCreate = null, ) { diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 6559f0d69dd..af5f952e116 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -15,9 +15,11 @@ use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\HeaderParameterInterface; +use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\OpenApi; use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithParameter; use Psr\Container\ContainerInterface; /** @@ -36,6 +38,7 @@ public function create(string $resourceClass): ResourceMetadataCollection foreach ($resourceMetadataCollection as $i => $resource) { $operations = $resource->getOperations(); + $internalPriority = -1; foreach ($operations as $operationName => $operation) { $parameters = []; foreach ($operation->getParameters() ?? [] as $key => $parameter) { @@ -48,54 +51,45 @@ public function create(string $resourceClass): ResourceMetadataCollection $filter = $this->filterLocator->get($filter); } - if (!$filter instanceof FilterInterface) { - $parameters[$key] = $parameter; - continue; - } - if ($filter instanceof SerializerFilterInterface && null === $parameter->getProvider()) { $parameter = $parameter->withProvider('api_platform.serializer.filter_parameter_provider'); } // Read filter description to populate the Parameter - $description = $filter->getDescription($resourceClass); - if (($schema = $description['schema'] ?? []) && null === $parameter->getSchema()) { + $description = $filter instanceof FilterInterface ? $filter->getDescription($resourceClass) : []; + if (($schema = $description['schema'] ?? null) && null === $parameter->getSchema()) { $parameter = $parameter->withSchema($schema); } - if (!($openApi = $description['openapi'] ?? null) && null === $parameter->getOpenApi()) { - $parameters[$key] = $parameter; - continue; - } - - if ($openApi instanceof OpenApi\Model\Parameter) { - $parameter = $parameter->withOpenApi($openApi); - $parameters[$key] = $parameter; - continue; + if (null === $parameter->getOpenApi() && $openApi = $description['openapi'] ?? null) { + if ($openApi instanceof OpenApi\Model\Parameter) { + $parameter = $parameter->withOpenApi($openApi); + } + + if (\is_array($openApi)) { + $parameter->withOpenApi(new OpenApi\Model\Parameter( + $key, + $parameter instanceof HeaderParameterInterface ? 'header' : 'query', + $description['description'] ?? '', + $description['required'] ?? $openApi['required'] ?? false, + $openApi['deprecated'] ?? false, + $openApi['allowEmptyValue'] ?? true, + $schema, + $openApi['style'] ?? null, + $openApi['explode'] ?? ('array' === ($schema['type'] ?? null)), + $openApi['allowReserved'] ?? false, + $openApi['example'] ?? null, + isset($openApi['examples'] + ) ? new \ArrayObject($openApi['examples']) : null + )); + } } - if (\is_array($openApi)) { - $parameters[] = new OpenApi\Model\Parameter( - $key, - $parameter instanceof HeaderParameterInterface ? 'header' : 'query', - $description['description'] ?? '', - $description['required'] ?? $openApi['required'] ?? false, - $openApi['deprecated'] ?? false, - $openApi['allowEmptyValue'] ?? true, - $schema, - $openApi['style'] ?? null, - $openApi['explode'] ?? ('array' === ($schema['type'] ?? null)), - $openApi['allowReserved'] ?? false, - $openApi['example'] ?? null, - isset($openApi['examples'] - ) ? new \ArrayObject($openApi['examples']) : null - ); - } - - $parameters[$key] = $parameter; + $priority = $parameter->getPriority() ?? $internalPriority--; + $parameters[$key] = $parameter->withPriority($priority); } - $operations->add($operationName, $operation->withParameters($parameters)); + $operations->add($operationName, $operation->withParameters(new Parameters($parameters))); } $resourceMetadataCollection[$i] = $resource->withOperations($operations->sort()); @@ -103,4 +97,25 @@ public function create(string $resourceClass): ResourceMetadataCollection return $resourceMetadataCollection; } + + /** + * @return Iterable + */ + private function getIterator(null|array|\SplPriorityQueue $parameters): Iterable { + if (!$parameters) { + return []; + } + + if (is_array($parameters)) { + foreach ($parameters as $key => $parameter) { + yield $key => $parameter; + } + + return $parameters; + } + + foreach ($parameters as $priority => $parameter) { + yield $parameter->getKey() => $parameter->withPriority($priority); + } + } } diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index b059d9c183d..4534ec429f7 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -308,9 +308,9 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $openapiParameters = $openapiOperation->getParameters(); - foreach ($operation->getParameters() ?? [] as $key => $p) { + foreach ($operation->getParameters() ?? [] as $p) { $in = $p instanceof HeaderParameterInterface ? 'header' : 'query'; - $parameter = new Parameter($key, $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); + $parameter = new Parameter($p->getKey(), $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); if ($linkParameter = $p->getOpenApi()) { $parameter = $this->mergeParameter($parameter, $linkParameter); diff --git a/src/State/Provider/ParameterProvider.php b/src/State/Provider/ParameterProvider.php index a4681d77539..1da3f623036 100644 --- a/src/State/Provider/ParameterProvider.php +++ b/src/State/Provider/ParameterProvider.php @@ -47,8 +47,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $context = ['operation' => $operation] + $context; - - foreach ($operation->getParameters() ?? [] as $key => $parameter) { + foreach ($operation->getParameters() ?? [] as $parameter) { + $key = $parameter->getKey(); if (null === ($provider = $parameter->getProvider())) { continue; } diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml index ad0d77a5121..0a6228a4e77 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -120,6 +120,13 @@ + + + + + + + diff --git a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php index 93534d8ba07..638095fbcc4 100644 --- a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -25,7 +25,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[Get( - uriTemplate: 'with_parameters/{id}', + uriTemplate: 'with_parameters/{id}{._format}', uriVariables: [ 'id' => new Link(schema: ['type' => 'uuid'], property: 'id'), ], @@ -35,6 +35,8 @@ 'properties' => new QueryParameter(filter: 'my_dummy.property'), 'service' => new QueryParameter(provider: CustomGroupParameterProvider::class), 'auth' => new HeaderParameter(provider: [self::class, 'restrictAccess']), + 'priority' => new QueryParameter(provider: [self::class, 'assertSecond'], priority: 10), + 'priorityb' => new QueryParameter(provider: [self::class, 'assertFirst'], priority: 20), ], provider: [self::class, 'provide'] )] @@ -47,6 +49,7 @@ )] class WithParameter { + private static int $counter = 1; public int $id = 1; #[Groups(['a'])] @@ -64,6 +67,17 @@ public static function provide() return new self(); } + public static function assertFirst() + { + assert(static::$counter === 1); + static::$counter++; + } + + public static function assertSecond() + { + assert(static::$counter === 2); + } + public static function provideGroup(Parameter $parameter, array $parameters = [], array $context = []) { $operation = $context['operation']; diff --git a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php new file mode 100644 index 00000000000..7a29adcd9aa --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php @@ -0,0 +1,42 @@ + new QueryParameter(filter: 'app_search_filter_via_parameter', property: 'foo'), + ] +)] +#[ORM\Entity] +class SearchFilterParameter +{ + /** + * @var int The id + */ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + #[ORM\Column(type: 'string')] + private string $foo = ''; + + public function getId(): ?int + { + return $this->id; + } + + public function getFoo(): string + { + return $this->foo; + } + + public function setFoo(string $foo): void + { + $this->foo = $foo; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 7bd2b6b82a5..332099bc075 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -450,3 +450,8 @@ services: tags: - name: 'api_platform.parameter_provider' key: 'ApiPlatform\Tests\Fixtures\TestBundle\Parameter\CustomGroupParameterProvider' + + app_search_filter_via_parameter: + parent: 'api_platform.doctrine.orm.search_filter' + arguments: [ { foo: 'exact' } ] + tags: [ { name: 'api_platform.filter', id: 'app_search_filter_via_parameter' } ] diff --git a/tests/Parameter/ParameterTests.php b/tests/Parameter/ParameterTests.php index e6b0e4deb63..f472bed1749 100644 --- a/tests/Parameter/ParameterTests.php +++ b/tests/Parameter/ParameterTests.php @@ -14,6 +14,8 @@ namespace ApiPlatform\Tests\Parameter; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SearchFilterParameter; +use Doctrine\ORM\Tools\SchemaTool; final class ParameterTests extends ApiTestCase { @@ -59,4 +61,46 @@ public function testHydraTemplate(): void ], ]], $response->toArray()); } + + public function testDoctrineEntitySearchFilter(): void + { + $this->recreateSchema(); + $registry = $this->getContainer()->get('doctrine'); + $entityManager = $registry->getManagerForClass(SearchFilterParameter::class); + + foreach (['foo', 'foo', 'foo', 'bar', 'bar'] as $t) { + $s = new SearchFilterParameter(); + $s->setFoo($t); + $entityManager->persist($s); + } + $entityManager->flush(); + + $response = self::createClient()->request('GET', 'search_filter_parameter?search=bar'); + $a = $response->toArray(); + $this->assertCount(2, $a['hydra:member']); + $this->assertEquals('bar', $a['hydra:member'][0]['foo']); + $this->assertEquals('bar', $a['hydra:member'][1]['foo']); + + $this->assertArraySubset(['hydra:search' => [ + 'hydra:template' => '/search_filter_parameter{?search}', + 'hydra:mapping' => [ + ['@type' => 'IriTemplateMapping', 'variable' => 'search', 'property' => 'foo'], + ], + ]], $a); + } + + private function recreateSchema(array $options = []): void + { + self::bootKernel($options); + + /** @var EntityManagerInterface $manager */ + $manager = static::getContainer()->get('doctrine')->getManager(); + /** @var ClassMetadata[] $classes */ + $classes = $manager->getMetadataFactory()->getAllMetadata(); + $schemaTool = new SchemaTool($manager); + + @$schemaTool->dropSchema($classes); + @$schemaTool->createSchema($classes); + } + } From 04158c2bdf7a8feb8195260461040d6af659b7da Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 18 Mar 2024 18:37:22 +0100 Subject: [PATCH 13/20] backup --- .../Orm/Extension/ParameterExtension.php | 7 ++-- .../CollectionFiltersNormalizer.php | 22 +++++++++--- src/Metadata/ApiResource.php | 2 +- src/Metadata/Delete.php | 2 +- .../Extractor/XmlResourceExtractor.php | 2 +- .../Extractor/YamlResourceExtractor.php | 4 +-- src/Metadata/Get.php | 2 +- src/Metadata/GetCollection.php | 2 +- src/Metadata/GraphQl/Operation.php | 2 +- src/Metadata/GraphQl/Query.php | 2 +- src/Metadata/GraphQl/QueryCollection.php | 2 +- src/Metadata/GraphQl/Subscription.php | 2 +- src/Metadata/HttpOperation.php | 2 +- src/Metadata/Metadata.php | 22 ++++++------ src/Metadata/Operation.php | 2 +- src/Metadata/Parameter.php | 2 +- src/Metadata/Parameters.php | 5 +++ src/Metadata/Patch.php | 2 +- src/Metadata/Post.php | 2 +- src/Metadata/Put.php | 2 +- ...meterResourceMetadataCollectionFactory.php | 30 +++------------- .../Tests/Extractor/XmlExtractorTest.php | 2 +- .../Tests/Extractor/YamlExtractorTest.php | 2 +- src/OpenApi/Factory/OpenApiFactory.php | 4 +-- .../TestBundle/ApiResource/WithParameter.php | 17 +++++---- .../Entity/SearchFilterParameter.php | 16 +++++++-- tests/Fixtures/app/AppKernel.php | 4 +-- tests/Fixtures/app/config/config_common.yml | 5 --- tests/Fixtures/app/config/config_doctrine.yml | 10 ++++++ tests/Parameter/ParameterTests.php | 36 ++++++++++++------- tests/Symfony/Bundle/Test/ClientTest.php | 2 +- 31 files changed, 124 insertions(+), 94 deletions(-) diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php index ba39b4a7765..20f28987b1f 100644 --- a/src/Doctrine/Orm/Extension/ParameterExtension.php +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -14,7 +14,6 @@ namespace ApiPlatform\Doctrine\Orm\Extension; use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; -use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Exception\InvalidArgumentException; use ApiPlatform\Metadata\HeaderParameterInterface; @@ -31,7 +30,8 @@ public function __construct(private readonly ContainerInterface $filterLocator) { } - private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []) { + private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void + { if (!($request = $context['request'] ?? null)) { return; } @@ -51,9 +51,10 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter continue; } + $value = $parameters[$key]; $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; if ($filter instanceof FilterInterface) { - $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => [$parameter->getProperty() ?? $key => $parameters[$key]]] + $context); + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => [$key => $value]] + $context); } } } diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index 54f6cbcd93c..621baccf041 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -128,7 +128,7 @@ public function normalize(mixed $object, ?string $format = null, array $context } } - if ($currentFilters || $parameters) { + if ($currentFilters || ($parameters && \count($parameters))) { $data['hydra:search'] = $this->getSearch($resourceClass, $requestParts, $currentFilters, $parameters); } @@ -151,7 +151,7 @@ public function setNormalizer(NormalizerInterface $normalizer): void * @param LegacyFilterInterface[]|FilterInterface[] $filters * @param array $parameters */ - private function getSearch(string $resourceClass, array $parts, array $filters, null|array|Parameters $parameters): array + private function getSearch(string $resourceClass, array $parts, array $filters, array|Parameters|null $parameters): array { $variables = []; $mapping = []; @@ -164,12 +164,26 @@ private function getSearch(string $resourceClass, array $parts, array $filters, foreach ($parameters ?? [] as $key => $parameter) { // Each IriTemplateMapping maps a variable used in the template to a property - if (!$parameter instanceof QueryParameterInterface || !($property = $parameter->getProperty())) { + if (!$parameter instanceof QueryParameterInterface) { + continue; + } + + if (!($property = $parameter->getProperty()) && ($filterId = $parameter->getFilter())) { + $filter = $this->getFilter($filterId); + foreach ($filter->getDescription($resourceClass) as $variable => $description) { + $variables[] = $variable; + $m = ['@type' => 'IriTemplateMapping', 'variable' => $variable, 'property' => $description['property'], 'required' => $description['required']]; + if (null !== ($required = $parameter->getRequired())) { + $m['required'] = $required; + } + $mapping[] = $m; + } + continue; } - $variables[] = $key; $m = ['@type' => 'IriTemplateMapping', 'variable' => $key, 'property' => $property]; + $variables[] = $key; if (null !== ($required = $parameter->getRequired())) { $m['required'] = $required; } diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index e519ddf1c60..12072f11263 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -960,7 +960,7 @@ public function __construct( $provider = null, $processor = null, protected ?OptionsInterface $stateOptions = null, - protected null|array|Parameters $parameters = null, + protected array|Parameters|null $parameters = null, protected array $extraProperties = [], ) { parent::__construct( diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index 47b9367e182..8cd4bcc9c2b 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -94,7 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - null|array|Parameters $parameters = null, + array|Parameters|null $parameters = null, array $extraProperties = [], ) { parent::__construct( diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 48c71dde349..e92785331a9 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -535,7 +535,7 @@ private function buildParameters(\SimpleXMLElement $resource): ?array property: $this->phpize($parameter, 'property', 'string'), description: $this->phpize($parameter, 'description', 'string'), priority: $this->phpize($parameter, 'priority', 'integer'), - extraProperties: $this->buildExtraProperties($parameter, 'extraProperties'), + extraProperties: $this->buildExtraProperties($parameter, 'extraProperties') ?? [], ); } diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index 089b0f643e2..da539bea856 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -470,7 +470,7 @@ private function buildParameters(array $resource): ?array key: $key, required: $this->phpize($parameter, 'required', 'bool'), schema: $parameter['schema'], - openapi: ($parameter['openapi'] ?? null) ? new Parameter( + openApi: ($parameter['openapi'] ?? null) ? new Parameter( name: $parameter['openapi']['name'], in: $parameter['in'] ?? 'query', description: $parameter['openapi']['description'] ?? '', @@ -490,7 +490,7 @@ private function buildParameters(array $resource): ?array property: $this->phpize($parameter, 'property', 'string'), description: $this->phpize($parameter, 'description', 'string'), priority: $this->phpize($parameter, 'priority', 'integer'), - extraProperties: $this->buildArrayValue($resource, 'extraProperties'), + extraProperties: $this->buildArrayValue($parameter, 'extraProperties') ?? [], ); } diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 22e343f6524..50d51adf282 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -94,7 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - null|array|Parameters $parameters = null, + array|Parameters|null $parameters = null, array $extraProperties = [], ) { parent::__construct( diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 1f139a3a568..d6e48716e65 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -94,7 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - null|array|Parameters $parameters = null, + array|Parameters|null $parameters = null, array $extraProperties = [], private ?string $itemUriTemplate = null, ) { diff --git a/src/Metadata/GraphQl/Operation.php b/src/Metadata/GraphQl/Operation.php index a8db6a989fa..5b3aa7338b8 100644 --- a/src/Metadata/GraphQl/Operation.php +++ b/src/Metadata/GraphQl/Operation.php @@ -85,7 +85,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - null|array|Parameters $parameters = null, + array|Parameters|null $parameters = null, array $extraProperties = [] ) { parent::__construct( diff --git a/src/Metadata/GraphQl/Query.php b/src/Metadata/GraphQl/Query.php index 1cf453aa007..16f9ee66b39 100644 --- a/src/Metadata/GraphQl/Query.php +++ b/src/Metadata/GraphQl/Query.php @@ -69,7 +69,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - null|array|Parameters $parameters = null, + array|Parameters|null $parameters = null, array $extraProperties = [], protected ?bool $nested = null, diff --git a/src/Metadata/GraphQl/QueryCollection.php b/src/Metadata/GraphQl/QueryCollection.php index 82a4d6933d2..a78c0ed7e41 100644 --- a/src/Metadata/GraphQl/QueryCollection.php +++ b/src/Metadata/GraphQl/QueryCollection.php @@ -70,7 +70,7 @@ public function __construct( $provider = null, $processor = null, protected ?OptionsInterface $stateOptions = null, - null|array|Parameters $parameters = null, + array|Parameters|null $parameters = null, array $extraProperties = [], ?bool $nested = null, diff --git a/src/Metadata/GraphQl/Subscription.php b/src/Metadata/GraphQl/Subscription.php index 708dfe9099c..9ae13d28511 100644 --- a/src/Metadata/GraphQl/Subscription.php +++ b/src/Metadata/GraphQl/Subscription.php @@ -69,7 +69,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - null|array|Parameters $parameters = null, + array|Parameters|null $parameters = null, array $extraProperties = [], ) { parent::__construct( diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 9ba8b2e0f68..99373140d26 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -200,7 +200,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - null|array|Parameters $parameters = null, + array|Parameters|null $parameters = null, array $extraProperties = [], ) { parent::__construct( diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index 4cbe4dee2aa..1e56e602641 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -21,15 +21,15 @@ abstract class Metadata { /** - * @param string|null $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties - * @param string|\Stringable|null $security https://api-platform.com/docs/core/security - * @param string|\Stringable|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization - * @param mixed|null $mercure - * @param mixed|null $messenger - * @param mixed|null $input - * @param mixed|null $output - * @param mixed|null $provider - * @param mixed|null $processor + * @param string|null $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties + * @param string|\Stringable|null $security https://api-platform.com/docs/core/security + * @param string|\Stringable|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization + * @param mixed|null $mercure + * @param mixed|null $messenger + * @param mixed|null $input + * @param mixed|null $output + * @param mixed|null $provider + * @param mixed|null $processor * @param Parameters|array $parameters */ public function __construct( @@ -73,7 +73,7 @@ public function __construct( /** * @experimental */ - protected null|array|Parameters $parameters = [], + protected array|Parameters|null $parameters = [], protected array $extraProperties = [] ) { } @@ -574,7 +574,7 @@ public function withStateOptions(?OptionsInterface $stateOptions): static /** * @return array */ - public function getParameters(): null|array|Parameters + public function getParameters(): array|Parameters|null { return $this->parameters; } diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php index 29622429d15..45b25dfeff7 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -806,7 +806,7 @@ public function __construct( protected $provider = null, protected $processor = null, protected ?OptionsInterface $stateOptions = null, - protected null|array|Parameters $parameters = [], + protected array|Parameters|null $parameters = [], protected array $extraProperties = [], ) { parent::__construct( diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index 832f840a83f..d0e9bb8c067 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -37,7 +37,7 @@ public function __construct( protected ?string $description = null, protected ?bool $required = null, protected ?int $priority = null, - protected array $extraProperties = [], + protected ?array $extraProperties = [], ) { } diff --git a/src/Metadata/Parameters.php b/src/Metadata/Parameters.php index f0b6afdbf8d..6c1e9902d2a 100644 --- a/src/Metadata/Parameters.php +++ b/src/Metadata/Parameters.php @@ -15,6 +15,8 @@ /** * An parameter dictionnary. + * + * @implements \IteratorAggregate */ final class Parameters implements \IteratorAggregate, \Countable { @@ -36,6 +38,9 @@ public function __construct(array $parameters = []) $this->sort(); } + /** + * @return \ArrayIterator + */ public function getIterator(): \Traversable { return (function (): \Generator { diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index 08f652bdc88..06adec587fa 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -94,7 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - null|array|Parameters $parameters = null, + array|Parameters|null $parameters = null, array $extraProperties = [], ) { parent::__construct( diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index 9067169909c..e79ff5f3cb2 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -94,7 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - null|array|Parameters $parameters = null, + array|Parameters|null $parameters = null, array $extraProperties = [], private ?string $itemUriTemplate = null ) { diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index f3b49734fc1..176ca17fa32 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -94,7 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, - null|array|Parameters $parameters = null, + array|Parameters|null $parameters = null, array $extraProperties = [], private ?bool $allowCreate = null, ) { diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index af5f952e116..77b41c0c4d2 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -19,7 +19,6 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\OpenApi; use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithParameter; use Psr\Container\ContainerInterface; /** @@ -57,11 +56,11 @@ public function create(string $resourceClass): ResourceMetadataCollection // Read filter description to populate the Parameter $description = $filter instanceof FilterInterface ? $filter->getDescription($resourceClass) : []; - if (($schema = $description['schema'] ?? null) && null === $parameter->getSchema()) { + if (($schema = $description[$key]['schema'] ?? null) && null === $parameter->getSchema()) { $parameter = $parameter->withSchema($schema); } - if (null === $parameter->getOpenApi() && $openApi = $description['openapi'] ?? null) { + if (null === $parameter->getOpenApi() && $openApi = $description[$key]['openapi'] ?? null) { if ($openApi instanceof OpenApi\Model\Parameter) { $parameter = $parameter->withOpenApi($openApi); } @@ -70,8 +69,8 @@ public function create(string $resourceClass): ResourceMetadataCollection $parameter->withOpenApi(new OpenApi\Model\Parameter( $key, $parameter instanceof HeaderParameterInterface ? 'header' : 'query', - $description['description'] ?? '', - $description['required'] ?? $openApi['required'] ?? false, + $description[$key]['description'] ?? '', + $description[$key]['required'] ?? $openApi['required'] ?? false, $openApi['deprecated'] ?? false, $openApi['allowEmptyValue'] ?? true, $schema, @@ -97,25 +96,4 @@ public function create(string $resourceClass): ResourceMetadataCollection return $resourceMetadataCollection; } - - /** - * @return Iterable - */ - private function getIterator(null|array|\SplPriorityQueue $parameters): Iterable { - if (!$parameters) { - return []; - } - - if (is_array($parameters)) { - foreach ($parameters as $key => $parameter) { - yield $key => $parameter; - } - - return $parameters; - } - - foreach ($parameters as $priority => $parameter) { - yield $parameter->getKey() => $parameter->withPriority($priority); - } - } } diff --git a/src/Metadata/Tests/Extractor/XmlExtractorTest.php b/src/Metadata/Tests/Extractor/XmlExtractorTest.php index c38dbd802c7..be195c2b976 100644 --- a/src/Metadata/Tests/Extractor/XmlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/XmlExtractorTest.php @@ -386,7 +386,7 @@ public function testValidXML(): void schema: [ 'type' => 'string', ], - extraProperties: [] + extraProperties: ['foo' => 'bar'] ), ], ], diff --git a/src/Metadata/Tests/Extractor/YamlExtractorTest.php b/src/Metadata/Tests/Extractor/YamlExtractorTest.php index 06fa95940b0..ea891fa654a 100644 --- a/src/Metadata/Tests/Extractor/YamlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/YamlExtractorTest.php @@ -405,7 +405,7 @@ public function testValidYaml(): void 'stateOptions' => null, 'links' => null, 'headers' => ['hello' => 'world'], - 'parameters' => ['author' => new QueryParameter(schema: ['type' => 'string'], required: true, key: 'author')], + 'parameters' => ['author' => new QueryParameter(schema: ['type' => 'string'], required: true, key: 'author', description: 'hello')], ], ], 'graphQlOperations' => null, diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 4534ec429f7..b059d9c183d 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -308,9 +308,9 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $openapiParameters = $openapiOperation->getParameters(); - foreach ($operation->getParameters() ?? [] as $p) { + foreach ($operation->getParameters() ?? [] as $key => $p) { $in = $p instanceof HeaderParameterInterface ? 'header' : 'query'; - $parameter = new Parameter($p->getKey(), $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); + $parameter = new Parameter($key, $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); if ($linkParameter = $p->getOpenApi()) { $parameter = $this->mergeParameter($parameter, $linkParameter); diff --git a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php index 638095fbcc4..7b6a210c929 100644 --- a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -37,6 +37,7 @@ 'auth' => new HeaderParameter(provider: [self::class, 'restrictAccess']), 'priority' => new QueryParameter(provider: [self::class, 'assertSecond'], priority: 10), 'priorityb' => new QueryParameter(provider: [self::class, 'assertFirst'], priority: 20), + 'array' => new QueryParameter(provider: [self::class, 'assertArray']), ], provider: [self::class, 'provide'] )] @@ -49,7 +50,7 @@ )] class WithParameter { - private static int $counter = 1; + public static int $counter = 1; public int $id = 1; #[Groups(['a'])] @@ -67,15 +68,19 @@ public static function provide() return new self(); } - public static function assertFirst() + public static function assertArray(): void { - assert(static::$counter === 1); - static::$counter++; } - public static function assertSecond() + public static function assertFirst(): void { - assert(static::$counter === 2); + \assert(1 === static::$counter); + ++static::$counter; + } + + public static function assertSecond(): void + { + \assert(2 === static::$counter); } public static function provideGroup(Parameter $parameter, array $parameters = [], array $context = []) diff --git a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php index 7a29adcd9aa..7d8525cca21 100644 --- a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Metadata\GetCollection; @@ -7,9 +18,10 @@ use Doctrine\ORM\Mapping as ORM; #[GetCollection( - uriTemplate: 'search_filter_parameter', + uriTemplate: 'search_filter_parameter{._format}', parameters: [ - 'search' => new QueryParameter(filter: 'app_search_filter_via_parameter', property: 'foo'), + 'foo' => new QueryParameter(filter: 'app_search_filter_via_parameter'), + 'order' => new QueryParameter(filter: 'app_search_filter_via_parameter.order_filter'), ] )] #[ORM\Entity] diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index 041db0c8101..34e151c88ab 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -124,7 +124,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load 'session' => class_exists(SessionFactory::class) ? ['storage_factory_id' => 'session.storage.factory.mock_file'] + $cookie : ['storage_id' => 'session.storage.mock_file'] + $cookie, 'profiler' => [ 'enabled' => true, - 'collect' => false, + 'collect' => true, ], 'php_errors' => ['log' => true], 'messenger' => $messengerConfig, @@ -143,7 +143,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load 'session' => class_exists(SessionFactory::class) ? ['storage_factory_id' => 'session.storage.factory.mock_file'] : ['storage_id' => 'session.storage.mock_file'], 'profiler' => [ 'enabled' => true, - 'collect' => false, + 'collect' => true, ], 'messenger' => $messengerConfig, 'router' => ['utf8' => true], diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 332099bc075..7bd2b6b82a5 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -450,8 +450,3 @@ services: tags: - name: 'api_platform.parameter_provider' key: 'ApiPlatform\Tests\Fixtures\TestBundle\Parameter\CustomGroupParameterProvider' - - app_search_filter_via_parameter: - parent: 'api_platform.doctrine.orm.search_filter' - arguments: [ { foo: 'exact' } ] - tags: [ { name: 'api_platform.filter', id: 'app_search_filter_via_parameter' } ] diff --git a/tests/Fixtures/app/config/config_doctrine.yml b/tests/Fixtures/app/config/config_doctrine.yml index f6b33631765..929fb6289ec 100644 --- a/tests/Fixtures/app/config/config_doctrine.yml +++ b/tests/Fixtures/app/config/config_doctrine.yml @@ -135,3 +135,13 @@ services: - name: 'api_platform.state_provider' arguments: $itemProvider: '@ApiPlatform\Doctrine\Orm\State\ItemProvider' + + app_search_filter_via_parameter: + parent: 'api_platform.doctrine.orm.search_filter' + arguments: [ { foo: 'exact' } ] + tags: [ { name: 'api_platform.filter', id: 'app_search_filter_via_parameter' } ] + + app_search_filter_via_parameter.order_filter: + parent: 'api_platform.doctrine.orm.order_filter' + arguments: [ { id: 'ASC', foo: 'DESC' } ] + tags: [ 'api_platform.filter' ] diff --git a/tests/Parameter/ParameterTests.php b/tests/Parameter/ParameterTests.php index f472bed1749..86172299326 100644 --- a/tests/Parameter/ParameterTests.php +++ b/tests/Parameter/ParameterTests.php @@ -15,6 +15,7 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SearchFilterParameter; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; final class ParameterTests extends ApiTestCase @@ -64,7 +65,10 @@ public function testHydraTemplate(): void public function testDoctrineEntitySearchFilter(): void { - $this->recreateSchema(); + if (false === $this->recreateSchema()) { + $this->markTestSkipped(); + } + $registry = $this->getContainer()->get('doctrine'); $entityManager = $registry->getManagerForClass(SearchFilterParameter::class); @@ -75,32 +79,38 @@ public function testDoctrineEntitySearchFilter(): void } $entityManager->flush(); - $response = self::createClient()->request('GET', 'search_filter_parameter?search=bar'); + $response = self::createClient()->request('GET', 'search_filter_parameter?foo=bar'); $a = $response->toArray(); $this->assertCount(2, $a['hydra:member']); $this->assertEquals('bar', $a['hydra:member'][0]['foo']); $this->assertEquals('bar', $a['hydra:member'][1]['foo']); $this->assertArraySubset(['hydra:search' => [ - 'hydra:template' => '/search_filter_parameter{?search}', + 'hydra:template' => '/search_filter_parameter{?foo,foo[],order[id],order[foo]}', 'hydra:mapping' => [ - ['@type' => 'IriTemplateMapping', 'variable' => 'search', 'property' => 'foo'], + ['@type' => 'IriTemplateMapping', 'variable' => 'foo', 'property' => 'foo'], ], ]], $a); + + $response = self::createClient()->request('GET', 'search_filter_parameter?order[foo]=asc'); + $this->assertEquals($response->toArray()['hydra:member'][0]['foo'], 'bar'); } - private function recreateSchema(array $options = []): void + /** + * @param array $options kernel options + */ + private function recreateSchema(array $options = []): ?bool { self::bootKernel($options); - /** @var EntityManagerInterface $manager */ - $manager = static::getContainer()->get('doctrine')->getManager(); - /** @var ClassMetadata[] $classes */ - $classes = $manager->getMetadataFactory()->getAllMetadata(); - $schemaTool = new SchemaTool($manager); + $manager = static::getContainer()->get('doctrine')->getManagerForClass(SearchFilterParameter::class); + if (!$manager instanceof EntityManagerInterface) { + return false; + } - @$schemaTool->dropSchema($classes); - @$schemaTool->createSchema($classes); + $classes = $manager->getClassMetadata(SearchFilterParameter::class); + $schemaTool = new SchemaTool($manager); + @$schemaTool->dropSchema([$classes]); + @$schemaTool->createSchema([$classes]); } - } diff --git a/tests/Symfony/Bundle/Test/ClientTest.php b/tests/Symfony/Bundle/Test/ClientTest.php index 8dc41114b2a..6c50d8258cb 100644 --- a/tests/Symfony/Bundle/Test/ClientTest.php +++ b/tests/Symfony/Bundle/Test/ClientTest.php @@ -112,7 +112,7 @@ public static function authBasicProvider(): iterable public function testComplexScenario(): void { - self::createClient()->request('GET', '/secured_dummies', ['auth_basic' => ['dunglas', 'kevin']]); + $r = self::createClient()->request('GET', '/secured_dummies', ['auth_basic' => ['dunglas', 'kevin']]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); From 26d37d2ece4a97697d15dafbed054ed775e6db09 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 23 Mar 2024 09:19:26 +0100 Subject: [PATCH 14/20] make doctrine filters decorable --- .../Orm/Extension/ParameterExtension.php | 6 +++ src/Doctrine/Orm/Filter/AbstractFilter.php | 2 +- .../CollectionFiltersNormalizer.php | 16 ++++++-- src/Metadata/ApiFilter.php | 2 + src/Metadata/FilterInterface.php | 2 + ...ltersResourceMetadataCollectionFactory.php | 9 +++- .../Util/AttributeFilterExtractorTrait.php | 10 ++--- .../Compiler/AttributeFilterPass.php | 6 ++- .../Bundle/Resources/config/doctrine_orm.xml | 22 ++++++++++ .../TestBundle/ApiResource/WithParameter.php | 2 +- .../Entity/SearchFilterParameter.php | 26 +++++++++++- .../Filter/SearchFilterValueTransformer.php | 39 ++++++++++++++++++ .../Filter/SearchTextAndDateFilter.php | 41 +++++++++++++++++++ tests/Parameter/ParameterTests.php | 27 ++++++++++-- 14 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php create mode 100644 tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php index 20f28987b1f..5a934c83152 100644 --- a/src/Doctrine/Orm/Extension/ParameterExtension.php +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -47,6 +47,12 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter } $parameters = $parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters'); + + $parsedKey = explode('[:property]', $key); + if (isset($parsedKey[0]) && isset($parameters[$parsedKey[0]])) { + $key = $parsedKey[0]; + } + if (!isset($parameters[$key])) { continue; } diff --git a/src/Doctrine/Orm/Filter/AbstractFilter.php b/src/Doctrine/Orm/Filter/AbstractFilter.php index c1deb1c0a90..5f6211f692f 100644 --- a/src/Doctrine/Orm/Filter/AbstractFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractFilter.php @@ -29,7 +29,7 @@ abstract class AbstractFilter implements FilterInterface use PropertyHelperTrait; protected LoggerInterface $logger; - public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) + public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, public ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) { $this->logger = $logger ?? new NullLogger(); } diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index 621baccf041..080a2465602 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -168,9 +168,15 @@ private function getSearch(string $resourceClass, array $parts, array $filters, continue; } - if (!($property = $parameter->getProperty()) && ($filterId = $parameter->getFilter())) { - $filter = $this->getFilter($filterId); - foreach ($filter->getDescription($resourceClass) as $variable => $description) { + if (!($property = $parameter->getProperty()) && ($filterId = $parameter->getFilter()) && ($filter = $this->getFilter($filterId))) { + foreach ($filter->getDescription($resourceClass) ?? [] as $variable => $description) { + // This is a practice induced by PHP and is not necessary when implementing URI template + if (str_ends_with((string) $variable, '[]')) { + continue; + } + + $k = str_replace(':property', $description['property'], $key); + $variable = str_replace($description['property'], $k, $variable); $variables[] = $variable; $m = ['@type' => 'IriTemplateMapping', 'variable' => $variable, 'property' => $description['property'], 'required' => $description['required']]; if (null !== ($required = $parameter->getRequired())) { @@ -182,6 +188,10 @@ private function getSearch(string $resourceClass, array $parts, array $filters, continue; } + if (!$property) { + continue; + } + $m = ['@type' => 'IriTemplateMapping', 'variable' => $key, 'property' => $property]; $variables[] = $key; if (null !== ($required = $parameter->getRequired())) { diff --git a/src/Metadata/ApiFilter.php b/src/Metadata/ApiFilter.php index b11e3db09d4..590b996b2da 100644 --- a/src/Metadata/ApiFilter.php +++ b/src/Metadata/ApiFilter.php @@ -26,6 +26,7 @@ final class ApiFilter { /** * @param string|class-string|class-string $filterClass + * @param string $alias a service alias to be referenced in a Parameter */ public function __construct( public string $filterClass, @@ -33,6 +34,7 @@ public function __construct( public ?string $strategy = null, public array $properties = [], public array $arguments = [], + public ?string $alias = null, ) { if (!is_a($this->filterClass, FilterInterface::class, true) && !is_a($this->filterClass, LegacyFilterInterface::class, true)) { throw new InvalidArgumentException(sprintf('The filter class "%s" does not implement "%s". Did you forget a use statement?', $this->filterClass, FilterInterface::class)); diff --git a/src/Metadata/FilterInterface.php b/src/Metadata/FilterInterface.php index 51ccea3521f..7f4efaf231f 100644 --- a/src/Metadata/FilterInterface.php +++ b/src/Metadata/FilterInterface.php @@ -63,6 +63,8 @@ interface FilterInterface * The description can contain additional data specific to a filter. * * @see \ApiPlatform\OpenApi\Factory\OpenApiFactory::getFiltersParameters + * + * @return array, schema: array}> */ public function getDescription(string $resourceClass): array; } diff --git a/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php index bfd2069e415..6ec1d31f2df 100644 --- a/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php @@ -47,7 +47,14 @@ public function create(string $resourceClass): ResourceMetadataCollection throw new ResourceClassNotFoundException(sprintf('Resource "%s" not found.', $resourceClass)); } - $filters = array_keys($this->readFilterAttributes($reflectionClass)); + $classFilters = $this->readFilterAttributes($reflectionClass); + $filters = []; + + foreach ($classFilters as $id => [$args, $filterClass, $attribute]) { + if (!$attribute->alias) { + $filters[] = $id; + } + } foreach ($resourceMetadataCollection as $i => $resource) { foreach ($operations = $resource->getOperations() ?? [] as $operationName => $operation) { diff --git a/src/Metadata/Util/AttributeFilterExtractorTrait.php b/src/Metadata/Util/AttributeFilterExtractorTrait.php index 70bf390fb0a..69cac537f94 100644 --- a/src/Metadata/Util/AttributeFilterExtractorTrait.php +++ b/src/Metadata/Util/AttributeFilterExtractorTrait.php @@ -75,7 +75,7 @@ private function getFilterProperties(ApiFilter $filterAttribute, \ReflectionClas /** * Reads filter attribute from a ReflectionClass. * - * @return array Key is the filter id. It has two values, properties and the ApiFilter instance + * @return array, class-string, ApiFilter}> indexed by the filter id, the filter tuple has the filter arguments, the filter class and the ApiFilter attribute instance */ private function readFilterAttributes(\ReflectionClass $reflectionClass): array { @@ -83,10 +83,10 @@ private function readFilterAttributes(\ReflectionClass $reflectionClass): array foreach ($this->getFilterAttributes($reflectionClass) as $filterAttribute) { $filterClass = $filterAttribute->filterClass; - $id = $this->generateFilterId($reflectionClass, $filterClass, $filterAttribute->id); + $id = $this->generateFilterId($reflectionClass, $filterClass, $filterAttribute->id ?? $filterAttribute->alias); if (!isset($filters[$id])) { - $filters[$id] = [$filterAttribute->arguments, $filterClass]; + $filters[$id] = [$filterAttribute->arguments, $filterClass, $filterAttribute]; } if ($properties = $this->getFilterProperties($filterAttribute, $reflectionClass)) { @@ -97,10 +97,10 @@ private function readFilterAttributes(\ReflectionClass $reflectionClass): array foreach ($reflectionClass->getProperties() as $reflectionProperty) { foreach ($this->getFilterAttributes($reflectionProperty) as $filterAttribute) { $filterClass = $filterAttribute->filterClass; - $id = $this->generateFilterId($reflectionClass, $filterClass, $filterAttribute->id); + $id = $this->generateFilterId($reflectionClass, $filterClass, $filterAttribute->id ?? $filterAttribute->alias); if (!isset($filters[$id])) { - $filters[$id] = [$filterAttribute->arguments, $filterClass]; + $filters[$id] = [$filterAttribute->arguments, $filterClass, $filterAttribute]; } if ($properties = $this->getFilterProperties($filterAttribute, $reflectionClass, $reflectionProperty)) { diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php index ed9b98c4c45..866ffafb56d 100644 --- a/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php @@ -52,7 +52,7 @@ public function process(ContainerBuilder $container): void */ private function createFilterDefinitions(\ReflectionClass $resourceReflectionClass, ContainerBuilder $container): void { - foreach ($this->readFilterAttributes($resourceReflectionClass) as $id => [$arguments, $filterClass]) { + foreach ($this->readFilterAttributes($resourceReflectionClass) as $id => [$arguments, $filterClass, $filterAttribute]) { if ($container->has($id)) { continue; } @@ -69,6 +69,10 @@ private function createFilterDefinitions(\ReflectionClass $resourceReflectionCla } $definition->addTag(self::TAG_FILTER_NAME); + if ($filterAttribute->alias) { + $definition->addTag(self::TAG_FILTER_NAME, ['id' => $filterAttribute->alias]); + } + $definition->setAutowired(true); $parameterNames = []; diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml index 0a6228a4e77..3f9b0d0350e 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -35,6 +35,9 @@ %api_platform.collection.order_nulls_comparison% + + + @@ -42,6 +45,9 @@ + + + @@ -49,6 +55,9 @@ + + + @@ -56,6 +65,9 @@ + + + @@ -63,6 +75,9 @@ + + + @@ -71,6 +86,9 @@ + + + @@ -166,6 +184,10 @@ + + + + diff --git a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php index 7b6a210c929..dbc9c20c75f 100644 --- a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -50,7 +50,7 @@ )] class WithParameter { - public static int $counter = 1; + protected static int $counter = 1; public int $id = 1; #[Groups(['a'])] diff --git a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php index 7d8525cca21..d3d16a06707 100644 --- a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php @@ -13,17 +13,28 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; +use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SearchFilterValueTransformer; +use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SearchTextAndDateFilter; use Doctrine\ORM\Mapping as ORM; #[GetCollection( uriTemplate: 'search_filter_parameter{._format}', parameters: [ 'foo' => new QueryParameter(filter: 'app_search_filter_via_parameter'), - 'order' => new QueryParameter(filter: 'app_search_filter_via_parameter.order_filter'), + 'order[:property]' => new QueryParameter(filter: 'app_search_filter_via_parameter.order_filter'), + + 'searchPartial[:property]' => new QueryParameter(filter: 'app_search_filter_partial'), + 'searchExact[:property]' => new QueryParameter(filter: 'app_search_filter_with_exact'), + 'searchOnTextAndDate[:property]' => new QueryParameter(filter: 'app_filter_date_and_search'), + 'q' => new QueryParameter(property: 'hydra:freetextQuery'), ] )] +#[ApiFilter(SearchFilterValueTransformer::class, alias: 'app_search_filter_partial', properties: ['foo' => 'partial'], arguments: ['key' => 'searchPartial'])] +#[ApiFilter(SearchFilterValueTransformer::class, alias: 'app_search_filter_with_exact', properties: ['foo' => 'exact'], arguments: ['key' => 'searchExact'])] +#[ApiFilter(SearchTextAndDateFilter::class, alias: 'app_filter_date_and_search', properties: ['foo', 'createdAt'], arguments: ['dateFilterProperties' => ['createdAt' => 'exclude_null'], 'searchFilterProperties' => ['foo' => 'exact']])] #[ORM\Entity] class SearchFilterParameter { @@ -37,6 +48,9 @@ class SearchFilterParameter #[ORM\Column(type: 'string')] private string $foo = ''; + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + private ?\DateTimeImmutable $createdAt = null; + public function getId(): ?int { return $this->id; @@ -51,4 +65,14 @@ public function setFoo(string $foo): void { $this->foo = $foo; } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } } diff --git a/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php new file mode 100644 index 00000000000..e97ddf04d00 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +final class SearchFilterValueTransformer implements FilterInterface +{ + public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, ?array $properties = null, private readonly ?string $key = null) + { + $searchFilter->properties = $properties; + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return $this->searchFilter->getDescription($resourceClass); + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $this->searchFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters'][$this->key]] + $context); + } +} diff --git a/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php new file mode 100644 index 00000000000..63567849698 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +final class SearchTextAndDateFilter implements FilterInterface +{ + public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, #[Autowire('@api_platform.doctrine.orm.date_filter.instance')] readonly FilterInterface $dateFilter, ?array $properties = null, array $dateFilterProperties = [], array $searchFilterProperties = []) + { + $searchFilter->properties = $searchFilterProperties; + $dateFilter->properties = $dateFilterProperties; + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return array_merge($this->searchFilter->getDescription($resourceClass), $this->dateFilter->getDescription($resourceClass)); + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $this->searchFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters']['searchOnTextAndDate']] + $context); + $this->dateFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters']['searchOnTextAndDate']] + $context); + } +} diff --git a/tests/Parameter/ParameterTests.php b/tests/Parameter/ParameterTests.php index 86172299326..b04b5704691 100644 --- a/tests/Parameter/ParameterTests.php +++ b/tests/Parameter/ParameterTests.php @@ -72,21 +72,27 @@ public function testDoctrineEntitySearchFilter(): void $registry = $this->getContainer()->get('doctrine'); $entityManager = $registry->getManagerForClass(SearchFilterParameter::class); - foreach (['foo', 'foo', 'foo', 'bar', 'bar'] as $t) { + $date = new \DateTimeImmutable('2024-01-21'); + foreach (['foo', 'foo', 'foo', 'bar', 'bar', 'baz'] as $t) { $s = new SearchFilterParameter(); $s->setFoo($t); + if ('bar' === $t) { + $s->setCreatedAt($date); + $date = new \DateTimeImmutable('2024-01-22'); + } + $entityManager->persist($s); } $entityManager->flush(); $response = self::createClient()->request('GET', 'search_filter_parameter?foo=bar'); - $a = $response->toArray(); + $a = $response->toArray(false); $this->assertCount(2, $a['hydra:member']); $this->assertEquals('bar', $a['hydra:member'][0]['foo']); $this->assertEquals('bar', $a['hydra:member'][1]['foo']); $this->assertArraySubset(['hydra:search' => [ - 'hydra:template' => '/search_filter_parameter{?foo,foo[],order[id],order[foo]}', + 'hydra:template' => '/search_filter_parameter{?foo,order[order[id]],order[order[foo]],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],q}', 'hydra:mapping' => [ ['@type' => 'IriTemplateMapping', 'variable' => 'foo', 'property' => 'foo'], ], @@ -94,6 +100,19 @@ public function testDoctrineEntitySearchFilter(): void $response = self::createClient()->request('GET', 'search_filter_parameter?order[foo]=asc'); $this->assertEquals($response->toArray()['hydra:member'][0]['foo'], 'bar'); + + $response = self::createClient()->request('GET', 'search_filter_parameter?order[foo]=asc'); + $this->assertEquals($response->toArray()['hydra:member'][0]['foo'], 'bar'); + + $response = self::createClient()->request('GET', 'search_filter_parameter?searchPartial[foo]=az'); + $members = $response->toArray()['hydra:member']; + $this->assertCount(1, $members); + $this->assertArraySubset(['foo' => 'baz'], $members[0]); + + $response = self::createClient()->request('GET', 'search_filter_parameter?searchOnTextAndDate[foo]=bar&searchOnTextAndDate[createdAt][before]=2024-01-21'); + $members = $response->toArray()['hydra:member']; + $this->assertCount(1, $members); + $this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $members[0]); } /** @@ -112,5 +131,7 @@ private function recreateSchema(array $options = []): ?bool $schemaTool = new SchemaTool($manager); @$schemaTool->dropSchema([$classes]); @$schemaTool->createSchema([$classes]); + + return null; } } From 23b0b6e36c07537d238b636a079a781b3b2b69ba Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 23 Mar 2024 10:24:46 +0100 Subject: [PATCH 15/20] add comments --- src/Doctrine/Orm/Extension/ParameterExtension.php | 3 ++- src/Doctrine/Orm/Filter/AbstractFilter.php | 10 +++++++++- src/Hydra/Serializer/CollectionFiltersNormalizer.php | 1 + src/Metadata/ApiFilter.php | 2 +- src/Metadata/Parameters.php | 2 +- .../ParameterResourceMetadataCollectionFactory.php | 2 ++ .../TestBundle/Filter/SearchFilterValueTransformer.php | 2 +- .../TestBundle/Filter/SearchTextAndDateFilter.php | 6 +++--- 8 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php index 5a934c83152..902bef984bb 100644 --- a/src/Doctrine/Orm/Extension/ParameterExtension.php +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -22,6 +22,8 @@ use Psr\Container\ContainerInterface; /** + * Reads operation parameters and execute its filter. + * * @author Antoine Bluchet */ final class ParameterExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface @@ -47,7 +49,6 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter } $parameters = $parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters'); - $parsedKey = explode('[:property]', $key); if (isset($parsedKey[0]) && isset($parameters[$parsedKey[0]])) { $key = $parsedKey[0]; diff --git a/src/Doctrine/Orm/Filter/AbstractFilter.php b/src/Doctrine/Orm/Filter/AbstractFilter.php index 5f6211f692f..444fb44ecdc 100644 --- a/src/Doctrine/Orm/Filter/AbstractFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractFilter.php @@ -29,7 +29,7 @@ abstract class AbstractFilter implements FilterInterface use PropertyHelperTrait; protected LoggerInterface $logger; - public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, public ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) + public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) { $this->logger = $logger ?? new NullLogger(); } @@ -64,6 +64,14 @@ protected function getLogger(): LoggerInterface return $this->logger; } + /** + * @param string[] $properties + */ + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + /** * Determines whether the given property is enabled. */ diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index 080a2465602..2bf0f0c7d78 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -175,6 +175,7 @@ private function getSearch(string $resourceClass, array $parts, array $filters, continue; } + // :property is a pattern allowed when defining parameters $k = str_replace(':property', $description['property'], $key); $variable = str_replace($description['property'], $k, $variable); $variables[] = $variable; diff --git a/src/Metadata/ApiFilter.php b/src/Metadata/ApiFilter.php index 590b996b2da..0e6004fcf24 100644 --- a/src/Metadata/ApiFilter.php +++ b/src/Metadata/ApiFilter.php @@ -26,7 +26,7 @@ final class ApiFilter { /** * @param string|class-string|class-string $filterClass - * @param string $alias a service alias to be referenced in a Parameter + * @param string $alias a filter tag alias to be referenced in a Parameter */ public function __construct( public string $filterClass, diff --git a/src/Metadata/Parameters.php b/src/Metadata/Parameters.php index 6c1e9902d2a..a562774d233 100644 --- a/src/Metadata/Parameters.php +++ b/src/Metadata/Parameters.php @@ -14,7 +14,7 @@ namespace ApiPlatform\Metadata; /** - * An parameter dictionnary. + * A parameter dictionnary. * * @implements \IteratorAggregate */ diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 77b41c0c4d2..02aa9f10c4a 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -22,6 +22,8 @@ use Psr\Container\ContainerInterface; /** + * Prepares Parameters documentation by reading its filter details and declaring an OpenApi parameter. + * * @experimental */ final class ParameterResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface diff --git a/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php index e97ddf04d00..64e9e3768e7 100644 --- a/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php +++ b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php @@ -23,7 +23,7 @@ final class SearchFilterValueTransformer implements FilterInterface { public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, ?array $properties = null, private readonly ?string $key = null) { - $searchFilter->properties = $properties; + $searchFilter->setProperties($properties); } // This function is only used to hook in documentation generators (supported by Swagger and Hydra) diff --git a/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php index 63567849698..9b9ccbd273e 100644 --- a/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php +++ b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php @@ -21,10 +21,10 @@ final class SearchTextAndDateFilter implements FilterInterface { - public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, #[Autowire('@api_platform.doctrine.orm.date_filter.instance')] readonly FilterInterface $dateFilter, ?array $properties = null, array $dateFilterProperties = [], array $searchFilterProperties = []) + public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, #[Autowire('@api_platform.doctrine.orm.date_filter.instance')] readonly FilterInterface $dateFilter, protected ?array $properties = null, array $dateFilterProperties = [], array $searchFilterProperties = []) { - $searchFilter->properties = $searchFilterProperties; - $dateFilter->properties = $dateFilterProperties; + $searchFilter->setProperties($searchFilterProperties); + $dateFilter->setProperties($dateFilterProperties); } // This function is only used to hook in documentation generators (supported by Swagger and Hydra) From 1092ad2cdca604e643651cb0af8bf741d750c2c1 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 23 Mar 2024 11:14:44 +0100 Subject: [PATCH 16/20] add interface --- src/Doctrine/Orm/Filter/AbstractFilter.php | 2 +- .../Orm/Filter/PropertyAwareFilterInterface.php | 16 ++++++++++++++++ .../Serializer/CollectionFiltersNormalizer.php | 2 +- .../Filter/SearchFilterValueTransformer.php | 5 ++++- .../Filter/SearchTextAndDateFilter.php | 9 +++++++-- 5 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 src/Doctrine/Orm/Filter/PropertyAwareFilterInterface.php diff --git a/src/Doctrine/Orm/Filter/AbstractFilter.php b/src/Doctrine/Orm/Filter/AbstractFilter.php index 444fb44ecdc..54c995b2d66 100644 --- a/src/Doctrine/Orm/Filter/AbstractFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractFilter.php @@ -23,7 +23,7 @@ use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -abstract class AbstractFilter implements FilterInterface +abstract class AbstractFilter implements PropertyAwareFilterInterface { use OrmPropertyHelperTrait; use PropertyHelperTrait; diff --git a/src/Doctrine/Orm/Filter/PropertyAwareFilterInterface.php b/src/Doctrine/Orm/Filter/PropertyAwareFilterInterface.php new file mode 100644 index 00000000000..f912416cc62 --- /dev/null +++ b/src/Doctrine/Orm/Filter/PropertyAwareFilterInterface.php @@ -0,0 +1,16 @@ + + * + * @experimental + */ +interface PropertyAwareFilterInterface extends FilterInterface +{ + /** + * @param string[] $properties + */ + public function setProperties(array $properties): void; +} diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index 2bf0f0c7d78..1f605aee7c0 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -169,7 +169,7 @@ private function getSearch(string $resourceClass, array $parts, array $filters, } if (!($property = $parameter->getProperty()) && ($filterId = $parameter->getFilter()) && ($filter = $this->getFilter($filterId))) { - foreach ($filter->getDescription($resourceClass) ?? [] as $variable => $description) { + foreach ($filter->getDescription($resourceClass) as $variable => $description) { // This is a practice induced by PHP and is not necessary when implementing URI template if (str_ends_with((string) $variable, '[]')) { continue; diff --git a/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php index 64e9e3768e7..1d39716f5e2 100644 --- a/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php +++ b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Filter; use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; @@ -23,7 +24,9 @@ final class SearchFilterValueTransformer implements FilterInterface { public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, ?array $properties = null, private readonly ?string $key = null) { - $searchFilter->setProperties($properties); + if ($searchFilter instanceof PropertyAwareFilterInterface) { + $searchFilter->setProperties($properties); + } } // This function is only used to hook in documentation generators (supported by Swagger and Hydra) diff --git a/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php index 9b9ccbd273e..dea857544c1 100644 --- a/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php +++ b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Filter; use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; @@ -23,8 +24,12 @@ final class SearchTextAndDateFilter implements FilterInterface { public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, #[Autowire('@api_platform.doctrine.orm.date_filter.instance')] readonly FilterInterface $dateFilter, protected ?array $properties = null, array $dateFilterProperties = [], array $searchFilterProperties = []) { - $searchFilter->setProperties($searchFilterProperties); - $dateFilter->setProperties($dateFilterProperties); + if ($searchFilter instanceof PropertyAwareFilterInterface) { + $searchFilter->setProperties($searchFilterProperties); + } + if ($dateFilter instanceof PropertyAwareFilterInterface) { + $dateFilter->setProperties($dateFilterProperties); + } } // This function is only used to hook in documentation generators (supported by Swagger and Hydra) From 7457b801eb0563d7306067851f6441dda7307790 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 23 Mar 2024 11:14:57 +0100 Subject: [PATCH 17/20] add interface --- .../Orm/Filter/PropertyAwareFilterInterface.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Doctrine/Orm/Filter/PropertyAwareFilterInterface.php b/src/Doctrine/Orm/Filter/PropertyAwareFilterInterface.php index f912416cc62..1f8fab7c69a 100644 --- a/src/Doctrine/Orm/Filter/PropertyAwareFilterInterface.php +++ b/src/Doctrine/Orm/Filter/PropertyAwareFilterInterface.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + namespace ApiPlatform\Doctrine\Orm\Filter; /** From 338ee9072becc3829e6ac6c5eac5fa71b996a7a7 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 25 Mar 2024 11:33:46 +0100 Subject: [PATCH 18/20] mongodb --- .../Filter/PropertyAwareFilterInterface.php | 4 +- .../Odm/Extension/ParameterExtension.php | 88 +++++++++++++++++++ src/Doctrine/Odm/Filter/AbstractFilter.php | 11 ++- src/Doctrine/Orm/Filter/AbstractFilter.php | 3 +- .../Resources/config/doctrine_mongodb_odm.xml | 49 ++++++++--- .../Bundle/Resources/config/doctrine_orm.xml | 2 +- .../SearchFilterParameterDocument.php | 76 ++++++++++++++++ .../ODMSearchFilterValueTransformer.php | 42 +++++++++ .../Filter/ODMSearchTextAndDateFilter.php | 46 ++++++++++ .../Filter/SearchFilterValueTransformer.php | 2 +- .../Filter/SearchTextAndDateFilter.php | 2 +- tests/Fixtures/app/config/config_mongodb.yml | 10 +++ tests/Parameter/ParameterTests.php | 73 ++++++++------- 13 files changed, 353 insertions(+), 55 deletions(-) rename src/Doctrine/{Orm => Common}/Filter/PropertyAwareFilterInterface.php (81%) create mode 100644 src/Doctrine/Odm/Extension/ParameterExtension.php create mode 100644 tests/Fixtures/TestBundle/Document/SearchFilterParameterDocument.php create mode 100644 tests/Fixtures/TestBundle/Filter/ODMSearchFilterValueTransformer.php create mode 100644 tests/Fixtures/TestBundle/Filter/ODMSearchTextAndDateFilter.php diff --git a/src/Doctrine/Orm/Filter/PropertyAwareFilterInterface.php b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php similarity index 81% rename from src/Doctrine/Orm/Filter/PropertyAwareFilterInterface.php rename to src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php index 1f8fab7c69a..aa0857cef20 100644 --- a/src/Doctrine/Orm/Filter/PropertyAwareFilterInterface.php +++ b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php @@ -11,14 +11,14 @@ declare(strict_types=1); -namespace ApiPlatform\Doctrine\Orm\Filter; +namespace ApiPlatform\Doctrine\Common\Filter; /** * @author Antoine Bluchet * * @experimental */ -interface PropertyAwareFilterInterface extends FilterInterface +interface PropertyAwareFilterInterface { /** * @param string[] $properties diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php new file mode 100644 index 00000000000..31f93179434 --- /dev/null +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm\Extension; + +use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; +use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\HeaderParameterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Psr\Container\ContainerInterface; + +/** + * Reads operation parameters and execute its filter. + * + * @author Antoine Bluchet + */ +final class ParameterExtension implements AggregationCollectionExtensionInterface, AggregationItemExtensionInterface +{ + public function __construct(private readonly ContainerInterface $filterLocator) + { + } + + private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass = null, ?Operation $operation = null, array &$context = []): void + { + if (!($request = $context['request'] ?? null)) { + return; + } + + if (null === $resourceClass) { + throw new InvalidArgumentException('The "$resourceClass" parameter must not be null'); + } + + foreach ($operation->getParameters() ?? [] as $parameter) { + $key = $parameter->getKey(); + if (null === ($filterId = $parameter->getFilter())) { + continue; + } + + $parameters = $parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters'); + $parsedKey = explode('[:property]', $key); + if (isset($parsedKey[0]) && isset($parameters[$parsedKey[0]])) { + $key = $parsedKey[0]; + } + + if (!isset($parameters[$key])) { + continue; + } + + $value = $parameters[$key]; + $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + if ($filter instanceof FilterInterface) { + $filterContext = ['filters' => [$key => $value]] + $context; + $filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); + // update by reference + if (isset($filterContext['mongodb_odm_sort_fields'])) { + $context['mongodb_odm_sort_fields'] = $filterContext['mongodb_odm_sort_fields']; + } + } + } + } + + /** + * {@inheritdoc} + */ + public function applyToCollection(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $this->applyFilter($aggregationBuilder, $resourceClass, $operation, $context); + } + + /** + * {@inheritdoc} + */ + public function applyToItem(Builder $aggregationBuilder, string $resourceClass, array $identifiers, ?Operation $operation = null, array &$context = []): void + { + $this->applyFilter($aggregationBuilder, $resourceClass, $operation, $context); + } +} diff --git a/src/Doctrine/Odm/Filter/AbstractFilter.php b/src/Doctrine/Odm/Filter/AbstractFilter.php index 40cecd1400f..42a698b63e8 100644 --- a/src/Doctrine/Odm/Filter/AbstractFilter.php +++ b/src/Doctrine/Odm/Filter/AbstractFilter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Odm\Filter; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait; use ApiPlatform\Metadata\Operation; @@ -29,7 +30,7 @@ * * @author Alan Poulain */ -abstract class AbstractFilter implements FilterInterface +abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface { use MongoDbOdmPropertyHelperTrait; use PropertyHelperTrait; @@ -65,6 +66,14 @@ protected function getProperties(): ?array return $this->properties; } + /** + * @param string[] $properties + */ + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + protected function getLogger(): LoggerInterface { return $this->logger; diff --git a/src/Doctrine/Orm/Filter/AbstractFilter.php b/src/Doctrine/Orm/Filter/AbstractFilter.php index 54c995b2d66..a0935b67aa5 100644 --- a/src/Doctrine/Orm/Filter/AbstractFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractFilter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Orm\Filter; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; @@ -23,7 +24,7 @@ use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -abstract class AbstractFilter implements PropertyAwareFilterInterface +abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface { use OrmPropertyHelperTrait; use PropertyHelperTrait; diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml index f9e7ce7156f..e4206ea097d 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml @@ -34,6 +34,18 @@ + + + + + + + + + + + + @@ -41,6 +53,9 @@ + + + @@ -48,6 +63,9 @@ + + + @@ -56,6 +74,9 @@ + + + @@ -63,6 +84,9 @@ + + + @@ -71,6 +95,9 @@ + + + @@ -78,6 +105,9 @@ + + + @@ -105,6 +135,14 @@ + + + + + + + + - - - - - - - - - - - diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml index 3f9b0d0350e..14d197c01fc 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -143,7 +143,7 @@ - + diff --git a/tests/Fixtures/TestBundle/Document/SearchFilterParameterDocument.php b/tests/Fixtures/TestBundle/Document/SearchFilterParameterDocument.php new file mode 100644 index 00000000000..d1b7da59538 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/SearchFilterParameterDocument.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Filter\ODMSearchFilterValueTransformer; +use ApiPlatform\Tests\Fixtures\TestBundle\Filter\ODMSearchTextAndDateFilter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[GetCollection( + uriTemplate: 'search_filter_parameter_document{._format}', + parameters: [ + 'foo' => new QueryParameter(filter: 'app_odm_search_filter_via_parameter'), + 'order[:property]' => new QueryParameter(filter: 'app_odm_search_filter_via_parameter.order_filter'), + + 'searchPartial[:property]' => new QueryParameter(filter: 'app_odm_search_filter_partial'), + 'searchExact[:property]' => new QueryParameter(filter: 'app_odm_search_filter_with_exact'), + 'searchOnTextAndDate[:property]' => new QueryParameter(filter: 'app_odm_filter_date_and_search'), + 'q' => new QueryParameter(property: 'hydra:freetextQuery'), + ] +)] +#[ApiFilter(ODMSearchFilterValueTransformer::class, alias: 'app_odm_search_filter_partial', properties: ['foo' => 'partial'], arguments: ['key' => 'searchPartial'])] +#[ApiFilter(ODMSearchFilterValueTransformer::class, alias: 'app_odm_search_filter_with_exact', properties: ['foo' => 'exact'], arguments: ['key' => 'searchExact'])] +#[ApiFilter(ODMSearchTextAndDateFilter::class, alias: 'app_odm_filter_date_and_search', properties: ['foo', 'createdAt'], arguments: ['dateFilterProperties' => ['createdAt' => 'exclude_null'], 'searchFilterProperties' => ['foo' => 'exact']])] +#[ODM\Document] +class SearchFilterParameterDocument +{ + /** + * @var int The id + */ + #[ODM\Field] + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + #[ODM\Field] + private string $foo = ''; + #[ODM\Field(type: 'date_immutable', nullable: true)] + private ?\DateTimeImmutable $createdAt = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getFoo(): string + { + return $this->foo; + } + + public function setFoo(string $foo): void + { + $this->foo = $foo; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/ODMSearchFilterValueTransformer.php b/tests/Fixtures/TestBundle/Filter/ODMSearchFilterValueTransformer.php new file mode 100644 index 00000000000..38d66a36a8e --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/ODMSearchFilterValueTransformer.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +final class ODMSearchFilterValueTransformer implements FilterInterface +{ + public function __construct(#[Autowire('@api_platform.doctrine_mongodb.odm.search_filter.instance')] readonly FilterInterface $searchFilter, ?array $properties = null, private readonly ?string $key = null) + { + if ($searchFilter instanceof PropertyAwareFilterInterface) { + $searchFilter->setProperties($properties); + } + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return $this->searchFilter->getDescription($resourceClass); + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $filterContext = ['filters' => $context['filters'][$this->key]] + $context; + $this->searchFilter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); + } +} diff --git a/tests/Fixtures/TestBundle/Filter/ODMSearchTextAndDateFilter.php b/tests/Fixtures/TestBundle/Filter/ODMSearchTextAndDateFilter.php new file mode 100644 index 00000000000..283a0cff2d0 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/ODMSearchTextAndDateFilter.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +final class ODMSearchTextAndDateFilter implements FilterInterface +{ + public function __construct(#[Autowire('@api_platform.doctrine_mongodb.odm.search_filter.instance')] readonly FilterInterface $searchFilter, #[Autowire('@api_platform.doctrine_mongodb.odm.date_filter.instance')] readonly FilterInterface $dateFilter, protected ?array $properties = null, array $dateFilterProperties = [], array $searchFilterProperties = []) + { + if ($searchFilter instanceof PropertyAwareFilterInterface) { + $searchFilter->setProperties($searchFilterProperties); + } + if ($dateFilter instanceof PropertyAwareFilterInterface) { + $dateFilter->setProperties($dateFilterProperties); + } + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return array_merge($this->searchFilter->getDescription($resourceClass), $this->dateFilter->getDescription($resourceClass)); + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $filterContext = ['filters' => $context['filters']['searchOnTextAndDate']] + $context; + $this->searchFilter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); + $this->dateFilter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); + } +} diff --git a/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php index 1d39716f5e2..b438ccc4cb0 100644 --- a/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php +++ b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php @@ -13,8 +13,8 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Filter; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; -use ApiPlatform\Doctrine\Orm\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; diff --git a/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php index dea857544c1..bd0bd90a2c8 100644 --- a/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php +++ b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php @@ -13,8 +13,8 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Filter; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; -use ApiPlatform\Doctrine\Orm\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; diff --git a/tests/Fixtures/app/config/config_mongodb.yml b/tests/Fixtures/app/config/config_mongodb.yml index 814603f182b..8f42de9e3ab 100644 --- a/tests/Fixtures/app/config/config_mongodb.yml +++ b/tests/Fixtures/app/config/config_mongodb.yml @@ -159,3 +159,13 @@ services: $decorated: '@ApiPlatform\Doctrine\Common\State\PersistProcessor' tags: - name: 'api_platform.state_processor' + + app_odm_search_filter_via_parameter: + parent: 'api_platform.doctrine_mongodb.odm.search_filter' + arguments: [ { foo: 'exact' } ] + tags: [ { name: 'api_platform.filter', id: 'app_odm_search_filter_via_parameter' } ] + + app_odm_search_filter_via_parameter.order_filter: + parent: 'api_platform.doctrine_mongodb.odm.order_filter' + arguments: [ { id: 'ASC', foo: 'DESC' } ] + tags: [ 'api_platform.filter' ] diff --git a/tests/Parameter/ParameterTests.php b/tests/Parameter/ParameterTests.php index b04b5704691..ee8477b0c04 100644 --- a/tests/Parameter/ParameterTests.php +++ b/tests/Parameter/ParameterTests.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Tests\Parameter; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SearchFilterParameterDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SearchFilterParameter; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; @@ -65,51 +66,33 @@ public function testHydraTemplate(): void public function testDoctrineEntitySearchFilter(): void { - if (false === $this->recreateSchema()) { - $this->markTestSkipped(); - } - - $registry = $this->getContainer()->get('doctrine'); - $entityManager = $registry->getManagerForClass(SearchFilterParameter::class); - - $date = new \DateTimeImmutable('2024-01-21'); - foreach (['foo', 'foo', 'foo', 'bar', 'bar', 'baz'] as $t) { - $s = new SearchFilterParameter(); - $s->setFoo($t); - if ('bar' === $t) { - $s->setCreatedAt($date); - $date = new \DateTimeImmutable('2024-01-22'); - } - - $entityManager->persist($s); - } - $entityManager->flush(); - - $response = self::createClient()->request('GET', 'search_filter_parameter?foo=bar'); + $this->recreateSchema(); + $container = static::getContainer(); + $route = 'mongodb' === $container->getParameter('kernel.environment') ? 'search_filter_parameter_document' : 'search_filter_parameter'; + $response = self::createClient()->request('GET', $route.'?foo=bar'); $a = $response->toArray(false); $this->assertCount(2, $a['hydra:member']); $this->assertEquals('bar', $a['hydra:member'][0]['foo']); $this->assertEquals('bar', $a['hydra:member'][1]['foo']); $this->assertArraySubset(['hydra:search' => [ - 'hydra:template' => '/search_filter_parameter{?foo,order[order[id]],order[order[foo]],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],q}', + 'hydra:template' => sprintf('/%s{?foo,order[order[id]],order[order[foo]],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],q}', $route), 'hydra:mapping' => [ ['@type' => 'IriTemplateMapping', 'variable' => 'foo', 'property' => 'foo'], ], ]], $a); - $response = self::createClient()->request('GET', 'search_filter_parameter?order[foo]=asc'); - $this->assertEquals($response->toArray()['hydra:member'][0]['foo'], 'bar'); - - $response = self::createClient()->request('GET', 'search_filter_parameter?order[foo]=asc'); + $response = self::createClient()->request('GET', $route.'?order[foo]=asc'); $this->assertEquals($response->toArray()['hydra:member'][0]['foo'], 'bar'); + $response = self::createClient()->request('GET', $route.'?order[foo]=desc'); + $this->assertEquals($response->toArray()['hydra:member'][0]['foo'], 'foo'); - $response = self::createClient()->request('GET', 'search_filter_parameter?searchPartial[foo]=az'); + $response = self::createClient()->request('GET', $route.'?searchPartial[foo]=az'); $members = $response->toArray()['hydra:member']; $this->assertCount(1, $members); $this->assertArraySubset(['foo' => 'baz'], $members[0]); - $response = self::createClient()->request('GET', 'search_filter_parameter?searchOnTextAndDate[foo]=bar&searchOnTextAndDate[createdAt][before]=2024-01-21'); + $response = self::createClient()->request('GET', $route.'?searchOnTextAndDate[foo]=bar&searchOnTextAndDate[createdAt][before]=2024-01-21'); $members = $response->toArray()['hydra:member']; $this->assertCount(1, $members); $this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $members[0]); @@ -118,20 +101,36 @@ public function testDoctrineEntitySearchFilter(): void /** * @param array $options kernel options */ - private function recreateSchema(array $options = []): ?bool + private function recreateSchema(array $options = []): void { self::bootKernel($options); - $manager = static::getContainer()->get('doctrine')->getManagerForClass(SearchFilterParameter::class); - if (!$manager instanceof EntityManagerInterface) { - return false; + $container = static::getContainer(); + $registry = $this->getContainer()->get('mongodb' === $container->getParameter('kernel.environment') ? 'doctrine_mongodb' : 'doctrine'); + $resource = 'mongodb' === $container->getParameter('kernel.environment') ? SearchFilterParameterDocument::class : SearchFilterParameter::class; + $manager = $registry->getManager(); + + if ($manager instanceof EntityManagerInterface) { + $classes = $manager->getClassMetadata($resource); + $schemaTool = new SchemaTool($manager); + @$schemaTool->dropSchema([$classes]); + @$schemaTool->createSchema([$classes]); + } else { + $schemaManager = $manager->getSchemaManager(); + $schemaManager->dropCollections(); } - $classes = $manager->getClassMetadata(SearchFilterParameter::class); - $schemaTool = new SchemaTool($manager); - @$schemaTool->dropSchema([$classes]); - @$schemaTool->createSchema([$classes]); + $date = new \DateTimeImmutable('2024-01-21'); + foreach (['foo', 'foo', 'foo', 'bar', 'bar', 'baz'] as $t) { + $s = new $resource(); + $s->setFoo($t); + if ('bar' === $t) { + $s->setCreatedAt($date); + $date = new \DateTimeImmutable('2024-01-22'); + } - return null; + $manager->persist($s); + } + $manager->flush(); } } From 40f12da436552c5d43d9455a0f943802c975dd84 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 25 Mar 2024 18:21:47 +0100 Subject: [PATCH 19/20] imp --- .../Filter/UrlPatternToFilterContextTrait.php | 16 +++ .../Odm/Extension/ParameterExtension.php | 23 +--- .../Orm/Extension/ParameterExtension.php | 30 ++--- ...meterResourceMetadataCollectionFactory.php | 111 +++++++++++------- src/State/Provider/ParameterProvider.php | 53 +++++++-- .../TestBundle/ApiResource/Headers.php | 4 +- .../Entity/SearchFilterParameter.php | 12 ++ tests/Parameter/ParameterTests.php | 2 +- 8 files changed, 153 insertions(+), 98 deletions(-) create mode 100644 src/Doctrine/Common/Filter/UrlPatternToFilterContextTrait.php diff --git a/src/Doctrine/Common/Filter/UrlPatternToFilterContextTrait.php b/src/Doctrine/Common/Filter/UrlPatternToFilterContextTrait.php new file mode 100644 index 00000000000..eb7fe549340 --- /dev/null +++ b/src/Doctrine/Common/Filter/UrlPatternToFilterContextTrait.php @@ -0,0 +1,16 @@ + + * @internal + */ +class UrlPatternToFilterContextTrait +{ +} diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php index 31f93179434..41b07329fa5 100644 --- a/src/Doctrine/Odm/Extension/ParameterExtension.php +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -33,34 +33,19 @@ public function __construct(private readonly ContainerInterface $filterLocator) private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass = null, ?Operation $operation = null, array &$context = []): void { - if (!($request = $context['request'] ?? null)) { - return; - } - - if (null === $resourceClass) { - throw new InvalidArgumentException('The "$resourceClass" parameter must not be null'); - } - foreach ($operation->getParameters() ?? [] as $parameter) { - $key = $parameter->getKey(); - if (null === ($filterId = $parameter->getFilter())) { + $values = $parameter->getExtraProperties()['_api_values'] ?? []; + if (!$values) { continue; } - $parameters = $parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters'); - $parsedKey = explode('[:property]', $key); - if (isset($parsedKey[0]) && isset($parameters[$parsedKey[0]])) { - $key = $parsedKey[0]; - } - - if (!isset($parameters[$key])) { + if (null === ($filterId = $parameter->getFilter())) { continue; } - $value = $parameters[$key]; $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; if ($filter instanceof FilterInterface) { - $filterContext = ['filters' => [$key => $value]] + $context; + $filterContext = ['filters' => $values]; $filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); // update by reference if (isset($filterContext['mongodb_odm_sort_fields'])) { diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php index 902bef984bb..3a040dec6b2 100644 --- a/src/Doctrine/Orm/Extension/ParameterExtension.php +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -32,36 +32,24 @@ public function __construct(private readonly ContainerInterface $filterLocator) { } - private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void + /** + * @param array $context + */ + private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { - if (!($request = $context['request'] ?? null)) { - return; - } - - if (null === $resourceClass) { - throw new InvalidArgumentException('The "$resourceClass" parameter must not be null'); - } - foreach ($operation->getParameters() ?? [] as $parameter) { - $key = $parameter->getKey(); - if (null === ($filterId = $parameter->getFilter())) { + $values = $parameter->getExtraProperties()['_api_values'] ?? []; + if (!$values) { continue; } - $parameters = $parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters'); - $parsedKey = explode('[:property]', $key); - if (isset($parsedKey[0]) && isset($parameters[$parsedKey[0]])) { - $key = $parsedKey[0]; - } - - if (!isset($parameters[$key])) { + if (null === ($filterId = $parameter->getFilter())) { continue; } - $value = $parameters[$key]; $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; if ($filter instanceof FilterInterface) { - $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => [$key => $value]] + $context); + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values] + $context); } } } @@ -69,7 +57,7 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter /** * {@inheritdoc} */ - public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { $this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 02aa9f10c4a..66dcb962890 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\HeaderParameterInterface; +use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\OpenApi; @@ -43,49 +44,7 @@ public function create(string $resourceClass): ResourceMetadataCollection foreach ($operations as $operationName => $operation) { $parameters = []; foreach ($operation->getParameters() ?? [] as $key => $parameter) { - if (null === $parameter->getKey()) { - $parameter = $parameter->withKey($key); - } - - $filter = $parameter->getFilter(); - if (\is_string($filter) && $this->filterLocator->has($filter)) { - $filter = $this->filterLocator->get($filter); - } - - if ($filter instanceof SerializerFilterInterface && null === $parameter->getProvider()) { - $parameter = $parameter->withProvider('api_platform.serializer.filter_parameter_provider'); - } - - // Read filter description to populate the Parameter - $description = $filter instanceof FilterInterface ? $filter->getDescription($resourceClass) : []; - if (($schema = $description[$key]['schema'] ?? null) && null === $parameter->getSchema()) { - $parameter = $parameter->withSchema($schema); - } - - if (null === $parameter->getOpenApi() && $openApi = $description[$key]['openapi'] ?? null) { - if ($openApi instanceof OpenApi\Model\Parameter) { - $parameter = $parameter->withOpenApi($openApi); - } - - if (\is_array($openApi)) { - $parameter->withOpenApi(new OpenApi\Model\Parameter( - $key, - $parameter instanceof HeaderParameterInterface ? 'header' : 'query', - $description[$key]['description'] ?? '', - $description[$key]['required'] ?? $openApi['required'] ?? false, - $openApi['deprecated'] ?? false, - $openApi['allowEmptyValue'] ?? true, - $schema, - $openApi['style'] ?? null, - $openApi['explode'] ?? ('array' === ($schema['type'] ?? null)), - $openApi['allowReserved'] ?? false, - $openApi['example'] ?? null, - isset($openApi['examples'] - ) ? new \ArrayObject($openApi['examples']) : null - )); - } - } - + $parameter = $this->setDefaults($key, $parameter, $resourceClass); $priority = $parameter->getPriority() ?? $internalPriority--; $parameters[$key] = $parameter->withPriority($priority); } @@ -94,8 +53,74 @@ public function create(string $resourceClass): ResourceMetadataCollection } $resourceMetadataCollection[$i] = $resource->withOperations($operations->sort()); + + $internalPriority = -1; + $graphQlOperations = $resource->getGraphQlOperations(); + foreach ($graphQlOperations ?? [] as $operationName => $operation) { + $parameters = []; + foreach ($operation->getParameters() ?? [] as $key => $parameter) { + $parameter = $this->setDefaults($key, $parameter, $resourceClass); + $priority = $parameter->getPriority() ?? $internalPriority--; + $parameters[$key] = $parameter->withPriority($priority); + } + + $graphQlOperations[$operationName] = $operation->withParameters(new Parameters($parameters)); + } + + if ($graphQlOperations) { + $resourceMetadataCollection[$i] = $resource->withGraphQlOperations($graphQlOperations); + } } return $resourceMetadataCollection; } + + private function setDefaults(string $key, Parameter $parameter, string $resourceClass): Parameter + { + if (null === $parameter->getKey()) { + $parameter = $parameter->withKey($key); + } + + $filter = $parameter->getFilter(); + if (\is_string($filter) && $this->filterLocator->has($filter)) { + $filter = $this->filterLocator->get($filter); + } + + if ($filter instanceof SerializerFilterInterface && null === $parameter->getProvider()) { + $parameter = $parameter->withProvider('api_platform.serializer.filter_parameter_provider'); + } + + // Read filter description to populate the Parameter + $description = $filter instanceof FilterInterface ? $filter->getDescription($resourceClass) : []; + if (($schema = $description[$key]['schema'] ?? null) && null === $parameter->getSchema()) { + $parameter = $parameter->withSchema($schema); + } + + if (null === $parameter->getOpenApi() && $openApi = $description[$key]['openapi'] ?? null) { + if ($openApi instanceof OpenApi\Model\Parameter) { + $parameter = $parameter->withOpenApi($openApi); + } + + if (\is_array($openApi)) { + $parameter->withOpenApi(new OpenApi\Model\Parameter( + $key, + $parameter instanceof HeaderParameterInterface ? 'header' : 'query', + $description[$key]['description'] ?? '', + $description[$key]['required'] ?? $openApi['required'] ?? false, + $openApi['deprecated'] ?? false, + $openApi['allowEmptyValue'] ?? true, + $schema, + $openApi['style'] ?? null, + $openApi['explode'] ?? ('array' === ($schema['type'] ?? null)), + $openApi['allowReserved'] ?? false, + $openApi['example'] ?? null, + isset( + $openApi['examples'] + ) ? new \ArrayObject($openApi['examples']) : null + )); + } + } + + return $parameter; + } } diff --git a/src/State/Provider/ParameterProvider.php b/src/State/Provider/ParameterProvider.php index 1da3f623036..dc47832839c 100644 --- a/src/State/Provider/ParameterProvider.php +++ b/src/State/Provider/ParameterProvider.php @@ -16,13 +16,19 @@ use ApiPlatform\Metadata\HeaderParameterInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use ApiPlatform\State\Exception\ProviderNotFoundException; use ApiPlatform\State\ParameterProviderInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\RequestParser; use Psr\Container\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; /** + * Loops over parameters to: + * - compute its values set as extra properties from the Parameter object (`_api_values`) + * - call the Parameter::provider if any and updates the Operation + * * @experimental */ final class ParameterProvider implements ProviderInterface @@ -33,35 +39,41 @@ public function __construct(private readonly ?ProviderInterface $decorated = nul public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - if (null === ($request = $context['request'])) { - return $this->decorated?->provide($operation, $uriVariables, $context); - } + $request = $context['request'] ?? null; - if (null === $request->attributes->get('_api_query_parameters')) { + if ($request && null === $request->attributes->get('_api_query_parameters')) { $queryString = RequestParser::getQueryString($request); $request->attributes->set('_api_query_parameters', $queryString ? RequestParser::parseRequestParams($queryString) : []); } - if (null === $request->attributes->get('_api_header_parameters')) { + if ($request && null === $request->attributes->get('_api_header_parameters')) { $request->attributes->set('_api_header_parameters', $request->headers->all()); } $context = ['operation' => $operation] + $context; - foreach ($operation->getParameters() ?? [] as $parameter) { + $operationParameters = iterator_to_array($operation->getParameters() ?? []); + foreach ($operationParameters as $parameter) { $key = $parameter->getKey(); - if (null === ($provider = $parameter->getProvider())) { - continue; + $parameters = $this->extractParameterValues($parameter, $request, $context); + $parsedKey = explode('[:property]', $key); + if (isset($parsedKey[0]) && isset($parameters[$parsedKey[0]])) { + $key = $parsedKey[0]; } - $parameters = $parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters'); if (!isset($parameters[$key])) { continue; } + $operationParameters[$key] = $parameter = $parameter->withExtraProperties( + $parameter->getExtraProperties() + ['_api_values' => [$key => $parameters[$key]]] + ); + + if (null === ($provider = $parameter->getProvider())) { + continue; + } + if (\is_callable($provider) && (($op = $provider($parameter, $parameters, $context)) instanceof HttpOperation)) { $operation = $op; - $request->attributes->set('_api_operation', $operation); - $context['operation'] = $operation; continue; } @@ -73,11 +85,26 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $providerInstance = $this->locator->get($provider); if (($op = $providerInstance->provide($parameter, $parameters, $context)) instanceof HttpOperation) { $operation = $op; - $request->attributes->set('_api_operation', $operation); - $context['operation'] = $operation; } } + $operation = $operation->withParameters($operationParameters); + $request->attributes->set('_api_operation', $operation); + $context['operation'] = $operation; + return $this->decorated?->provide($operation, $uriVariables, $context); } + + /** + * @param array $context + */ + private function extractParameterValues(Parameter $parameter, ?Request $request, array $context) + { + if ($request) { + return $parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters'); + } + + // GraphQl + return $context['_api_search_parameters'] ?? []; + } } diff --git a/tests/Fixtures/TestBundle/ApiResource/Headers.php b/tests/Fixtures/TestBundle/ApiResource/Headers.php index c84441e586b..93f216c71d9 100644 --- a/tests/Fixtures/TestBundle/ApiResource/Headers.php +++ b/tests/Fixtures/TestBundle/ApiResource/Headers.php @@ -22,8 +22,10 @@ output: false, operations: [ new Get(uriTemplate: 'redirect_to_foobar'), - ] + ], + graphQlOperations: [] )] class Headers { + public $id; } diff --git a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php index d3d16a06707..133e98adcd1 100644 --- a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SearchFilterValueTransformer; use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SearchTextAndDateFilter; @@ -32,6 +33,17 @@ 'q' => new QueryParameter(property: 'hydra:freetextQuery'), ] )] +#[QueryCollection( + parameters: [ + 'foo' => new QueryParameter(filter: 'app_search_filter_via_parameter'), + 'order[:property]' => new QueryParameter(filter: 'app_search_filter_via_parameter.order_filter'), + + 'searchPartial[:property]' => new QueryParameter(filter: 'app_search_filter_partial'), + 'searchExact[:property]' => new QueryParameter(filter: 'app_search_filter_with_exact'), + 'searchOnTextAndDate[:property]' => new QueryParameter(filter: 'app_filter_date_and_search'), + 'q' => new QueryParameter(property: 'hydra:freetextQuery'), + ] +)] #[ApiFilter(SearchFilterValueTransformer::class, alias: 'app_search_filter_partial', properties: ['foo' => 'partial'], arguments: ['key' => 'searchPartial'])] #[ApiFilter(SearchFilterValueTransformer::class, alias: 'app_search_filter_with_exact', properties: ['foo' => 'exact'], arguments: ['key' => 'searchExact'])] #[ApiFilter(SearchTextAndDateFilter::class, alias: 'app_filter_date_and_search', properties: ['foo', 'createdAt'], arguments: ['dateFilterProperties' => ['createdAt' => 'exclude_null'], 'searchFilterProperties' => ['foo' => 'exact']])] diff --git a/tests/Parameter/ParameterTests.php b/tests/Parameter/ParameterTests.php index ee8477b0c04..3c0dec30494 100644 --- a/tests/Parameter/ParameterTests.php +++ b/tests/Parameter/ParameterTests.php @@ -70,7 +70,7 @@ public function testDoctrineEntitySearchFilter(): void $container = static::getContainer(); $route = 'mongodb' === $container->getParameter('kernel.environment') ? 'search_filter_parameter_document' : 'search_filter_parameter'; $response = self::createClient()->request('GET', $route.'?foo=bar'); - $a = $response->toArray(false); + $a = $response->toArray(); $this->assertCount(2, $a['hydra:member']); $this->assertEquals('bar', $a['hydra:member'][0]['foo']); $this->assertEquals('bar', $a['hydra:member'][1]['foo']); From b81ed0edc3ce82aca7f4e1e8401573776d1d678c Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 26 Mar 2024 15:26:06 +0100 Subject: [PATCH 20/20] graphql --- .../Filter/UrlPatternToFilterContextTrait.php | 14 ++- .../Odm/Extension/ParameterExtension.php | 2 - .../Orm/Extension/ParameterExtension.php | 2 - src/GraphQl/Type/FieldsBuilder.php | 115 +++++++++++++++++- src/State/Provider/ParameterProvider.php | 7 +- .../Bundle/Resources/config/graphql.xml | 5 + .../TestBundle/Document/DummyDtoNoOutput.php | 2 +- .../TestBundle/Entity/DummyDtoNoOutput.php | 2 +- .../Filter/SearchFilterValueTransformer.php | 13 +- .../Filter/SearchTextAndDateFilter.php | 22 ++-- tests/Parameter/ParameterTests.php | 26 ++++ 11 files changed, 185 insertions(+), 25 deletions(-) diff --git a/src/Doctrine/Common/Filter/UrlPatternToFilterContextTrait.php b/src/Doctrine/Common/Filter/UrlPatternToFilterContextTrait.php index eb7fe549340..8d07488f411 100644 --- a/src/Doctrine/Common/Filter/UrlPatternToFilterContextTrait.php +++ b/src/Doctrine/Common/Filter/UrlPatternToFilterContextTrait.php @@ -1,14 +1,26 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + namespace ApiPlatform\Doctrine\Common\Filter; /** - * Handles the :property url pattern from ApiPlatform parameters and prepares the `filter` $context + * Handles the :property url pattern from ApiPlatform parameters and prepares the `filter` $context. * * @see ApiPlatform\Doctrine\Orm\Filter\FilterInterface * @see ApiPlatform\Metadata\Parameter * * @author Antoine Bluchet + * * @internal */ class UrlPatternToFilterContextTrait diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php index 41b07329fa5..8fa4ca5db78 100644 --- a/src/Doctrine/Odm/Extension/ParameterExtension.php +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Doctrine\Odm\Extension; use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; -use ApiPlatform\Exception\InvalidArgumentException; -use ApiPlatform\Metadata\HeaderParameterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Psr\Container\ContainerInterface; diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php index 3a040dec6b2..792f311dd09 100644 --- a/src/Doctrine/Orm/Extension/ParameterExtension.php +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -15,8 +15,6 @@ use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; -use ApiPlatform\Metadata\HeaderParameterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; use Psr\Container\ContainerInterface; diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 3eaa9c6fbb7..3b36694a5ed 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -290,9 +290,105 @@ public function resolveResourceArgs(array $args, Operation $operation): array $args[$id]['type'] = $this->typeConverter->resolveType($arg['type']); } + foreach ($operation->getParameters() ?? [] as $parameter) { + $key = $parameter->getKey(); + + if (str_contains($key, ':property')) { + if (!($filterId = $parameter->getFilter()) || !$this->filterLocator->has($filterId)) { + continue; + } + + $parsedKey = explode('[:property]', $key); + $flattenFields = []; + foreach ($this->filterLocator->get($filterId)->getDescription($operation->getClass()) as $key => $value) { + $values = []; + parse_str($key, $values); + if (isset($values[$parsedKey[0]])) { + $values = $values[$parsedKey[0]]; + } + + $name = key($values); + $flattenFields[] = ['name' => $name, 'required' => $value['required'] ?? null, 'description' => $value['description'] ?? null, 'leafs' => $values[$name], 'type' => $value['type'] ?? 'string']; + } + + $args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0]); + continue; + } + + $args[$key] = ['type' => GraphQLType::string()]; + + if ($parameter->getRequired()) { + $args[$key]['type'] = GraphQLType::nonNull($args[$key]['type']); + } + } + return $args; } + /** + * Transform the result of a parse_str to a GraphQL object type. + * + * @param array $flattenFields + */ + private function parameterToObjectType(array $flattenFields, string $name): InputObjectType + { + $fields = []; + foreach ($flattenFields as $field) { + $key = $field['name']; + $type = $this->getParameterType(\in_array($field['type'], Type::$builtinTypes, true) ? new Type($field['type'], !$field['required']) : new Type('object', !$field['required'], $field['type'])); + + if (\is_array($l = $field['leafs'])) { + if (0 === key($l)) { + $key = $key; + $type = GraphQLType::listOf($type); + } else { + $n = []; + foreach ($field['leafs'] as $l => $value) { + $n[] = ['required' => null, 'name' => $l, 'leafs' => $value, 'type' => 'string', 'description' => null]; + } + + $type = $this->parameterToObjectType($n, $key); + if (isset($fields[$key]) && ($t = $fields[$key]['type']) instanceof InputObjectType) { + $t = $fields[$key]['type']; + $t->config['fields'] = array_merge($t->config['fields'], $type->config['fields']); + $type = $t; + } + } + } + + if ($field['required']) { + $type = GraphQLType::nonNull($type); + } + + if (isset($fields[$key])) { + if ($type instanceof ListOfType) { + $key .= '_list'; + } + } + + $fields[$key] = ['type' => $type, 'name' => $key]; + } + + return new InputObjectType(['name' => $name, 'fields' => $fields]); + } + + /** + * A simplified version of convert type that does not support resources. + */ + private function getParameterType(Type $type): GraphQLType + { + return match ($type->getBuiltinType()) { + Type::BUILTIN_TYPE_BOOL => GraphQLType::boolean(), + Type::BUILTIN_TYPE_INT => GraphQLType::int(), + Type::BUILTIN_TYPE_FLOAT => GraphQLType::float(), + Type::BUILTIN_TYPE_STRING => GraphQLType::string(), + Type::BUILTIN_TYPE_ARRAY => GraphQLType::listOf($this->getParameterType($type->getCollectionValueTypes()[0])), + Type::BUILTIN_TYPE_ITERABLE => GraphQLType::listOf($this->getParameterType($type->getCollectionValueTypes()[0])), + Type::BUILTIN_TYPE_OBJECT => GraphQLType::string(), + default => GraphQLType::string(), + }; + } + /** * Get the field configuration of a resource. * @@ -433,6 +529,7 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root if (null === $resourceClass) { return $args; } + $d = false; foreach ($resourceOperation->getFilters() ?? [] as $filterId) { if (!$this->filterLocator->has($filterId)) { @@ -450,9 +547,9 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root } } - foreach ($this->filterLocator->get($filterId)->getDescription($entityClass) as $key => $value) { - $nullable = isset($value['required']) ? !$value['required'] : true; - $filterType = \in_array($value['type'], Type::$builtinTypes, true) ? new Type($value['type'], $nullable) : new Type('object', $nullable, $value['type']); + foreach ($this->filterLocator->get($filterId)->getDescription($entityClass) as $key => $description) { + $nullable = isset($description['required']) ? !$description['required'] : true; + $filterType = \in_array($description['type'], Type::$builtinTypes, true) ? new Type($description['type'], $nullable) : new Type('object', $nullable, $description['type']); $graphqlFilterType = $this->convertType($filterType, false, $resourceOperation, $rootOperation, $resourceClass, $rootResource, $property, $depth); if (str_ends_with($key, '[]')) { @@ -467,13 +564,21 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root if (\array_key_exists($key, $parsed) && \is_array($parsed[$key])) { $parsed = [$key => '']; } - array_walk_recursive($parsed, static function (&$value) use ($graphqlFilterType): void { - $value = $graphqlFilterType; + array_walk_recursive($parsed, static function (&$v) use ($graphqlFilterType): void { + $v = $graphqlFilterType; }); $args = $this->mergeFilterArgs($args, $parsed, $resourceOperation, $key); + + if (str_contains($filterId, 'order')) { + $d = true; + } } } + if (true === $d) { + // dd($this->convertFilterArgsToTypes($args)); + } + return $this->convertFilterArgsToTypes($args); } diff --git a/src/State/Provider/ParameterProvider.php b/src/State/Provider/ParameterProvider.php index dc47832839c..20329842b19 100644 --- a/src/State/Provider/ParameterProvider.php +++ b/src/State/Provider/ParameterProvider.php @@ -40,6 +40,9 @@ public function __construct(private readonly ?ProviderInterface $decorated = nul public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { $request = $context['request'] ?? null; + // if (!$request) { + // return $this->decorated?->provide($operation, $uriVariables, $context); + // } if ($request && null === $request->attributes->get('_api_query_parameters')) { $queryString = RequestParser::getQueryString($request); @@ -89,7 +92,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $operation = $operation->withParameters($operationParameters); - $request->attributes->set('_api_operation', $operation); + $request?->attributes->set('_api_operation', $operation); $context['operation'] = $operation; return $this->decorated?->provide($operation, $uriVariables, $context); @@ -105,6 +108,6 @@ private function extractParameterValues(Parameter $parameter, ?Request $request, } // GraphQl - return $context['_api_search_parameters'] ?? []; + return $context['args'] ?? []; } } diff --git a/src/Symfony/Bundle/Resources/config/graphql.xml b/src/Symfony/Bundle/Resources/config/graphql.xml index a089eebb2bc..9edae7441f5 100644 --- a/src/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Symfony/Bundle/Resources/config/graphql.xml @@ -153,6 +153,11 @@ %api_platform.graphql.nesting_separator% + + + + + diff --git a/tests/Fixtures/TestBundle/Document/DummyDtoNoOutput.php b/tests/Fixtures/TestBundle/Document/DummyDtoNoOutput.php index 5b17d0c71d6..37c242691eb 100644 --- a/tests/Fixtures/TestBundle/Document/DummyDtoNoOutput.php +++ b/tests/Fixtures/TestBundle/Document/DummyDtoNoOutput.php @@ -22,7 +22,7 @@ * * @author Vincent Chalamon */ -#[ApiResource(input: InputDto::class, output: false)] +#[ApiResource(input: InputDto::class)] #[ODM\Document] class DummyDtoNoOutput { diff --git a/tests/Fixtures/TestBundle/Entity/DummyDtoNoOutput.php b/tests/Fixtures/TestBundle/Entity/DummyDtoNoOutput.php index 9dc4bc7c792..1f80d917a0b 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyDtoNoOutput.php +++ b/tests/Fixtures/TestBundle/Entity/DummyDtoNoOutput.php @@ -22,7 +22,7 @@ * * @author Vincent Chalamon */ -#[ApiResource(input: InputDto::class, output: false)] +#[ApiResource(input: InputDto::class)] #[ORM\Entity] class DummyDtoNoOutput { diff --git a/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php index b438ccc4cb0..979824463fd 100644 --- a/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php +++ b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php @@ -22,21 +22,26 @@ final class SearchFilterValueTransformer implements FilterInterface { - public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, ?array $properties = null, private readonly ?string $key = null) + public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, private ?array $properties = null, private readonly ?string $key = null) { - if ($searchFilter instanceof PropertyAwareFilterInterface) { - $searchFilter->setProperties($properties); - } } // This function is only used to hook in documentation generators (supported by Swagger and Hydra) public function getDescription(string $resourceClass): array { + if ($this->searchFilter instanceof PropertyAwareFilterInterface) { + $this->searchFilter->setProperties($this->properties); + } + return $this->searchFilter->getDescription($resourceClass); } public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { + if ($this->searchFilter instanceof PropertyAwareFilterInterface) { + $this->searchFilter->setProperties($this->properties); + } + $this->searchFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters'][$this->key]] + $context); } } diff --git a/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php index bd0bd90a2c8..8f01e0570e4 100644 --- a/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php +++ b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php @@ -22,24 +22,32 @@ final class SearchTextAndDateFilter implements FilterInterface { - public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, #[Autowire('@api_platform.doctrine.orm.date_filter.instance')] readonly FilterInterface $dateFilter, protected ?array $properties = null, array $dateFilterProperties = [], array $searchFilterProperties = []) + public function __construct(#[Autowire('@api_platform.doctrine.orm.search_filter.instance')] readonly FilterInterface $searchFilter, #[Autowire('@api_platform.doctrine.orm.date_filter.instance')] readonly FilterInterface $dateFilter, protected ?array $properties = null, private array $dateFilterProperties = [], private array $searchFilterProperties = []) { - if ($searchFilter instanceof PropertyAwareFilterInterface) { - $searchFilter->setProperties($searchFilterProperties); - } - if ($dateFilter instanceof PropertyAwareFilterInterface) { - $dateFilter->setProperties($dateFilterProperties); - } } // This function is only used to hook in documentation generators (supported by Swagger and Hydra) public function getDescription(string $resourceClass): array { + if ($this->searchFilter instanceof PropertyAwareFilterInterface) { + $this->searchFilter->setProperties($this->searchFilterProperties); + } + if ($this->dateFilter instanceof PropertyAwareFilterInterface) { + $this->dateFilter->setProperties($this->dateFilterProperties); + } + return array_merge($this->searchFilter->getDescription($resourceClass), $this->dateFilter->getDescription($resourceClass)); } public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { + if ($this->searchFilter instanceof PropertyAwareFilterInterface) { + $this->searchFilter->setProperties($this->searchFilterProperties); + } + if ($this->dateFilter instanceof PropertyAwareFilterInterface) { + $this->dateFilter->setProperties($this->dateFilterProperties); + } + $this->searchFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters']['searchOnTextAndDate']] + $context); $this->dateFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $context['filters']['searchOnTextAndDate']] + $context); } diff --git a/tests/Parameter/ParameterTests.php b/tests/Parameter/ParameterTests.php index 3c0dec30494..68982c85f0b 100644 --- a/tests/Parameter/ParameterTests.php +++ b/tests/Parameter/ParameterTests.php @@ -98,6 +98,32 @@ public function testDoctrineEntitySearchFilter(): void $this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $members[0]); } + public function testGraphQl() { + $this->recreateSchema(); + $container = static::getContainer(); + $object = 'mongodb' === $container->getParameter('kernel.environment') ? 'searchFilterParameterDocuments' : 'searchFilterParameters'; + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => sprintf('{ %s(foo: "bar") { edges { node { id foo createdAt } } } }', $object) + ]]); + $this->assertEquals('bar', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); + + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => sprintf('{ %s(searchPartial: {foo: "az"}) { edges { node { id foo createdAt } } } }', $object) + ]]); + $this->assertEquals('baz', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); + + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => sprintf('{ %s(searchExact: {foo: "baz"}) { edges { node { id foo createdAt } } } }', $object) + ]]); + $this->assertEquals('baz', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); + + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => sprintf('{ %s(searchOnTextAndDate: {foo: "bar", createdAt: {before: "2024-01-21"}}) { edges { node { id foo createdAt } } } }', $object) + ]]); + $this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $response->toArray()['data'][$object]['edges'][0]['node']); + + } + /** * @param array $options kernel options */