From 78d4308f8c1c9f9980f3efdcee989bc7f4158cb4 Mon Sep 17 00:00:00 2001 From: Jonathan LELIEVRE Date: Mon, 13 Jan 2025 13:26:58 +0100 Subject: [PATCH] OpenApi schema format uses CQRS command constructor argument input types over the class properties --- .../CQRSCommandPropertyMetadataFactory.php | 98 ++++++++++++++++ .../config/services/bundle/api_platform.yml | 25 ++++ .../ApiPlatform/OpenApiFactoryTest.php | 111 ++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 src/PrestaShopBundle/ApiPlatform/Metadata/Property/Factory/CQRSCommandPropertyMetadataFactory.php create mode 100644 tests/Integration/PrestaShopBundle/ApiPlatform/OpenApiFactoryTest.php diff --git a/src/PrestaShopBundle/ApiPlatform/Metadata/Property/Factory/CQRSCommandPropertyMetadataFactory.php b/src/PrestaShopBundle/ApiPlatform/Metadata/Property/Factory/CQRSCommandPropertyMetadataFactory.php new file mode 100644 index 0000000000000..276d5d2096473 --- /dev/null +++ b/src/PrestaShopBundle/ApiPlatform/Metadata/Property/Factory/CQRSCommandPropertyMetadataFactory.php @@ -0,0 +1,98 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +namespace PrestaShopBundle\ApiPlatform\Metadata\Property\Factory; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use Doctrine\Common\Collections\ArrayCollection; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\PropertyInfo\Type; + +/** + * This service is used so that the parameters of the CQRS command constructor are used in priority over + * their class fields. This is mostly because CQRS commands often change their initial scalar inputs into + * ValueObjects: + * ex: input is inr $productId turned into a protected ProductId $productId; + * + * In the JSON schema we don't want to document the ValueObject but the actual input used to create the command + * that should be used in the JSON body content. + * + * To do so we integrate this service in the decoration chain used by SchemaPropertyMetadataFactory, so we can replace + * the types of property fields with their associated constructor types when present. + */ +class CQRSCommandPropertyMetadataFactory implements PropertyMetadataFactoryInterface +{ + /** + * Define the priority so this decorator is the last one in the chain right before SchemaPropertyMetadataFactory, + * this is the last opportunity to change the extracted types before the JSON schema is generated based on those types. + */ + public const DECORATION_PRIORITY = 11; + + public function __construct( + protected readonly PropertyMetadataFactoryInterface $decorated, + protected readonly PropertyTypeExtractorInterface $constructorExtractor, + protected readonly array $commandsAndQueries, + ) { + } + + public function create(string $resourceClass, string $property, array $options = []): ApiProperty + { + $parentApiProperty = $this->decorated->create($resourceClass, $property, $options); + + // We only handle parameters in the constructor + if (!$parentApiProperty->isInitializable()) { + return $parentApiProperty; + } + + // This service only targets CQRS commands schemas, we don't want it to impact other classes + if (!in_array($resourceClass, $this->commandsAndQueries, true)) { + return $parentApiProperty; + } + + return $this->overrideConstructorTypes($parentApiProperty, $resourceClass, $property, $options); + } + + /** + * This method code is mostly copied from the PropertyInfoPropertyMetadataFactory except it extracts the type from the constructor + */ + protected function overrideConstructorTypes(ApiProperty $propertyMetadata, string $resourceClass, string $property, array $options = []): ApiProperty + { + $types = $this->constructorExtractor->getTypes($resourceClass, $property, $options) ?? []; + if (empty($types)) { + return $propertyMetadata; + } + + foreach ($types as $i => $type) { + // Temp fix for https://github.com/symfony/symfony/pull/52699 + if (ArrayCollection::class === $type->getClassName()) { + $types[$i] = new Type($type->getBuiltinType(), $type->isNullable(), $type->getClassName(), true, $type->getCollectionKeyTypes(), $type->getCollectionValueTypes()); + } + } + + return $propertyMetadata->withBuiltinTypes($types)->withSchema([]); + } +} diff --git a/src/PrestaShopBundle/Resources/config/services/bundle/api_platform.yml b/src/PrestaShopBundle/Resources/config/services/bundle/api_platform.yml index d73a2ec6c101b..14d47d1d17e24 100644 --- a/src/PrestaShopBundle/Resources/config/services/bundle/api_platform.yml +++ b/src/PrestaShopBundle/Resources/config/services/bundle/api_platform.yml @@ -59,3 +59,28 @@ services: decorates: PrestaShopBundle\ApiPlatform\Scopes\ApiResourceScopesExtractor PrestaShopBundle\ApiPlatform\Scopes\ApiResourceScopesExtractorInterface: '@PrestaShopBundle\ApiPlatform\Scopes\CachedApiResourceScopesExtractor' + + # + # These decorators are used to generate the OpenApi Json schema automatically for CQRS commands with format and examples matching our expected contract + # + + # This decorator's job is to use the CQRS commands constructor scalar inputs in priority in the expected schema (not the Value Objects) + PrestaShopBundle\ApiPlatform\Metadata\Property\Factory\CQRSCommandPropertyMetadataFactory: + decorates: 'api_platform.metadata.property.metadata_factory' + decoration_priority: !php/const PrestaShopBundle\ApiPlatform\Metadata\Property\Factory\CQRSCommandPropertyMetadataFactory::DECORATION_PRIORITY + autowire: true + arguments: + $decorated: '@.inner' + $constructorExtractor: '@prestashop_bundle.api_platform.open_api.constructor_extractor' + $commandsAndQueries: '%prestashop.commands_and_queries%' + + prestashop_bundle.api_platform.open_api.constructor_extractor: + class: Symfony\Component\PropertyInfo\Extractor\ConstructorExtractor + autowire: true + arguments: + $extractors: + - '@prestashop_bundle.api_platform.open_api.reflection_extractor' + + prestashop_bundle.api_platform.open_api.reflection_extractor: + class: Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor + autowire: true diff --git a/tests/Integration/PrestaShopBundle/ApiPlatform/OpenApiFactoryTest.php b/tests/Integration/PrestaShopBundle/ApiPlatform/OpenApiFactoryTest.php new file mode 100644 index 0000000000000..3ffab96c4342c --- /dev/null +++ b/tests/Integration/PrestaShopBundle/ApiPlatform/OpenApiFactoryTest.php @@ -0,0 +1,111 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +namespace Tests\Integration\PrestaShopBundle\ApiPlatform; + +use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; +use ApiPlatform\OpenApi\OpenApi; +use ArrayObject; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class OpenApiFactoryTest extends KernelTestCase +{ + /** + * @dataProvider provideJsonSchemaFactoryCases + */ + public function testJsonSchemaFactory(string $schemaDefinitionName, ArrayObject $expectedDefinition): void + { + /** @var OpenApiFactoryInterface $openApiFactory */ + $openApiFactory = $this->getContainer()->get(OpenApiFactoryInterface::class); + /** @var OpenApi $openApi */ + $openApi = $openApiFactory->__invoke(); + $schemas = $openApi->getComponents()->getSchemas(); + $this->assertArrayHasKey($schemaDefinitionName, $schemas); + + /** @var ArrayObject $resourceDefinition */ + $resourceDefinition = $schemas[$schemaDefinitionName]; + $this->assertEquals($expectedDefinition, $resourceDefinition); + } + + public static function provideJsonSchemaFactoryCases(): iterable + { + yield 'Product output is based on the ApiPlatform resource' => [ + 'Product', + new ArrayObject([ + 'type' => 'object', + 'description' => '', + 'deprecated' => false, + 'properties' => [ + 'productId' => new ArrayObject([ + 'type' => 'integer', + ]), + 'type' => new ArrayObject([ + 'type' => 'string', + ]), + 'active' => new ArrayObject([ + 'type' => 'boolean', + ]), + 'names' => new ArrayObject([ + 'type' => 'array', + 'items' => ['type' => 'string'], + ]), + 'descriptions' => new ArrayObject([ + 'type' => 'array', + 'items' => ['type' => 'string'], + ]), + // Shop IDs are documented via an ApiProperty attribute + 'shopIds' => new ArrayObject([ + 'type' => 'array', + 'items' => ['type' => 'integer'], + 'example' => [1, 3], + ]), + ], + ]), + ]; + + // Type and shopId must use scalar type not ShopId and ProductType + yield 'Product input for creation based on AddProductCommand' => [ + 'Product.AddProductCommand', + new ArrayObject([ + 'type' => 'object', + 'description' => '', + 'deprecated' => false, + 'properties' => [ + 'productType' => new ArrayObject([ + 'type' => 'string', + ]), + 'localizedNames' => new ArrayObject([ + 'type' => 'array', + 'items' => ['type' => 'string'], + ]), + 'shopId' => new ArrayObject([ + 'type' => 'integer', + ]), + ], + ]), + ]; + } +}