From 20af9313f67cf966b6daa90d420f8b25d76ee53d Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 10 Feb 2026 17:01:22 +0100 Subject: [PATCH 1/4] fix(serializer): prevent context leakage with service-based entity resolution (#7733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Q | A | ------------- | --- | Branch? | 4.2 | Tickets | Fixes #7733 | License | MIT | Doc PR | ∅ * Create OperationResourceResolverInterface service to validate entity-to-resource mappings * Add framework-specific decorators (Doctrine, Eloquent) to handle stateOptions validation * Remove force_resource_class propagation to nested objects preventing DateTimeImmutable issues --- .../DoctrineOperationResourceResolver.php | 59 ++++++++++++ src/Hal/Serializer/ItemNormalizer.php | 5 +- src/JsonApi/Serializer/ItemNormalizer.php | 5 +- src/JsonLd/Serializer/ItemNormalizer.php | 79 +++++++++++---- src/Laravel/ApiPlatformProvider.php | 14 ++- src/Laravel/Routing/IriConverter.php | 56 ++++++++++- .../EloquentOperationResourceResolver.php | 54 +++++++++++ .../AbstractCollectionNormalizer.php | 19 ++-- src/Serializer/AbstractItemNormalizer.php | 54 +++++++---- src/Serializer/ItemNormalizer.php | 4 +- src/Serializer/OperationResourceResolver.php | 48 ++++++++++ .../OperationResourceResolverInterface.php | 38 ++++++++ .../Tests/AbstractItemNormalizerTest.php | 1 - src/Symfony/Bundle/Resources/config/api.php | 6 ++ .../Resources/config/doctrine_mongodb_odm.php | 3 + .../Bundle/Resources/config/doctrine_orm.php | 3 + src/Symfony/Bundle/Resources/config/hal.php | 1 + .../Bundle/Resources/config/jsonapi.php | 1 + .../Bundle/Resources/config/jsonld.php | 2 + .../DateTimeNormalizationIssue.php | 45 +++++++++ .../DateTimeNormalizerPriorityTest.php | 95 +++++++++++++++++++ 21 files changed, 534 insertions(+), 58 deletions(-) create mode 100644 src/Doctrine/Orm/Serializer/DoctrineOperationResourceResolver.php create mode 100644 src/Laravel/Serializer/EloquentOperationResourceResolver.php create mode 100644 src/Serializer/OperationResourceResolver.php create mode 100644 src/Serializer/OperationResourceResolverInterface.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/DateTimeNormalizationIssue.php create mode 100644 tests/Functional/DateTimeNormalizerPriorityTest.php diff --git a/src/Doctrine/Orm/Serializer/DoctrineOperationResourceResolver.php b/src/Doctrine/Orm/Serializer/DoctrineOperationResourceResolver.php new file mode 100644 index 00000000000..50f00da0d61 --- /dev/null +++ b/src/Doctrine/Orm/Serializer/DoctrineOperationResourceResolver.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Orm\Serializer; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Serializer\OperationResourceResolver; + +/** + * Doctrine-specific operation resource resolver. + * + * Handles entity-to-resource mappings from Doctrine's stateOptions: + * - getEntityClass() for ORM entities + * - getDocumentClass() for ODM documents + * + * @author Kévin Dunglas + */ +final class DoctrineOperationResourceResolver extends OperationResourceResolver +{ + use ClassInfoTrait; + + public function resolve(object|string $resource, Operation $operation): string + { + if (\is_string($resource)) { + return $resource; + } + + $objectClass = $this->getObjectClass($resource); + $stateOptions = $operation->getStateOptions(); + + // Doctrine-specific: Check for entity or document class in stateOptions + if ($stateOptions) { + $entityClass = method_exists($stateOptions, 'getEntityClass') + ? $stateOptions->getEntityClass() + : (method_exists($stateOptions, 'getDocumentClass') + ? $stateOptions->getDocumentClass() + : null); + + // Validate object matches the backing entity/document class + if ($entityClass && is_a($objectClass, $entityClass, true)) { + return $operation->getClass(); + } + } + + // Fallback to core behavior + return parent::resolve($resource, $operation); + } +} diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index eba5420b98e..7d0493cd1a0 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -25,6 +25,7 @@ use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; +use ApiPlatform\Serializer\OperationResourceResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; @@ -59,7 +60,7 @@ final class ItemNormalizer extends AbstractItemNormalizer private array $componentsCache = []; private array $attributesMetadataCache = []; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, ?TagCollectorInterface $tagCollector = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, ?TagCollectorInterface $tagCollector = null, ?OperationResourceResolverInterface $operationResourceResolver = null) { $defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] = function ($object): ?array { $iri = $this->iriConverter->getIriFromResource($object); @@ -70,7 +71,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName return ['_links' => ['self' => ['href' => $iri]]]; }; - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); } /** diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 4b3084db277..4bb4f776032 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -27,6 +27,7 @@ use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; +use ApiPlatform\Serializer\OperationResourceResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -59,9 +60,9 @@ final class ItemNormalizer extends AbstractItemNormalizer private array $componentsCache = []; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceResolverInterface $operationResourceResolver = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); } /** diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index e4e42b9a30d..f1223db4dac 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -18,6 +18,7 @@ use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -27,6 +28,7 @@ use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\ContextTrait; +use ApiPlatform\Serializer\OperationResourceResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\LogicException; @@ -70,9 +72,9 @@ final class ItemNormalizer extends AbstractItemNormalizer '@vocab', ]; - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, private ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ?OperationResourceResolverInterface $operationResourceResolver = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); } /** @@ -84,9 +86,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; } @@ -96,20 +98,39 @@ public function getSupportedTypes($format): array * * @throws LogicException */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { - $resourceClass = $this->getObjectClass($object); + $resourceClass = $this->getObjectClass($data); $outputClass = $this->getOutputClass($context); - if ($outputClass && !($context['item_uri_template'] ?? null)) { - return parent::normalize($object, $format, $context); + if ($outputClass) { + if ($context['item_uri_template'] ?? null) { + // When both output and item_uri_template are present, temporarily remove + // item_uri_template so the output re-dispatch produces the correct @type + // from the output class (not from the item_uri_template operation). + $itemUriTemplate = $context['item_uri_template']; + unset($context['item_uri_template']); + $originalData = $data; + $data = parent::normalize($data, $format, $context); + if (\is_array($data)) { + try { + $context['item_uri_template'] = $itemUriTemplate; + $data['@id'] = $this->iriConverter->getIriFromResource($originalData, UrlGeneratorInterface::ABS_PATH, null, $context); + } catch (\Exception) { + } + } + + return $data; + } + + return parent::normalize($data, $format, $context); } // TODO: we should not remove the resource_class in the normalizeRawCollection as we would find out anyway that it's not the same as the requested one $previousResourceClass = $context['resource_class'] ?? null; $metadata = []; if ($isResourceClass = $this->resourceClassResolver->isResourceClass($resourceClass) && (null === $previousResourceClass || $this->resourceClassResolver->isResourceClass($previousResourceClass))) { - $resourceClass = $this->resourceClassResolver->getResourceClass($object, $previousResourceClass); + $resourceClass = $this->resourceClassResolver->getResourceClass($data, $previousResourceClass); $context = $this->initContext($resourceClass, $context); $metadata = $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context); } elseif ($this->contextBuilder instanceof AnonymousContextBuilderInterface) { @@ -119,12 +140,21 @@ public function normalize(mixed $object, ?string $format = null, array $context $context['output']['iri'] = null; } - if ($this->resourceClassResolver->isResourceClass($resourceClass)) { + if (isset($context['item_uri_template']) && $this->operationMetadataFactory) { + $itemOp = $this->operationMetadataFactory->create($context['item_uri_template']); + // Use resource-level shortName for @type, not operation-specific shortName + try { + $itemResourceShortName = $this->resourceMetadataCollectionFactory->create($itemOp->getClass())[0]->getShortName(); + $context['output']['operation'] = $itemOp->withShortName($itemResourceShortName); + } catch (\Exception) { + $context['output']['operation'] = $itemOp; + } + } elseif ($this->resourceClassResolver->isResourceClass($resourceClass)) { $context['output']['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); } // We should improve what's behind the context creation, its probably more complicated then it should - $metadata = $this->createJsonLdContext($this->contextBuilder, $object, $context); + $metadata = $this->createJsonLdContext($this->contextBuilder, $data, $context); } // Special case: non-resource got serialized and contains a resource therefore we need to reset part of the context @@ -132,19 +162,24 @@ public function normalize(mixed $object, ?string $format = null, array $context unset($context['operation'], $context['operation_name'], $context['output']); } - if (true === ($context['output']['gen_id'] ?? true) && true === ($context['force_iri_generation'] ?? true) && $iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context)) { + if (true === ($context['output']['gen_id'] ?? true) && true === ($context['force_iri_generation'] ?? true) && $iri = $this->iriConverter->getIriFromResource($data, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context)) { $context['iri'] = $iri; $metadata['@id'] = $iri; } $context['api_normalize'] = true; - $data = parent::normalize($object, $format, $context); - if (!\is_array($data)) { - return $data; + $normalizedData = parent::normalize($data, $format, $context); + if (!\is_array($normalizedData)) { + return $normalizedData; } $operation = $context['operation'] ?? null; + + if ($this->operationMetadataFactory && isset($context['item_uri_template']) && !$operation) { + $operation = $this->operationMetadataFactory->create($context['item_uri_template']); + } + if ($isResourceClass && !$operation) { $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); } @@ -152,12 +187,18 @@ public function normalize(mixed $object, ?string $format = null, array $context if (!isset($metadata['@type']) && $operation) { $types = $operation instanceof HttpOperation ? $operation->getTypes() : null; if (null === $types) { - $types = [$operation->getShortName()]; + // Use resource-level shortName to avoid operation-specific overrides + $typeClass = $isResourceClass ? $resourceClass : ($operation->getClass() ?? $resourceClass); + try { + $types = [$this->resourceMetadataCollectionFactory->create($typeClass)[0]->getShortName()]; + } catch (\Exception) { + $types = [$operation->getShortName()]; + } } $metadata['@type'] = 1 === \count($types) ? $types[0] : $types; } - return $metadata + $data; + return $metadata + $normalizedData; } /** @@ -173,7 +214,7 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form * * @throws NotNormalizableValueException */ - public function denormalize(mixed $data, string $class, ?string $format = null, array $context = []): mixed + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { // Avoid issues with proxies if we populated the object if (isset($data['@id']) && !isset($context[self::OBJECT_TO_POPULATE])) { @@ -192,7 +233,7 @@ public function denormalize(mixed $data, string $class, ?string $format = null, } } - return parent::denormalize($data, $class, $format, $context); + return parent::denormalize($data, $type, $format, $context); } protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index b9f87470ee9..f7ad64adb57 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -102,6 +102,7 @@ use ApiPlatform\Laravel\Routing\Router as UrlGeneratorRouter; use ApiPlatform\Laravel\Routing\SkolemIriConverter; use ApiPlatform\Laravel\Security\ResourceAccessChecker; +use ApiPlatform\Laravel\Serializer\EloquentOperationResourceResolver; use ApiPlatform\Laravel\State\AccessCheckerProvider; use ApiPlatform\Laravel\State\SwaggerUiProcessor; use ApiPlatform\Laravel\State\SwaggerUiProvider; @@ -142,6 +143,7 @@ use ApiPlatform\Serializer\JsonEncoder; use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory as SerializerClassMetadataFactory; use ApiPlatform\Serializer\Mapping\Loader\PropertyMetadataLoader; +use ApiPlatform\Serializer\OperationResourceResolverInterface; use ApiPlatform\Serializer\SerializerContextBuilder; use ApiPlatform\State\CallableProcessor; use ApiPlatform\State\CallableProvider; @@ -269,6 +271,11 @@ public function register(): void return new EloquentResourceClassResolver(new ResourceClassResolver($app->make(ResourceNameCollectionFactoryInterface::class))); }); + $this->app->bind(OperationResourceResolverInterface::class, EloquentOperationResourceResolver::class); + $this->app->singleton(EloquentOperationResourceResolver::class, static function (Application $app) { + return new EloquentOperationResourceResolver(); + }); + $this->app->singleton(PropertyMetadataFactoryInterface::class, static function (Application $app) { /** @var ConfigRepository $config */ $config = $app['config']; @@ -495,7 +502,7 @@ public function register(): void $this->app->bind(IriConverterInterface::class, IriConverter::class); $this->app->singleton(IriConverter::class, static function (Application $app) { - return new IriConverter($app->make(CallableProvider::class), $app->make(OperationMetadataFactoryInterface::class), $app->make(UrlGeneratorRouter::class), $app->make(IdentifiersExtractorInterface::class), $app->make(ResourceClassResolverInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class), $app->make(SkolemIriConverter::class)); + return new IriConverter($app->make(CallableProvider::class), $app->make(OperationMetadataFactoryInterface::class), $app->make(UrlGeneratorRouter::class), $app->make(IdentifiersExtractorInterface::class), $app->make(ResourceClassResolverInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class), $app->make(SkolemIriConverter::class), null, $app->make(OperationResourceResolverInterface::class)); }); $this->app->singleton(SkolemIriConverter::class, static function (Application $app) { @@ -575,6 +582,8 @@ public function register(): void $app->make(ResourceAccessCheckerInterface::class), $defaultContext, // $app->make(TagCollectorInterface::class) + null, + $app->make(OperationResourceResolverInterface::class), ); }); @@ -967,6 +976,9 @@ public function register(): void $defaultContext, $app->make(ResourceAccessCheckerInterface::class), // $app->make(TagCollectorInterface::class) + null, + null, + $app->make(OperationResourceResolverInterface::class), ); }); diff --git a/src/Laravel/Routing/IriConverter.php b/src/Laravel/Routing/IriConverter.php index 460db550e9a..4443ed851b4 100644 --- a/src/Laravel/Routing/IriConverter.php +++ b/src/Laravel/Routing/IriConverter.php @@ -30,6 +30,7 @@ use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; +use ApiPlatform\Serializer\OperationResourceResolverInterface; use ApiPlatform\State\ProviderInterface; use Illuminate\Database\Eloquent\Relations\Relation; // use Illuminate\Routing\Router; @@ -56,7 +57,7 @@ class IriConverter implements IriConverterInterface /** * @param ProviderInterface $provider */ - public function __construct(private readonly ProviderInterface $provider, private readonly OperationMetadataFactoryInterface $operationMetadataFactory, private readonly RouterInterface $router, private readonly IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ?IriConverterInterface $decorated = null) + public function __construct(private readonly ProviderInterface $provider, private readonly OperationMetadataFactoryInterface $operationMetadataFactory, private readonly RouterInterface $router, private readonly IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ?IriConverterInterface $decorated = null, private readonly ?OperationResourceResolverInterface $operationResourceResolver = null) { $this->resourceClassResolver = $resourceClassResolver; } @@ -93,7 +94,7 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation */ public function getIriFromResource(object|string $resource, int $referenceType = UrlGeneratorInterface::ABS_PATH, ?Operation $operation = null, array $context = []): ?string { - $resourceClass = $context['force_resource_class'] ?? (\is_string($resource) ? $resource : $this->getObjectClass($resource)); + $resourceClass = $this->getResourceClassForIri($resource, $context); if ($resource instanceof Relation) { $resourceClass = $this->getObjectClass($resource->getRelated()); } @@ -112,8 +113,10 @@ public function getIriFromResource(object|string $resource, int $referenceType = } // This is only for when a class (that is not a resource) extends another one that is a resource, we should remove this behavior - if (!\is_string($resource) && !isset($context['force_resource_class'])) { - $resourceClass = $this->getResourceClass($resource, true); + // Skip this if getResourceClassForIri already determined the class via operation stateOptions + if (!\is_string($resource) && $resourceClass === $this->getObjectClass($resource)) { + /** @var class-string $resourceClass */ + $resourceClass = $this->getResourceClass($resource, true) ?? $resourceClass; } if (!$operation) { @@ -189,4 +192,49 @@ private function generateSkolemIri(object|string $resource, int $referenceType = // Use a skolem iri, the route is defined in genid.xml return $this->decorated->getIriFromResource($resource, $referenceType, $operation, $context); } + + /** + * Determines which resource class to use for IRI generation. + * + * When an operation has stateOptions (entity/model class), this validates + * that the object being normalized matches the expected backing class before + * treating it as the resource class. + * + * This prevents context leakage where unrelated objects are incorrectly + * treated as resources for IRI generation. + * + * @param array $context + */ + private function getResourceClassForIri(object|string $resource, array $context): string + { + if (\is_string($resource)) { + return $resource; + } + + // force_resource_class is set when operation has stateOptions with entity/model class + if (isset($context['force_resource_class'])) { + return $context['force_resource_class']; + } + + // Explicit resource_class in context takes precedence + if (isset($context['resource_class'])) { + return $context['resource_class']; + } + + // When item_uri_template is present, operation will be created from it in getIriFromResource, + // so we can't use operationResourceResolver here. Return object class and let the operation + // resolution happen after the operation is created from the template. + if (isset($context['item_uri_template'])) { + return $this->getObjectClass($resource); + } + + // Use the service to resolve resource class when operation is available + $operation = $context['operation'] ?? $context['root_operation'] ?? null; + if ($operation && $this->operationResourceResolver) { + return $this->operationResourceResolver->resolve($resource, $operation); + } + + // Fallback to object's actual class + return $this->getObjectClass($resource); + } } diff --git a/src/Laravel/Serializer/EloquentOperationResourceResolver.php b/src/Laravel/Serializer/EloquentOperationResourceResolver.php new file mode 100644 index 00000000000..d45d1552a38 --- /dev/null +++ b/src/Laravel/Serializer/EloquentOperationResourceResolver.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Serializer; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Serializer\OperationResourceResolver; + +/** + * Laravel Eloquent-specific operation resource resolver. + * + * Handles model-to-resource mappings from Laravel's stateOptions: + * - getModelClass() for Eloquent models + * + * @author Kévin Dunglas + */ +final class EloquentOperationResourceResolver extends OperationResourceResolver +{ + use ClassInfoTrait; + + public function resolve(object|string $resource, Operation $operation): string + { + if (\is_string($resource)) { + return $resource; + } + + $objectClass = $this->getObjectClass($resource); + $stateOptions = $operation->getStateOptions(); + + // Laravel-specific: Check for model class in stateOptions + if ($stateOptions && method_exists($stateOptions, 'getModelClass')) { + $modelClass = $stateOptions->getModelClass(); + + // Validate object matches the backing model class + if ($modelClass && is_a($objectClass, $modelClass, true)) { + return $operation->getClass(); + } + } + + // Fallback to core behavior + return parent::resolve($resource, $operation); + } +} diff --git a/src/Serializer/AbstractCollectionNormalizer.php b/src/Serializer/AbstractCollectionNormalizer.php index bde6ab863b9..d757a3b35b5 100644 --- a/src/Serializer/AbstractCollectionNormalizer.php +++ b/src/Serializer/AbstractCollectionNormalizer.php @@ -53,6 +53,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return static::FORMAT === $format && is_iterable($data); } + /** + * {@inheritdoc} + */ public function getSupportedTypes(?string $format): array { /* @@ -73,27 +76,27 @@ public function getSupportedTypes(?string $format): array /** * {@inheritdoc} * - * @param iterable $object + * @param iterable $data */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { if (!isset($context['resource_class']) || isset($context['api_sub_level'])) { - return $this->normalizeRawCollection($object, $format, $context); + return $this->normalizeRawCollection($data, $format, $context); } - $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class']); + $resourceClass = $this->resourceClassResolver->getResourceClass($data, $context['resource_class']); $collectionContext = $this->initContext($resourceClass, $context); - $data = []; - $paginationData = $this->getPaginationData($object, $collectionContext); + $normalizedData = []; + $paginationData = $this->getPaginationData($data, $collectionContext); $childContext = $this->createOperationContext($collectionContext, $resourceClass); if (isset($collectionContext['force_resource_class'])) { $childContext['force_resource_class'] = $collectionContext['force_resource_class']; } - $itemsData = $this->getItemsData($object, $format, $childContext); + $itemsData = $this->getItemsData($data, $format, $childContext); - return array_merge_recursive($data, $paginationData, $itemsData); + return array_merge_recursive($normalizedData, $paginationData, $itemsData); } /** diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 457a1eff044..a2cc6be05ab 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -76,7 +76,7 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer protected array $localFactoryOptionsCache = []; protected ?ResourceAccessCheckerInterface $resourceAccessChecker; - public function __construct(protected PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, protected PropertyMetadataFactoryInterface $propertyMetadataFactory, protected IriConverterInterface $iriConverter, protected ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null) + public function __construct(protected PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, protected PropertyMetadataFactoryInterface $propertyMetadataFactory, protected IriConverterInterface $iriConverter, protected ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, protected ?OperationResourceResolverInterface $operationResourceResolver = null) { if (!isset($defaultContext['circular_reference_handler'])) { $defaultContext['circular_reference_handler'] = fn ($object): ?string => $this->iriConverter->getIriFromResource($object); @@ -97,7 +97,21 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return false; } - $class = $context['force_resource_class'] ?? $this->getObjectClass($data); + $class = $this->getObjectClass($data); + + // Only honor force_resource_class if the resolver confirms this object + // maps to the operation's resource class (prevents context leakage to + // unrelated objects like DateTimeImmutable) + if (isset($context['force_resource_class']) && $context['force_resource_class'] !== $class) { + $operation = $context['operation'] ?? $context['root_operation'] ?? null; + if ($operation && $this->operationResourceResolver) { + $resolvedClass = $this->operationResourceResolver->resolve($data, $operation); + if ($resolvedClass !== $class) { + $class = $context['force_resource_class']; + } + } + } + if (($context['output']['class'] ?? null) === $class) { return true; } @@ -105,6 +119,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return $this->resourceClassResolver->isResourceClass($class); } + /** + * {@inheritdoc} + */ public function getSupportedTypes(?string $format): array { return [ @@ -117,9 +134,11 @@ public function getSupportedTypes(?string $format): array * * @throws LogicException */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { - $resourceClass = $context['force_resource_class'] ?? $this->getObjectClass($object); + $resourceClass = $context['force_resource_class'] ?? $this->getObjectClass($data); + // Prevent force_resource_class from leaking to child property normalizations + unset($context['force_resource_class']); if ($outputClass = $this->getOutputClass($context)) { if (!$this->serializer instanceof NormalizerInterface) { throw new LogicException('Cannot normalize the output because the injected serializer is not a normalizer'); @@ -130,7 +149,7 @@ public function normalize(mixed $object, ?string $format = null, array $context $context['api_sub_level'] = true; $context[self::ALLOW_EXTRA_ATTRIBUTES] = false; - return $this->serializer->normalize($object, $format, $context); + return $this->serializer->normalize($data, $format, $context); } // Never remove this, with `application/json` we don't use our AbstractCollectionNormalizer and we need @@ -144,7 +163,7 @@ public function normalize(mixed $object, ?string $format = null, array $context } $context['api_normalize'] = true; - $iri = $context['iri'] ??= $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL, $context['operation'] ?? null, $context); + $iri = $context['iri'] ??= $this->iriConverter->getIriFromResource($data, UrlGeneratorInterface::ABS_URL, $context['operation'] ?? null, $context); /* * When true, converts the normalized data array of a resource into an @@ -163,10 +182,10 @@ public function normalize(mixed $object, ?string $format = null, array $context $context['resources'][$iri] = $iri; } - $context['object'] = $object; + $context['object'] = $data; $context['format'] = $format; - $data = parent::normalize($object, $format, $context); + $data = parent::normalize($data, $format, $context); $context['data'] = $data; unset($context['property_metadata'], $context['api_attribute']); @@ -203,9 +222,9 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form /** * {@inheritdoc} */ - public function denormalize(mixed $data, string $class, ?string $format = null, array $context = []): mixed + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { - $resourceClass = $class; + $resourceClass = $type; if ($inputClass = $this->getInputClass($context)) { if (!$this->serializer instanceof DenormalizerInterface) { @@ -224,13 +243,13 @@ public function denormalize(mixed $data, string $class, ?string $format = null, if (null === $objectToPopulate = $this->extractObjectToPopulate($resourceClass, $context, static::OBJECT_TO_POPULATE)) { $normalizedData = \is_scalar($data) ? [$data] : $this->prepareForDenormalization($data); - $class = $this->getClassDiscriminatorResolvedClass($normalizedData, $class, $context); + $type = $this->getClassDiscriminatorResolvedClass($normalizedData, $type, $context); } $context['api_denormalize'] = true; - if ($this->resourceClassResolver->isResourceClass($class)) { - $resourceClass = $this->resourceClassResolver->getResourceClass($objectToPopulate, $class); + if ($this->resourceClassResolver->isResourceClass($type)) { + $resourceClass = $this->resourceClassResolver->getResourceClass($objectToPopulate, $type); $context['resource_class'] = $resourceClass; } @@ -257,9 +276,9 @@ public function denormalize(mixed $data, string $class, ?string $format = null, } $previousObject = $this->clone($objectToPopulate); - $object = parent::denormalize($data, $class, $format, $context); + $object = parent::denormalize($data, $type, $format, $context); - if (!$this->resourceClassResolver->isResourceClass($class)) { + if (!$this->resourceClassResolver->isResourceClass($type)) { return $object; } @@ -428,12 +447,9 @@ protected function createConstructorArgument(mixed $parameterData, string $key, * * Unused in this context. * - * @param object $object - * @param string|null $format - * * @return string[] */ - protected function extractAttributes($object, $format = null, array $context = []): array + protected function extractAttributes(object $object, ?string $format = null, array $context = []): array { return []; } diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index 01e069b8994..fe5334e41d2 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -41,9 +41,9 @@ class ItemNormalizer extends AbstractItemNormalizer { private readonly LoggerInterface $logger; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, ?LoggerInterface $logger = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, array $defaultContext = [], protected ?TagCollectorInterface $tagCollector = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, ?LoggerInterface $logger = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, array $defaultContext = [], protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceResolverInterface $operationResourceResolver = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataFactory, $resourceAccessChecker, $tagCollector); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); $this->logger = $logger ?: new NullLogger(); } diff --git a/src/Serializer/OperationResourceResolver.php b/src/Serializer/OperationResourceResolver.php new file mode 100644 index 00000000000..b07f34c0119 --- /dev/null +++ b/src/Serializer/OperationResourceResolver.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\ClassInfoTrait; + +/** + * Generic operation resource resolver. + * + * This is the base implementation that simply returns the object's actual class. + * Framework-specific resolvers (Doctrine, Laravel, etc.) extend this to add + * validation against entity/model classes from their stateOptions. + * + * @author Kévin Dunglas + */ +class OperationResourceResolver implements OperationResourceResolverInterface +{ + use ClassInfoTrait; + + /** + * Generic implementation: returns the object's actual class. + * + * Framework-specific resolvers will override to validate against entity/model + * classes from their stateOptions. + */ + public function resolve(object|string $resource, Operation $operation): string + { + if (\is_string($resource)) { + return $resource; + } + + // Core: just return the object's actual class + // Decorators will add framework-specific entity validation + return $this->getObjectClass($resource); + } +} diff --git a/src/Serializer/OperationResourceResolverInterface.php b/src/Serializer/OperationResourceResolverInterface.php new file mode 100644 index 00000000000..4fab7ae6827 --- /dev/null +++ b/src/Serializer/OperationResourceResolverInterface.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\Serializer; + +use ApiPlatform\Metadata\Operation; + +/** + * Resolves the resource class to use for serializing or generating IRIs. + * + * Validates that objects match the entity/model class from operation's stateOptions + * before mapping them to the resource class. This prevents entity-to-resource + * mappings from leaking to unrelated objects (e.g., DateTimeImmutable). + * + * @author Kévin Dunglas + */ +interface OperationResourceResolverInterface +{ + /** + * Resolves the resource class to use for serializing/generating IRIs. + * + * @param object|string $resource The object or class name to resolve + * @param Operation $operation The operation context providing stateOptions + * + * @return string The resource class to use + */ + public function resolve(object|string $resource, Operation $operation): string; +} diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 567f131ed3b..d0c15eac0af 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -826,7 +826,6 @@ public function testNormalizeReadableLinks(): void ]; $this->assertSame($expected, $normalizer->normalize($dummy, null, [ 'resources' => [], - 'force_resource_class' => Dummy::class, ])); } diff --git a/src/Symfony/Bundle/Resources/config/api.php b/src/Symfony/Bundle/Resources/config/api.php index e3efe8b7a5a..7a4399a4962 100644 --- a/src/Symfony/Bundle/Resources/config/api.php +++ b/src/Symfony/Bundle/Resources/config/api.php @@ -32,6 +32,8 @@ use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory; use ApiPlatform\Serializer\Mapping\Loader\PropertyMetadataLoader; +use ApiPlatform\Serializer\OperationResourceResolver; +use ApiPlatform\Serializer\OperationResourceResolverInterface; use ApiPlatform\Serializer\Parameter\SerializerFilterParameterProvider; use ApiPlatform\Serializer\SerializerContextBuilder; use ApiPlatform\Serializer\SerializerFilterContextBuilder; @@ -115,6 +117,9 @@ $services->alias(GroupFilter::class, 'api_platform.serializer.group_filter'); + $services->set('api_platform.serializer.operation_resource_resolver', OperationResourceResolver::class); + $services->alias(OperationResourceResolverInterface::class, 'api_platform.serializer.operation_resource_resolver'); + $services->set('api_platform.serializer.normalizer.item', ItemNormalizer::class) ->args([ service('api_platform.metadata.property.name_collection_factory'), @@ -129,6 +134,7 @@ service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), [], service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), + service('api_platform.serializer.operation_resource_resolver'), ]) ->tag('serializer.normalizer', ['priority' => -895]); diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php index f32fe5229e5..f13ae5b9096 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php @@ -32,6 +32,7 @@ use ApiPlatform\Doctrine\Odm\State\CollectionProvider; use ApiPlatform\Doctrine\Odm\State\ItemProvider; use ApiPlatform\Doctrine\Odm\State\LinksHandler; +use ApiPlatform\Doctrine\Orm\Serializer\DoctrineOperationResourceResolver; use Doctrine\Persistence\Mapping\ClassMetadataFactory; return function (ContainerConfigurator $container) { @@ -45,6 +46,8 @@ $services->set('api_platform.doctrine.metadata_factory', ClassMetadataFactory::class)->factory([service('doctrine_mongodb.odm.default_document_manager'), 'getMetadataFactory']); + $services->set('api_platform.serializer.operation_resource_resolver', DoctrineOperationResourceResolver::class); + $services->set('api_platform.doctrine_mongodb.odm.state.remove_processor', RemoveProcessor::class) ->args([service('doctrine_mongodb')]) ->tag('api_platform.state_processor', ['priority' => -100, 'key' => 'api_platform.doctrine_mongodb.odm.state.remove_processor']) diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.php b/src/Symfony/Bundle/Resources/config/doctrine_orm.php index f893cda9d16..61564869b00 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.php @@ -32,6 +32,7 @@ use ApiPlatform\Doctrine\Orm\Metadata\Property\DoctrineOrmPropertyMetadataFactory; use ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmLinkFactory; use ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmResourceCollectionMetadataFactory; +use ApiPlatform\Doctrine\Orm\Serializer\DoctrineOperationResourceResolver; use ApiPlatform\Doctrine\Orm\State\CollectionProvider; use ApiPlatform\Doctrine\Orm\State\ItemProvider; use ApiPlatform\Doctrine\Orm\State\LinksHandler; @@ -42,6 +43,8 @@ $services->set('api_platform.doctrine.metadata_factory', ClassMetadataFactory::class)->factory([service('doctrine.orm.default_entity_manager'), 'getMetadataFactory']); + $services->set('api_platform.serializer.operation_resource_resolver', DoctrineOperationResourceResolver::class); + $services->set('api_platform.doctrine.orm.state.remove_processor', RemoveProcessor::class) ->args([service('doctrine')]) ->tag('api_platform.state_processor', ['priority' => -100, 'key' => 'api_platform.doctrine.orm.state.remove_processor']) diff --git a/src/Symfony/Bundle/Resources/config/hal.php b/src/Symfony/Bundle/Resources/config/hal.php index 1e2d91ce2a3..933eaff113e 100644 --- a/src/Symfony/Bundle/Resources/config/hal.php +++ b/src/Symfony/Bundle/Resources/config/hal.php @@ -64,6 +64,7 @@ service('api_platform.metadata.resource.metadata_collection_factory')->ignoreOnInvalid(), service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), + service('api_platform.serializer.operation_resource_resolver'), ]) ->tag('serializer.normalizer', ['priority' => -890]); diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.php b/src/Symfony/Bundle/Resources/config/jsonapi.php index da7781e58cf..c4e7c895bb7 100644 --- a/src/Symfony/Bundle/Resources/config/jsonapi.php +++ b/src/Symfony/Bundle/Resources/config/jsonapi.php @@ -72,6 +72,7 @@ service('api_platform.metadata.resource.metadata_collection_factory'), service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), + service('api_platform.serializer.operation_resource_resolver'), ]) ->tag('serializer.normalizer', ['priority' => -890]); diff --git a/src/Symfony/Bundle/Resources/config/jsonld.php b/src/Symfony/Bundle/Resources/config/jsonld.php index 1f9b2b7b315..33859c30723 100644 --- a/src/Symfony/Bundle/Resources/config/jsonld.php +++ b/src/Symfony/Bundle/Resources/config/jsonld.php @@ -49,6 +49,8 @@ '%api_platform.serializer.default_context%', service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), + service('api_platform.metadata.operation.metadata_factory')->ignoreOnInvalid(), + service('api_platform.serializer.operation_resource_resolver'), ]) ->tag('serializer.normalizer', ['priority' => -890]); diff --git a/tests/Fixtures/TestBundle/ApiResource/DateTimeNormalizationIssue.php b/tests/Fixtures/TestBundle/ApiResource/DateTimeNormalizationIssue.php new file mode 100644 index 00000000000..632c92b9a26 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DateTimeNormalizationIssue.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/datetime_normalization_issues/{id}', + provider: [self::class, 'provide'] + ), + ] +)] +class DateTimeNormalizationIssue +{ + public function __construct( + public ?int $id = null, + public ?string $name = null, + public ?\DateTimeImmutable $updatedAt = null, + ) { + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + return new self( + id: (int) ($uriVariables['id'] ?? 1), + name: 'Test Resource', + updatedAt: new \DateTimeImmutable('2024-01-15T10:30:00+00:00'), + ); + } +} diff --git a/tests/Functional/DateTimeNormalizerPriorityTest.php b/tests/Functional/DateTimeNormalizerPriorityTest.php new file mode 100644 index 00000000000..b3ad733b6cc --- /dev/null +++ b/tests/Functional/DateTimeNormalizerPriorityTest.php @@ -0,0 +1,95 @@ + + * + * 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\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DateTimeNormalizationIssue; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Tests that ItemNormalizer does not intercept DateTimeImmutable objects + * before Symfony's DateTimeNormalizer when context leaks through custom normalizers. + * + * Reproduces and validates the fix for issue #7733: + * https://github.com/api-platform/core/issues/7733 + * + * The issue occurs when a custom normalizer delegates DateTimeImmutable + * normalization to the serializer with a context containing force_resource_class, + * causing ItemNormalizer to incorrectly intercept it. + */ +final class DateTimeNormalizerPriorityTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DateTimeNormalizationIssue::class]; + } + + /** + * Test that DateTimeImmutable is properly serialized to a string + * in the API response. + */ + public function testDateTimeImmutableIsNormalizedAsString(): void + { + $response = self::createClient()->request('GET', '/datetime_normalization_issues/1'); + + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + + $this->assertSame(1, $data['id']); + $this->assertSame('Test Resource', $data['name']); + $this->assertArrayHasKey('updatedAt', $data); + $this->assertIsString($data['updatedAt']); + $this->assertStringContainsString('2024-01-15', $data['updatedAt']); + } + + /** + * Tests that ItemNormalizer::supportsNormalization returns false for DateTimeImmutable + * even when force_resource_class leaks through context from a parent resource normalization. + * + * This directly reproduces the bug in issue #7733 where a custom normalizer delegates + * DateTimeImmutable normalization to the serializer with a context containing + * force_resource_class, causing ItemNormalizer to incorrectly claim it can handle + * the DateTimeImmutable object and then fail with: + * + * LogicException: Can't get a way to read the property "id" in class "DateTimeImmutable". + */ + public function testDateTimeImmutableIsNotInterceptedByItemNormalizer(): void + { + self::bootKernel(); + $serializer = self::getContainer()->get('serializer'); + + // Simulate a custom normalizer that delegates DateTimeImmutable normalization + // with a context that still contains force_resource_class from the parent resource + $dateTime = new \DateTimeImmutable('2024-01-15T10:30:00+00:00'); + + // Before the fix, this would throw a LogicException about the "id" property + // After the fix, DateTimeNormalizer handles it correctly + $result = $serializer->normalize($dateTime, 'jsonld', [ + 'force_resource_class' => DateTimeNormalizationIssue::class, + ]); + + // DateTimeNormalizer should handle this, producing a string + // ItemNormalizer must NOT intercept it + $this->assertIsString($result); + $this->assertStringContainsString('2024-01-15', $result); + } +} From 09f248b26c536e2bd459464a4d807572dfb897ad Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 13 Feb 2026 15:56:42 +0100 Subject: [PATCH 2/4] refactor --- ...> DoctrineOperationResourceClassResolver.php} | 4 ++-- src/Hal/Serializer/ItemNormalizer.php | 4 ++-- src/JsonApi/Serializer/ItemNormalizer.php | 4 ++-- src/JsonLd/Serializer/ItemNormalizer.php | 12 +++--------- src/Laravel/ApiPlatformProvider.php | 16 ++++++++-------- src/Laravel/Routing/IriConverter.php | 4 ++-- ...> EloquentOperationResourceClassResolver.php} | 4 ++-- src/Serializer/AbstractItemNormalizer.php | 2 +- src/Serializer/ItemNormalizer.php | 2 +- ...er.php => OperationResourceClassResolver.php} | 2 +- ... OperationResourceClassResolverInterface.php} | 4 ++-- src/Symfony/Bundle/Resources/config/api.php | 8 ++++---- .../Resources/config/doctrine_mongodb_odm.php | 4 ++-- .../Bundle/Resources/config/doctrine_orm.php | 4 ++-- 14 files changed, 34 insertions(+), 40 deletions(-) rename src/Doctrine/Orm/Serializer/{DoctrineOperationResourceResolver.php => DoctrineOperationResourceClassResolver.php} (91%) rename src/Laravel/Serializer/{EloquentOperationResourceResolver.php => EloquentOperationResourceClassResolver.php} (90%) rename src/Serializer/{OperationResourceResolver.php => OperationResourceClassResolver.php} (93%) rename src/Serializer/{OperationResourceResolverInterface.php => OperationResourceClassResolverInterface.php} (91%) diff --git a/src/Doctrine/Orm/Serializer/DoctrineOperationResourceResolver.php b/src/Doctrine/Orm/Serializer/DoctrineOperationResourceClassResolver.php similarity index 91% rename from src/Doctrine/Orm/Serializer/DoctrineOperationResourceResolver.php rename to src/Doctrine/Orm/Serializer/DoctrineOperationResourceClassResolver.php index 50f00da0d61..c78e78ee3e5 100644 --- a/src/Doctrine/Orm/Serializer/DoctrineOperationResourceResolver.php +++ b/src/Doctrine/Orm/Serializer/DoctrineOperationResourceClassResolver.php @@ -15,7 +15,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Util\ClassInfoTrait; -use ApiPlatform\Serializer\OperationResourceResolver; +use ApiPlatform\Serializer\OperationResourceClassResolver; /** * Doctrine-specific operation resource resolver. @@ -26,7 +26,7 @@ * * @author Kévin Dunglas */ -final class DoctrineOperationResourceResolver extends OperationResourceResolver +final class DoctrineOperationResourceClassResolver extends OperationResourceClassResolver { use ClassInfoTrait; diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index 7d0493cd1a0..4d03105dfa3 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -25,7 +25,7 @@ use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; -use ApiPlatform\Serializer\OperationResourceResolverInterface; +use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; @@ -60,7 +60,7 @@ final class ItemNormalizer extends AbstractItemNormalizer private array $componentsCache = []; private array $attributesMetadataCache = []; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, ?TagCollectorInterface $tagCollector = null, ?OperationResourceResolverInterface $operationResourceResolver = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) { $defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] = function ($object): ?array { $iri = $this->iriConverter->getIriFromResource($object); diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 4bb4f776032..3ecb096b0f7 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -27,7 +27,7 @@ use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; -use ApiPlatform\Serializer\OperationResourceResolverInterface; +use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -60,7 +60,7 @@ final class ItemNormalizer extends AbstractItemNormalizer private array $componentsCache = []; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceResolverInterface $operationResourceResolver = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); } diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index f1223db4dac..87b3a12fc89 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -28,7 +28,7 @@ use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\ContextTrait; -use ApiPlatform\Serializer\OperationResourceResolverInterface; +use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\LogicException; @@ -72,7 +72,7 @@ final class ItemNormalizer extends AbstractItemNormalizer '@vocab', ]; - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, private ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ?OperationResourceResolverInterface $operationResourceResolver = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, private ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); } @@ -187,13 +187,7 @@ public function normalize(mixed $data, ?string $format = null, array $context = if (!isset($metadata['@type']) && $operation) { $types = $operation instanceof HttpOperation ? $operation->getTypes() : null; if (null === $types) { - // Use resource-level shortName to avoid operation-specific overrides - $typeClass = $isResourceClass ? $resourceClass : ($operation->getClass() ?? $resourceClass); - try { - $types = [$this->resourceMetadataCollectionFactory->create($typeClass)[0]->getShortName()]; - } catch (\Exception) { - $types = [$operation->getShortName()]; - } + $types = [$operation->getShortName()]; } $metadata['@type'] = 1 === \count($types) ? $types[0] : $types; } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index f7ad64adb57..46aaa4b0e3b 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -102,7 +102,7 @@ use ApiPlatform\Laravel\Routing\Router as UrlGeneratorRouter; use ApiPlatform\Laravel\Routing\SkolemIriConverter; use ApiPlatform\Laravel\Security\ResourceAccessChecker; -use ApiPlatform\Laravel\Serializer\EloquentOperationResourceResolver; +use ApiPlatform\Laravel\Serializer\EloquentOperationResourceClassResolver; use ApiPlatform\Laravel\State\AccessCheckerProvider; use ApiPlatform\Laravel\State\SwaggerUiProcessor; use ApiPlatform\Laravel\State\SwaggerUiProvider; @@ -143,7 +143,7 @@ use ApiPlatform\Serializer\JsonEncoder; use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory as SerializerClassMetadataFactory; use ApiPlatform\Serializer\Mapping\Loader\PropertyMetadataLoader; -use ApiPlatform\Serializer\OperationResourceResolverInterface; +use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\SerializerContextBuilder; use ApiPlatform\State\CallableProcessor; use ApiPlatform\State\CallableProvider; @@ -271,9 +271,9 @@ public function register(): void return new EloquentResourceClassResolver(new ResourceClassResolver($app->make(ResourceNameCollectionFactoryInterface::class))); }); - $this->app->bind(OperationResourceResolverInterface::class, EloquentOperationResourceResolver::class); - $this->app->singleton(EloquentOperationResourceResolver::class, static function (Application $app) { - return new EloquentOperationResourceResolver(); + $this->app->bind(OperationResourceClassResolverInterface::class, EloquentOperationResourceClassResolver::class); + $this->app->singleton(EloquentOperationResourceClassResolver::class, static function (Application $app) { + return new EloquentOperationResourceClassResolver(); }); $this->app->singleton(PropertyMetadataFactoryInterface::class, static function (Application $app) { @@ -502,7 +502,7 @@ public function register(): void $this->app->bind(IriConverterInterface::class, IriConverter::class); $this->app->singleton(IriConverter::class, static function (Application $app) { - return new IriConverter($app->make(CallableProvider::class), $app->make(OperationMetadataFactoryInterface::class), $app->make(UrlGeneratorRouter::class), $app->make(IdentifiersExtractorInterface::class), $app->make(ResourceClassResolverInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class), $app->make(SkolemIriConverter::class), null, $app->make(OperationResourceResolverInterface::class)); + return new IriConverter($app->make(CallableProvider::class), $app->make(OperationMetadataFactoryInterface::class), $app->make(UrlGeneratorRouter::class), $app->make(IdentifiersExtractorInterface::class), $app->make(ResourceClassResolverInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class), $app->make(SkolemIriConverter::class), null, $app->make(OperationResourceClassResolverInterface::class)); }); $this->app->singleton(SkolemIriConverter::class, static function (Application $app) { @@ -583,7 +583,7 @@ public function register(): void $defaultContext, // $app->make(TagCollectorInterface::class) null, - $app->make(OperationResourceResolverInterface::class), + $app->make(OperationResourceClassResolverInterface::class), ); }); @@ -978,7 +978,7 @@ public function register(): void // $app->make(TagCollectorInterface::class) null, null, - $app->make(OperationResourceResolverInterface::class), + $app->make(OperationResourceClassResolverInterface::class), ); }); diff --git a/src/Laravel/Routing/IriConverter.php b/src/Laravel/Routing/IriConverter.php index 4443ed851b4..24de1e9fb74 100644 --- a/src/Laravel/Routing/IriConverter.php +++ b/src/Laravel/Routing/IriConverter.php @@ -30,7 +30,7 @@ use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; -use ApiPlatform\Serializer\OperationResourceResolverInterface; +use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\State\ProviderInterface; use Illuminate\Database\Eloquent\Relations\Relation; // use Illuminate\Routing\Router; @@ -57,7 +57,7 @@ class IriConverter implements IriConverterInterface /** * @param ProviderInterface $provider */ - public function __construct(private readonly ProviderInterface $provider, private readonly OperationMetadataFactoryInterface $operationMetadataFactory, private readonly RouterInterface $router, private readonly IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ?IriConverterInterface $decorated = null, private readonly ?OperationResourceResolverInterface $operationResourceResolver = null) + public function __construct(private readonly ProviderInterface $provider, private readonly OperationMetadataFactoryInterface $operationMetadataFactory, private readonly RouterInterface $router, private readonly IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ?IriConverterInterface $decorated = null, private readonly ?OperationResourceClassResolverInterface $operationResourceResolver = null) { $this->resourceClassResolver = $resourceClassResolver; } diff --git a/src/Laravel/Serializer/EloquentOperationResourceResolver.php b/src/Laravel/Serializer/EloquentOperationResourceClassResolver.php similarity index 90% rename from src/Laravel/Serializer/EloquentOperationResourceResolver.php rename to src/Laravel/Serializer/EloquentOperationResourceClassResolver.php index d45d1552a38..f4f690a77ca 100644 --- a/src/Laravel/Serializer/EloquentOperationResourceResolver.php +++ b/src/Laravel/Serializer/EloquentOperationResourceClassResolver.php @@ -15,7 +15,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Util\ClassInfoTrait; -use ApiPlatform\Serializer\OperationResourceResolver; +use ApiPlatform\Serializer\OperationResourceClassResolver; /** * Laravel Eloquent-specific operation resource resolver. @@ -25,7 +25,7 @@ * * @author Kévin Dunglas */ -final class EloquentOperationResourceResolver extends OperationResourceResolver +final class EloquentOperationResourceClassResolver extends OperationResourceClassResolver { use ClassInfoTrait; diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index a2cc6be05ab..b4bd8e67147 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -76,7 +76,7 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer protected array $localFactoryOptionsCache = []; protected ?ResourceAccessCheckerInterface $resourceAccessChecker; - public function __construct(protected PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, protected PropertyMetadataFactoryInterface $propertyMetadataFactory, protected IriConverterInterface $iriConverter, protected ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, protected ?OperationResourceResolverInterface $operationResourceResolver = null) + public function __construct(protected PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, protected PropertyMetadataFactoryInterface $propertyMetadataFactory, protected IriConverterInterface $iriConverter, protected ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, protected ?OperationResourceClassResolverInterface $operationResourceResolver = null) { if (!isset($defaultContext['circular_reference_handler'])) { $defaultContext['circular_reference_handler'] = fn ($object): ?string => $this->iriConverter->getIriFromResource($object); diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index fe5334e41d2..40dadc3b3e6 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -41,7 +41,7 @@ class ItemNormalizer extends AbstractItemNormalizer { private readonly LoggerInterface $logger; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, ?LoggerInterface $logger = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, array $defaultContext = [], protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceResolverInterface $operationResourceResolver = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, ?LoggerInterface $logger = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, array $defaultContext = [], protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); diff --git a/src/Serializer/OperationResourceResolver.php b/src/Serializer/OperationResourceClassResolver.php similarity index 93% rename from src/Serializer/OperationResourceResolver.php rename to src/Serializer/OperationResourceClassResolver.php index b07f34c0119..fc0a8c99556 100644 --- a/src/Serializer/OperationResourceResolver.php +++ b/src/Serializer/OperationResourceClassResolver.php @@ -25,7 +25,7 @@ * * @author Kévin Dunglas */ -class OperationResourceResolver implements OperationResourceResolverInterface +class OperationResourceClassResolver implements OperationResourceClassResolverInterface { use ClassInfoTrait; diff --git a/src/Serializer/OperationResourceResolverInterface.php b/src/Serializer/OperationResourceClassResolverInterface.php similarity index 91% rename from src/Serializer/OperationResourceResolverInterface.php rename to src/Serializer/OperationResourceClassResolverInterface.php index 4fab7ae6827..68446dacd6a 100644 --- a/src/Serializer/OperationResourceResolverInterface.php +++ b/src/Serializer/OperationResourceClassResolverInterface.php @@ -24,7 +24,7 @@ * * @author Kévin Dunglas */ -interface OperationResourceResolverInterface +interface OperationResourceClassResolverInterface { /** * Resolves the resource class to use for serializing/generating IRIs. @@ -32,7 +32,7 @@ interface OperationResourceResolverInterface * @param object|string $resource The object or class name to resolve * @param Operation $operation The operation context providing stateOptions * - * @return string The resource class to use + * @return class-string The resource class to use */ public function resolve(object|string $resource, Operation $operation): string; } diff --git a/src/Symfony/Bundle/Resources/config/api.php b/src/Symfony/Bundle/Resources/config/api.php index 7a4399a4962..c1685a0c4ae 100644 --- a/src/Symfony/Bundle/Resources/config/api.php +++ b/src/Symfony/Bundle/Resources/config/api.php @@ -32,8 +32,8 @@ use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory; use ApiPlatform\Serializer\Mapping\Loader\PropertyMetadataLoader; -use ApiPlatform\Serializer\OperationResourceResolver; -use ApiPlatform\Serializer\OperationResourceResolverInterface; +use ApiPlatform\Serializer\OperationResourceClassResolver; +use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\Parameter\SerializerFilterParameterProvider; use ApiPlatform\Serializer\SerializerContextBuilder; use ApiPlatform\Serializer\SerializerFilterContextBuilder; @@ -117,8 +117,8 @@ $services->alias(GroupFilter::class, 'api_platform.serializer.group_filter'); - $services->set('api_platform.serializer.operation_resource_resolver', OperationResourceResolver::class); - $services->alias(OperationResourceResolverInterface::class, 'api_platform.serializer.operation_resource_resolver'); + $services->set('api_platform.serializer.operation_resource_resolver', OperationResourceClassResolver::class); + $services->alias(OperationResourceClassResolverInterface::class, 'api_platform.serializer.operation_resource_resolver'); $services->set('api_platform.serializer.normalizer.item', ItemNormalizer::class) ->args([ diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php index f13ae5b9096..5600c81bf96 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php @@ -32,7 +32,7 @@ use ApiPlatform\Doctrine\Odm\State\CollectionProvider; use ApiPlatform\Doctrine\Odm\State\ItemProvider; use ApiPlatform\Doctrine\Odm\State\LinksHandler; -use ApiPlatform\Doctrine\Orm\Serializer\DoctrineOperationResourceResolver; +use ApiPlatform\Doctrine\Orm\Serializer\DoctrineOperationResourceClassResolver; use Doctrine\Persistence\Mapping\ClassMetadataFactory; return function (ContainerConfigurator $container) { @@ -46,7 +46,7 @@ $services->set('api_platform.doctrine.metadata_factory', ClassMetadataFactory::class)->factory([service('doctrine_mongodb.odm.default_document_manager'), 'getMetadataFactory']); - $services->set('api_platform.serializer.operation_resource_resolver', DoctrineOperationResourceResolver::class); + $services->set('api_platform.serializer.operation_resource_resolver', DoctrineOperationResourceClassResolver::class); $services->set('api_platform.doctrine_mongodb.odm.state.remove_processor', RemoveProcessor::class) ->args([service('doctrine_mongodb')]) diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.php b/src/Symfony/Bundle/Resources/config/doctrine_orm.php index 61564869b00..be414b9868f 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.php @@ -32,7 +32,7 @@ use ApiPlatform\Doctrine\Orm\Metadata\Property\DoctrineOrmPropertyMetadataFactory; use ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmLinkFactory; use ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmResourceCollectionMetadataFactory; -use ApiPlatform\Doctrine\Orm\Serializer\DoctrineOperationResourceResolver; +use ApiPlatform\Doctrine\Orm\Serializer\DoctrineOperationResourceClassResolver; use ApiPlatform\Doctrine\Orm\State\CollectionProvider; use ApiPlatform\Doctrine\Orm\State\ItemProvider; use ApiPlatform\Doctrine\Orm\State\LinksHandler; @@ -43,7 +43,7 @@ $services->set('api_platform.doctrine.metadata_factory', ClassMetadataFactory::class)->factory([service('doctrine.orm.default_entity_manager'), 'getMetadataFactory']); - $services->set('api_platform.serializer.operation_resource_resolver', DoctrineOperationResourceResolver::class); + $services->set('api_platform.serializer.operation_resource_resolver', DoctrineOperationResourceClassResolver::class); $services->set('api_platform.doctrine.orm.state.remove_processor', RemoveProcessor::class) ->args([service('doctrine')]) From 616146f00e433a8197cb8cb32b5fac5795428070 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 13 Feb 2026 16:15:29 +0100 Subject: [PATCH 3/4] todo --- src/JsonLd/Serializer/ItemNormalizer.php | 11 ++++++++++- src/Laravel/Routing/IriConverter.php | 3 +-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 87b3a12fc89..230ecc0f6b9 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -187,7 +187,16 @@ public function normalize(mixed $data, ?string $format = null, array $context = if (!isset($metadata['@type']) && $operation) { $types = $operation instanceof HttpOperation ? $operation->getTypes() : null; if (null === $types) { - $types = [$operation->getShortName()]; + // TODO: 5.x break on this as this looks wrong, CollectionReferencingItem returns an IRI that point through + // ItemReferencedInCollection but it returns a CollectionReferencingItem therefore we should use the current + // object's class Type and not rely on operation ? + // Use resource-level shortName to avoid operation-specific overrides + $typeClass = $isResourceClass ? $resourceClass : ($operation->getClass() ?? $resourceClass); + try { + $types = [$this->resourceMetadataCollectionFactory->create($typeClass)[0]->getShortName()]; + } catch (\Exception) { + $types = [$operation->getShortName()]; + } } $metadata['@type'] = 1 === \count($types) ? $types[0] : $types; } diff --git a/src/Laravel/Routing/IriConverter.php b/src/Laravel/Routing/IriConverter.php index 24de1e9fb74..8239130fd12 100644 --- a/src/Laravel/Routing/IriConverter.php +++ b/src/Laravel/Routing/IriConverter.php @@ -115,12 +115,11 @@ public function getIriFromResource(object|string $resource, int $referenceType = // This is only for when a class (that is not a resource) extends another one that is a resource, we should remove this behavior // Skip this if getResourceClassForIri already determined the class via operation stateOptions if (!\is_string($resource) && $resourceClass === $this->getObjectClass($resource)) { - /** @var class-string $resourceClass */ $resourceClass = $this->getResourceClass($resource, true) ?? $resourceClass; } if (!$operation) { - $operation = (new Get())->withClass($resourceClass); + $operation = (new Get())->withClass($resourceClass); // @phpstan-ignore-line } if ($operation instanceof HttpOperation && 301 === $operation->getStatus()) { From 36bcdfd49f96158ba060660b3fe45162f1ed085f Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 13 Feb 2026 17:00:30 +0100 Subject: [PATCH 4/4] split doctrine odm/orm --- ...trineOdmOperationResourceClassResolver.php | 54 +++++++++++++++++++ ...rineOrmOperationResourceClassResolver.php} | 21 +++----- .../Resources/config/doctrine_mongodb_odm.php | 4 +- .../Bundle/Resources/config/doctrine_orm.php | 4 +- 4 files changed, 66 insertions(+), 17 deletions(-) create mode 100644 src/Doctrine/Odm/Serializer/DoctrineOdmOperationResourceClassResolver.php rename src/Doctrine/Orm/Serializer/{DoctrineOperationResourceClassResolver.php => DoctrineOrmOperationResourceClassResolver.php} (58%) diff --git a/src/Doctrine/Odm/Serializer/DoctrineOdmOperationResourceClassResolver.php b/src/Doctrine/Odm/Serializer/DoctrineOdmOperationResourceClassResolver.php new file mode 100644 index 00000000000..bb5afc41cf2 --- /dev/null +++ b/src/Doctrine/Odm/Serializer/DoctrineOdmOperationResourceClassResolver.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Odm\Serializer; + +use ApiPlatform\Doctrine\Odm\State\Options; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Serializer\OperationResourceClassResolver; + +/** + * Doctrine ODM operation resource resolver. + * + * Handles document-to-resource mappings from Doctrine ODM's stateOptions. + * + * @author Kévin Dunglas + */ +final class DoctrineOdmOperationResourceClassResolver extends OperationResourceClassResolver +{ + use ClassInfoTrait; + + public function resolve(object|string $resource, Operation $operation): string + { + if (\is_string($resource)) { + return $resource; + } + + $objectClass = $this->getObjectClass($resource); + $stateOptions = $operation->getStateOptions(); + + // Doctrine ODM: Check for document class in stateOptions + if ($stateOptions instanceof Options) { + $documentClass = $stateOptions->getDocumentClass(); + + // Validate object matches the backing document class + if ($documentClass && is_a($objectClass, $documentClass, true)) { + return $operation->getClass(); + } + } + + // Fallback to core behavior + return parent::resolve($resource, $operation); + } +} diff --git a/src/Doctrine/Orm/Serializer/DoctrineOperationResourceClassResolver.php b/src/Doctrine/Orm/Serializer/DoctrineOrmOperationResourceClassResolver.php similarity index 58% rename from src/Doctrine/Orm/Serializer/DoctrineOperationResourceClassResolver.php rename to src/Doctrine/Orm/Serializer/DoctrineOrmOperationResourceClassResolver.php index c78e78ee3e5..b6d2535b668 100644 --- a/src/Doctrine/Orm/Serializer/DoctrineOperationResourceClassResolver.php +++ b/src/Doctrine/Orm/Serializer/DoctrineOrmOperationResourceClassResolver.php @@ -13,20 +13,19 @@ namespace ApiPlatform\Doctrine\Orm\Serializer; +use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Serializer\OperationResourceClassResolver; /** - * Doctrine-specific operation resource resolver. + * Doctrine ORM operation resource resolver. * - * Handles entity-to-resource mappings from Doctrine's stateOptions: - * - getEntityClass() for ORM entities - * - getDocumentClass() for ODM documents + * Handles entity-to-resource mappings from Doctrine ORM's stateOptions. * * @author Kévin Dunglas */ -final class DoctrineOperationResourceClassResolver extends OperationResourceClassResolver +final class DoctrineOrmOperationResourceClassResolver extends OperationResourceClassResolver { use ClassInfoTrait; @@ -39,15 +38,11 @@ public function resolve(object|string $resource, Operation $operation): string $objectClass = $this->getObjectClass($resource); $stateOptions = $operation->getStateOptions(); - // Doctrine-specific: Check for entity or document class in stateOptions - if ($stateOptions) { - $entityClass = method_exists($stateOptions, 'getEntityClass') - ? $stateOptions->getEntityClass() - : (method_exists($stateOptions, 'getDocumentClass') - ? $stateOptions->getDocumentClass() - : null); + // Doctrine ORM: Check for entity class in stateOptions + if ($stateOptions instanceof Options) { + $entityClass = $stateOptions->getEntityClass(); - // Validate object matches the backing entity/document class + // Validate object matches the backing entity class if ($entityClass && is_a($objectClass, $entityClass, true)) { return $operation->getClass(); } diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php index 5600c81bf96..17d22bbf589 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php @@ -29,10 +29,10 @@ use ApiPlatform\Doctrine\Odm\Metadata\Property\DoctrineMongoDbOdmPropertyMetadataFactory; use ApiPlatform\Doctrine\Odm\Metadata\Resource\DoctrineMongoDbOdmResourceCollectionMetadataFactory; use ApiPlatform\Doctrine\Odm\PropertyInfo\DoctrineExtractor; +use ApiPlatform\Doctrine\Odm\Serializer\DoctrineOdmOperationResourceClassResolver; use ApiPlatform\Doctrine\Odm\State\CollectionProvider; use ApiPlatform\Doctrine\Odm\State\ItemProvider; use ApiPlatform\Doctrine\Odm\State\LinksHandler; -use ApiPlatform\Doctrine\Orm\Serializer\DoctrineOperationResourceClassResolver; use Doctrine\Persistence\Mapping\ClassMetadataFactory; return function (ContainerConfigurator $container) { @@ -46,7 +46,7 @@ $services->set('api_platform.doctrine.metadata_factory', ClassMetadataFactory::class)->factory([service('doctrine_mongodb.odm.default_document_manager'), 'getMetadataFactory']); - $services->set('api_platform.serializer.operation_resource_resolver', DoctrineOperationResourceClassResolver::class); + $services->set('api_platform.serializer.operation_resource_resolver', DoctrineOdmOperationResourceClassResolver::class); $services->set('api_platform.doctrine_mongodb.odm.state.remove_processor', RemoveProcessor::class) ->args([service('doctrine_mongodb')]) diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.php b/src/Symfony/Bundle/Resources/config/doctrine_orm.php index be414b9868f..964686b1401 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.php @@ -32,7 +32,7 @@ use ApiPlatform\Doctrine\Orm\Metadata\Property\DoctrineOrmPropertyMetadataFactory; use ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmLinkFactory; use ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmResourceCollectionMetadataFactory; -use ApiPlatform\Doctrine\Orm\Serializer\DoctrineOperationResourceClassResolver; +use ApiPlatform\Doctrine\Orm\Serializer\DoctrineOrmOperationResourceClassResolver; use ApiPlatform\Doctrine\Orm\State\CollectionProvider; use ApiPlatform\Doctrine\Orm\State\ItemProvider; use ApiPlatform\Doctrine\Orm\State\LinksHandler; @@ -43,7 +43,7 @@ $services->set('api_platform.doctrine.metadata_factory', ClassMetadataFactory::class)->factory([service('doctrine.orm.default_entity_manager'), 'getMetadataFactory']); - $services->set('api_platform.serializer.operation_resource_resolver', DoctrineOperationResourceClassResolver::class); + $services->set('api_platform.serializer.operation_resource_resolver', DoctrineOrmOperationResourceClassResolver::class); $services->set('api_platform.doctrine.orm.state.remove_processor', RemoveProcessor::class) ->args([service('doctrine')])