From bf743db1bffdd61e35bcbd331cba90195586a018 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 17 Nov 2025 12:29:52 +0100 Subject: [PATCH] fix(state): object mapper flag --- src/Metadata/ApiResource.php | 6 +- src/Metadata/Delete.php | 2 + src/Metadata/Error.php | 2 + .../Extractor/XmlResourceExtractor.php | 1 + .../Extractor/YamlResourceExtractor.php | 1 + src/Metadata/Extractor/schema/resources.xsd | 1 + src/Metadata/Get.php | 2 + src/Metadata/GetCollection.php | 2 + src/Metadata/GraphQl/Operation.php | 4 +- src/Metadata/GraphQl/Query.php | 4 +- src/Metadata/GraphQl/Subscription.php | 2 + src/Metadata/HttpOperation.php | 4 +- src/Metadata/Metadata.php | 14 +++ src/Metadata/NotExposed.php | 2 + src/Metadata/Operation.php | 2 + src/Metadata/Patch.php | 4 +- src/Metadata/Post.php | 4 +- src/Metadata/Put.php | 4 +- .../ObjectMapperMetadataCollectionFactory.php | 99 +++++++++++++++++++ .../Extractor/Adapter/XmlResourceAdapter.php | 8 ++ .../Tests/Extractor/Adapter/resources.yaml | 1 + .../ResourceMetadataCompatibilityTest.php | 5 + .../Tests/Extractor/XmlExtractorTest.php | 4 + .../Tests/Extractor/YamlExtractorTest.php | 6 ++ src/State/Processor/ObjectMapperProcessor.php | 33 +++---- src/State/Provider/ObjectMapperProvider.php | 43 +------- .../Resources/config/state/object_mapper.php | 7 ++ src/Symfony/Controller/MainController.php | 1 + .../ApiResource/MappedResourceNoMap.php | 42 ++++++++ .../TestBundle/Entity/MappedEntityNoMap.php | 45 +++++++++ tests/Functional/MappingTest.php | 52 +++++++++- 31 files changed, 335 insertions(+), 72 deletions(-) create mode 100644 src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/MappedResourceNoMap.php create mode 100644 tests/Fixtures/TestBundle/Entity/MappedEntityNoMap.php diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index 70759a4be24..28580ea7c3c 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -969,8 +969,9 @@ public function __construct( array|Parameters|null $parameters = null, protected ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, - ?bool $jsonStream = null, + protected ?bool $jsonStream = null, protected array $extraProperties = [], + ?bool $map = null, ) { parent::__construct( shortName: $shortName, @@ -1016,7 +1017,8 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, - extraProperties: $extraProperties + extraProperties: $extraProperties, + map: $map ); /* @var Operations $operations> */ diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index 5b412c1851f..5f459c0e35e 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -101,6 +101,7 @@ public function __construct( protected ?bool $hideHydraOperation = null, ?bool $jsonStream = null, array $extraProperties = [], + ?bool $map = null, ) { parent::__construct( method: 'DELETE', @@ -183,6 +184,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, stateOptions: $stateOptions, + map: $map ); } } diff --git a/src/Metadata/Error.php b/src/Metadata/Error.php index 79e1781a0d8..abeb8ed7a58 100644 --- a/src/Metadata/Error.php +++ b/src/Metadata/Error.php @@ -95,6 +95,7 @@ public function __construct( ?OptionsInterface $stateOptions = null, ?bool $hideHydraOperation = null, ?bool $jsonStream = null, + ?bool $map = null, array $extraProperties = [], ) { parent::__construct( @@ -171,6 +172,7 @@ class: $class, stateOptions: $stateOptions, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + map: $map, extraProperties: $extraProperties, ); } diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 24554044545..b726ac03e68 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -147,6 +147,7 @@ private function buildBase(\SimpleXMLElement $resource): array 'read' => $this->phpize($resource, 'read', 'bool'), 'write' => $this->phpize($resource, 'write', 'bool'), 'jsonStream' => $this->phpize($resource, 'jsonStream', 'bool'), + 'map' => $this->phpize($resource, 'map', 'bool'), ]; } diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index f5b07d5c286..10130bcb2f5 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -173,6 +173,7 @@ private function buildBase(array $resource): array 'read' => $this->phpize($resource, 'read', 'bool'), 'write' => $this->phpize($resource, 'write', 'bool'), 'jsonStream' => $this->phpize($resource, 'jsonStream', 'bool'), + 'map' => $this->phpize($resource, 'map', 'bool'), ]; } diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index e9c849ac86a..80b56ad0bef 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -524,6 +524,7 @@ + diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 13f497679d2..0139b825611 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -101,6 +101,7 @@ public function __construct( protected ?bool $hideHydraOperation = null, ?bool $jsonStream = null, array $extraProperties = [], + ?bool $map = null, ) { parent::__construct( uriTemplate: $uriTemplate, @@ -182,6 +183,7 @@ class: $class, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, extraProperties: $extraProperties, + map: $map ); } } diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 7d1a2d73a69..74886491a23 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -102,6 +102,7 @@ public function __construct( ?bool $jsonStream = null, array $extraProperties = [], private ?string $itemUriTemplate = null, + ?bool $map = null, ) { parent::__construct( uriTemplate: $uriTemplate, @@ -183,6 +184,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, stateOptions: $stateOptions, + map: $map ); } diff --git a/src/Metadata/GraphQl/Operation.php b/src/Metadata/GraphQl/Operation.php index 1e0cd2e6e06..2dbbaa4fddf 100644 --- a/src/Metadata/GraphQl/Operation.php +++ b/src/Metadata/GraphQl/Operation.php @@ -92,6 +92,7 @@ public function __construct( mixed $rules = null, ?string $policy = null, array $extraProperties = [], + ?bool $map = null, ) { parent::__construct( shortName: $shortName, @@ -141,7 +142,8 @@ class: $class, queryParameterValidationEnabled: $queryParameterValidationEnabled, rules: $rules, policy: $policy, - extraProperties: $extraProperties + extraProperties: $extraProperties, + map: $map ); } diff --git a/src/Metadata/GraphQl/Query.php b/src/Metadata/GraphQl/Query.php index a9859c6900f..b877afa5e57 100644 --- a/src/Metadata/GraphQl/Query.php +++ b/src/Metadata/GraphQl/Query.php @@ -77,6 +77,7 @@ public function __construct( array $extraProperties = [], protected ?bool $nested = null, + ?bool $map = null, ) { parent::__construct( resolver: $resolver, @@ -132,7 +133,8 @@ class: $class, queryParameterValidationEnabled: $queryParameterValidationEnabled, policy: $policy, rules: $rules, - extraProperties: $extraProperties + extraProperties: $extraProperties, + map: $map ); } diff --git a/src/Metadata/GraphQl/Subscription.php b/src/Metadata/GraphQl/Subscription.php index fb924c7f268..dbd54aff922 100644 --- a/src/Metadata/GraphQl/Subscription.php +++ b/src/Metadata/GraphQl/Subscription.php @@ -75,6 +75,7 @@ public function __construct( mixed $rules = null, ?string $policy = null, array $extraProperties = [], + ?bool $map = null, ) { parent::__construct( resolver: $resolver, @@ -131,6 +132,7 @@ class: $class, policy: $policy, rules: $rules, extraProperties: $extraProperties, + map: $map ); } } diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 8a7a052a8da..3f5e0daaeb4 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -221,6 +221,7 @@ public function __construct( ?bool $queryParameterValidationEnabled = null, ?bool $jsonStream = null, array $extraProperties = [], + ?bool $map = null, ) { $this->formats = (null === $formats || \is_array($formats)) ? $formats : [$formats]; $this->inputFormats = (null === $inputFormats || \is_array($inputFormats)) ? $inputFormats : [$inputFormats]; @@ -279,7 +280,8 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, - extraProperties: $extraProperties + extraProperties: $extraProperties, + map: $map ); } diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index a598f7e017c..009612c9900 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -81,6 +81,7 @@ public function __construct( protected ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, protected ?bool $jsonStream = null, + protected ?bool $map = null, protected array $extraProperties = [], ) { if (\is_array($parameters) && $parameters) { @@ -90,6 +91,19 @@ public function __construct( $this->parameters = $parameters; } + public function canMap(): ?bool + { + return $this->map; + } + + public function withMap(bool $map): static + { + $self = clone $this; + $self->map = $map; + + return $self; + } + public function getShortName(): ?string { return $this->shortName; diff --git a/src/Metadata/NotExposed.php b/src/Metadata/NotExposed.php index 0ca3770ea33..c7afb4941f0 100644 --- a/src/Metadata/NotExposed.php +++ b/src/Metadata/NotExposed.php @@ -107,6 +107,7 @@ public function __construct( $processor = null, array $extraProperties = [], ?OptionsInterface $stateOptions = null, + ?bool $map = null, ) { parent::__construct( method: $method, @@ -182,6 +183,7 @@ class: $class, processor: $processor, stateOptions: $stateOptions, extraProperties: $extraProperties, + map: $map ); } } diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php index 42f936789e0..359f583d163 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -814,6 +814,7 @@ public function __construct( protected ?bool $hideHydraOperation = null, protected ?bool $jsonStream = null, protected array $extraProperties = [], + ?bool $map = null, ) { parent::__construct( shortName: $shortName, @@ -861,6 +862,7 @@ class: $class, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, extraProperties: $extraProperties, + map: $map ); } diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index 5a399725c5b..b81814350d7 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -101,6 +101,7 @@ public function __construct( ?bool $hideHydraOperation = null, ?bool $jsonStream = null, array $extraProperties = [], + ?bool $map = null, ) { parent::__construct( method: 'PATCH', @@ -182,7 +183,8 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, - extraProperties: $extraProperties + extraProperties: $extraProperties, + map: $map ); } } diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index 4db4e245aa8..208366234ef 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -102,6 +102,7 @@ public function __construct( private ?string $itemUriTemplate = null, ?bool $strictQueryParameterValidation = null, ?bool $hideHydraOperation = null, + ?bool $map = null, ) { parent::__construct( method: 'POST', @@ -183,7 +184,8 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, - extraProperties: $extraProperties + extraProperties: $extraProperties, + map: $map ); } diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 81e33066710..5fbfbfd49f6 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -102,6 +102,7 @@ public function __construct( ?bool $strictQueryParameterValidation = null, ?bool $hideHydraOperation = null, private ?bool $allowCreate = null, + ?bool $map = null, ) { parent::__construct( method: 'PUT', @@ -183,7 +184,8 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, - extraProperties: $extraProperties + extraProperties: $extraProperties, + map: $map ); } diff --git a/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php new file mode 100644 index 00000000000..d4026dd30fb --- /dev/null +++ b/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Resource\Factory; + +use ApiPlatform\Doctrine\Odm\State\Options as OdmOptions; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use Symfony\Component\ObjectMapper\Metadata\Mapping; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; + +class ObjectMapperMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + public function __construct( + private readonly ResourceMetadataCollectionFactoryInterface $decorated, + private readonly ObjectMapperMetadataFactoryInterface $objectMapperMetadata, + ) { + } + + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = $this->decorated->create($resourceClass); + + foreach ($resourceMetadataCollection as $key => $resourceMetadata) { + $operations = $resourceMetadata->getOperations(); + + if (!$operations) { + continue; + } + + foreach ($operations as $operationKey => $operation) { + if (null !== $operation->canMap()) { + continue; + } + + $entityClass = null; + if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getEntityClass()) { + $entityClass = $options->getEntityClass(); + } + + if (($options = $operation->getStateOptions()) && $options instanceof OdmOptions && $options->getDocumentClass()) { + $entityClass = $options->getDocumentClass(); + } + + $class = $operation->getInput()['class'] ?? $operation->getClass(); + $entityMap = null; + + // Look for Mapping metadata + if ($this->canBeMapped($class) || ($entityClass && ($entityMap = $this->canBeMapped($entityClass)))) { + $found = true; + if ($entityMap) { + foreach ($entityMap as $mapping) { + if ($found = ($mapping->source === $operation->getClass() || $mapping->target === $operation->getClass())) { + break; + } + } + } + + if (!$found) { + continue; + } + + $operations->add($operationKey, $operation->withMap(true)); + } + } + + $resourceMetadataCollection[$key] = $resourceMetadata->withOperations($operations); + } + + return $resourceMetadataCollection; + } + + /** + * @return bool|list + */ + private function canBeMapped(string $class): bool|array + { + try { + $r = new \ReflectionClass($class); + if (!$r->isInstantiable() || !($mapping = $this->objectMapperMetadata->create($r->newInstanceWithoutConstructor(), null, ['_api_check_can_be_mapped' => true]))) { + return false; + } + } catch (\ReflectionException $e) { + return false; + } + + return $mapping; + } +} diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php index 3fd2bf0bf1a..cf1f5738640 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -550,6 +550,14 @@ private function buildJsonStream(\SimpleXMLElement $resource, bool $value): void $resource->addAttribute('jsonStream', $this->parse($value)); } + private function buildMap(\SimpleXMLElement $resource, ?bool $value): void + { + if (null === $value) { + return; + } + $resource->addAttribute('map', $this->parse($value)); + } + private function parse($value): ?string { if (null === $value) { diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml index 515981dd993..4ba7ce8cd19 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -343,3 +343,4 @@ resources: custom_property: 'Lorem ipsum dolor sit amet' another_custom_property: 'Lorem ipsum': 'Dolor sit amet' + map: null diff --git a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php index 57d736c92e6..80d5cec4e38 100644 --- a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php +++ b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php @@ -761,4 +761,9 @@ private function withJsonStream(bool $value): bool { return $value; } + + private function withMap(bool $value): bool + { + return $value; + } } diff --git a/src/Metadata/Tests/Extractor/XmlExtractorTest.php b/src/Metadata/Tests/Extractor/XmlExtractorTest.php index 4f26329a723..4f260de5079 100644 --- a/src/Metadata/Tests/Extractor/XmlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/XmlExtractorTest.php @@ -104,6 +104,7 @@ public function testValidXML(): void 'headers' => null, 'parameters' => null, 'jsonStream' => null, + 'map' => null, ], [ 'uriTemplate' => '/users/{author}/comments{._format}', @@ -280,6 +281,7 @@ public function testValidXML(): void 'parameters' => null, 'routeName' => 'custom_route_name', 'jsonStream' => null, + 'map' => null, ], [ 'name' => null, @@ -393,6 +395,7 @@ public function testValidXML(): void ], 'routeName' => null, 'jsonStream' => null, + 'map' => null, ], ], 'graphQlOperations' => null, @@ -405,6 +408,7 @@ public function testValidXML(): void 'headers' => ['hello' => 'world'], 'parameters' => null, 'jsonStream' => null, + 'map' => null, ], ], ], $extractor->getResources()); diff --git a/src/Metadata/Tests/Extractor/YamlExtractorTest.php b/src/Metadata/Tests/Extractor/YamlExtractorTest.php index 9d08bb94ef4..3c1a1d88972 100644 --- a/src/Metadata/Tests/Extractor/YamlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/YamlExtractorTest.php @@ -103,6 +103,7 @@ public function testValidYaml(): void 'headers' => null, 'parameters' => null, 'jsonStream' => null, + 'map' => null, ], ], Program::class => [ @@ -175,6 +176,7 @@ public function testValidYaml(): void 'headers' => null, 'parameters' => null, 'jsonStream' => null, + 'map' => null, ], [ 'uriTemplate' => '/users/{author}/programs{._format}', @@ -318,6 +320,7 @@ public function testValidYaml(): void 'headers' => ['hello' => 'world'], 'parameters' => null, 'jsonStream' => null, + 'map' => null, ], [ 'name' => null, @@ -404,6 +407,7 @@ public function testValidYaml(): void 'headers' => ['hello' => 'world'], 'parameters' => ['author' => new QueryParameter(schema: ['type' => 'string'], required: true, key: 'author', description: 'hello')], 'jsonStream' => null, + 'map' => null, ], ], 'graphQlOperations' => null, @@ -416,6 +420,7 @@ public function testValidYaml(): void 'headers' => ['hello' => 'world'], 'parameters' => null, 'jsonStream' => null, + 'map' => null, ], ], SingleFileConfigDummy::class => [ @@ -488,6 +493,7 @@ public function testValidYaml(): void 'headers' => null, 'parameters' => null, 'jsonStream' => null, + 'map' => null, ], ], ], $extractor->getResources()); diff --git a/src/State/Processor/ObjectMapperProcessor.php b/src/State/Processor/ObjectMapperProcessor.php index e0f62ba7693..c749c3f8f83 100644 --- a/src/State/Processor/ObjectMapperProcessor.php +++ b/src/State/Processor/ObjectMapperProcessor.php @@ -15,8 +15,6 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; -use Symfony\Component\ObjectMapper\Attribute\Map; -use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; use Symfony\Component\ObjectMapper\ObjectMapperInterface; /** @@ -30,12 +28,7 @@ final class ObjectMapperProcessor implements ProcessorInterface public function __construct( private readonly ?ObjectMapperInterface $objectMapper, private readonly ProcessorInterface $decorated, - private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null, ) { - // TODO: 4.3 add this deprecation - // if (!$objectMapperMetadata) { - // trigger_deprecation('api-platform/state', '4.3', 'Not injecting "%s" in "%s" will not be possible anymore in 5.0.', ObjectMapperMetadataFactoryInterface::class, __CLASS__); - // } } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null @@ -47,28 +40,26 @@ public function process(mixed $data, Operation $operation, array $uriVariables = || !$operation->canWrite() || null === $data || !is_a($data, $class, true) + || !$operation->canMap() ) { return $this->decorated->process($data, $operation, $uriVariables, $context); } - if ($this->objectMapperMetadata) { - if (!$this->objectMapperMetadata->create($data)) { - return $this->decorated->process($data, $operation, $uriVariables, $context); - } - } elseif (!(new \ReflectionClass($operation->getClass()))->getAttributes(Map::class)) { - return $this->decorated->process($data, $operation, $uriVariables, $context); - } + $request = $context['request'] ?? null; + $persisted = $this->decorated->process( + // maps the Resource to an Entity + $this->objectMapper->map($data, $request?->attributes->get('mapped_data')), + $operation, + $uriVariables, + $context, + ); + + $request?->attributes->set('persisted_data', $persisted); // return the Resource representation of the persisted entity return $this->objectMapper->map( // persist the entity - $this->decorated->process( - // maps the Resource to an Entity - $this->objectMapper->map($data), - $operation, - $uriVariables, - $context, - ), + $persisted, $operation->getClass() ); } diff --git a/src/State/Provider/ObjectMapperProvider.php b/src/State/Provider/ObjectMapperProvider.php index 7f8dfae60a9..89f1c332fce 100644 --- a/src/State/Provider/ObjectMapperProvider.php +++ b/src/State/Provider/ObjectMapperProvider.php @@ -13,15 +13,11 @@ namespace ApiPlatform\State\Provider; -use ApiPlatform\Doctrine\Odm\State\Options as OdmOptions; -use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Util\CloneTrait; use ApiPlatform\State\Pagination\ArrayPaginator; use ApiPlatform\State\Pagination\PaginatorInterface; use ApiPlatform\State\ProviderInterface; -use Symfony\Component\ObjectMapper\Attribute\Map; -use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; use Symfony\Component\ObjectMapper\ObjectMapperInterface; /** @@ -37,40 +33,19 @@ final class ObjectMapperProvider implements ProviderInterface public function __construct( private readonly ?ObjectMapperInterface $objectMapper, private readonly ProviderInterface $decorated, - private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null, ) { - // TODO: 4.3 add this deprecation - // if (!$objectMapperMetadata) { - // trigger_deprecation('api-platform/state', '4.3', 'Not injecting "%s" in "%s" will not be possible anymore in 5.0.', ObjectMapperMetadataFactoryInterface::class, __CLASS__); - // } } public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { $data = $this->decorated->provide($operation, $uriVariables, $context); - if (!$this->objectMapper || !\is_object($data)) { + if (!$this->objectMapper || !\is_object($data) || !$operation->canMap()) { return $data; } $request = $context['request'] ?? null; - $entityClass = null; - if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getEntityClass()) { - $entityClass = $options->getEntityClass(); - } - - if (($options = $operation->getStateOptions()) && $options instanceof OdmOptions && $options->getDocumentClass()) { - $entityClass = $options->getDocumentClass(); - } - - // Look for Mapping metadata - if ($this->objectMapperMetadata) { - if (!$this->canBeMapped($operation->getClass()) && (!$entityClass || !$this->canBeMapped($entityClass))) { - return $data; - } - } elseif (!(new \ReflectionClass($operation->getClass()))->getAttributes(Map::class) && !(new \ReflectionClass($entityClass))->getAttributes(Map::class)) { - return $data; - } + $request?->attributes->set('mapped_data', $data); if ($data instanceof PaginatorInterface) { $data = new ArrayPaginator(array_map(fn ($v) => $this->objectMapper->map($v, $operation->getClass()), iterator_to_array($data)), 0, \count($data)); @@ -83,18 +58,4 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $data; } - - private function canBeMapped(string $class): bool - { - try { - $r = new \ReflectionClass($class); - if (!$r->isInstantiable() || !$this->objectMapperMetadata->create($r->newInstanceWithoutConstructor(), null, ['_api_check_can_be_mapped' => true])) { - return false; - } - } catch (\ReflectionException $e) { - return false; - } - - return true; - } } diff --git a/src/Symfony/Bundle/Resources/config/state/object_mapper.php b/src/Symfony/Bundle/Resources/config/state/object_mapper.php index 000c73aae97..e21e722c7ed 100644 --- a/src/Symfony/Bundle/Resources/config/state/object_mapper.php +++ b/src/Symfony/Bundle/Resources/config/state/object_mapper.php @@ -45,4 +45,11 @@ service('api_platform.state_processor.object_mapper.inner'), service('api_platform.object_mapper.metadata_factory'), ]); + + $services->set('api_platform.metadata.resource.metadata_collection_factory.object_mapper', 'ApiPlatform\Metadata\Resource\Factory\ObjectMapperMetadataCollectionFactory') + ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, 100) + ->args([ + service('api_platform.metadata.resource.metadata_collection_factory.object_mapper.inner'), + service('api_platform.object_mapper.metadata_factory'), + ]); }; diff --git a/src/Symfony/Controller/MainController.php b/src/Symfony/Controller/MainController.php index 508c9f072d6..f50c49ec105 100644 --- a/src/Symfony/Controller/MainController.php +++ b/src/Symfony/Controller/MainController.php @@ -112,6 +112,7 @@ public function __invoke(Request $request): Response $context['previous_data'] = $request->attributes->get('previous_data'); $context['data'] = $request->attributes->get('data'); $context['read_data'] = $request->attributes->get('read_data'); + $context['mapped_data'] = $request->attributes->get('mapped_data'); if (null === $operation->canWrite()) { $operation = $operation->withWrite(!$request->isMethodSafe()); diff --git a/tests/Fixtures/TestBundle/ApiResource/MappedResourceNoMap.php b/tests/Fixtures/TestBundle/ApiResource/MappedResourceNoMap.php new file mode 100644 index 00000000000..1f2ce644de3 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/MappedResourceNoMap.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\JsonLd\ContextBuilder; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntityNoMap; + +#[ApiResource( + operations: [ + new Get(map: false, uriVariables: ['id'], provider: [self::class, 'provide']), + new Post(map: false), + ], + stateOptions: new Options(entityClass: MappedEntityNoMap::class), + normalizationContext: [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false], +)] +class MappedResourceNoMap +{ + public function __construct(public ?int $id = null, public ?string $name = null) + { + } + + public static function provide(Operation $operation, array $uriVariables = []) + { + return new self($uriVariables['id'], 'test name'); + } +} diff --git a/tests/Fixtures/TestBundle/Entity/MappedEntityNoMap.php b/tests/Fixtures/TestBundle/Entity/MappedEntityNoMap.php new file mode 100644 index 00000000000..d45fed6117c --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MappedEntityNoMap.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\Entity; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class MappedEntityNoMap +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column] + private ?string $name = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } +} diff --git a/tests/Functional/MappingTest.php b/tests/Functional/MappingTest.php index 79522492379..232298b0209 100644 --- a/tests/Functional/MappingTest.php +++ b/tests/Functional/MappingTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FirstResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceNoMap; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceOdm; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceSourceOnly; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceWithInput; @@ -24,6 +25,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\SecondResource; use ApiPlatform\Tests\Fixtures\TestBundle\Document\MappedDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntityNoMap; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntitySourceOnly; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceWithRelationEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceWithRelationRelatedEntity; @@ -52,6 +54,7 @@ public static function getResources(): array MappedResourceWithRelationRelated::class, MappedResourceWithInput::class, MappedResourceSourceOnly::class, + MappedResourceNoMap::class, ]; } @@ -63,15 +66,19 @@ public function testShouldMapBetweenResourceAndEntity(): void $this->recreateSchema([MappedEntity::class]); $this->loadFixtures(); - $r = self::createClient()->request('GET', $this->isMongoDB() ? 'mapped_resource_odms' : 'mapped_resources'); + $client = self::createClient(); + $client->request('GET', $this->isMongoDB() ? 'mapped_resource_odms' : 'mapped_resources'); + $this->assertArrayHasKey('mapped_data', $client->getKernelBrowser()->getRequest()->attributes->all()); $this->assertJsonContains(['member' => [ ['username' => 'B0 A0'], ['username' => 'B1 A1'], ['username' => 'B2 A2'], ]]); - $r = self::createClient()->request('POST', $this->isMongoDB() ? 'mapped_resource_odms' : 'mapped_resources', ['json' => ['username' => 'so yuka']]); + $client = self::createClient(); + $r = $client->request('POST', $this->isMongoDB() ? 'mapped_resource_odms' : 'mapped_resources', ['json' => ['username' => 'so yuka']]); $this->assertJsonContains(['username' => 'so yuka']); + $this->assertArrayHasKey('persisted_data', $client->getKernelBrowser()->getRequest()->attributes->all()); $manager = $this->getManager(); $repo = $manager->getRepository($this->isMongoDB() ? MappedDocument::class : MappedEntity::class); @@ -216,6 +223,47 @@ public function testShouldMapWithSourceOnly(): void $this->assertJsonContains(['username' => 'ba zar']); } + public function testShouldNotMapWhenCanMapIsFalse(): void + { + if (!$this->getContainer()->has('api_platform.object_mapper')) { + $this->markTestSkipped('ObjectMapper not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not tested'); + } + + $this->recreateSchema([MappedEntityNoMap::class]); + + $client = self::createClient(); + $client->request('POST', '/mapped_resource_no_maps', [ + 'json' => ['name' => 'test name', 'id' => 1], + 'headers' => ['content-type' => 'application/ld+json'], + ]); + $this->assertArrayNotHasKey('mapped_data', $client->getKernelBrowser()->getRequest()->attributes->all()); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + '@context' => '/contexts/MappedResourceNoMap', + '@id' => '/mapped_resource_no_maps/1', + '@type' => 'MappedResourceNoMap', + 'id' => 1, + 'name' => 'test name', + ]); + + $client = self::createClient(); + $client->request('GET', '/mapped_resource_no_maps/1'); + $this->assertArrayNotHasKey('persisted_data', $client->getKernelBrowser()->getRequest()->attributes->all()); + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@context' => '/contexts/MappedResourceNoMap', + '@id' => '/mapped_resource_no_maps/1', + '@type' => 'MappedResourceNoMap', + 'id' => 1, + 'name' => 'test name', + ]); + } + private function loadFixtures(): void { $manager = $this->getManager();