Skip to content

Commit ea1ede5

Browse files
authored
refactor(jsonapi): single source of truth for the attribute/relationship split (#8325)
1 parent 8999b60 commit ea1ede5

7 files changed

Lines changed: 334 additions & 165 deletions

File tree

src/JsonApi/JsonSchema/SchemaFactory.php

Lines changed: 21 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
namespace ApiPlatform\JsonApi\JsonSchema;
1515

16+
use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter;
17+
use ApiPlatform\JsonApi\Util\ResourceLinkageResolver;
1618
use ApiPlatform\JsonSchema\DefinitionNameFactory;
1719
use ApiPlatform\JsonSchema\DefinitionNameFactoryInterface;
1820
use ApiPlatform\JsonSchema\ResourceMetadataTrait;
@@ -25,12 +27,7 @@
2527
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2628
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2729
use ApiPlatform\Metadata\ResourceClassResolverInterface;
28-
use ApiPlatform\Metadata\Util\TypeHelper;
2930
use ApiPlatform\State\ApiResource\Error;
30-
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
31-
use Symfony\Component\TypeInfo\Type;
32-
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
33-
use Symfony\Component\TypeInfo\Type\ObjectType;
3431

3532
/**
3633
* Decorator factory which adds JSON:API properties to the JSON Schema document.
@@ -83,7 +80,9 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI
8380
*/
8481
private $builtSchema = [];
8582

86-
public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null)
83+
private readonly ResourceLinkageResolver $resourceLinkageResolver;
84+
85+
public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null, ?ResourceLinkageResolver $resourceLinkageResolver = null)
8786
{
8887
if (!$definitionNameFactory) {
8988
$this->definitionNameFactory = new DefinitionNameFactory();
@@ -93,6 +92,7 @@ public function __construct(private readonly SchemaFactoryInterface $schemaFacto
9392
}
9493
$this->resourceClassResolver = $resourceClassResolver;
9594
$this->resourceMetadataFactory = $resourceMetadataFactory;
95+
$this->resourceLinkageResolver = $resourceLinkageResolver ?? new ResourceLinkageResolver($resourceClassResolver);
9696
}
9797

9898
/**
@@ -326,28 +326,27 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
326326
foreach (array_keys($refs) as $ref) {
327327
$relatedDefinitions[$ref] = ['$ref' => $ref];
328328
}
329+
// A relationship literally named "relationships"/"included" is prefixed at runtime too.
330+
$relationshipName = ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$propertyName] ?? $propertyName;
329331
if ($isOne) {
330-
$relationships[$propertyName]['properties']['data'] = [
332+
$relationships[$relationshipName]['properties']['data'] = [
331333
'oneOf' => [
332334
['type' => 'null'],
333335
self::RELATION_PROPS,
334336
],
335337
];
336338
continue;
337339
}
338-
$relationships[$propertyName]['properties']['data'] = [
340+
$relationships[$relationshipName]['properties']['data'] = [
339341
'type' => 'array',
340342
'items' => self::RELATION_PROPS,
341343
];
342344
continue;
343345
}
344346

345-
if ('id' === $propertyName) {
346-
// should probably be renamed "lid" and moved to the above node
347-
$attributes['_id'] = $property;
348-
continue;
349-
}
350-
$attributes[$propertyName] = $property;
347+
// Reserved names (id, type, links, relationships, included) are prefixed by the
348+
// ReservedAttributeNameConverter at runtime; mirror that single source of truth here.
349+
$attributes[ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$propertyName] ?? $propertyName] = $property;
351350
}
352351

353352
$replacement = self::PROPERTY_PROPS;
@@ -391,67 +390,21 @@ private function getRelationship(string $resourceClass, string $property, ?array
391390
{
392391
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $serializerContext ?? []);
393392

394-
if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
395-
$types = $propertyMetadata->getBuiltinTypes() ?? [];
396-
$isRelationship = false;
397-
$isOne = $isMany = false;
398-
$relatedClasses = [];
399-
400-
foreach ($types as $type) {
401-
if ($type->isCollection()) {
402-
$collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
403-
$isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
404-
} else {
405-
$isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
406-
}
407-
if (!isset($className) || (!$isOne && !$isMany)) {
408-
continue;
409-
}
410-
$isRelationship = true;
411-
$resourceMetadata = $this->resourceMetadataFactory->create($className);
412-
$operation = $resourceMetadata->getOperation();
413-
// @see https://github.com/api-platform/core/issues/5501
414-
// @see https://github.com/api-platform/core/pull/5722
415-
$relatedClasses[$className] = $operation->canRead();
416-
}
417-
418-
return $isRelationship ? [$isOne, $relatedClasses] : null;
419-
}
420-
421-
if (null === $type = $propertyMetadata->getNativeType()) {
393+
// Share the runtime attributes/relationships split so the generated schema cannot drift from the document.
394+
$relationships = $this->resourceLinkageResolver->getRelationships($propertyMetadata);
395+
if ([] === $relationships) {
422396
return null;
423397
}
424398

425-
$isRelationship = false;
426-
$isOne = $isMany = false;
399+
$isOne = false;
427400
$relatedClasses = [];
428-
429-
/** @var class-string|null $className */
430-
$className = null;
431-
432-
$typeIsResourceClass = function (Type $type) use (&$className): bool {
433-
return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName());
434-
};
435-
436-
foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) {
437-
if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) {
438-
$isMany = true;
439-
} elseif ($t->isSatisfiedBy($typeIsResourceClass)) {
440-
$isOne = true;
441-
}
442-
443-
if (!$className || (!$isOne && !$isMany)) {
444-
continue;
445-
}
446-
447-
$isRelationship = true;
448-
$resourceMetadata = $this->resourceMetadataFactory->create($className);
449-
$operation = $resourceMetadata->getOperation();
401+
foreach ($relationships as [$className, $isCollection]) {
402+
$isOne = $isOne || !$isCollection;
450403
// @see https://github.com/api-platform/core/issues/5501
451404
// @see https://github.com/api-platform/core/pull/5722
452-
$relatedClasses[$className] = $operation->canRead();
405+
$relatedClasses[$className] = $this->resourceMetadataFactory->create($className)->getOperation()->canRead();
453406
}
454407

