Skip to content

Commit 2e7aa3c

Browse files
committed
feat(discriminator): add support for nested discriminator maps in JSON schema
1 parent 020579c commit 2e7aa3c

45 files changed

Lines changed: 2799 additions & 19 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/JsonApi/JsonSchema/SchemaFactory.php‎

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2828
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2929
use ApiPlatform\Metadata\ResourceClassResolverInterface;
30+
use ApiPlatform\Metadata\Util\ClassDiscriminatorHelper;
3031
use ApiPlatform\State\ApiResource\Error;
32+
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface;
3133

3234
/**
3335
* Decorator factory which adds JSON:API properties to the JSON Schema document.
@@ -82,7 +84,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI
8284

8385
private readonly ResourceLinkageResolver $resourceLinkageResolver;
8486

85-
public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null, ?ResourceLinkageResolver $resourceLinkageResolver = null)
87+
public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null, ?ResourceLinkageResolver $resourceLinkageResolver = null, private readonly ?ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null)
8688
{
8789
if (!$definitionNameFactory) {
8890
$this->definitionNameFactory = new DefinitionNameFactory();
@@ -377,15 +379,90 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
377379
// Only relax the requirement on the input schema; responses still always carry an `id`.
378380
$required = Schema::TYPE_INPUT === $type && $resourceOperation instanceof HttpOperation && 'POST' === $resourceOperation->getMethod() ? ['type'] : ['type', 'id'];
379381

382+
$data = [
383+
'type' => 'object',
384+
'properties' => $replacement,
385+
'required' => $required,
386+
];
387+
388+
// Subtypes declared in a serializer discriminator map: the type property lives in "attributes" so an OpenAPI discriminator cannot be used
389+
$typeProperty = null;
390+
foreach ($this->getDiscriminatedSubtypes($key, $className, $definitions, $schema->getVersion()) as [$subKey, $subClass, $typeProperty]) {
391+
$subSchema = $this->buildDefinitionPropertiesSchema($subKey, $subClass, $format, $type, $resourceOperation, $schema, $serializerContext);
392+
$definitions = $schema->getDefinitions();
393+
$subData = $subSchema['data'];
394+
if (\in_array($typeProperty, $definitions[$subKey]['required'] ?? [], true)) {
395+
$subData['properties']['attributes']['required'] = [ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$typeProperty] ?? $typeProperty];
396+
}
397+
$data['oneOf'][] = $subData;
398+
399+
foreach ($subSchema['included']['items']['anyOf'] ?? [] as $relatedDefinition) {
400+
$relatedDefinitions[$relatedDefinition['$ref']] = $relatedDefinition;
401+
}
402+
}
403+
404+
// The inline "oneOf" branch of a class declared in its own discriminator map describes the instances of the class itself
405+
foreach (null !== $typeProperty ? $definitions[$key]['oneOf'] : [] as $branch) {
406+
if (isset($branch['$ref']) || !isset($branch['properties'][$typeProperty])) {
407+
continue;
408+
}
409+
410+
$attributeName = ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$typeProperty] ?? $typeProperty;
411+
$ownData = $data;
412+
unset($ownData['oneOf']);
413+
$ownData['properties']['attributes']['properties'][$attributeName] = $branch['properties'][$typeProperty];
414+
if (\in_array($typeProperty, $branch['required'] ?? [], true)) {
415+
$ownData['properties']['attributes']['required'] = [$attributeName];
416+
}
417+
array_unshift($data['oneOf'], $ownData);
418+
}
419+
420+
if ($relatedDefinitions && !$included) {
421+
$included = [
422+
'included' => [
423+
'description' => 'Related resources requested via the "include" query parameter.',
424+
'type' => 'array',
425+
'items' => [],
426+
'readOnly' => true,
427+
'externalDocs' => [
428+
'url' => 'https://jsonapi.org/format/#fetching-includes',
429+
],
430+
],
431+
];
432+
}
433+
434+
if ($included) {
435+
$included['included']['items'] = ['anyOf' => array_values($relatedDefinitions)];
436+
}
437+
380438
return [
381-
'data' => [
382-
'type' => 'object',
383-
'properties' => $replacement,
384-
'required' => $required,
385-
],
439+
'data' => $data,
386440
] + $included;
387441
}
388442

443+
/**
444+
* @return iterable<array{string, class-string, string}>
445+
*/
446+
private function getDiscriminatedSubtypes(string $key, string $className, \ArrayObject $definitions, string $version): iterable
447+
{
448+
$discriminator = $definitions[$key]['discriminator'] ?? null;
449+
if (null === $this->classDiscriminatorResolver || !isset($definitions[$key]['oneOf'], $discriminator['mapping']) || null === $mapping = $this->classDiscriminatorResolver->getMappingForClass($className)) {
450+
return;
451+
}
452+
453+
$prefix = $this->getSchemaUriPrefix($version);
454+
foreach (ClassDiscriminatorHelper::getDirectSubtypes($mapping, $className) as $typeValue => $subClass) {
455+
if (null === $ref = $discriminator['mapping'][$typeValue] ?? null) {
456+
continue;
457+
}
458+
459+
$subKey = substr($ref, \strlen($prefix));
460+
if (isset($definitions[$subKey])) {
461+
yield [$subKey, $subClass, $mapping->getTypeProperty()];
462+
}
463+
}
464+
}
465+
389466
private function getRelationship(string $resourceClass, string $property, ?array $serializerContext): ?array
390467
{
391468
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $serializerContext ?? []);
@@ -400,6 +477,8 @@ private function getRelationship(string $resourceClass, string $property, ?array
400477
$relatedClasses = [];
401478
foreach ($relationships as [$className, $isCollection]) {
402479
$isOne = $isOne || !$isCollection;
480+
// A relation narrowed to a subtype of a resource (e.g. declared in a discriminator map) targets the resource itself
481+
$className = $this->resourceClassResolver->getResourceClass(null, $className);
403482
// @see https://github.com/api-platform/core/issues/5501
404483
// @see https://github.com/api-platform/core/pull/5722
405484
$relatedClasses[$className] = $this->resourceMetadataFactory->create($className)->getOperation()->canRead();

‎src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ protected function setUp(): void
6969
);
7070

7171
$resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class);
72+
$resourceClassResolver->getResourceClass(null, Argument::type('string'))->willReturnArgument(1);
7273
$resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true);
7374

