From a31edccf982396faed09ab3be99f5d5b57b8ab34 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 12 Nov 2026 10:28:01 +0100 Subject: [PATCH] feat(metadata): several resources on a shared class Alternative to #8813 for #8791: keep a real class, carry one ApiResource per dynamic model and pick it through ResourceAwareInterface; property factories receive the object's operation_name next to root_operation_name. --- src/JsonLd/Serializer/ItemNormalizer.php | 8 +- src/JsonSchema/SchemaFactory.php | 8 +- .../Resource/ResourceMetadataCollection.php | 15 ++ src/Metadata/ResourceAwareInterface.php | 19 +++ src/Serializer/AbstractItemNormalizer.php | 19 ++- .../Tests/AbstractItemNormalizerTest.php | 2 +- src/Symfony/Routing/IriConverter.php | 6 +- .../DynamicPropertyMetadataFactory.php | 38 +++++ .../DynamicPropertyNameCollectionFactory.php | 37 +++++ .../DynamicResource/DynamicRecord.php | 46 ++++++ .../DynamicResourceDefinitions.php | 136 ++++++++++++++++++ .../DynamicResource/DynamicResourceHost.php | 33 +++++ ...namicResourceMetadataCollectionFactory.php | 33 +++++ .../DynamicResourceNameCollectionFactory.php | 35 +++++ tests/Fixtures/app/config/config_common.yml | 20 +++ tests/Functional/DynamicResourceTest.php | 109 ++++++++++++++ 16 files changed, 554 insertions(+), 10 deletions(-) create mode 100644 src/Metadata/ResourceAwareInterface.php create mode 100644 tests/Fixtures/TestBundle/DynamicResource/DynamicPropertyMetadataFactory.php create mode 100644 tests/Fixtures/TestBundle/DynamicResource/DynamicPropertyNameCollectionFactory.php create mode 100644 tests/Fixtures/TestBundle/DynamicResource/DynamicRecord.php create mode 100644 tests/Fixtures/TestBundle/DynamicResource/DynamicResourceDefinitions.php create mode 100644 tests/Fixtures/TestBundle/DynamicResource/DynamicResourceHost.php create mode 100644 tests/Fixtures/TestBundle/DynamicResource/DynamicResourceMetadataCollectionFactory.php create mode 100644 tests/Fixtures/TestBundle/DynamicResource/DynamicResourceNameCollectionFactory.php create mode 100644 tests/Functional/DynamicResourceTest.php diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index e63202613f6..4f83ec831ea 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\Metadata\ResourceAwareInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; @@ -91,8 +92,11 @@ public function normalize(mixed $data, ?string $format = null, array $context = $isResourceClass = $this->resourceClassResolver->isResourceClass($resourceClass); if ($isResourceClass && (null === $previousResourceClass || $this->resourceClassResolver->isResourceClass($previousResourceClass))) { $resourceClass = $this->resourceClassResolver->getResourceClass($data, $previousResourceClass); - if (isset($context['operation']) && $context['operation'] instanceof HttpOperation && $context['operation']->getClass() !== $resourceClass) { - $context['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(null, false, true); + if ( + ($data instanceof ResourceAwareInterface && !isset($context['operation'])) + || (isset($context['operation']) && $context['operation'] instanceof HttpOperation && $context['operation']->getClass() !== $resourceClass) + ) { + $context['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperationForObject($data, false, true); } $context = $this->initContext($resourceClass, $context); $metadata = $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context); diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index d2b0da0ebe8..aa3afdb83ec 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -154,7 +154,7 @@ public function buildSchema(string $className, string $format = 'json', string $ $definition['externalDocs'] = ['url' => $operation->getTypes()[0]]; } - $options = ['schema_type' => $type] + $this->getFactoryOptions($serializerContext, $validationGroups); + $options = ['schema_type' => $type] + $this->getFactoryOptions($serializerContext, $validationGroups, $operation); foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) { $propertyMetadata = $this->propertyMetadataFactory->create($inputOrOutputClass, $propertyName, $options); @@ -387,7 +387,7 @@ private function getValidationGroups(Operation $operation): array /** * Gets the options for the property name collection / property metadata factories. */ - private function getFactoryOptions(array $serializerContext, array $validationGroups): array + private function getFactoryOptions(array $serializerContext, array $validationGroups, ?Operation $operation = null): array { $options = [ /* @see https://github.com/symfony/symfony/blob/v5.1.0/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php */ @@ -403,6 +403,10 @@ private function getFactoryOptions(array $serializerContext, array $validationGr $options['serializer_attributes'] = (array) $serializerContext[AbstractNormalizer::ATTRIBUTES]; } + if ($operationName = $operation?->getName()) { + $options['operation_name'] = $operationName; + } + if ($validationGroups) { $options['validation_groups'] = $validationGroups; } diff --git a/src/Metadata/Resource/ResourceMetadataCollection.php b/src/Metadata/Resource/ResourceMetadataCollection.php index c51f084441b..a459ea122fd 100644 --- a/src/Metadata/Resource/ResourceMetadataCollection.php +++ b/src/Metadata/Resource/ResourceMetadataCollection.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Exception\OperationNotFoundException; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\ResourceAwareInterface; use ApiPlatform\Metadata\Util\UriTemplateHelper; /** @@ -36,6 +37,20 @@ public function __construct(private readonly string $resourceClass, array $input parent::__construct($input); } + public function getOperationForObject(object $object, bool $forceCollection = false, bool $httpOperation = false): Operation + { + if ($object instanceof ResourceAwareInterface) { + $shortName = $object->getResource(); + foreach ($this as $resource) { + if ($shortName === $resource->getShortName()) { + return (new self($this->resourceClass, [$resource]))->getOperation(null, $forceCollection, $httpOperation); + } + } + } + + return $this->getOperation(null, $forceCollection, $httpOperation); + } + public function getOperation(?string $operationName = null, bool $forceCollection = false, bool $httpOperation = false, bool $forceGraphQl = false): Operation { $operationName ??= ''; diff --git a/src/Metadata/ResourceAwareInterface.php b/src/Metadata/ResourceAwareInterface.php new file mode 100644 index 00000000000..70d48d3d268 --- /dev/null +++ b/src/Metadata/ResourceAwareInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +interface ResourceAwareInterface +{ + public function getResource(): string; +} diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index c65db601ed2..f1857f3ba37 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -24,6 +24,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\Metadata\ResourceAwareInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; @@ -172,6 +173,10 @@ public function normalize(mixed $data, ?string $format = null, array $context = $context = $this->initContext($resourceClass, $context); } + if ($data instanceof ResourceAwareInterface && !isset($context['operation']) && $this->resourceMetadataCollectionFactory && $this->resourceClassResolver->isResourceClass($resourceClass)) { + $context['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperationForObject($data, false, true); + } + $context['api_normalize'] = true; $iri = $context['iri'] ??= $this->iriConverter->getIriFromResource($data, UrlGeneratorInterface::ABS_URL, $context['operation'] ?? null, $context); @@ -813,7 +818,10 @@ protected function getFactoryOptions(array $context): array $options['serializer_groups'] = (array) $context[self::GROUPS]; } - $operationCacheKey = ($context['resource_class'] ?? '').($context['operation_name'] ?? '').($context['root_operation_name'] ?? ''); + $operationName = ($context['operation'] ?? null)?->getName() ?? $context['operation_name'] ?? null; + $rootOperationName = $context['root_operation_name'] ?? $operationName; + + $operationCacheKey = ($context['resource_class'] ?? '').$operationName.$rootOperationName; $suffix = ($context['api_normalize'] ?? '') ? 'n' : ''; if ($operationCacheKey && isset($this->localFactoryOptionsCache[$operationCacheKey.$suffix])) { return $options + $this->localFactoryOptionsCache[$operationCacheKey.$suffix]; @@ -821,11 +829,16 @@ protected function getFactoryOptions(array $context): array // This is a hot spot if (isset($context['resource_class'])) { + if (null !== $operationName) { + $options['operation_name'] = $operationName; + $options['root_operation_name'] = $rootOperationName; + } + // Note that the groups need to be read on the root operation if ($operation = ($context['root_operation'] ?? null)) { $options['normalization_groups'] = $operation->getNormalizationContext()['groups'] ?? null; $options['denormalization_groups'] = $operation->getDenormalizationContext()['groups'] ?? null; - $options['operation_name'] = $operation->getName(); + $options['root_operation_name'] = $operation->getName(); } } @@ -995,7 +1008,7 @@ protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, $objResourceClass = $this->resourceClassResolver->getResourceClass($obj, $resourceClass); $context['resource_class'] = $objResourceClass; if ($this->resourceMetadataCollectionFactory) { - $context['operation'] = $this->resourceMetadataCollectionFactory->create($objResourceClass)->getOperation(); + $context['operation'] = $this->resourceMetadataCollectionFactory->create($objResourceClass)->getOperationForObject($obj); } $value[$index] = $this->normalizeRelation($propertyMetadata, $obj, $resourceClass, $format, $context); diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index fa4ba033753..4c199d12e15 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -2207,7 +2207,7 @@ public function testCacheKey(): void $operationCacheKey = (new \ReflectionClass($normalizer))->getProperty('localFactoryOptionsCache')->getValue($normalizer); $this->assertEquals(array_keys($operationCacheKey), [\sprintf('%s%s%s%s', Dummy::class, 'operation_name', 'root_operation_name', 'n')]); - $this->assertEquals(current($operationCacheKey), ['serializer_groups' => ['group'], 'api_allow_update' => false]); + $this->assertEquals(current($operationCacheKey), ['serializer_groups' => ['group'], 'api_allow_update' => false, 'operation_name' => 'operation_name', 'root_operation_name' => 'root_operation_name']); } public function testSupportsDenormalizationWithApiPlatformInputContext(): void diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index a5e316c2e53..634936bde72 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -29,6 +29,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAwareInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UriVariablesConverterInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; @@ -148,7 +149,7 @@ public function getIriFromResource(object|string $resource, int $referenceType = // item_uri_template steers both the skolem check and the resource class promotion below, // so it has to be part of the key even when it did not resolve to an operation. - $localOperationCacheKey = ($operation?->getName() ?? '').$resourceClass.(\is_string($resource) ? '_s' : '_o').($operation instanceof CollectionOperationInterface ? '_c' : '_i').($context['item_uri_template'] ?? ''); + $localOperationCacheKey = ($operation?->getName() ?? '').$resourceClass.($resource instanceof ResourceAwareInterface ? '@'.$resource->getResource() : '').(\is_string($resource) ? '_s' : '_o').($operation instanceof CollectionOperationInterface ? '_c' : '_i').($context['item_uri_template'] ?? ''); if ($operation && isset($this->localOperationCache[$localOperationCacheKey])) { return $this->generateSymfonyRoute($resource, $referenceType, $this->localOperationCache[$localOperationCacheKey], $context, $this->localIdentifiersExtractorOperationCache[$localOperationCacheKey] ?? null); } @@ -186,7 +187,8 @@ public function getIriFromResource(object|string $resource, int $referenceType = } else { $forceCollection = $operation instanceof CollectionOperationInterface; try { - $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(null, $forceCollection, true); + $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); + $operation = \is_object($resource) ? $resourceMetadataCollection->getOperationForObject($resource, $forceCollection, true) : $resourceMetadataCollection->getOperation(null, $forceCollection, true); $identifiersExtractorOperation = $operation; } catch (OperationNotFoundException) { } diff --git a/tests/Fixtures/TestBundle/DynamicResource/DynamicPropertyMetadataFactory.php b/tests/Fixtures/TestBundle/DynamicResource/DynamicPropertyMetadataFactory.php new file mode 100644 index 00000000000..c5e79cf9e99 --- /dev/null +++ b/tests/Fixtures/TestBundle/DynamicResource/DynamicPropertyMetadataFactory.php @@ -0,0 +1,38 @@ + + * + * 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\DynamicResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\PropertyNotFoundException; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; + +final class DynamicPropertyMetadataFactory implements PropertyMetadataFactoryInterface +{ + public function __construct( + private readonly PropertyMetadataFactoryInterface $decorated, + private readonly \Closure $resourceMetadataCollectionFactory, + ) { + } + + public function create(string $resourceClass, string $property, array $options = []): ApiProperty + { + if (DynamicRecord::class !== $resourceClass) { + return $this->decorated->create($resourceClass, $property, $options); + } + + $resource = DynamicResourceDefinitions::resourceForOperation(($this->resourceMetadataCollectionFactory)(), $options['operation_name'] ?? null); + + return DynamicResourceDefinitions::properties($resource)[$property] ?? throw new PropertyNotFoundException(\sprintf('Property "%s" of "%s" not found.', $property, $resource ?? DynamicRecord::class)); + } +} diff --git a/tests/Fixtures/TestBundle/DynamicResource/DynamicPropertyNameCollectionFactory.php b/tests/Fixtures/TestBundle/DynamicResource/DynamicPropertyNameCollectionFactory.php new file mode 100644 index 00000000000..6f6dd58e9ab --- /dev/null +++ b/tests/Fixtures/TestBundle/DynamicResource/DynamicPropertyNameCollectionFactory.php @@ -0,0 +1,37 @@ + + * + * 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\DynamicResource; + +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; + +final class DynamicPropertyNameCollectionFactory implements PropertyNameCollectionFactoryInterface +{ + public function __construct( + private readonly PropertyNameCollectionFactoryInterface $decorated, + private readonly \Closure $resourceMetadataCollectionFactory, + ) { + } + + public function create(string $resourceClass, array $options = []): PropertyNameCollection + { + if (DynamicRecord::class !== $resourceClass) { + return $this->decorated->create($resourceClass, $options); + } + + $resource = DynamicResourceDefinitions::resourceForOperation(($this->resourceMetadataCollectionFactory)(), $options['operation_name'] ?? null); + + return new PropertyNameCollection(array_keys(DynamicResourceDefinitions::properties($resource))); + } +} diff --git a/tests/Fixtures/TestBundle/DynamicResource/DynamicRecord.php b/tests/Fixtures/TestBundle/DynamicResource/DynamicRecord.php new file mode 100644 index 00000000000..6988eb16ed7 --- /dev/null +++ b/tests/Fixtures/TestBundle/DynamicResource/DynamicRecord.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\DynamicResource; + +use ApiPlatform\Metadata\ResourceAwareInterface; + +final class DynamicRecord implements ResourceAwareInterface +{ + /** + * @param array $data + */ + public function __construct(private readonly string $resource, private array $data = []) + { + } + + public function getResource(): string + { + return $this->resource; + } + + public function __get(string $name): mixed + { + return $this->data[$name] ?? null; + } + + public function __set(string $name, mixed $value): void + { + $this->data[$name] = $value; + } + + public function __isset(string $name): bool + { + return isset($this->data[$name]); + } +} diff --git a/tests/Fixtures/TestBundle/DynamicResource/DynamicResourceDefinitions.php b/tests/Fixtures/TestBundle/DynamicResource/DynamicResourceDefinitions.php new file mode 100644 index 00000000000..bb7397f3a76 --- /dev/null +++ b/tests/Fixtures/TestBundle/DynamicResource/DynamicResourceDefinitions.php @@ -0,0 +1,136 @@ + + * + * 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\DynamicResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use Symfony\Component\TypeInfo\Type; + +final class DynamicResourceDefinitions +{ + public const FAQ = 'DynamicFaq'; + public const NEWS = 'DynamicNews'; + + /** + * @return array}> + */ + public static function all(): array + { + return [ + self::FAQ => [ + 'uriTemplate' => '/dynamic/faq', + 'properties' => [ + 'id' => new ApiProperty(identifier: true, readable: true, writable: false, nativeType: Type::int()), + 'question' => new ApiProperty(readable: true, writable: true, required: true, nativeType: Type::string()), + 'answer' => new ApiProperty(readable: true, writable: true, nativeType: Type::string()), + 'related' => new ApiProperty(readable: true, writable: false, readableLink: true, nativeType: Type::object(DynamicRecord::class)), + ], + ], + self::NEWS => [ + 'uriTemplate' => '/dynamic/news', + 'properties' => [ + 'id' => new ApiProperty(identifier: true, readable: true, writable: false, nativeType: Type::int()), + 'title' => new ApiProperty(readable: true, writable: true, required: true, nativeType: Type::string()), + 'body' => new ApiProperty(readable: true, writable: true, nativeType: Type::string()), + ], + ], + ]; + } + + public static function has(string $resource): bool + { + return isset(self::all()[$resource]); + } + + /** + * @return list + */ + public static function resources(): array + { + $resources = []; + foreach (self::all() as $name => $definition) { + $link = new Link(fromClass: DynamicRecord::class, identifiers: ['id'], parameterName: 'id'); + $resources[] = new ApiResource( + class: DynamicRecord::class, + shortName: $name, + operations: [ + new Get(class: DynamicRecord::class, shortName: $name, name: $name.'_get', uriTemplate: $definition['uriTemplate'].'/{id}', uriVariables: ['id' => $link], provider: [self::class, 'provide']), + new GetCollection(class: DynamicRecord::class, shortName: $name, name: $name.'_get_collection', uriTemplate: $definition['uriTemplate'], provider: [self::class, 'provide']), + ], + ); + } + + return $resources; + } + + public static function resourceForOperation(ResourceMetadataCollectionFactoryInterface $factory, ?string $operationName): ?string + { + if (null === $operationName) { + return null; + } + + $shortName = $factory->create(DynamicRecord::class)->getOperation($operationName)->getShortName(); + + return null !== $shortName && self::has($shortName) ? $shortName : null; + } + + /** + * @return array + */ + public static function properties(?string $resource): array + { + if (null !== $resource) { + return self::all()[$resource]['properties']; + } + + $properties = []; + foreach (self::all() as $definition) { + $properties += $definition['properties']; + } + + return $properties; + } + + /** + * @param array $uriVariables + * + * @return DynamicRecord|list|null + */ + public static function provide(Operation $operation, array $uriVariables = []): DynamicRecord|array|null + { + $resource = $operation->getShortName(); + $items = match ($resource) { + self::FAQ => [ + 1 => new DynamicRecord($resource, ['id' => 1, 'question' => 'What is API Platform?', 'answer' => 'A framework.', 'related' => new DynamicRecord(self::NEWS, ['id' => 1, 'title' => 'API Platform 5.0', 'body' => 'Released.'])]), + 2 => new DynamicRecord($resource, ['id' => 2, 'question' => 'Is it free?', 'answer' => 'Yes.']), + ], + self::NEWS => [ + 1 => new DynamicRecord($resource, ['id' => 1, 'title' => 'API Platform 5.0', 'body' => 'Released.']), + 2 => new DynamicRecord($resource, ['id' => 2, 'title' => 'SymfonyCon', 'body' => 'See you there.']), + ], + default => [], + }; + + if ($operation instanceof GetCollection) { + return array_values($items); + } + + return $items[(int) $uriVariables['id']] ?? null; + } +} diff --git a/tests/Fixtures/TestBundle/DynamicResource/DynamicResourceHost.php b/tests/Fixtures/TestBundle/DynamicResource/DynamicResourceHost.php new file mode 100644 index 00000000000..2522ebe9ac8 --- /dev/null +++ b/tests/Fixtures/TestBundle/DynamicResource/DynamicResourceHost.php @@ -0,0 +1,33 @@ + + * + * 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\DynamicResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; + +/** + * Class-based resource enabling the runtime resources in tests: + * they are only declared when this class is part of the resource name collection. + */ +#[Get(uriTemplate: '/dynamic_resource_hosts/{id}', provider: [self::class, 'provide'])] +final class DynamicResourceHost +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public static function provide(): self + { + return new self(); + } +} diff --git a/tests/Fixtures/TestBundle/DynamicResource/DynamicResourceMetadataCollectionFactory.php b/tests/Fixtures/TestBundle/DynamicResource/DynamicResourceMetadataCollectionFactory.php new file mode 100644 index 00000000000..ee25d175134 --- /dev/null +++ b/tests/Fixtures/TestBundle/DynamicResource/DynamicResourceMetadataCollectionFactory.php @@ -0,0 +1,33 @@ + + * + * 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\DynamicResource; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; + +final class DynamicResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $decorated) + { + } + + public function create(string $resourceClass): ResourceMetadataCollection + { + if (DynamicRecord::class !== $resourceClass) { + return $this->decorated->create($resourceClass); + } + + return new ResourceMetadataCollection($resourceClass, DynamicResourceDefinitions::resources()); + } +} diff --git a/tests/Fixtures/TestBundle/DynamicResource/DynamicResourceNameCollectionFactory.php b/tests/Fixtures/TestBundle/DynamicResource/DynamicResourceNameCollectionFactory.php new file mode 100644 index 00000000000..b19f86a53e0 --- /dev/null +++ b/tests/Fixtures/TestBundle/DynamicResource/DynamicResourceNameCollectionFactory.php @@ -0,0 +1,35 @@ + + * + * 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\DynamicResource; + +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; + +final class DynamicResourceNameCollectionFactory implements ResourceNameCollectionFactoryInterface +{ + public function __construct(private readonly ResourceNameCollectionFactoryInterface $decorated) + { + } + + public function create(): ResourceNameCollection + { + $names = iterator_to_array($this->decorated->create()); + + if (!\in_array(DynamicResourceHost::class, $names, true)) { + return new ResourceNameCollection($names); + } + + return new ResourceNameCollection([...$names, DynamicRecord::class]); + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index b1d280c406f..19ab61d515e 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -427,6 +427,26 @@ services: decorates: api_platform.metadata.resource.metadata_collection_factory arguments: ['@ApiPlatform\Tests\Fixtures\TestBundle\Metadata\ProviderResourceMetadatatCollectionFactory.inner'] + ApiPlatform\Tests\Fixtures\TestBundle\DynamicResource\DynamicResourceNameCollectionFactory: + decorates: api_platform.metadata.resource.name_collection_factory + decoration_priority: -20 + arguments: ['@.inner'] + + ApiPlatform\Tests\Fixtures\TestBundle\DynamicResource\DynamicResourceMetadataCollectionFactory: + decorates: api_platform.metadata.resource.metadata_collection_factory + decoration_priority: 1500 + arguments: ['@.inner'] + + ApiPlatform\Tests\Fixtures\TestBundle\DynamicResource\DynamicPropertyNameCollectionFactory: + decorates: api_platform.metadata.property.name_collection_factory + decoration_priority: -5 + arguments: ['@.inner', !service_closure '@api_platform.metadata.resource.metadata_collection_factory'] + + ApiPlatform\Tests\Fixtures\TestBundle\DynamicResource\DynamicPropertyMetadataFactory: + decorates: api_platform.metadata.property.metadata_factory + decoration_priority: -5 + arguments: ['@.inner', !service_closure '@api_platform.metadata.resource.metadata_collection_factory'] + ApiPlatform\Tests\Fixtures\TestBundle\OpenApi\Issue8143ResponseComponentFactory: decorates: api_platform.openapi.factory arguments: ['@ApiPlatform\Tests\Fixtures\TestBundle\OpenApi\Issue8143ResponseComponentFactory.inner'] diff --git a/tests/Functional/DynamicResourceTest.php b/tests/Functional/DynamicResourceTest.php new file mode 100644 index 00000000000..5cfba85df2e --- /dev/null +++ b/tests/Functional/DynamicResourceTest.php @@ -0,0 +1,109 @@ + + * + * 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\Functional; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\DynamicResource\DynamicRecord; +use ApiPlatform\Tests\Fixtures\TestBundle\DynamicResource\DynamicResourceHost; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class DynamicResourceTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DynamicResourceHost::class]; + } + + public function testResourceMetadata(): void + { + self::bootKernel(); + $resourceMetadataCollection = static::getContainer()->get(ResourceMetadataCollectionFactoryInterface::class)->create(DynamicRecord::class); + + foreach (['DynamicFaq' => '/dynamic/faq/{id}', 'DynamicNews' => '/dynamic/news/{id}'] as $shortName => $uriTemplate) { + $found = null; + foreach ($resourceMetadataCollection as $resource) { + if ($shortName === $resource->getShortName()) { + $found = $resource->getOperations()->getIterator()->current(); + } + } + + $this->assertInstanceOf(HttpOperation::class, $found); + $this->assertSame(DynamicRecord::class, $found->getClass()); + $this->assertSame($shortName, $found->getShortName()); + $this->assertSame($uriTemplate, $found->getUriTemplate()); + } + } + + public function testGetResourceFromIri(): void + { + self::bootKernel(); + $iriConverter = static::getContainer()->get(IriConverterInterface::class); + + $resource = $iriConverter->getResourceFromIri('/dynamic/faq/1'); + $this->assertInstanceOf(DynamicRecord::class, $resource); + $this->assertSame('DynamicFaq', $resource->getResource()); + $this->assertSame('What is API Platform?', $resource->__get('question')); + + $resource = $iriConverter->getResourceFromIri('/dynamic/news/1'); + $this->assertInstanceOf(DynamicRecord::class, $resource); + $this->assertSame('DynamicNews', $resource->getResource()); + $this->assertSame('API Platform 5.0', $resource->__get('title')); + } + + public function testGetItem(): void + { + $client = self::createClient(); + + $client->request('GET', '/dynamic/faq/1', ['headers' => ['Accept' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['@id' => '/dynamic/faq/1', '@type' => 'DynamicFaq', 'question' => 'What is API Platform?']); + + $client->request('GET', '/dynamic/news/1', ['headers' => ['Accept' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['@id' => '/dynamic/news/1', '@type' => 'DynamicNews', 'title' => 'API Platform 5.0']); + } + + public function testOpenApi(): void + { + $response = self::createClient()->request('GET', '/docs', ['headers' => ['Accept' => 'application/vnd.openapi+json']]); + + $this->assertResponseIsSuccessful(); + $openApi = $response->toArray(); + $this->assertArrayHasKey('/dynamic/faq/{id}', $openApi['paths']); + $this->assertArrayHasKey('/dynamic/faq', $openApi['paths']); + $this->assertArrayHasKey('/dynamic/news/{id}', $openApi['paths']); + $this->assertArrayHasKey('/dynamic/news', $openApi['paths']); + $this->assertSame(['id', 'question', 'answer', 'related'], array_keys($openApi['components']['schemas']['DynamicFaq']['properties'])); + $this->assertSame(['id', 'title', 'body'], array_keys($openApi['components']['schemas']['DynamicNews']['properties'])); + } + + public function testNestedResourceOnSharedClass(): void + { + $response = self::createClient()->request('GET', '/dynamic/faq/1', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['related' => ['@id' => '/dynamic/news/1', '@type' => 'DynamicNews', 'title' => 'API Platform 5.0']]); + $this->assertArrayNotHasKey('question', $response->toArray()['related']); + } +}