diff --git a/features/doctrine/transform_model.feature b/features/doctrine/transform_model.feature new file mode 100644 index 00000000000..f8dfac469a8 --- /dev/null +++ b/features/doctrine/transform_model.feature @@ -0,0 +1,64 @@ +Feature: Use an entity or document transformer to return the correct ressource + + @createSchema + @!mongodb + Scenario: Get transformed collection from entities + Given there is a TransformedDummy for date '2025-01-01' + When I send a "GET" request to "/transformed_dummy_entity_ressources" + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "hydra:totalItems" should be equal to 1 + + @!mongodb + Scenario: Get transform item from entity + Given there is a TransformedDummy for date '2025-01-01' + When I send a "GET" request to "/transformed_dummy_entity_ressources/1" + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "year" should exist + And the JSON node year should be equal to "2025" + + @!mongodb + Scenario: Post new entity from transformed resource + Given I add "Content-type" header equal to "application/ld+json" + When I send a "POST" request to "/transformed_dummy_entity_ressources" with body: + """ + { + "year" : 2020 + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the JSON node "year" should be equal to "2020" + + @!mongodb + Scenario: Patch entity from transformed resource + Given there is a TransformedDummy for date '2025-01-01' + And I add "Content-type" header equal to "application/merge-patch+json" + When I send a "PATCH" request to "/transformed_dummy_entity_ressources/1" with body: + """ + { + "year" : 2020 + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "year" should be equal to "2020" + + @createSchema + @mongodb + Scenario: Get collection from documents + Given there is a TransformedDummy for date '2025-01-01' + When I send a "GET" request to "/transformed_dummy_document_ressources" + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "hydra:totalItems" should be equal to 1 + + @mongodb + Scenario: Get item from document + Given there is a TransformedDummy for date '2025-01-01' + When I send a "GET" request to "/transformed_dummy_document_ressources/1" + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "year" should exist + And the JSON node year should be equal to "2025" diff --git a/features/hydra/error.feature b/features/hydra/error.feature index 07fe8210f02..40f2eb85fa4 100644 --- a/features/hydra/error.feature +++ b/features/hydra/error.feature @@ -16,7 +16,7 @@ Feature: Error handling And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' And the JSON node "type" should exist - And the JSON node "title" should not exists + And the JSON node "title" should not exist And the JSON node "hydra:title" should be equal to "An error occurred" And the JSON node "detail" should exist And the JSON node "description" should not exist diff --git a/src/Doctrine/Common/State/Options.php b/src/Doctrine/Common/State/Options.php index df42fc6cb84..9970ded9bfd 100644 --- a/src/Doctrine/Common/State/Options.php +++ b/src/Doctrine/Common/State/Options.php @@ -18,10 +18,14 @@ class Options implements OptionsInterface { /** - * @param mixed $handleLinks experimental callable, typed mixed as we may want a service name in the future + * @param mixed $handleLinks experimental callable, typed mixed as we may want a service name in the future + * @param mixed $toResourceTransformer experimental callable, typed mixed as we may want a service name in the future + * @param mixed $fromResourceTransformer experimental callable, typed mixed as we may want a service name in the future */ public function __construct( protected mixed $handleLinks = null, + protected mixed $toResourceTransformer = null, + protected mixed $fromResourceTransformer = null, ) { } @@ -37,4 +41,30 @@ public function withHandleLinks(mixed $handleLinks): self return $self; } + + public function getToResourceTransformer(): mixed + { + return $this->toResourceTransformer; + } + + public function withToResourceTransformer(mixed $toResourceTransformer): self + { + $self = clone $this; + $self->toResourceTransformer = $toResourceTransformer; + + return $self; + } + + public function getFromResourceTransformer(): mixed + { + return $this->fromResourceTransformer; + } + + public function withFromResourceTransformer(mixed $fromResourceTransformer): self + { + $self = clone $this; + $self->fromResourceTransformer = $fromResourceTransformer; + + return $self; + } } diff --git a/src/Doctrine/Common/State/PersistProcessor.php b/src/Doctrine/Common/State/PersistProcessor.php index abc21bf9e15..c61ba15afe4 100644 --- a/src/Doctrine/Common/State/PersistProcessor.php +++ b/src/Doctrine/Common/State/PersistProcessor.php @@ -19,14 +19,18 @@ use ApiPlatform\State\ProcessorInterface; use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ObjectManager as DoctrineObjectManager; +use Psr\Container\ContainerInterface; final class PersistProcessor implements ProcessorInterface { use ClassInfoTrait; use LinksHandlerTrait; + use ResourceTransformerLocatorTrait; - public function __construct(private readonly ManagerRegistry $managerRegistry) + public function __construct(private readonly ManagerRegistry $managerRegistry, + ?ContainerInterface $resourceTransformerLocator = null) { + $this->resourceTransformerLocator = $resourceTransformerLocator; } /** @@ -38,6 +42,12 @@ public function __construct(private readonly ManagerRegistry $managerRegistry) */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed { + // if a transformer is defined, start with that + $data = match ($transformer = $this->getFromResourceTransformer($operation)) { + null => $data, + default => $transformer($data, $this->getManager($operation, $this->managerRegistry)), + }; + if ( !\is_object($data) || !$manager = $this->managerRegistry->getManagerForClass($class = $this->getObjectClass($data)) @@ -104,7 +114,10 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $manager->flush(); $manager->refresh($data); - return $data; + return match ($transformer = $this->getToResourceTransformer($operation)) { + null => $data, + default => $transformer($data, $operation, $uriVariables, $context), + }; } /** diff --git a/src/Doctrine/Common/State/ResourceTransformerInterface.php b/src/Doctrine/Common/State/ResourceTransformerInterface.php new file mode 100644 index 00000000000..eeda20f5630 --- /dev/null +++ b/src/Doctrine/Common/State/ResourceTransformerInterface.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\State; + +use Doctrine\Persistence\ObjectManager; + +interface ResourceTransformerInterface +{ + /** + * @param object $entityOrDocument the doctrine entity or document to make a resource from + * + * @return object the resulting ApiResource + */ + public function toResource(object $entityOrDocument): object; + + /** + * @param object $resource the resource you want to persist + * @param ObjectManager $objectManager the object manager to handle this kind of resources + * + * @return object the existing or new entity or document + */ + public function fromResource(object $resource, ObjectManager $objectManager): object; +} diff --git a/src/Doctrine/Common/State/ResourceTransformerLocatorTrait.php b/src/Doctrine/Common/State/ResourceTransformerLocatorTrait.php new file mode 100644 index 00000000000..58066e0bf48 --- /dev/null +++ b/src/Doctrine/Common/State/ResourceTransformerLocatorTrait.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\State; + +use ApiPlatform\Metadata\Operation; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\ObjectManager; +use Psr\Container\ContainerInterface; + +/** + * Maybe merge this and LinksHandlerLocatorTrait into a OptionsHooksLocatorTrait or something similar? + */ +trait ResourceTransformerLocatorTrait +{ + private ?ContainerInterface $resourceTransformerLocator; + + protected function getToResourceTransformer(Operation $operation): ?callable + { + if (!($options = $operation->getStateOptions()) || !$options instanceof Options) { + return null; + } + + $transformer = $options->getToResourceTransformer(); + if (\is_callable($transformer)) { + return $transformer; + } + + if ($this->resourceTransformerLocator && \is_string($transformer) && $this->resourceTransformerLocator->has($transformer)) { + return [$this->resourceTransformerLocator->get($transformer), 'toResource']; + } + + return null; + } + + protected function getFromResourceTransformer(Operation $operation): ?callable + { + if (!($options = $operation->getStateOptions()) || !$options instanceof Options) { + return null; + } + + $transformer = $options->getFromResourceTransformer(); + if (\is_callable($transformer)) { + return $transformer; + } + + if ($this->resourceTransformerLocator && \is_string($transformer) && $this->resourceTransformerLocator->has($transformer)) { + return [$this->resourceTransformerLocator->get($transformer), 'fromResource']; + } + + return null; + } + + protected function getManager(Operation $operation, ManagerRegistry $managerRegistry): ObjectManager + { + $options = $operation->getStateOptions(); + $entityClass = match (true) { + $options instanceof \ApiPlatform\Doctrine\Orm\State\Options => $options->getEntityClass(), + $options instanceof \ApiPlatform\Doctrine\Odm\State\Options => $options->getDocumentClass(), + default => null, + }; + + if ($entityClass) { + return $managerRegistry->getManagerForClass($entityClass); + } + + throw new \RuntimeException('This should not be called on an operation without StateOptions'); + } +} diff --git a/src/Doctrine/Odm/State/CollectionProvider.php b/src/Doctrine/Odm/State/CollectionProvider.php index 6c68b663f3e..82e40801f2a 100644 --- a/src/Doctrine/Odm/State/CollectionProvider.php +++ b/src/Doctrine/Odm/State/CollectionProvider.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Odm\State; use ApiPlatform\Doctrine\Common\State\LinksHandlerLocatorTrait; +use ApiPlatform\Doctrine\Common\State\ResourceTransformerLocatorTrait; use ApiPlatform\Doctrine\Odm\Extension\AggregationCollectionExtensionInterface; use ApiPlatform\Doctrine\Odm\Extension\AggregationResultCollectionExtensionInterface; use ApiPlatform\Metadata\Exception\RuntimeException; @@ -33,15 +34,20 @@ final class CollectionProvider implements ProviderInterface { use LinksHandlerLocatorTrait; use LinksHandlerTrait; + use ResourceTransformerLocatorTrait; use StateOptionsTrait; /** * @param AggregationCollectionExtensionInterface[] $collectionExtensions */ - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [], ?ContainerInterface $handleLinksLocator = null) - { + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, + private readonly iterable $collectionExtensions = [], + ?ContainerInterface $handleLinksLocator = null, + ?ContainerInterface $resourceTransformerLocator = null, + ) { $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; $this->handleLinksLocator = $handleLinksLocator; + $this->resourceTransformerLocator = $resourceTransformerLocator; $this->managerRegistry = $managerRegistry; } @@ -69,13 +75,19 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $extension->applyToCollection($aggregationBuilder, $documentClass, $operation, $context); if ($extension instanceof AggregationResultCollectionExtensionInterface && $extension->supportsResult($documentClass, $operation, $context)) { - return $extension->getResult($aggregationBuilder, $documentClass, $operation, $context); + $result = $extension->getResult($aggregationBuilder, $documentClass, $operation, $context); + break; } } $attribute = $operation->getExtraProperties()['doctrine_mongodb'] ?? []; $executeOptions = $attribute['execute_options'] ?? []; - return $aggregationBuilder->hydrate($documentClass)->getAggregation($executeOptions)->getIterator(); + $result = $result ?? $aggregationBuilder->hydrate($documentClass)->getAggregation($executeOptions)->getIterator(); + + return match ($transformer = $this->getToResourceTransformer($operation)) { + null => $result, + default => array_map($transformer, iterator_to_array($result)), + }; } } diff --git a/src/Doctrine/Odm/State/ItemProvider.php b/src/Doctrine/Odm/State/ItemProvider.php index b1b8999e38a..1c8d82df953 100644 --- a/src/Doctrine/Odm/State/ItemProvider.php +++ b/src/Doctrine/Odm/State/ItemProvider.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Odm\State; use ApiPlatform\Doctrine\Common\State\LinksHandlerLocatorTrait; +use ApiPlatform\Doctrine\Common\State\ResourceTransformerLocatorTrait; use ApiPlatform\Doctrine\Odm\Extension\AggregationItemExtensionInterface; use ApiPlatform\Doctrine\Odm\Extension\AggregationResultItemExtensionInterface; use ApiPlatform\Metadata\Exception\RuntimeException; @@ -36,15 +37,20 @@ final class ItemProvider implements ProviderInterface { use LinksHandlerLocatorTrait; use LinksHandlerTrait; + use ResourceTransformerLocatorTrait; use StateOptionsTrait; /** * @param AggregationItemExtensionInterface[] $itemExtensions */ - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [], ?ContainerInterface $handleLinksLocator = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, + private readonly iterable $itemExtensions = [], + ?ContainerInterface $handleLinksLocator = null, + ?ContainerInterface $resourceTransformerLocator = null) { $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; $this->handleLinksLocator = $handleLinksLocator; + $this->resourceTransformerLocator = $resourceTransformerLocator; $this->managerRegistry = $managerRegistry; } @@ -77,12 +83,18 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $extension->applyToItem($aggregationBuilder, $documentClass, $uriVariables, $operation, $context); if ($extension instanceof AggregationResultItemExtensionInterface && $extension->supportsResult($documentClass, $operation, $context)) { - return $extension->getResult($aggregationBuilder, $documentClass, $operation, $context); + $result = $extension->getResult($aggregationBuilder, $documentClass, $operation, $context); + break; } } $executeOptions = $operation->getExtraProperties()['doctrine_mongodb']['execute_options'] ?? []; - return $aggregationBuilder->hydrate($documentClass)->getAggregation($executeOptions)->getIterator()->current() ?: null; + $result = $result ?? ($aggregationBuilder->hydrate($documentClass)->getAggregation($executeOptions)->getIterator()->current() ?: null); + + return match ($transformer = $this->getToResourceTransformer($operation)) { + null => $result, + default => (null !== $result) ? $transformer($result) : null, + }; } } diff --git a/src/Doctrine/Odm/State/Options.php b/src/Doctrine/Odm/State/Options.php index 459d6bc49ec..dceb161ebd4 100644 --- a/src/Doctrine/Odm/State/Options.php +++ b/src/Doctrine/Odm/State/Options.php @@ -19,15 +19,17 @@ class Options extends CommonOptions implements OptionsInterface { /** - * @param mixed $handleLinks experimental callable, typed mixed as we may want a service name in the future + * @param mixed $handleLinks experimental callable, typed mixed as we may want a service name in the future + * @param mixed $transformFromDocument experimental callable, typed mixed as we may want a service name in the future * * @see LinksHandlerInterface */ public function __construct( protected ?string $documentClass = null, mixed $handleLinks = null, + mixed $transformFromDocument = null, ) { - parent::__construct(handleLinks: $handleLinks); + parent::__construct(handleLinks: $handleLinks, toResourceTransformer: $transformFromDocument); } public function getDocumentClass(): ?string @@ -42,4 +44,17 @@ public function withDocumentClass(?string $documentClass): self return $self; } + + public function getTransformDocument(): mixed + { + return $this->getToResourceTransformer(); + } + + public function withTransformDocument(mixed $transformDocument): self + { + $self = clone $this; + $self->toResourceTransformer = $transformDocument; + + return $self; + } } diff --git a/src/Doctrine/Orm/State/CollectionProvider.php b/src/Doctrine/Orm/State/CollectionProvider.php index 3815447a8d3..65da11a56ca 100644 --- a/src/Doctrine/Orm/State/CollectionProvider.php +++ b/src/Doctrine/Orm/State/CollectionProvider.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Orm\State; use ApiPlatform\Doctrine\Common\State\LinksHandlerLocatorTrait; +use ApiPlatform\Doctrine\Common\State\ResourceTransformerLocatorTrait; use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; use ApiPlatform\Doctrine\Orm\Extension\QueryResultCollectionExtensionInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGenerator; @@ -36,15 +37,21 @@ final class CollectionProvider implements ProviderInterface { use LinksHandlerLocatorTrait; use LinksHandlerTrait; + use ResourceTransformerLocatorTrait; use StateOptionsTrait; /** * @param QueryCollectionExtensionInterface[] $collectionExtensions */ - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [], ?ContainerInterface $handleLinksLocator = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + ManagerRegistry $managerRegistry, + private readonly iterable $collectionExtensions = [], + ?ContainerInterface $handleLinksLocator = null, + ?ContainerInterface $resourceTransformerLocator = null) { $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; $this->handleLinksLocator = $handleLinksLocator; + $this->resourceTransformerLocator = $resourceTransformerLocator; $this->managerRegistry = $managerRegistry; } @@ -73,10 +80,16 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $extension->applyToCollection($queryBuilder, $queryNameGenerator, $entityClass, $operation, $context); if ($extension instanceof QueryResultCollectionExtensionInterface && $extension->supportsResult($entityClass, $operation, $context)) { - return $extension->getResult($queryBuilder, $entityClass, $operation, $context); + $result = $extension->getResult($queryBuilder, $entityClass, $operation, $context); + break; } } - return $queryBuilder->getQuery()->getResult(); + $result = $result ?? $queryBuilder->getQuery()->getResult(); + + return match ($transformer = $this->getToResourceTransformer($operation)) { + null => $result, + default => array_map($transformer, iterator_to_array($result)), + }; } } diff --git a/src/Doctrine/Orm/State/ItemProvider.php b/src/Doctrine/Orm/State/ItemProvider.php index b201d03b7d0..9f330559ca2 100644 --- a/src/Doctrine/Orm/State/ItemProvider.php +++ b/src/Doctrine/Orm/State/ItemProvider.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Orm\State; use ApiPlatform\Doctrine\Common\State\LinksHandlerLocatorTrait; +use ApiPlatform\Doctrine\Common\State\ResourceTransformerLocatorTrait; use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface; use ApiPlatform\Doctrine\Orm\Extension\QueryResultItemExtensionInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGenerator; @@ -36,15 +37,20 @@ final class ItemProvider implements ProviderInterface { use LinksHandlerLocatorTrait; use LinksHandlerTrait; + use ResourceTransformerLocatorTrait; use StateOptionsTrait; /** * @param QueryItemExtensionInterface[] $itemExtensions */ - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [], ?ContainerInterface $handleLinksLocator = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, + private readonly iterable $itemExtensions = [], + ?ContainerInterface $handleLinksLocator = null, + ?ContainerInterface $resourceTransformerLocator = null) { $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; $this->handleLinksLocator = $handleLinksLocator; + $this->resourceTransformerLocator = $resourceTransformerLocator; $this->managerRegistry = $managerRegistry; } @@ -82,10 +88,16 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $extension->applyToItem($queryBuilder, $queryNameGenerator, $entityClass, $uriVariables, $operation, $context); if ($extension instanceof QueryResultItemExtensionInterface && $extension->supportsResult($entityClass, $operation, $context)) { - return $extension->getResult($queryBuilder, $entityClass, $operation, $context); + $result = $extension->getResult($queryBuilder, $entityClass, $operation, $context); + break; } } - return $queryBuilder->getQuery()->getOneOrNullResult(); + $result = $result ?? $queryBuilder->getQuery()->getOneOrNullResult(); + + return match ($transformer = $this->getToResourceTransformer($operation)) { + null => $result, + default => $transformer($result), + }; } } diff --git a/src/Doctrine/Orm/State/Options.php b/src/Doctrine/Orm/State/Options.php index 3a9a46c3825..ba82dc349ad 100644 --- a/src/Doctrine/Orm/State/Options.php +++ b/src/Doctrine/Orm/State/Options.php @@ -19,15 +19,18 @@ class Options extends CommonOptions implements OptionsInterface { /** - * @param string|callable $handleLinks experimental callable, typed mixed as we may want a service name in the future + * @param string|callable $handleLinks experimental callable, typed mixed as we may want a service name in the future + * @param string|callable $transformFromEntity experimental callable, typed mixed as we may want a service name in the future * * @see LinksHandlerInterface */ public function __construct( protected ?string $entityClass = null, mixed $handleLinks = null, + mixed $transformFromEntity = null, + mixed $transformToEntity = null, ) { - parent::__construct(handleLinks: $handleLinks); + parent::__construct(handleLinks: $handleLinks, toResourceTransformer: $transformFromEntity, fromResourceTransformer: $transformToEntity); } public function getEntityClass(): ?string @@ -42,4 +45,30 @@ public function withEntityClass(?string $entityClass): self return $self; } + + public function getTransformFromEntity(): mixed + { + return $this->getToResourceTransformer(); + } + + public function withTransformFromEntity(mixed $transformFromEntity): self + { + $self = clone $this; + $self->toResourceTransformer = $transformFromEntity; + + return $self; + } + + public function getTransformToEntity(): mixed + { + return $this->getFromResourceTransformer(); + } + + public function withTransformToEntity(mixed $transformToEntity): self + { + $self = clone $this; + $self->fromResourceTransformer = $transformToEntity; + + return $self; + } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 883a6a411a1..293579e4aa3 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Symfony\Bundle\DependencyInjection; +use ApiPlatform\Doctrine\Common\State\ResourceTransformerInterface; use ApiPlatform\Doctrine\Odm\Extension\AggregationCollectionExtensionInterface; use ApiPlatform\Doctrine\Odm\Extension\AggregationItemExtensionInterface; use ApiPlatform\Doctrine\Odm\Filter\AbstractFilter as DoctrineMongoDbOdmAbstractFilter; @@ -674,6 +675,8 @@ private function registerDoctrineOrmConfiguration(ContainerBuilder $container, a $container->registerForAutoconfiguration(OrmLinksHandlerInterface::class) ->addTag('api_platform.doctrine.orm.links_handler'); + $container->registerForAutoconfiguration(ResourceTransformerInterface::class) + ->addTag('api_platform.doctrine.orm.resource_transformer'); $loader->load('doctrine_orm.xml'); @@ -701,6 +704,8 @@ private function registerDoctrineMongoDbOdmConfiguration(ContainerBuilder $conta ->setBindings(['$managerRegistry' => new Reference('doctrine_mongodb')]); $container->registerForAutoconfiguration(OdmLinksHandlerInterface::class) ->addTag('api_platform.doctrine.odm.links_handler'); + $container->registerForAutoconfiguration(ResourceTransformerInterface::class) + ->addTag('api_platform.doctrine.odm.resource_transformer'); $loader->load('doctrine_mongodb_odm.xml'); } diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 493c98523d0..a4387ac52a6 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -82,7 +82,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultValue('0.0.0') ->end() ->booleanNode('show_webby')->defaultTrue()->info('If true, show Webby on the documentation page')->end() - ->booleanNode('use_symfony_listeners')->defaultFalse()->info(sprintf('Uses Symfony event listeners instead of the %s.', MainController::class))->end() + ->booleanNode('use_symfony_listeners')->defaultFalse()->info(\sprintf('Uses Symfony event listeners instead of the %s.', MainController::class))->end() ->scalarNode('name_converter')->defaultNull()->info('Specify a name converter to use.')->end() ->scalarNode('asset_package')->defaultNull()->info('Specify an asset package name to use.')->end() ->scalarNode('path_segment_name_generator')->defaultValue('api_platform.metadata.path_segment_name_generator.underscore')->info('Specify a path name generator to use.')->end() @@ -319,7 +319,7 @@ private function addSwaggerSection(ArrayNodeDefinition $rootNode): void ->end() ->validate() ->ifTrue(static fn ($v): bool => $v !== array_intersect($v, $supportedVersions)) - ->thenInvalid(sprintf('Only the versions %s are supported. Got %s.', implode(' and ', $supportedVersions), '%s')) + ->thenInvalid(\sprintf('Only the versions %s are supported. Got %s.', implode(' and ', $supportedVersions), '%s')) ->end() ->prototype('scalar')->end() ->end() @@ -570,7 +570,7 @@ private function addExceptionToStatusSection(ArrayNodeDefinition $rootNode): voi ->then(static function (array $exceptionToStatus): array { foreach ($exceptionToStatus as $httpStatusCode) { if ($httpStatusCode < 100 || $httpStatusCode >= 600) { - throw new InvalidConfigurationException(sprintf('The HTTP status code "%s" is not valid.', $httpStatusCode)); + throw new InvalidConfigurationException(\sprintf('The HTTP status code "%s" is not valid.', $httpStatusCode)); } } diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml index 8b39e43a9c8..ec74e031df7 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml @@ -27,6 +27,7 @@ + @@ -156,6 +157,7 @@ + @@ -167,6 +169,7 @@ + diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml index 7f34ecacc27..c8f28f7adb3 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -19,6 +19,7 @@ + @@ -167,6 +168,7 @@ + @@ -178,6 +180,7 @@ + diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index 4639644beb4..e24de9be09d 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -97,6 +97,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Document\SoMany as SoManyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\Taxon as TaxonDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\TransformedDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\UrlEncodedId as UrlEncodedIdDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\User as UserDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\VideoGame as VideoGameDocument; @@ -199,6 +200,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SymfonyUuidDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Taxon; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\TransformedDummyEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\TreeDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UrlEncodedId; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\User; @@ -2325,6 +2327,15 @@ public function thereAreIssue6039Users(): void $this->manager->flush(); } + /** + * @Given there is a TransformedDummy for date :date + */ + public function thereIsATransformedDummyEntity(string $date): void + { + $this->manager->persist($this->buildTransformedDummy(new \DateTimeImmutable($date))); + $this->manager->flush(); + } + private function isOrm(): bool { return null !== $this->schemaTool; @@ -2704,4 +2715,9 @@ private function buildLinkHandledDummy(string $slug): LinkHandledDummy|LinkHandl { return $this->isOrm() ? new LinkHandledDummy($slug) : new LinkHandledDummyDocument($slug); } + + private function buildTransformedDummy(\DateTimeImmutable $dateTime): TransformedDummyEntity|TransformedDummyDocument + { + return $this->isOrm() ? new TransformedDummyEntity($dateTime) : new TransformedDummyDocument($dateTime); + } } diff --git a/tests/Fixtures/TestBundle/ApiResource/TransformedDummyDocumentRessource.php b/tests/Fixtures/TestBundle/ApiResource/TransformedDummyDocumentRessource.php new file mode 100644 index 00000000000..bc55d335b55 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/TransformedDummyDocumentRessource.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\GetCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\TransformedDummyDocument; + +#[ApiResource( + operations : [ + new GetCollection(uriTemplate: '/transformed_dummy_document_ressources'), + new Get(uriTemplate: '/transformed_dummy_document_ressources/{id}'), + ], + stateOptions: new \ApiPlatform\Doctrine\Odm\State\Options( + documentClass: TransformedDummyDocument::class, + transformFromDocument: [self::class, 'transformToResource'], + ) +)] +class TransformedDummyDocumentRessource +{ + public ?int $id = null; + + public ?int $year = null; + + public static function transformToResource(TransformedDummyDocument $model): self + { + $resource = new self(); + $resource->id = $model->getId(); + $resource->year = (int) $model->getDate()->format('Y'); + + return $resource; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/TransformedDummyEntityRessource.php b/tests/Fixtures/TestBundle/ApiResource/TransformedDummyEntityRessource.php new file mode 100644 index 00000000000..5a6264f75d3 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/TransformedDummyEntityRessource.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\TransformedDummyEntity; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + operations: [ + new GetCollection(uriTemplate: '/transformed_dummy_entity_ressources'), + new Get(uriTemplate: '/transformed_dummy_entity_ressources/{id}'), + new Post(uriTemplate: '/transformed_dummy_entity_ressources'), + new Patch(uriTemplate: '/transformed_dummy_entity_ressources/{id}'), + ], + normalizationContext: ['groups' => ['read']], + denormalizationContext: ['groups' => ['write']], + stateOptions: new Options( + entityClass: TransformedDummyEntity::class, + transformFromEntity: TransformedDummyEntityRessourceTransformer::class, + transformToEntity: TransformedDummyEntityRessourceTransformer::class, + ), +)] +class TransformedDummyEntityRessource +{ + #[Groups(['read'])] + public ?int $id = null; + + #[Groups(['read', 'write'])] + public ?int $year = null; + + public ?TransformedDummyEntity $entityRef = null; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/TransformedDummyEntityRessourceTransformer.php b/tests/Fixtures/TestBundle/ApiResource/TransformedDummyEntityRessourceTransformer.php new file mode 100644 index 00000000000..2ad52a3ba1a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/TransformedDummyEntityRessourceTransformer.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Doctrine\Common\State\ResourceTransformerInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\TransformedDummyEntity; +use Doctrine\Persistence\ObjectManager; + +class TransformedDummyEntityRessourceTransformer implements ResourceTransformerInterface +{ + public function toResource(object $entityOrDocument): object + { + \assert($entityOrDocument instanceof TransformedDummyEntity); + + $resource = new TransformedDummyEntityRessource(); + $resource->id = $entityOrDocument->getId(); + $resource->year = (int) $entityOrDocument->getDate()->format('Y'); + + // since patches will call the provider first, we might as well keep a ref to the entity + $resource->entityRef = $entityOrDocument; + + return $resource; + } + + public function fromResource(object $resource, ObjectManager $objectManager): object + { + \assert($resource instanceof TransformedDummyEntityRessource); + + // since we keep the ref, we can do this + $entity = $resource->entityRef ?? new TransformedDummyEntity(); + + // otherwise we could do that + $entity = match ($resource->id) { + null => new TransformedDummyEntity(), + default => $objectManager->find(TransformedDummyEntity::class, $resource->id) ?? new TransformedDummyEntity(), + }; + + // set the date to 01/01 by default + $entity->setDate(new \DateTimeImmutable($resource->year.'-01-01')); + + return $entity; + } +} diff --git a/tests/Fixtures/TestBundle/Document/TransformedDummyDocument.php b/tests/Fixtures/TestBundle/Document/TransformedDummyDocument.php new file mode 100644 index 00000000000..d3511295cae --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/TransformedDummyDocument.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ ODM\Document] +class TransformedDummyDocument +{ + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + private ?int $id = null; + + #[ODM\Field(type: 'date_immutable')] + private \DateTimeInterface $date; + + public function __construct(?\DateTimeInterface $date = null) + { + $this->setDate($date ?? new \DateTimeImmutable()); + } + + public function getDate(): \DateTimeInterface + { + return $this->date; + } + + public function setDate(\DateTimeInterface $date): void + { + $this->date = $date; + } + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/TransformedDummyEntity.php b/tests/Fixtures/TestBundle/Entity/TransformedDummyEntity.php new file mode 100644 index 00000000000..9ffe6d5aff0 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/TransformedDummyEntity.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class TransformedDummyEntity +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private \DateTimeInterface $date; + + public function __construct(?\DateTimeInterface $date = null) + { + $this->setDate($date ?? new \DateTimeImmutable()); + } + + public function getDate(): \DateTimeInterface + { + return $this->date; + } + + public function setDate(\DateTimeInterface $date): void + { + $this->date = $date; + } + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index f9d2cd2afba..5b9951a5057 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -447,6 +447,11 @@ services: tags: - name: 'serializer.normalizer' + ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\TransformedDummyEntityRessourceTransformer: + tags: + - name: 'api_platform.doctrine.orm.resource_transformer' + + api_platform.http_cache.tag_collector: class: ApiPlatform\Tests\Fixtures\TestBundle\HttpCache\TagCollectorDefault public: true