From 3ad3836d5427951ff2519380738f6c423f697742 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 26 Mar 2024 18:32:30 +0100 Subject: [PATCH] feat(metadata): attribute Parameter (#6246) --- src/Metadata/ApiFilter.php | 2 + src/Metadata/ApiResource.php | 2 + src/Metadata/Delete.php | 2 + .../Extractor/XmlResourceExtractor.php | 51 ++++- .../Extractor/YamlResourceExtractor.php | 46 +++++ src/Metadata/Extractor/schema/resources.xsd | 24 +++ src/Metadata/FilterInterface.php | 2 + src/Metadata/Get.php | 2 + src/Metadata/GetCollection.php | 2 + src/Metadata/GraphQl/Operation.php | 3 + src/Metadata/GraphQl/Query.php | 3 + src/Metadata/GraphQl/QueryCollection.php | 3 + src/Metadata/GraphQl/Subscription.php | 3 + src/Metadata/HeaderParameter.php | 22 ++ src/Metadata/HeaderParameterInterface.php | 21 ++ src/Metadata/HttpOperation.php | 2 + src/Metadata/IdentifiersExtractor.php | 4 +- src/Metadata/Link.php | 41 +++- src/Metadata/Metadata.php | 39 +++- src/Metadata/Operation.php | 27 +-- src/Metadata/Parameter.php | 191 ++++++++++++++++++ src/Metadata/Parameters.php | 114 +++++++++++ src/Metadata/Patch.php | 2 + src/Metadata/Post.php | 2 + src/Metadata/Put.php | 2 + src/Metadata/QueryParameter.php | 22 ++ src/Metadata/QueryParameterInterface.php | 21 ++ ...butesResourceMetadataCollectionFactory.php | 47 ++++- ...ltersResourceMetadataCollectionFactory.php | 9 +- ...meterResourceMetadataCollectionFactory.php | 126 ++++++++++++ ...plateResourceMetadataCollectionFactory.php | 3 +- .../Extractor/Adapter/XmlResourceAdapter.php | 17 ++ .../Tests/Extractor/Adapter/resources.xml | 2 +- .../Tests/Extractor/Adapter/resources.yaml | 6 + .../ResourceMetadataCompatibilityTest.php | 27 ++- .../Tests/Extractor/XmlExtractorTest.php | 14 ++ .../Tests/Extractor/YamlExtractorTest.php | 7 + src/Metadata/Tests/Extractor/xml/valid.xml | 17 ++ src/Metadata/Tests/Extractor/yaml/valid.yaml | 7 + .../Fixtures/ApiResource/WithParameter.php | 39 ++++ ...sResourceMetadataCollectionFactoryTest.php | 12 ++ ...ResourceMetadataCollectionFactoryTests.php | 49 +++++ .../Util/AttributeFilterExtractorTrait.php | 10 +- 43 files changed, 1004 insertions(+), 43 deletions(-) create mode 100644 src/Metadata/HeaderParameter.php create mode 100644 src/Metadata/HeaderParameterInterface.php create mode 100644 src/Metadata/Parameter.php create mode 100644 src/Metadata/Parameters.php create mode 100644 src/Metadata/QueryParameter.php create mode 100644 src/Metadata/QueryParameterInterface.php create mode 100644 src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php create mode 100644 src/Metadata/Tests/Fixtures/ApiResource/WithParameter.php create mode 100644 src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTests.php 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..b8b1afb08b5 --- /dev/null +++ b/src/Metadata/HeaderParameter.php @@ -0,0 +1,22 @@ + + * + * 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 + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +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..a66e588d3b6 --- /dev/null +++ b/src/Metadata/Parameters.php @@ -0,0 +1,114 @@ + + * + * 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 get(string $key): ?Parameter + { + foreach ($this->parameters as $i => [$parameterName, $parameter]) { + if ($parameterName === $key) { + return $parameter; + } + } + + return null; + } + + 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..0b01bc75929 --- /dev/null +++ b/src/Metadata/QueryParameter.php @@ -0,0 +1,22 @@ + + * + * 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 + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +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/AttributesResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php index cd391d0cb1a..01c1d628c00 100644 --- a/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php @@ -15,9 +15,12 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Metadata; use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\Util\CamelCaseToSnakeCaseNameConverter; use Psr\Log\LoggerInterface; @@ -83,8 +86,18 @@ private function buildResourceOperations(array $attributes, string $resourceClas $index = -1; $operationPriority = 0; $hasApiResource = false; + $globalParameters = []; foreach ($attributes as $attribute) { + if (is_a($attribute->getName(), Parameter::class, true)) { + $parameter = $attribute->newInstance(); + if (!$k = $parameter->getKey()) { + throw new RuntimeException('Parameter "key" is mandatory when used on a class.'); + } + $globalParameters[$k] = $parameter; + continue; + } + if (is_a($attribute->getName(), ApiResource::class, true)) { $hasApiResource = true; $resource = $this->getResourceWithDefaults($resourceClass, $shortName, $attribute->newInstance()); @@ -128,6 +141,10 @@ private function buildResourceOperations(array $attributes, string $resourceClas // Loop again and set default operations if none where found foreach ($resources as $index => $resource) { + if ($globalParameters) { + $resources[$index] = $resource = $this->mergeOperationParameters($resource, $globalParameters); + } + if (null === $resource->getOperations()) { $operations = []; foreach ($this->getDefaultHttpOperations($resource) as $operation) { @@ -137,8 +154,15 @@ private function buildResourceOperations(array $attributes, string $resourceClas $resources[$index] = $resource->withOperations(new Operations($operations)); } - $graphQlOperations = $resource->getGraphQlOperations(); + if ($parameters = $resource->getParameters()) { + $operations = []; + foreach ($resource->getOperations() ?? [] as $operation) { + $operations[$operation->getName()] = $this->mergeOperationParameters($operation, $parameters); + } + $resources[$index] = $resource = $resource->withOperations(new Operations($operations)); // @phpstan-ignore-line + } + $graphQlOperations = $resource->getGraphQlOperations(); if (!$this->graphQlEnabled) { continue; } @@ -162,6 +186,10 @@ private function buildResourceOperations(array $attributes, string $resourceClas $graphQlOperationsWithDefaults = []; foreach ($graphQlOperations as $operation) { [$key, $operation] = $this->getOperationWithDefaults($resource, $operation); + if ($parameters) { + $operation = $this->mergeOperationParameters($operation, $parameters); + } + $graphQlOperationsWithDefaults[$key] = $operation; } @@ -200,4 +228,21 @@ private function hasSameOperation(ApiResource $resource, string $operationClass, return false; } + + /** + * @template T of Metadata + * + * @param Parameter[] $globalParameters + * @param T $resource + * + * @return T + */ + private function mergeOperationParameters(Metadata $resource, array $globalParameters): Metadata + { + $parameters = $resource->getParameters() ?? []; + + return $resource->withParameters( + (\is_array($parameters) ? $parameters : iterator_to_array($parameters)) + $globalParameters + ); + } } 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..00680c26fec --- /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 = $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['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/Tests/Fixtures/ApiResource/WithParameter.php b/src/Metadata/Tests/Fixtures/ApiResource/WithParameter.php new file mode 100644 index 00000000000..08cce46ada4 --- /dev/null +++ b/src/Metadata/Tests/Fixtures/ApiResource/WithParameter.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HeaderParameter; +use ApiPlatform\Metadata\QueryParameter; + +#[ApiResource( + parameters: [ + 'another' => new HeaderParameter(), + ] +)] +#[GetCollection( + name: 'collection', + uriTemplate: 'collection', + parameters: [ + 'hydra' => new QueryParameter(property: 'a', required: true, filter: 'filter'), + ], +)] +#[QueryParameter(key: 'everywhere', filter: 'filter')] +class WithParameter +{ + public int $id = 1; + + public $a = 'foo'; +} diff --git a/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php index 8c9820a1ecd..f3c457553d2 100644 --- a/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php @@ -34,6 +34,7 @@ use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\AttributeResources; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\ExtraPropertiesResource; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\PasswordResource; +use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\WithParameter; use ApiPlatform\Metadata\Tests\Fixtures\State\AttributeResourceProcessor; use ApiPlatform\Metadata\Tests\Fixtures\State\AttributeResourceProvider; use PHPUnit\Framework\TestCase; @@ -259,4 +260,15 @@ public function testNameDeclarationShouldNotBeRemoved(): void $this->assertTrue($operations->has('password_reset_token')); $this->assertTrue($operations->has('password_reset')); } + + public function testWithParameters(): void + { + $attributeResourceMetadataCollectionFactory = new AttributesResourceMetadataCollectionFactory(); + + $metadataCollection = $attributeResourceMetadataCollectionFactory->create(WithParameter::class); + $parameters = $metadataCollection[0]->getParameters(); + $this->assertCount(2, $parameters); + $parameters = $metadataCollection->getOperation('collection')->getParameters(); + $this->assertCount(3, $parameters); + } } diff --git a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTests.php b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTests.php new file mode 100644 index 00000000000..02e16afda21 --- /dev/null +++ b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTests.php @@ -0,0 +1,49 @@ + + * + * 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\Tests\Resource\Factory; + +use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\Resource\Factory\AttributesResourceMetadataCollectionFactory; +use ApiPlatform\Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory; +use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\WithParameter; +use ApiPlatform\OpenApi\Model\Parameter; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; + +class ParameterResourceMetadataCollectionFactoryTests extends TestCase +{ + public function testParameterFactory(): void + { + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(true); + $filterLocator->method('get')->willReturn(new class() implements FilterInterface { + public function getDescription(string $resourceClass): array + { + return [ + 'hydra' => ['schema' => ['type' => 'foo'], 'openapi' => new Parameter('test', 'query')], + 'everywhere' => ['openapi' => ['allowEmptyValue' => true]], + ]; + } + }); + $parameter = new ParameterResourceMetadataCollectionFactory(new AttributesResourceMetadataCollectionFactory(), $filterLocator); + $operation = $parameter->create(WithParameter::class)->getOperation('collection'); + $this->assertInstanceOf(Parameters::class, $parameters = $operation->getParameters()); + $hydraParameter = $parameters->get('hydra'); + $this->assertEquals(['type' => 'foo'], $hydraParameter->getSchema()); + $this->assertEquals(new Parameter('test', 'query'), $hydraParameter->getOpenApi()); + $everywhere = $parameters->get('everywhere'); + $this->assertEquals(new Parameter('everywhere', 'query', allowEmptyValue: true), $everywhere->getOpenApi()); + } +} 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)) {