455-
return $isRelationship ? [$isOne, $relatedClasses] : null;
408+
return [$isOne, $relatedClasses];
456409
}
457410
}

src/JsonApi/Serializer/ItemNormalizer.php

Lines changed: 27 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\JsonApi\Serializer;
1515

16+
use ApiPlatform\JsonApi\Util\ResourceLinkageResolver;
1617
use ApiPlatform\Metadata\ApiProperty;
1718
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
1819
use ApiPlatform\Metadata\HttpOperation;
@@ -26,24 +27,19 @@
2627
use ApiPlatform\Metadata\UrlGeneratorInterface;
2728
use ApiPlatform\Metadata\Util\ClassInfoTrait;
2829
use ApiPlatform\Metadata\Util\CompositeIdentifierParser;
29-
use ApiPlatform\Metadata\Util\TypeHelper;
3030
use ApiPlatform\Serializer\AbstractItemNormalizer;
3131
use ApiPlatform\Serializer\ContextTrait;
3232
use ApiPlatform\Serializer\OperationResourceClassResolverInterface;
3333
use ApiPlatform\Serializer\TagCollectorInterface;
3434
use Symfony\Component\ErrorHandler\Exception\FlattenException;
3535
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
36-
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
3736
use Symfony\Component\Serializer\Exception\LogicException;
3837
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
3938
use Symfony\Component\Serializer\Exception\RuntimeException;
4039
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
4140
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
4241
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
4342
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
44-
use Symfony\Component\TypeInfo\Type;
45-
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
46-
use Symfony\Component\TypeInfo\Type\ObjectType;
4743