7475
$this->schemaFactory = new SchemaFactory(
@@ -312,6 +313,7 @@ private function buildSchemaFactoryWithPolymorphicRelation(): SchemaFactory
312313
);
313314

314315
$resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class);
316+
$resourceClassResolver->getResourceClass(null, Argument::type('string'))->willReturnArgument(1);
315317
$resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true);
316318
$resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true);
317319
$resourceClassResolver->isResourceClass(OtherRelatedDummy::class)->willReturn(true);
@@ -374,6 +376,7 @@ private function buildSchemaFactoryWithRelation(): SchemaFactory
374376
);
375377

376378
$resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class);
379+
$resourceClassResolver->getResourceClass(null, Argument::type('string'))->willReturnArgument(1);
377380
$resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true);
378381
$resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true);
379382

‎src/JsonSchema/SchemaFactory.php‎

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2323
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2424
use ApiPlatform\Metadata\ResourceClassResolverInterface;
25+
use ApiPlatform\Metadata\Util\ClassDiscriminatorHelper;
2526
use ApiPlatform\Metadata\Util\TypeHelper;
27+
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface;
2628
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
2729
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
2830
use Symfony\Component\TypeInfo\Type\BuiltinType;
@@ -48,7 +50,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI
4850
public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name';
4951
public const PARTIAL_UPDATE = 'partial_update';
5052

