Skip to content

Commit

Permalink
OpenAPI schema adapts fields with LocalizedValue attribute automatica…
Browse files Browse the repository at this point in the history
…lly (better type and example)
  • Loading branch information
jolelievre committed Jan 13, 2025
1 parent c0388f5 commit 822393a
Show file tree
Hide file tree
Showing 2 changed files with 93 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,17 @@
use ApiPlatform\JsonSchema\DefinitionNameFactoryInterface;
use ApiPlatform\JsonSchema\ResourceMetadataTrait;
use ApiPlatform\JsonSchema\Schema;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
use ApiPlatform\OpenApi\OpenApi;
use ArrayObject;
use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;

/**
* This service decorates the main service that builds the Open API schema. It waits for the whole generation
Expand All @@ -51,11 +54,12 @@ class CQRSOpenApiFactory implements OpenApiFactoryInterface
protected PropertyAccessorInterface $propertyAccessor;

public function __construct(
private readonly OpenApiFactoryInterface $decorated,
private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
protected readonly OpenApiFactoryInterface $decorated,
protected readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
protected readonly DefinitionNameFactoryInterface $definitionNameFactory,
protected readonly ClassMetadataFactoryInterface $classMetadataFactory,
// No property promotion for this one since it's already defined in the ResourceMetadataTrait
ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory,
private readonly DefinitionNameFactoryInterface $definitionNameFactory,
) {
$this->resourceMetadataFactory = $resourceMetadataFactory;
$this->propertyAccessor = PropertyAccess::createPropertyAccessorBuilder()
Expand All @@ -73,9 +77,16 @@ public function __invoke(array $context = []): OpenApi
$resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass);

foreach ($resourceMetadataCollection as $resourceMetadata) {
$resourceDefinitionName = $this->definitionNameFactory->create($resourceMetadata->getClass());

// Adapt localized value schema for API resource schema (mostly for read schema)
if ($parentOpenApi->getComponents()->getSchemas()->offsetExists($resourceDefinitionName)) {
$this->adaptLocalizedValues($resourceMetadata, $parentOpenApi->getComponents()->getSchemas()->offsetGet($resourceDefinitionName));
}

/** @var Operation $operation */
foreach ($resourceMetadata->getOperations() as $operation) {
if (empty($operation->getExtraProperties()['CQRSCommand']) || empty($operation->getExtraProperties()['CQRSCommandMapping'])) {
if (empty($operation->getExtraProperties()['CQRSCommand'])) {
continue;
}

Expand All @@ -85,40 +96,82 @@ public function __invoke(array $context = []): OpenApi
}

// Build the operation name like SchemaFactory does so that we have the proper definition in the schema matching this operation
$definitionName = $this->definitionNameFactory->create($operation->getClass(), 'json', $inputClass, $operation, []);
if (!$parentOpenApi->getComponents()->getSchemas()->offsetExists($definitionName)) {
$operationDefinitionName = $this->definitionNameFactory->create($operation->getClass(), 'json', $inputClass, $operation, []);
if (!$parentOpenApi->getComponents()->getSchemas()->offsetExists($operationDefinitionName)) {
continue;
}

/** @var ArrayObject $definition */
$definition = $parentOpenApi->getComponents()->getSchemas()->offsetGet($definitionName);
$definition = $parentOpenApi->getComponents()->getSchemas()->offsetGet($operationDefinitionName);
if (empty($definition['properties'])) {
continue;
}

foreach ($operation->getExtraProperties()['CQRSCommandMapping'] as $apiPath => $cqrsPath) {
// Replace properties that are scanned from CQRS command to their expected API path
if ($this->propertyAccessor->isReadable($definition['properties'], $cqrsPath) || $this->propertyAccessor->isWritable($definition['properties'], $cqrsPath)) {
// Automatic value from context are simply remove from the schema, the others are "moved" to match the expected property path
if (!str_starts_with($apiPath, '[_context]')) {
$this->propertyAccessor->setValue($definition['properties'], $apiPath, $this->propertyAccessor->getValue($definition['properties'], $cqrsPath));
}

// Use property path to set null, the null values will then be cleaned in a second loop (because unset cannot use property path as an input)
$this->propertyAccessor->setValue($definition['properties'], $cqrsPath, null);
}
}

// Now clean the values that were set to null by the previous loop
foreach ($definition['properties'] as $propertyName => $propertyValue) {
if (null === $propertyValue) {
unset($definition['properties'][$propertyName]);
}
$this->applyCommandMapping($operation, $definition);
if ($resourceMetadata instanceof ApiResource) {
// Adapt localized value schema for operation definition (for valid input example)
$this->adaptLocalizedValues($resourceMetadata, $definition);
}
}
}
}

