diff --git a/docs/adr/0006-filtering-system-and-parameters.md b/docs/adr/0006-filtering-system-and-parameters.md new file mode 100644 index 00000000000..9f87fcbd115 --- /dev/null +++ b/docs/adr/0006-filtering-system-and-parameters.md @@ -0,0 +1,259 @@ +# 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 +- 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. + +### 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 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. + +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. 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. + +## 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 a `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. + */ +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; +} +``` + +This provider can: + +1. alter the HTTP Operation to provide additional context: + +```php +class GroupsParameterProvider implements ParameterProviderInterface { + public function provider(Parameter $parameter, array $uriVariables = [], array $context = []): HttpOperation + { + $request = $context['request']; + return $context['operation']->withNormalizationContext(['groups' => $request->query->all('groups')]); + } +} +``` + +2. alter the parameter context: + +```php +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); + 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 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 +// 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] +* [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" diff --git a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php new file mode 100644 index 00000000000..aa0857cef20 --- /dev/null +++ b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php @@ -0,0 +1,27 @@ + + * + * 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; + +/** + * @author Antoine Bluchet + * + * @experimental + */ +interface PropertyAwareFilterInterface +{ + /** + * @param string[] $properties + */ + public function setProperties(array $properties): void; +} diff --git a/src/Doctrine/Common/Filter/UrlPatternToFilterContextTrait.php b/src/Doctrine/Common/Filter/UrlPatternToFilterContextTrait.php new file mode 100644 index 00000000000..8d07488f411 --- /dev/null +++ b/src/Doctrine/Common/Filter/UrlPatternToFilterContextTrait.php @@ -0,0 +1,28 @@ + + * + * 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. + * + * @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 new file mode 100644 index 00000000000..8fa4ca5db78 --- /dev/null +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -0,0 +1,71 @@ + + * + * 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\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 + { + foreach ($operation->getParameters() ?? [] as $parameter) { + $values = $parameter->getExtraProperties()['_api_values'] ?? []; + if (!$values) { + continue; + } + + if (null === ($filterId = $parameter->getFilter())) { + continue; + } + + $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + if ($filter instanceof FilterInterface) { + $filterContext = ['filters' => $values]; + $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/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php new file mode 100644 index 00000000000..792f311dd09 --- /dev/null +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -0,0 +1,70 @@ + + * + * 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\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; +use Psr\Container\ContainerInterface; + +/** + * Reads operation parameters and execute its filter. + * + * @author Antoine Bluchet + */ +final class ParameterExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface +{ + public function __construct(private readonly ContainerInterface $filterLocator) + { + } + + /** + * @param array $context + */ + private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + foreach ($operation->getParameters() ?? [] as $parameter) { + $values = $parameter->getExtraProperties()['_api_values'] ?? []; + if (!$values) { + continue; + } + + if (null === ($filterId = $parameter->getFilter())) { + continue; + } + + $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + if ($filter instanceof FilterInterface) { + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values] + $context); + } + } + } + + /** + * {@inheritdoc} + */ + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?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/Doctrine/Orm/Filter/AbstractFilter.php b/src/Doctrine/Orm/Filter/AbstractFilter.php index c1deb1c0a90..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 FilterInterface +abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface { use OrmPropertyHelperTrait; use PropertyHelperTrait; @@ -64,6 +65,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/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/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index 1ccc0203222..1f605aee7c0 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -18,6 +18,9 @@ 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\Parameters; +use ApiPlatform\Metadata\QueryParameterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Serializer\CacheableSupportsMethodInterface; @@ -97,8 +100,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 +128,8 @@ public function normalize(mixed $object, ?string $format = null, array $context } } - if ($currentFilters) { - $data['hydra:search'] = $this->getSearch($resourceClass, $requestParts, $currentFilters); + if ($currentFilters || ($parameters && \count($parameters))) { + $data['hydra:search'] = $this->getSearch($resourceClass, $requestParts, $currentFilters, $parameters); } return $data; @@ -144,8 +149,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|null $parameters): array { $variables = []; $mapping = []; @@ -156,6 +162,45 @@ 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) { + continue; + } + + 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; + } + + // :property is a pattern allowed when defining parameters + $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())) { + $m['required'] = $required; + } + $mapping[] = $m; + } + + continue; + } + + if (!$property) { + continue; + } + + $m = ['@type' => 'IriTemplateMapping', 'variable' => $key, 'property' => $property]; + $variables[] = $key; + 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/ApiFilter.php b/src/Metadata/ApiFilter.php index b11e3db09d4..0e6004fcf24 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 filter tag 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/ApiResource.php b/src/Metadata/ApiResource.php index 7cc6e2f2a19..12072f11263 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 $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/Delete.php b/src/Metadata/Delete.php index cfaa3d0f03f..8cd4bcc9c2b 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, + array|Parameters|null $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 e676fcbe929..e92785331a9 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,48 @@ 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, + 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') ?? [], + ); + } + + return $parameters; + } } diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index 2858752cd24..da539bea856 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,47 @@ 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'], + 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($parameter, 'extraProperties') ?? [], + ); + } + + return $parameters; + } } diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index dbc818d5fe8..6121622d758 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -433,6 +433,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -444,6 +467,7 @@ + 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/Get.php b/src/Metadata/Get.php index fe098a2002f..50d51adf282 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 $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..d6e48716e65 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 $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..5b3aa7338b8 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,6 +85,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + array|Parameters|null $parameters = null, array $extraProperties = [] ) { parent::__construct( @@ -131,6 +133,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..16f9ee66b39 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,6 +69,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + array|Parameters|null $parameters = null, array $extraProperties = [], protected ?bool $nested = null, @@ -121,6 +123,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..a78c0ed7e41 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,6 +70,7 @@ public function __construct( $provider = null, $processor = null, protected ?OptionsInterface $stateOptions = null, + array|Parameters|null $parameters = null, array $extraProperties = [], ?bool $nested = null, @@ -121,6 +123,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..9ae13d28511 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,6 +69,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + array|Parameters|null $parameters = null, array $extraProperties = [], ) { parent::__construct( @@ -119,6 +121,7 @@ class: $class, provider: $provider, processor: $processor, stateOptions: $stateOptions, + parameters: $parameters, extraProperties: $extraProperties, ); } diff --git a/src/Metadata/HeaderParameter.php b/src/Metadata/HeaderParameter.php new file mode 100644 index 00000000000..be63df9dfd2 --- /dev/null +++ b/src/Metadata/HeaderParameter.php @@ -0,0 +1,21 @@ + + * + * 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; + +/** + * @experimental + */ +class HeaderParameter extends Parameter implements HeaderParameterInterface +{ +} diff --git a/src/Metadata/HeaderParameterInterface.php b/src/Metadata/HeaderParameterInterface.php new file mode 100644 index 00000000000..b54a943579d --- /dev/null +++ b/src/Metadata/HeaderParameterInterface.php @@ -0,0 +1,21 @@ + + * + * 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; + +/** + * @experimental + */ +interface HeaderParameterInterface +{ +} diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index dfbc84abdf0..99373140d26 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 $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/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 4f2715aa55d..1e56e602641 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|\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( protected ?string $shortName = null, @@ -69,6 +70,10 @@ public function __construct( protected $provider = null, protected $processor = null, protected ?OptionsInterface $stateOptions = null, + /** + * @experimental + */ + protected array|Parameters|null $parameters = [], protected array $extraProperties = [] ) { } @@ -566,6 +571,22 @@ public function withStateOptions(?OptionsInterface $stateOptions): static return $self; } + /** + * @return array + */ + public function getParameters(): array|Parameters|null + { + return $this->parameters; + } + + public function withParameters(array|Parameters $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..45b25dfeff7 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -47,18 +47,19 @@ 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( protected ?string $shortName = null, @@ -805,6 +806,7 @@ public function __construct( protected $provider = null, protected $processor = null, protected ?OptionsInterface $stateOptions = null, + protected array|Parameters|null $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..d0e9bb8c067 --- /dev/null +++ b/src/Metadata/Parameter.php @@ -0,0 +1,191 @@ + + * + * 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; + +/** + * @experimental + */ +abstract class Parameter +{ + /** + * @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 ?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 ?int $priority = null, + protected ?array $extraProperties = [], + ) { + } + + public function getKey(): ?string + { + return $this->key; + } + + /** + * @return array{type?: string}|null $schema + */ + public function getSchema(): ?array + { + return $this->schema; + } + + public function getOpenApi(): ?OpenApi\Model\Parameter + { + return $this->openApi; + } + + public function getProvider(): mixed + { + return $this->provider; + } + + public function getProperty(): ?string + { + return $this->property; + } + + public function getFilter(): mixed + { + return $this->filter; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function getRequired(): ?bool + { + return $this->required; + } + + public function getPriority(): ?int + { + return $this->priority; + } + + /** + * @return array + */ + public function getExtraProperties(): array + { + return $this->extraProperties; + } + + public function withKey(string $key): static + { + $self = clone $this; + $self->key = $key; + + return $self; + } + + public function withPriority(int $priority): static + { + $self = clone $this; + $self->priority = $priority; + + return $self; + } + + /** + * @param array{type?: string} $schema + */ + public function withSchema(array $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; + } + + /** + * @param ProviderInterface|string $provider + */ + public function withProvider(mixed $provider): static + { + $self = clone $this; + $self->provider = $provider; + + return $self; + } + + /** + * @param FilterInterface|string $filter + */ + public function withFilter(mixed $filter): static + { + $self = clone $this; + $self->filter = $filter; + + return $self; + } + + public function withProperty(string $property): static + { + $self = clone $this; + $self->property = $property; + + 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 + */ + public function withExtraProperties(array $extraProperties): static + { + $self = clone $this; + $self->extraProperties = $extraProperties; + + return $self; + } +} diff --git a/src/Metadata/Parameters.php b/src/Metadata/Parameters.php new file mode 100644 index 00000000000..a562774d233 --- /dev/null +++ b/src/Metadata/Parameters.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; + +/** + * A parameter dictionnary. + * + * @implements \IteratorAggregate + */ +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(); + } + + /** + * @return \ArrayIterator + */ + 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 e136934d33f..06adec587fa 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -94,6 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + array|Parameters|null $parameters = null, array $extraProperties = [], ) { parent::__construct( @@ -170,6 +171,7 @@ class: $class, provider: $provider, processor: $processor, stateOptions: $stateOptions, + parameters: $parameters, extraProperties: $extraProperties ); } diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index 3fa8fda7593..e79ff5f3cb2 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -94,6 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + array|Parameters|null $parameters = null, array $extraProperties = [], private ?string $itemUriTemplate = null ) { @@ -171,6 +172,7 @@ class: $class, provider: $provider, processor: $processor, stateOptions: $stateOptions, + parameters: $parameters, extraProperties: $extraProperties ); } diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index b000ac50f8d..176ca17fa32 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -94,6 +94,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + array|Parameters|null $parameters = null, array $extraProperties = [], private ?bool $allowCreate = null, ) { @@ -171,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..776fd7f70d2 --- /dev/null +++ b/src/Metadata/QueryParameter.php @@ -0,0 +1,21 @@ + + * + * 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; + +/** + * @experimental + */ +class QueryParameter extends Parameter implements QueryParameterInterface +{ +} diff --git a/src/Metadata/QueryParameterInterface.php b/src/Metadata/QueryParameterInterface.php new file mode 100644 index 00000000000..3315b96d84c --- /dev/null +++ b/src/Metadata/QueryParameterInterface.php @@ -0,0 +1,21 @@ + + * + * 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; + +/** + * @experimental + */ +interface QueryParameterInterface +{ +} 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/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php new file mode 100644 index 00000000000..66dcb962890 --- /dev/null +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -0,0 +1,126 @@ + + * + * 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\Parameter; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\OpenApi; +use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; +use Psr\Container\ContainerInterface; + +/** + * Prepares Parameters documentation by reading its filter details and declaring an OpenApi parameter. + * + * @experimental + */ +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(); + + $internalPriority = -1; + foreach ($operations 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); + } + + $operations->add($operationName, $operation->withParameters(new Parameters($parameters))); + } + + $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/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/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 698b6213871..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 @@ -330,6 +335,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..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 @@ -749,4 +750,18 @@ private function withLinks(array $values): ?array return [new Link($values[0]['rel'] ?? null, $values[0]['href'] ?? null)]; } + + private function withParameters(array $values): ?array + { + 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..be195c2b976 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: ['foo' => 'bar'] + ), + ], ], ], '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..ea891fa654a 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', description: 'hello')], ], ], '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 52276015291..a725e652894 100644 --- a/src/Metadata/Tests/Extractor/xml/valid.xml +++ b/src/Metadata/Tests/Extractor/xml/valid.xml @@ -112,7 +112,24 @@ true + + + + + + string + + + + + bar + + + + + + 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/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/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..4780a582e25 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('hi', $parameter->getDescription()); } } diff --git a/src/Serializer/Parameter/SerializerFilterParameterProvider.php b/src/Serializer/Parameter/SerializerFilterParameterProvider.php new file mode 100644 index 00000000000..2be9600e533 --- /dev/null +++ b/src/Serializer/Parameter/SerializerFilterParameterProvider.php @@ -0,0 +1,64 @@ + + * + * 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\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Serializer\Filter\FilterInterface; +use ApiPlatform\State\ParameterProviderInterface; +use ApiPlatform\State\Util\RequestAttributesExtractor; +use Psr\Container\ContainerInterface; + +/** + * @experimental + */ +final class SerializerFilterParameterProvider implements ParameterProviderInterface +{ + public function __construct(private readonly ?ContainerInterface $filterLocator) + { + } + + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation + { + 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..da8d6ef1f99 --- /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\Operation; +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: Operation} $context + */ + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation; +} diff --git a/src/State/Provider/ParameterProvider.php b/src/State/Provider/ParameterProvider.php new file mode 100644 index 00000000000..20329842b19 --- /dev/null +++ b/src/State/Provider/ParameterProvider.php @@ -0,0 +1,113 @@ + + * + * 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\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 +{ + 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 + { + $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); + $request->attributes->set('_api_query_parameters', $queryString ? RequestParser::parseRequestParams($queryString) : []); + } + + if ($request && null === $request->attributes->get('_api_header_parameters')) { + $request->attributes->set('_api_header_parameters', $request->headers->all()); + } + + $context = ['operation' => $operation] + $context; + $operationParameters = iterator_to_array($operation->getParameters() ?? []); + foreach ($operationParameters as $parameter) { + $key = $parameter->getKey(); + $parameters = $this->extractParameterValues($parameter, $request, $context); + $parsedKey = explode('[:property]', $key); + if (isset($parsedKey[0]) && isset($parameters[$parsedKey[0]])) { + $key = $parsedKey[0]; + } + + 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; + continue; + } + + if (!\is_string($provider) || !$this->locator->has($provider)) { + throw new ProviderNotFoundException(sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); + } + + /** @var ParameterProviderInterface $providerInstance */ + $providerInstance = $this->locator->get($provider); + if (($op = $providerInstance->provide($parameter, $parameters, $context)) instanceof HttpOperation) { + $operation = $op; + } + } + + $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['args'] ?? []; + } +} 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/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/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 ad0d77a5121..14d197c01fc 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 @@ + + + @@ -120,6 +138,13 @@ + + + + + + + @@ -159,6 +184,10 @@ + + + + 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/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/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/ApiResource/WithParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php new file mode 100644 index 00000000000..dbc9c20c75f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -0,0 +1,97 @@ + + * + * 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\GetCollection; +use ApiPlatform\Metadata\HeaderParameter; +use ApiPlatform\Metadata\Link; +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( + uriTemplate: 'with_parameters/{id}{._format}', + 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']), + '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), + 'array' => new QueryParameter(provider: [self::class, 'assertArray']), + ], + provider: [self::class, 'provide'] +)] +#[GetCollection( + uriTemplate: 'with_parameters_collection', + parameters: [ + 'hydra' => new QueryParameter(property: 'a', required: true), + ], + provider: [self::class, 'collectionProvider'] +)] +class WithParameter +{ + protected static int $counter = 1; + 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(); + } + + public static function assertArray(): void + { + } + + public static function assertFirst(): void + { + \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 = []) + { + $operation = $context['operation']; + + return $operation->withNormalizationContext(['groups' => $parameters['group']]); + } + + public static function restrictAccess(): void + { + throw new AccessDeniedHttpException(); + } +} 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/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/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/Entity/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php new file mode 100644 index 00000000000..133e98adcd1 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php @@ -0,0 +1,90 @@ + + * + * 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\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; +use Doctrine\ORM\Mapping as ORM; + +#[GetCollection( + uriTemplate: 'search_filter_parameter{._format}', + 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'), + ] +)] +#[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']])] +#[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 = ''; + + #[ORM\Column(type: 'datetime_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 new file mode 100644 index 00000000000..979824463fd --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/SearchFilterValueTransformer.php @@ -0,0 +1,47 @@ + + * + * 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\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, private ?array $properties = null, private readonly ?string $key = null) + { + } + + // 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 new file mode 100644 index 00000000000..8f01e0570e4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/SearchTextAndDateFilter.php @@ -0,0 +1,54 @@ + + * + * 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\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, protected ?array $properties = null, private array $dateFilterProperties = [], private array $searchFilterProperties = []) + { + } + + // 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/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/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 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/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/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 new file mode 100644 index 00000000000..68982c85f0b --- /dev/null +++ b/tests/Parameter/ParameterTests.php @@ -0,0 +1,162 @@ + + * + * 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; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SearchFilterParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SearchFilterParameter; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Tools\SchemaTool; + +final class ParameterTests extends ApiTestCase +{ + public function testWithGroupFilter(): void + { + $response = self::createClient()->request('GET', 'with_parameters/1?groups[]=b'); + $this->assertArraySubset(['b' => 'bar'], $response->toArray()); + $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/1?group[]=b&group[]=a'); + $this->assertArraySubset(['a' => 'foo', 'b' => 'bar'], $response->toArray()); + } + + public function testWithServiceFilter(): void + { + $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/1?service=blabla'); + $this->assertArrayNotHasKey('a', $response->toArray()); + } + + public function testWithHeader(): void + { + 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()); + } + + public function testDoctrineEntitySearchFilter(): void + { + $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(); + $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' => 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', $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', $route.'?searchPartial[foo]=az'); + $members = $response->toArray()['hydra:member']; + $this->assertCount(1, $members); + $this->assertArraySubset(['foo' => 'baz'], $members[0]); + + $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]); + } + + 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 + */ + private function recreateSchema(array $options = []): void + { + self::bootKernel($options); + + $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(); + } + + $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'); + } + + $manager->persist($s); + } + $manager->flush(); + } +} 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');