diff --git a/src/PrestaShopBundle/ApiPlatform/Metadata/Resource/Factory/OpenApiMetadataCollectionFactoryDecorator.php b/src/PrestaShopBundle/ApiPlatform/Metadata/Resource/Factory/OpenApiMetadataCollectionFactoryDecorator.php index 7c829de2c08bb..a84d64a3777ec 100644 --- a/src/PrestaShopBundle/ApiPlatform/Metadata/Resource/Factory/OpenApiMetadataCollectionFactoryDecorator.php +++ b/src/PrestaShopBundle/ApiPlatform/Metadata/Resource/Factory/OpenApiMetadataCollectionFactoryDecorator.php @@ -47,6 +47,8 @@ public function create(string $resourceClass): ResourceMetadataCollection // We call the original method since we only want to alter the result of this method. $resourceMetadataCollection = $this->innerFactory->create($resourceClass); + return $resourceMetadataCollection; + /** @var ApiResource $resourceMetadata */ foreach ($resourceMetadataCollection as $resourceMetadata) { $operations = $resourceMetadata->getOperations(); diff --git a/src/PrestaShopBundle/ApiPlatform/OpenApi/RequestBodyFactory.php b/src/PrestaShopBundle/ApiPlatform/OpenApi/RequestBodyFactory.php index 9635f56fbea65..4c57e89189e00 100644 --- a/src/PrestaShopBundle/ApiPlatform/OpenApi/RequestBodyFactory.php +++ b/src/PrestaShopBundle/ApiPlatform/OpenApi/RequestBodyFactory.php @@ -26,29 +26,59 @@ namespace PrestaShopBundle\ApiPlatform\OpenApi; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\OpenApi\Model\RequestBody; use ArrayObject; use PrestaShopBundle\ApiPlatform\Metadata\CQRSCommand; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyInfo\Extractor\ConstructorExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface; class RequestBodyFactory { + protected PropertyAccessorInterface $propertyAccessor; + public function __construct( - protected readonly PropertyInfoExtractorInterface|PropertyInitializableExtractorInterface $propertyInfoExtractor + protected readonly PropertyInfoExtractorInterface|PropertyInitializableExtractorInterface $propertyInfoExtractor, + protected readonly ConstructorExtractor $constructorExtractor, ) { + // Invalid (or absent) indexes or properties in array/objects are invalid, therefore ignored when checking isReadable + // which is important for the normalization mapping process + $this->propertyAccessor = PropertyAccess::createPropertyAccessorBuilder() + ->enableExceptionOnInvalidIndex() + ->enableExceptionOnInvalidPropertyPath() + ->getPropertyAccessor() + ; } - public function build(CQRSCommand $operation): ?RequestBody + public function build(HttpOperation $operation): ?RequestBody { - if (empty($operation->getCQRSCommand()) || !class_exists($operation->getCQRSCommand())) { + if ($operation instanceof CQRSCommand && !empty($operation->getCqrsCommand())) { + $cqrsCommand = $operation->getCqrsCommand(); + } elseif (!empty($operation->getExtraProperties()['CQRSCommand'])) { + $cqrsCommand = $operation->getExtraProperties()['CQRSCommand']; + } else { + return null; + } + + if (!class_exists($cqrsCommand)) { return null; } + if ($operation instanceof CQRSCommand && !empty($operation->getCQRSCommandMapping())) { + $commandMapping = $operation->getCQRSCommandMapping(); + } elseif (!empty($operation->getExtraProperties()['CQRSCommandMapping'])) { + $commandMapping = $operation->getExtraProperties()['CQRSCommandMapping']; + } else { + $commandMapping = null; + } + $requestMimeTypes = $this->flattenMimeTypes($operation->getInputFormats() ?: []); $inputSchema = []; foreach ($requestMimeTypes as $requestMimeType) { - $operationProperties = $this->getOperationProperties($operation); + $operationProperties = $this->getObjectProperties($cqrsCommand, $commandMapping); $inputSchema[$requestMimeType] = [ 'schema' => [ 'type' => 'object', @@ -63,27 +93,74 @@ public function build(CQRSCommand $operation): ?RequestBody ); } - private function getOperationProperties(CQRSCommand $operation): array + private function getObjectProperties(string $cqrsCommand, ?array $commandMapping): array { $operationProperties = []; - $classProperties = $this->propertyInfoExtractor->getProperties($operation->getCQRSCommand()); + $classProperties = $this->propertyInfoExtractor->getProperties($cqrsCommand); foreach ($classProperties as $property) { - if ($this->propertyInfoExtractor->isWritable($operation->getCQRSCommand(), $property) - || $this->propertyInfoExtractor->isInitializable($operation->getCQRSCommand(), $property)) { - $propertyTypes = $this->propertyInfoExtractor->getTypes($operation->getCQRSCommand(), $property); - if (count($propertyTypes) === 1) { + if ($this->propertyInfoExtractor->isWritable($cqrsCommand, $property) + || $this->propertyInfoExtractor->isInitializable($cqrsCommand, $property)) { + // If property is in the constructor we want the constructor type not the field one that may have been transformed + if ($this->propertyInfoExtractor->isInitializable($cqrsCommand, $property)) { + $propertyTypes = $this->constructorExtractor->getTypes($cqrsCommand, $property); + } else { + $propertyTypes = $this->propertyInfoExtractor->getTypes($cqrsCommand, $property); + } + + // Only one identified type + if (!empty($propertyTypes) && count($propertyTypes) === 1) { $propertyType = $propertyTypes[0]; - $type = $propertyType->getClassName() ?: $propertyType->getBuiltinType(); + if ($propertyType->getClassName() && class_exists($propertyType->getClassName())) { + $operationProperties[$property] = [ + 'type' => 'object', + 'properties' => $this->getObjectProperties($propertyType->getClassName(), $commandMapping), + ]; + } else { + $operationProperties[$property] = [ + 'type' => $propertyType->getBuiltinType(), + ]; + } } else { - $type = 'mixed'; + // Else fallback to mixed for now + $operationProperties[$property] = ['type' => 'mixed']; } - $operationProperties[$property] = ['type' => $type]; } } + if (!empty($commandMapping)) { + $this->mapProperties($operationProperties, $commandMapping); + } + return $operationProperties; } + private function mapProperties(array &$operationProperties, array $commandMapping): void + { + foreach ($commandMapping as $inputPath => $commandPath) { + // If data is mapped from the context it means it will be automatically injected in the object so no need to specify it in the input data + if (str_starts_with($inputPath, '[_context]')) { + $this->propertyAccessor->setValue($operationProperties, $commandPath, null); + } elseif ($this->propertyAccessor->isReadable($operationProperties, $commandPath) && $this->propertyAccessor->isWritable($operationProperties, $inputPath)) { + $this->propertyAccessor->setValue($operationProperties, $inputPath, $this->propertyAccessor->getValue($operationProperties, $commandPath)); + $this->propertyAccessor->setValue($operationProperties, $commandPath, null); + } + } + + // Now remove null values + $this->removeNullable($operationProperties); + } + + protected function removeNullable(array &$operationProperties): void + { + foreach ($operationProperties as $property => $propertyInfo) { + if ($propertyInfo === null) { + unset($operationProperties[$property]); + } elseif (!empty($propertyInfo['properties'])) { + $this->removeNullable($propertyInfo['properties']); + } + } + } + private function flattenMimeTypes(array $responseFormats): array { $responseMimeTypes = []; diff --git a/src/PrestaShopBundle/Resources/config/services/bundle/api_platform.yml b/src/PrestaShopBundle/Resources/config/services/bundle/api_platform.yml index 013f34ee6474d..332dafb13b9cd 100644 --- a/src/PrestaShopBundle/Resources/config/services/bundle/api_platform.yml +++ b/src/PrestaShopBundle/Resources/config/services/bundle/api_platform.yml @@ -68,3 +68,9 @@ services: PrestaShopBundle\ApiPlatform\OpenApi\RequestBodyFactory: autowire: true + arguments: + $constructorExtractor: '@prestashop_bundle.api_platform.open_api.constructor_extractor' + + prestashop_bundle.api_platform.open_api.constructor_extractor: + class: Symfony\Component\PropertyInfo\Extractor\ConstructorExtractor + autowire: true diff --git a/tests/Unit/PrestaShopBundle/ApiPlatform/OpenApi/RequestBodyFactoryTest.php b/tests/Unit/PrestaShopBundle/ApiPlatform/OpenApi/RequestBodyFactoryTest.php new file mode 100644 index 0000000000000..bbc125475921f --- /dev/null +++ b/tests/Unit/PrestaShopBundle/ApiPlatform/OpenApi/RequestBodyFactoryTest.php @@ -0,0 +1,128 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +namespace PrestaShopBundle\ApiPlatform\OpenApi; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\OpenApi\Model\RequestBody; +use ArrayObject; +use PHPUnit\Framework\TestCase; +use PrestaShop\Module\APIResources\ApiPlatform\Resources\Product\Product; +use PrestaShop\PrestaShop\Core\Domain\Product\Command\AddProductCommand; +use PrestaShop\PrestaShop\Core\Domain\Product\Query\GetProductForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use Symfony\Component\PropertyInfo\Extractor\ConstructorExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; + +class RequestBodyFactoryTest extends TestCase +{ + /** + * @dataProvider getOperations + */ + public function testCreateRequestBody(HttpOperation $operation, ?RequestBody $expectedRequestBody): void + { + // Create simple extractor for now only based on ReflectionExtractor + $extractors = [new ReflectionExtractor()]; + $propertyInfoExtractor = new PropertyInfoExtractor($extractors, $extractors, $extractors, $extractors, $extractors); + $constructorExtractor = new ConstructorExtractor($extractors); + + $factory = new RequestBodyFactory($propertyInfoExtractor, $constructorExtractor); + $requestBody = $factory->build($operation); + $this->assertEquals($expectedRequestBody, $requestBody); + } + + public static function getOperations(): iterable + { + // AddProductCommand only has three arguments so only these three are relevant and should be returned + yield 'create product only required fields are returned' => [ + new CQRSCreate( + uriTemplate: '/product', + inputFormats: ['json' => ['application/json']], + shortName: 'Product', + CQRSCommand: AddProductCommand::class, + CQRSQuery: GetProductForEditing::class, + scopes: [ + 'product_write', + ], + ), + new RequestBody( + 'The new Product resource', + new ArrayObject([ + 'json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'localizedNames' => [ + 'type' => 'array', + ], + 'productType' => ['type' => 'string'], + 'shopId' => ['type' => 'int'], + ], + ], + ], + ]), + ), + ]; + + // Now mapping is specified so the input format is adapted based on it + // Values from the context are removed as they are provided automatically + yield 'create product with mapping' => [ + new CQRSCreate( + uriTemplate: '/product', + inputFormats: ['json' => ['application/json']], + shortName: 'Product', + CQRSCommand: AddProductCommand::class, + CQRSQuery: GetProductForEditing::class, + scopes: [ + 'product_write', + ], + CQRSQueryMapping: Product::QUERY_MAPPING, + CQRSCommandMapping: [ + '[_context][shopId]' => '[shopId]', + '[type]' => '[productType]', + '[names]' => '[localizedNames]', + ], + ), + new RequestBody( + 'The new Product resource', + new ArrayObject([ + 'json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'names' => [ + 'type' => 'array', + ], + 'type' => ['type' => 'string'], + ], + ], + ], + ]), + ), + ]; + } +}