return $parentOpenApi;
}

protected function adaptLocalizedValues(ApiResource $apiResource, ArrayObject $definition): void
{
if (empty($definition['properties'])) {
return;
}

$resourceClassMetadata = $this->classMetadataFactory->getMetadataFor($apiResource->getClass());
$resourceReflectionClass = $resourceClassMetadata->getReflectionClass();

foreach ($definition['properties'] as $propertyName => $propertySchema) {
if (!$resourceReflectionClass->hasProperty($propertyName)) {
continue;
}

$property = $resourceReflectionClass->getProperty($propertyName);
foreach ($property->getAttributes() as $attribute) {
// Adapt the schema of localized values, they must be an object index by the Language's locale (not an array)
if ($attribute->getName() === LocalizedValue::class || is_subclass_of($attribute->getName(), LocalizedValue::class)) {
$definition['properties'][$propertyName]['type'] = 'object';
$definition['properties'][$propertyName]['example'] = [
'en-US' => 'value',
'fr-FR' => 'valeur',
];
unset($definition['properties'][$propertyName]['items']);
}
}
}
}

protected function applyCommandMapping(Operation $operation, ArrayObject $definition): void
{
if (empty($operation->getExtraProperties()['CQRSCommandMapping'])) {
return;
}

foreach ($operation->getExtraProperties()['CQRSCommandMapping'] as $apiPath => $cqrsPath) {
// Replace properties that are scanned from CQRS command to their expected API path
if ($this->propertyAccessor->isReadable($definition['properties'], $cqrsPath) || $this->propertyAccessor->isWritable($definition['properties'], $cqrsPath)) {
// Automatic value from context are simply remove from the schema, the others are "moved" to match the expected property path
if (!str_starts_with($apiPath, '[_context]')) {
$this->propertyAccessor->setValue($definition['properties'], $apiPath, $this->propertyAccessor->getValue($definition['properties'], $cqrsPath));
}

// Use property path to set null, the null values will then be cleaned in a second loop (because unset cannot use property path as an input)
$this->propertyAccessor->setValue($definition['properties'], $cqrsPath, null);
}
}

// Now clean the values that were set to null by the previous loop
foreach ($definition['properties'] as $propertyName => $propertyValue) {
if (null === $propertyValue) {
unset($definition['properties'][$propertyName]);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,18 @@ public static function provideJsonSchemaFactoryCases(): iterable
'type' => 'boolean',
]),
'names' => new ArrayObject([
'type' => 'array',
'items' => ['type' => 'string'],
'type' => 'object',
'example' => [
'en-US' => 'value',
'fr-FR' => 'valeur',
],
]),
'descriptions' => new ArrayObject([
'type' => 'array',
'items' => ['type' => 'string'],
'type' => 'object',
'example' => [
'en-US' => 'value',
'fr-FR' => 'valeur',
],
]),
// Shop IDs are documented via an ApiProperty attribute
'shopIds' => new ArrayObject([
Expand All @@ -100,8 +106,11 @@ public static function provideJsonSchemaFactoryCases(): iterable
'type' => 'string',
]),
'names' => new ArrayObject([
'type' => 'array',
'items' => ['type' => 'string'],
'type' => 'object',
'example' => [
'en-US' => 'value',
'fr-FR' => 'valeur',
],
]),
],
]),
Expand Down

0 comments on commit 822393a

Please sign in to comment.