51-
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null)
53+
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null, private readonly ?ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null)
5254
{
5355
if (!$definitionNameFactory) {
5456
$this->definitionNameFactory = new DefinitionNameFactory();
@@ -152,22 +154,140 @@ public function buildSchema(string $className, string $format = 'json', string $
152154
}
153155

154156
$options = ['schema_type' => $type] + $this->getFactoryOptions($serializerContext, $validationGroups, $operation instanceof HttpOperation ? $operation : null);
155-
foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) {
156-
$propertyMetadata = $this->propertyMetadataFactory->create($inputOrOutputClass, $propertyName, $options);
157+
$this->buildDefinitionProperties($schema, $definition, $definitionName, $inputOrOutputClass, $options, $serializerContext, $format, $type, $isPartialUpdate);
158+
159+
// Subtypes explicitly declared in a serializer discriminator map are documented using "oneOf" and an OpenAPI "discriminator"
160+
$this->buildDiscriminatedDefinitions($schema, $definition, $className, $inputOrOutputClass, $definitionFormat, $operation, $options, $serializerContext, $serializerContext + [
161+
'schema_type' => $type,
162+
self::PARTIAL_UPDATE => $isPartialUpdate && !$isJsonMergePatch,
163+
], $format, $type, $isPartialUpdate);
164+
165+
return $schema;
166+
}
167+
168+
/**
169+
* @param \ArrayObject<string, mixed> $definition
170+
*/
171+
private function buildDefinitionProperties(Schema $schema, \ArrayObject $definition, string $definitionName, string $class, array $options, array $serializerContext, string $format, string $type, bool $isPartialUpdate): void
172+
{
173+
foreach ($this->propertyNameCollectionFactory->create($class, $options) as $propertyName) {
174+
$propertyMetadata = $this->propertyMetadataFactory->create($class, $propertyName, $options);
157175

158176
if (false === $propertyMetadata->isReadable() && false === $propertyMetadata->isWritable()) {
159177
continue;
160178
}
161179

162-
$normalizedPropertyName = $this->nameConverter ? $this->nameConverter->normalize($propertyName, $inputOrOutputClass, $format, $serializerContext) : $propertyName;
180+
$normalizedPropertyName = $this->nameConverter ? $this->nameConverter->normalize($propertyName, $class, $format, $serializerContext) : $propertyName;
163181
if ($propertyMetadata->isRequired() && !$isPartialUpdate) {
164182
$definition['required'][] = $normalizedPropertyName;
165183
}
166184

167185
$this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type);
168186
}
187+
}
169188

