diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index e86aedfe6d7..620572a9dee 100644 --- a/src/JsonApi/JsonSchema/SchemaFactory.php +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -13,6 +13,8 @@ namespace ApiPlatform\JsonApi\JsonSchema; +use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; +use ApiPlatform\JsonApi\Util\ResourceLinkageResolver; use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\DefinitionNameFactoryInterface; use ApiPlatform\JsonSchema\ResourceMetadataTrait; @@ -25,12 +27,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\State\ApiResource\Error; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; -use Symfony\Component\TypeInfo\Type\ObjectType; /** * Decorator factory which adds JSON:API properties to the JSON Schema document. @@ -83,7 +80,9 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI */ private $builtSchema = []; - public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) + private readonly ResourceLinkageResolver $resourceLinkageResolver; + + public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null, ?ResourceLinkageResolver $resourceLinkageResolver = null) { if (!$definitionNameFactory) { $this->definitionNameFactory = new DefinitionNameFactory(); @@ -93,6 +92,7 @@ public function __construct(private readonly SchemaFactoryInterface $schemaFacto } $this->resourceClassResolver = $resourceClassResolver; $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->resourceLinkageResolver = $resourceLinkageResolver ?? new ResourceLinkageResolver($resourceClassResolver); } /** @@ -326,8 +326,10 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, foreach (array_keys($refs) as $ref) { $relatedDefinitions[$ref] = ['$ref' => $ref]; } + // A relationship literally named "relationships"/"included" is prefixed at runtime too. + $relationshipName = ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$propertyName] ?? $propertyName; if ($isOne) { - $relationships[$propertyName]['properties']['data'] = [ + $relationships[$relationshipName]['properties']['data'] = [ 'oneOf' => [ ['type' => 'null'], self::RELATION_PROPS, @@ -335,19 +337,16 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, ]; continue; } - $relationships[$propertyName]['properties']['data'] = [ + $relationships[$relationshipName]['properties']['data'] = [ 'type' => 'array', 'items' => self::RELATION_PROPS, ]; continue; } - if ('id' === $propertyName) { - // should probably be renamed "lid" and moved to the above node - $attributes['_id'] = $property; - continue; - } - $attributes[$propertyName] = $property; + // Reserved names (id, type, links, relationships, included) are prefixed by the + // ReservedAttributeNameConverter at runtime; mirror that single source of truth here. + $attributes[ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$propertyName] ?? $propertyName] = $property; } $replacement = self::PROPERTY_PROPS; @@ -391,67 +390,21 @@ private function getRelationship(string $resourceClass, string $property, ?array { $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $serializerContext ?? []); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - $isRelationship = false; - $isOne = $isMany = false; - $relatedClasses = []; - - foreach ($types as $type) { - if ($type->isCollection()) { - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - $isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } else { - $isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } - if (!isset($className) || (!$isOne && !$isMany)) { - continue; - } - $isRelationship = true; - $resourceMetadata = $this->resourceMetadataFactory->create($className); - $operation = $resourceMetadata->getOperation(); - // @see https://github.com/api-platform/core/issues/5501 - // @see https://github.com/api-platform/core/pull/5722 - $relatedClasses[$className] = $operation->canRead(); - } - - return $isRelationship ? [$isOne, $relatedClasses] : null; - } - - if (null === $type = $propertyMetadata->getNativeType()) { + // Share the runtime attributes/relationships split so the generated schema cannot drift from the document. + $relationships = $this->resourceLinkageResolver->getRelationships($propertyMetadata); + if ([] === $relationships) { return null; } - $isRelationship = false; - $isOne = $isMany = false; + $isOne = false; $relatedClasses = []; - - /** @var class-string|null $className */ - $className = null; - - $typeIsResourceClass = function (Type $type) use (&$className): bool { - return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); - }; - - foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { - if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) { - $isMany = true; - } elseif ($t->isSatisfiedBy($typeIsResourceClass)) { - $isOne = true; - } - - if (!$className || (!$isOne && !$isMany)) { - continue; - } - - $isRelationship = true; - $resourceMetadata = $this->resourceMetadataFactory->create($className); - $operation = $resourceMetadata->getOperation(); + foreach ($relationships as [$className, $isCollection]) { + $isOne = $isOne || !$isCollection; // @see https://github.com/api-platform/core/issues/5501 // @see https://github.com/api-platform/core/pull/5722 - $relatedClasses[$className] = $operation->canRead(); + $relatedClasses[$className] = $this->resourceMetadataFactory->create($className)->getOperation()->canRead(); } - return $isRelationship ? [$isOne, $relatedClasses] : null; + return [$isOne, $relatedClasses]; } } diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index ee2b7bfb34c..b3bcd2c40dc 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -13,6 +13,7 @@ namespace ApiPlatform\JsonApi\Serializer; +use ApiPlatform\JsonApi\Util\ResourceLinkageResolver; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\HttpOperation; @@ -26,14 +27,12 @@ use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\CompositeIdentifierParser; -use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\RuntimeException; @@ -41,9 +40,6 @@ use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; -use Symfony\Component\TypeInfo\Type\ObjectType; /** * Converts between objects and array. @@ -69,6 +65,7 @@ final class ItemNormalizer extends AbstractItemNormalizer private array $componentsCache = []; private bool $useIriAsId; + private readonly ResourceLinkageResolver $resourceLinkageResolver; public function __construct( PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, @@ -85,9 +82,11 @@ public function __construct( ?OperationResourceClassResolverInterface $operationResourceResolver = null, private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null, bool $useIriAsId = true, + ?ResourceLinkageResolver $resourceLinkageResolver = null, ) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); $this->useIriAsId = $useIriAsId; + $this->resourceLinkageResolver = $resourceLinkageResolver ?? new ResourceLinkageResolver($resourceClassResolver); } /** @@ -409,105 +408,36 @@ private function getComponents(object $object, ?string $format, array $context): ->propertyMetadataFactory ->create($context['resource_class'], $attribute, $options); - // prevent declaring $attribute as attribute if it's already declared as relationship - $isRelationship = false; + // Shared with the JSON Schema SchemaFactory so the documented split cannot drift from this output. + $relationships = $this->resourceLinkageResolver->getRelationships($propertyMetadata); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - - foreach ($types as $type) { - $isOne = $isMany = false; - - if ($type->isCollection()) { - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - $isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } else { - $isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } - - if (!isset($className) || !$isOne && !$isMany) { - // don't declare it as an attribute too quick: maybe the next type is a valid resource - continue; - } + foreach ($relationships as [$className, $isCollection]) { + $relation = [ + 'name' => $attribute, + 'type' => $this->getResourceShortName($className), + 'cardinality' => $isCollection ? 'many' : 'one', + ]; - $relation = [ - 'name' => $attribute, - 'type' => $this->getResourceShortName($className), - 'cardinality' => $isOne ? 'one' : 'many', - ]; - - // if we specify the uriTemplate, generates its value for link definition - // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content - if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - $childContext = $this->createChildContext($context, $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); - - $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( - operationName: $itemUriTemplate, - httpOperation: true - ); - - $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); - } + // if we specify the uriTemplate, generates its value for link definition + // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content + if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); + $childContext = $this->createChildContext($context, $attribute, $format); + unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); + + $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( + operationName: $itemUriTemplate, + httpOperation: true + ); - $components['relationships'][] = $relation; - $isRelationship = true; + $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); } - } else { - if ($type = $propertyMetadata->getNativeType()) { - /** @var class-string|null $className */ - $className = null; - - $typeIsResourceClass = function (Type $type) use (&$className): bool { - return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); - }; - foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { - $isOne = $isMany = false; - - if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) { - $isMany = true; - } elseif ($t->isSatisfiedBy($typeIsResourceClass)) { - $isOne = true; - } - - if (!$className || (!$isOne && !$isMany)) { - // don't declare it as an attribute too quick: maybe the next type is a valid resource - continue; - } - - $relation = [ - 'name' => $attribute, - 'type' => $this->getResourceShortName($className), - 'cardinality' => $isOne ? 'one' : 'many', - ]; - - // if we specify the uriTemplate, generates its value for link definition - // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content - if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - $childContext = $this->createChildContext($context, $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); - - $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( - operationName: $itemUriTemplate, - httpOperation: true - ); - - $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); - } - - $components['relationships'][] = $relation; - $isRelationship = true; - } - } + $components['relationships'][] = $relation; } - // if all types are not relationships, declare it as an attribute - if (!$isRelationship) { + if ([] === $relationships) { $components['attributes'][] = $attribute; } } diff --git a/src/JsonApi/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php b/src/JsonApi/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php new file mode 100644 index 00000000000..0b0d46b6452 --- /dev/null +++ b/src/JsonApi/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonApi\Tests\JsonSchema; + +use ApiPlatform\JsonApi\JsonSchema\SchemaFactory; +use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\JsonSchema\DefinitionNameFactory; +use ApiPlatform\JsonSchema\SchemaFactory as BaseSchemaFactory; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\TypeInfo\Type; + +class ReservedAttributeNameSchemaFactoryTest extends TestCase +{ + use ProphecyTrait; + + private SchemaFactory $schemaFactory; + + protected function setUp(): void + { + $resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willReturn( + new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withName('get'), + ])), + ]) + ); + + // A scalar property for every JSON:API reserved attribute name, plus a regular one. + $propertyNames = ['id', 'type', 'links', 'relationships', 'included', 'name']; + + $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection($propertyNames)); + + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + foreach ($propertyNames as $propertyName) { + $propertyMetadataFactory->create(Dummy::class, $propertyName, Argument::any())->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true) + ); + } + + $definitionNameFactory = new DefinitionNameFactory(null); + + $baseSchemaFactory = new BaseSchemaFactory( + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactory->reveal(), + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); + + $this->schemaFactory = new SchemaFactory( + schemaFactory: $baseSchemaFactory, + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + } + + public function testReservedAttributeNamesAreRenamedLikeTheResponse(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $attributes = $resultSchema['definitions'][$rootDefinitionKey]['properties']['data']['properties']['attributes']['properties']; + + // Every reserved name must be documented under the prefixed key the ReservedAttributeNameConverter emits. + $this->assertArrayHasKey('_id', $attributes); + $this->assertArrayHasKey('_type', $attributes); + $this->assertArrayHasKey('_links', $attributes); + $this->assertArrayHasKey('_relationships', $attributes); + $this->assertArrayHasKey('_included', $attributes); + $this->assertArrayHasKey('name', $attributes); + + // The bare reserved names must never leak: the response never emits them. + $this->assertArrayNotHasKey('id', $attributes); + $this->assertArrayNotHasKey('type', $attributes); + $this->assertArrayNotHasKey('links', $attributes); + $this->assertArrayNotHasKey('relationships', $attributes); + $this->assertArrayNotHasKey('included', $attributes); + } +} diff --git a/src/JsonApi/Tests/Util/ResourceLinkageResolverTest.php b/src/JsonApi/Tests/Util/ResourceLinkageResolverTest.php new file mode 100644 index 00000000000..695d5d9cc82 --- /dev/null +++ b/src/JsonApi/Tests/Util/ResourceLinkageResolverTest.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonApi\Tests\Util; + +use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy; +use ApiPlatform\JsonApi\Util\ResourceLinkageResolver; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\TypeInfo\Type; + +class ResourceLinkageResolverTest extends TestCase +{ + use ProphecyTrait; + + private ResourceLinkageResolver $resourceLinkageResolver; + + protected function setUp(): void + { + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); + $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolver->isResourceClass(\ArrayObject::class)->willReturn(false); + + $this->resourceLinkageResolver = new ResourceLinkageResolver($resourceClassResolver->reveal()); + } + + public function testScalarPropertyIsNotARelationship(): void + { + $property = (new ApiProperty())->withNativeType(Type::string()); + + $this->assertSame([], $this->resourceLinkageResolver->getRelationships($property)); + } + + public function testPropertyWithoutNativeTypeIsNotARelationship(): void + { + $this->assertSame([], $this->resourceLinkageResolver->getRelationships(new ApiProperty())); + } + + public function testObjectThatIsNotAResourceIsNotARelationship(): void + { + $property = (new ApiProperty())->withNativeType(Type::object(\ArrayObject::class)); + + $this->assertSame([], $this->resourceLinkageResolver->getRelationships($property)); + } + + public function testToOneRelationship(): void + { + $property = (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class)); + + $this->assertSame([[RelatedDummy::class, false]], $this->resourceLinkageResolver->getRelationships($property)); + } + + public function testNullableToOneRelationship(): void + { + $property = (new ApiProperty())->withNativeType(Type::nullable(Type::object(RelatedDummy::class))); + + $this->assertSame([[RelatedDummy::class, false]], $this->resourceLinkageResolver->getRelationships($property)); + } + + public function testToManyRelationship(): void + { + $property = (new ApiProperty())->withNativeType(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class))); + + $this->assertSame([[RelatedDummy::class, true]], $this->resourceLinkageResolver->getRelationships($property)); + } +} diff --git a/src/JsonApi/Util/ResourceLinkageResolver.php b/src/JsonApi/Util/ResourceLinkageResolver.php new file mode 100644 index 00000000000..73cacb9d67c --- /dev/null +++ b/src/JsonApi/Util/ResourceLinkageResolver.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonApi\Util; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\TypeHelper; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; + +/** + * Decides whether a property is a JSON:API relationship and with which related resource(s). + * + * Single source of truth for the attributes/relationships split, shared by the runtime + * {@see \ApiPlatform\JsonApi\Serializer\ItemNormalizer} and the doc-time + * {@see \ApiPlatform\JsonApi\JsonSchema\SchemaFactory} so the generated schema cannot drift + * from the emitted document. + * + * @author Antoine Bluchet + * + * @internal + */ +final class ResourceLinkageResolver +{ + public function __construct(private readonly ResourceClassResolverInterface $resourceClassResolver) + { + } + + /** + * Returns the related resource classes a property points to, in declaration order. + * + * @return list ordered [relatedClass, isCollection] pairs; empty when the property is a plain attribute + */ + public function getRelationships(ApiProperty $propertyMetadata): array + { + $relationships = []; + + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + foreach ($propertyMetadata->getBuiltinTypes() ?? [] as $type) { + if ($type->isCollection()) { + $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; + if ($collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) { + $relationships[] = [$className, true]; + } + + continue; + } + + if (($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) { + $relationships[] = [$className, false]; + } + } + + return $relationships; + } + + if (null === $type = $propertyMetadata->getNativeType()) { + return $relationships; + } + + /** @var class-string|null $className */ + $className = null; + $typeIsResourceClass = function (Type $type) use (&$className): bool { + return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); + }; + + foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { + if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) { + $relationships[] = [$className, true]; + } elseif ($t->isSatisfiedBy($typeIsResourceClass)) { + $relationships[] = [$className, false]; + } + } + + return $relationships; + } +} diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 4584a9c9e0e..14410c5ff01 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -710,6 +710,7 @@ private function registerJsonApiConfiguration(ContainerBuilder $container, array $itemNormalizer = $container->getDefinition('api_platform.jsonapi.normalizer.item'); $itemNormalizer->replaceArgument(7, [JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false]); $itemNormalizer->addArgument($config['jsonapi']['use_iri_as_id']); + $itemNormalizer->addArgument(new Reference('api_platform.jsonapi.resource_linkage_resolver')); } private function registerJsonLdHydraConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.php b/src/Symfony/Bundle/Resources/config/jsonapi.php index 6ad6d49ab4c..5c494fd6715 100644 --- a/src/Symfony/Bundle/Resources/config/jsonapi.php +++ b/src/Symfony/Bundle/Resources/config/jsonapi.php @@ -21,11 +21,17 @@ use ApiPlatform\JsonApi\Serializer\ItemNormalizer; use ApiPlatform\JsonApi\Serializer\ObjectNormalizer; use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; +use ApiPlatform\JsonApi\Util\ResourceLinkageResolver; use ApiPlatform\Serializer\JsonEncoder; return static function (ContainerConfigurator $container) { $services = $container->services(); + $services->set('api_platform.jsonapi.resource_linkage_resolver', ResourceLinkageResolver::class) + ->args([ + service('api_platform.resource_class_resolver'), + ]); + $services->set('api_platform.jsonapi.json_schema.schema_factory', SchemaFactory::class) ->decorate('api_platform.json_schema.schema_factory', null, 0) ->args([ @@ -34,6 +40,7 @@ service('api_platform.resource_class_resolver'), service('api_platform.metadata.resource.metadata_collection_factory')->ignoreOnInvalid(), service('api_platform.json_schema.definition_name_factory')->ignoreOnInvalid(), + service('api_platform.jsonapi.resource_linkage_resolver'), ]); $services->set('api_platform.jsonapi.encoder', JsonEncoder::class)