4844
/**
4945
* Converts between objects and array.
@@ -69,6 +65,7 @@ final class ItemNormalizer extends AbstractItemNormalizer
6965

7066
private array $componentsCache = [];
7167
private bool $useIriAsId;
68+
private readonly ResourceLinkageResolver $resourceLinkageResolver;
7269

7370
public function __construct(
7471
PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory,
@@ -85,9 +82,11 @@ public function __construct(
8582
?OperationResourceClassResolverInterface $operationResourceResolver = null,
8683
private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null,
8784
bool $useIriAsId = true,
85+
?ResourceLinkageResolver $resourceLinkageResolver = null,
8886
) {
8987
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver);
9088
$this->useIriAsId = $useIriAsId;
89+
$this->resourceLinkageResolver = $resourceLinkageResolver ?? new ResourceLinkageResolver($resourceClassResolver);
9190
}
9291

9392
/**
@@ -409,105 +408,36 @@ private function getComponents(object $object, ?string $format, array $context):
409408
->propertyMetadataFactory
410409
->create($context['resource_class'], $attribute, $options);
411410

412-
// prevent declaring $attribute as attribute if it's already declared as relationship
413-
$isRelationship = false;
411+
// Shared with the JSON Schema SchemaFactory so the documented split cannot drift from this output.
412+
$relationships = $this->resourceLinkageResolver->getRelationships($propertyMetadata);
414413

415-
if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
416-
$types = $propertyMetadata->getBuiltinTypes() ?? [];
417-
418-
foreach ($types as $type) {
419-
$isOne = $isMany = false;
420-
421-
if ($type->isCollection()) {
422-
$collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
423-
$isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
424-
} else {
425-
$isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
426-
}
427-
428-
if (!isset($className) || !$isOne && !$isMany) {
429-
// don't declare it as an attribute too quick: maybe the next type is a valid resource
430-
continue;
431-
}
414+
foreach ($relationships as [$className, $isCollection]) {
415+
$relation = [
416+
'name' => $attribute,
417+
'type' => $this->getResourceShortName($className),
418+
'cardinality' => $isCollection ? 'many' : 'one',
419+
];
432420

433-
$relation = [
434-
'name' => $attribute,
435-
'type' => $this->getResourceShortName($className),
436-
'cardinality' => $isOne ? 'one' : 'many',
437-
];
438-
439-
// if we specify the uriTemplate, generates its value for link definition
440-
// @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content
441-
if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) {
442-
$attributeValue = $this->propertyAccessor->getValue($object, $attribute);
443-
$resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
444-
$childContext = $this->createChildContext($context, $attribute, $format);
445-
unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']);
446-
447-
$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(
448-
operationName: $itemUriTemplate,
449-
httpOperation: true
450-
);
451-
452-
$components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
453-
}
421+
// if we specify the uriTemplate, generates its value for link definition
422+
// @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content
423+
if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) {
424+
$attributeValue = $this->propertyAccessor->getValue($object, $attribute);
425+
$resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
426+
$childContext = $this->createChildContext($context, $attribute, $format);
427+
unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']);
428+
429+
$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(
430+
operationName: $itemUriTemplate,
431+
httpOperation: true
432+
);
454433

455-
$components['relationships'][] = $relation;
456-
$isRelationship = true;
434+
$components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
457435
}
458-
} else {
459-
if ($type = $propertyMetadata->getNativeType()) {
460-
/** @var class-string|null $className */
461-
$className = null;
462-
463-
$typeIsResourceClass = function (Type $type) use (&$className): bool {
464-
return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName());
465-
};
466436

467-
foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) {
468-
$isOne = $isMany = false;
469-
470-
if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) {
471-
$isMany = true;
472-
} elseif ($t->isSatisfiedBy($typeIsResourceClass)) {
473-
$isOne = true;
474-
}
475-
476-
if (!$className || (!$isOne && !$isMany)) {
477-
// don't declare it as an attribute too quick: maybe the next type is a valid resource
478-
continue;
479-
}
480-
481-
$relation = [
482-
'name' => $attribute,
483-
'type' => $this->getResourceShortName($className),
484-
'cardinality' => $isOne ? 'one' : 'many',
485-
];
486-
487-
// if we specify the uriTemplate, generates its value for link definition
488-
// @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content
489-
if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) {
490-
$attributeValue = $this->propertyAccessor->getValue($object, $attribute);
491-
$resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
492-
$childContext = $this->createChildContext($context, $attribute, $format);
493-
unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']);
494-
495-
$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(
496-
operationName: $itemUriTemplate,
497-
httpOperation: true
498-
);
499-
500-
$components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
501-
}
502-
503-
$components['relationships'][] = $relation;
504-
$isRelationship = true;
505-
}
506-
}
437+
$components['relationships'][] = $relation;
507438
}
508439

509-
// if all types are not relationships, declare it as an attribute
510-
if (!$isRelationship) {
440+
if ([] === $relationships) {
511441
$components['attributes'][] = $attribute;
512442
}
513443
}

0 commit comments

Comments
 (0)