170-
return $schema;
189+
/**
190+
* @param \ArrayObject<string, mixed> $definition
191+
* @param array<string, array<string, mixed>> $parentTypeProperties type properties of the outer discriminator maps the class belongs to
192+
*/
193+
private function buildDiscriminatedDefinitions(Schema $schema, \ArrayObject $definition, string $className, string $class, string $definitionFormat, ?Operation $operation, array $options, array $serializerContext, array $definitionNameContext, string $format, string $type, bool $isPartialUpdate, array $parentTypeProperties = []): void
194+
{
195+
if (null === $this->classDiscriminatorResolver || null === $mapping = $this->classDiscriminatorResolver->getMappingForClass($class)) {
196+
return;
197+
}
198+
199+
$typeProperty = $mapping->getTypeProperty();
200+
$definitions = $schema->getDefinitions();
201+
$prefix = $this->getSchemaUriPrefix($schema->getVersion());
202+
$discriminatorMapping = [];
203+
$oneOf = [];
204+
205+
foreach (ClassDiscriminatorHelper::getDirectSubtypes($mapping, $class) as $typeValue => $subClass) {
206+
$subDefinitionName = $this->definitionNameFactory->create($className, $definitionFormat, $subClass, $operation, $definitionNameContext);
207+
$discriminatorMapping[$typeValue] = $oneOf[]['$ref'] = $prefix.$subDefinitionName;
208+
209+
if (isset($definitions[$subDefinitionName])) {
210+
continue;
211+
}
212+
213+
/** @var \ArrayObject<string, mixed> $subDefinition */
214+
$subDefinition = new \ArrayObject(['type' => 'object']);
215+
$definitions[$subDefinitionName] = $subDefinition;
216+
if (isset($definition['additionalProperties'])) {
217+
$subDefinition['additionalProperties'] = $definition['additionalProperties'];
218+
}
219+
220+
$this->buildDefinitionProperties($schema, $subDefinition, $subDefinitionName, $subClass, $options, $serializerContext, $format, $type, $isPartialUpdate);
221+
222+
// The type properties of the outer maps are part of the payload of a nested subtype too
223+
$typeProperties = $parentTypeProperties + [$typeProperty => ['type' => 'string', 'enum' => [(string) $typeValue]]];
224+
$this->addTypeProperties($subDefinition, $typeProperties, $isPartialUpdate);
225+
226+
$this->buildDiscriminatedDefinitions($schema, $subDefinition, $className, $subClass, $definitionFormat, $operation, $options, $serializerContext, $definitionNameContext, $format, $type, $isPartialUpdate, $typeProperties);
227+
}
228+
229+
$ownTypeValues = ClassDiscriminatorHelper::getOwnTypes($mapping, $class);
230+
foreach ($ownTypeValues as $typeValue) {
231+
$discriminatorMapping[$typeValue] = $prefix.$this->definitionNameFactory->create($className, $definitionFormat, $class, $operation, $definitionNameContext);
232+
}
233+
234+
if (!$discriminatorMapping) {
235+
return;
236+
}
237+
238+
if ($oneOf) {
239+
// An instance of the class itself is described by its own branch: it carries its own type and none of the subtype properties
240+
if ($ownTypeValues) {
241+
$ownBranch = new \ArrayObject(['type' => 'object']);
242+
foreach (['properties', 'required', 'additionalProperties'] as $keyword) {
243+
if (isset($definition[$keyword])) {
244+
$ownBranch[$keyword] = $definition[$keyword];
245+
}
246+
}
247+
248+
$this->addTypeProperties($ownBranch, [$typeProperty => ['type' => 'string', 'enum' => $ownTypeValues]], $isPartialUpdate || \in_array($mapping->getDefaultType(), $ownTypeValues, true));
249+
array_unshift($oneOf, $ownBranch->getArrayCopy());
250+
}
251+
252+
$definition['oneOf'] = $oneOf;
253+
254+
// "additionalProperties" ignores the properties declared in the "oneOf" branches, they are restricted by the branches instead
255+
unset($definition['additionalProperties']);
256+
}
257+
258+
// An instance of the class itself carries its own type
259+
if ($ownTypeValues && !isset($definition['properties'][$typeProperty])) {
260+
if ($oneOf) {
261+
$properties = $definition['properties'] ?? [];
262+
$properties[$typeProperty] = ['type' => 'string', 'enum' => array_map('strval', array_keys($discriminatorMapping))];
263+
$definition['properties'] = $properties;
264+
} else {
265+
$this->addTypeProperties($definition, [$typeProperty => ['type' => 'string', 'enum' => $ownTypeValues]], $isPartialUpdate || \in_array($mapping->getDefaultType(), $ownTypeValues, true));
266+
}
267+
}
268+
269+
$definition['discriminator'] = ['propertyName' => $typeProperty, 'mapping' => $discriminatorMapping];
270+
}
271+
272+
/**
273+
* @param \ArrayObject<string, mixed> $definition
274+
* @param array<string, array<string, mixed>> $typeProperties
275+
*/
276+
private function addTypeProperties(\ArrayObject $definition, array $typeProperties, bool $optional): void
277+
{
278+
$properties = $definition['properties'] ?? [];
279+
$required = $definition['required'] ?? [];
280+
foreach ($typeProperties as $name => $typePropertySchema) {
281+
$properties[$name] = $typePropertySchema;
282+
if (!$optional && !\in_array($name, $required, true)) {
283+
$required[] = $name;
284+
}
285+
}
286+
287+
$definition['properties'] = $properties;
288+
if ($required) {
289+
$definition['required'] = $required;
290+
}
171291
}
172292

173293
private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\JsonSchema\Tests\Fixtures\Discriminator;
15+
16+
use Symfony\Component\Serializer\Attribute\DiscriminatorMap;
17+
18+
#[DiscriminatorMap(typeProperty: 'species', mapping: ['cat' => DiscriminatedCat::class, 'dog' => DiscriminatedDog::class])]
19+
abstract class DiscriminatedAnimal
20+
{
21+
public string $name = '';
22+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\JsonSchema\Tests\Fixtures\Discriminator;
15+
16+
final class DiscriminatedCat extends DiscriminatedAnimal
17+
{
18+
public int $lives = 9;
19+
}

0 commit comments

Comments
 (0)