Skip to content

Commit

Permalink
Handle form for command with restricted number of parameters, automat…
Browse files Browse the repository at this point in the history
…ically hide values fetched from context
  • Loading branch information
jolelievre committed Jan 7, 2025
1 parent 17ef00b commit 8fae1e0
Show file tree
Hide file tree
Showing 2 changed files with 217 additions and 12 deletions.
101 changes: 89 additions & 12 deletions src/PrestaShopBundle/ApiPlatform/OpenApi/RequestBodyFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 ($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 (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['type'] === null) {
unset($operationProperties[$property]);
} elseif (!empty($propertyInfo['properties'])) {
$this->removeNullable($propertyInfo['properties']);
}
}
}

private function flattenMimeTypes(array $responseFormats): array
{
$responseMimeTypes = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to [email protected] so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <[email protected]>
* @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);

Check failure on line 50 in tests/Unit/PrestaShopBundle/ApiPlatform/OpenApi/RequestBodyFactoryTest.php

View workflow job for this annotation

GitHub Actions / PHP Static Analysis (8.4)

Parameter #3 $descriptionExtractors of class Symfony\Component\PropertyInfo\PropertyInfoExtractor constructor expects iterable<Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface>, array<int, Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor> given.

Check failure on line 50 in tests/Unit/PrestaShopBundle/ApiPlatform/OpenApi/RequestBodyFactoryTest.php

View workflow job for this annotation

GitHub Actions / PHP Static Analysis (8.3)

Parameter #3 $descriptionExtractors of class Symfony\Component\PropertyInfo\PropertyInfoExtractor constructor expects iterable<Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface>, array<int, Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor> given.

Check failure on line 50 in tests/Unit/PrestaShopBundle/ApiPlatform/OpenApi/RequestBodyFactoryTest.php

View workflow job for this annotation

GitHub Actions / PHP Static Analysis (8.2)

Parameter #3 $descriptionExtractors of class Symfony\Component\PropertyInfo\PropertyInfoExtractor constructor expects iterable<Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface>, array<int, Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor> given.

Check failure on line 50 in tests/Unit/PrestaShopBundle/ApiPlatform/OpenApi/RequestBodyFactoryTest.php

View workflow job for this annotation

GitHub Actions / PHP Static Analysis (8.1)

Parameter #3 $descriptionExtractors of class Symfony\Component\PropertyInfo\PropertyInfoExtractor constructor expects iterable<Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface>, array<int, Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor> given.
$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'],
],
],
],
]),
),
];
}
}

0 comments on commit 8fae1e0

Please sign in to comment.