diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 17f3e17..620572a 100644 --- a/JsonSchema/SchemaFactory.php +++ b/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; @@ -20,16 +22,12 @@ use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface; use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\JsonSchema\SchemaUriPrefixTrait; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; 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. @@ -62,6 +60,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI 'format' => 'iri-reference', ], ], + 'required' => ['type', 'id'], ]; private const PROPERTY_PROPS = [ 'id' => [ @@ -81,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(); @@ -91,6 +92,7 @@ public function __construct(private readonly SchemaFactoryInterface $schemaFacto } $this->resourceClassResolver = $resourceClassResolver; $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->resourceLinkageResolver = $resourceLinkageResolver ?? new ResourceLinkageResolver($resourceClassResolver); } /** @@ -147,7 +149,7 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin 'items' => [ 'allOf' => [ ['$ref' => $prefix.$key], - ['type' => 'object', 'properties' => ['source' => ['type' => 'object'], 'status' => ['type' => 'string']]], + ['type' => 'object', 'properties' => ['source' => ['type' => 'object'], 'status' => ['type' => ['string', 'integer', 'null']]]], ], ], ], @@ -257,17 +259,16 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin unset($schema['type']); $properties = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); - $properties['data']['properties']['attributes']['$ref'] = $prefix.$key; + + $properties['data'] = [ + 'type' => 'array', + 'items' => $properties['data'], + ]; $schema['description'] = "$definitionName collection."; $schema['allOf'] = [ ['$ref' => $prefix.(false === $operation->getPaginationEnabled() ? self::COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION : self::COLLECTION_BASE_SCHEMA_NAME)], - ['type' => 'object', 'properties' => [ - 'data' => [ - 'type' => 'array', - 'items' => $properties['data'], - ], - ]], + ['type' => 'object', 'properties' => $properties], ]; return $schema; @@ -282,6 +283,8 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void private function buildDefinitionPropertiesSchema(string $key, string $className, string $format, string $type, ?Operation $operation, Schema $schema, ?array $serializerContext): array { + // Capture the resource's operation up front; the relationship loop below reassigns $operation. + $resourceOperation = $operation; $definitions = $schema->getDefinitions(); $properties = $definitions[$key]['properties'] ?? []; @@ -319,9 +322,14 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, $refs[$this->getSchemaUriPrefix($schema->getVersion()).$definitionName] = '$ref'; } - $relatedDefinitions[$propertyName] = array_flip($refs); + // keep one entry per related definition: a polymorphic relation targets several resource classes, all of which may appear in "included" + 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, @@ -329,24 +337,20 @@ 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; } - $currentRef = $this->getSchemaUriPrefix($schema->getVersion()).$schema->getRootDefinitionKey(); $replacement = self::PROPERTY_PROPS; - $replacement['attributes'] = ['$ref' => $currentRef]; + $replacement['attributes']['properties'] = $attributes; $included = []; if (\count($relationships) > 0) { @@ -369,11 +373,15 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, ]; } + // https://jsonapi.org/format/#crud-creating — clients MAY supply an id when creating a resource. + // Only relax the requirement on the input schema; responses still always carry an `id`. + $required = Schema::TYPE_INPUT === $type && $resourceOperation instanceof HttpOperation && 'POST' === $resourceOperation->getMethod() ? ['type'] : ['type', 'id']; + return [ 'data' => [ 'type' => 'object', 'properties' => $replacement, - 'required' => ['type', 'id'], + 'required' => $required, ], ] + $included; } @@ -382,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/Serializer/ConstraintViolationListNormalizer.php b/Serializer/ConstraintViolationListNormalizer.php index 081ccb6..ce0c728 100644 --- a/Serializer/ConstraintViolationListNormalizer.php +++ b/Serializer/ConstraintViolationListNormalizer.php @@ -14,7 +14,6 @@ namespace ApiPlatform\JsonApi\Serializer; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -37,10 +36,10 @@ public function __construct(private readonly PropertyMetadataFactoryInterface $p /** * {@inheritdoc} */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array + public function normalize(mixed $data, ?string $format = null, array $context = []): array { $violations = []; - foreach ($object as $violation) { + foreach ($data as $violation) { $violations[] = [ 'detail' => $violation->getMessage(), 'source' => [ @@ -61,9 +60,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? [ConstraintViolationListInterface::class => true] : []; } @@ -94,15 +93,8 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio $fieldName = $this->nameConverter->normalize($fieldName, $class, self::FORMAT); } - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - if ($type && null !== $type->getClassName()) { - return "data/relationships/$fieldName"; - } - } else { - if ($propertyMetadata->getNativeType()?->isSatisfiedBy(static fn ($t) => $t instanceof ObjectType)) { - return "data/relationships/$fieldName"; - } + if ($propertyMetadata->getNativeType()?->isSatisfiedBy(static fn ($t) => $t instanceof ObjectType)) { + return "data/relationships/$fieldName"; } return "data/attributes/$fieldName"; diff --git a/Serializer/EntrypointNormalizer.php b/Serializer/EntrypointNormalizer.php index 394bcb3..a7a6f67 100644 --- a/Serializer/EntrypointNormalizer.php +++ b/Serializer/EntrypointNormalizer.php @@ -39,11 +39,11 @@ public function __construct(private readonly ResourceMetadataCollectionFactoryIn /** * {@inheritdoc} */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array + public function normalize(mixed $data, ?string $format = null, array $context = []): array { $entrypoint = ['links' => ['self' => $this->urlGenerator->generate('api_entrypoint', [], UrlGeneratorInterface::ABS_URL)]]; - foreach ($object->getResourceNameCollection() as $resourceClass) { + foreach ($data->getResourceNameCollection() as $resourceClass) { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); foreach ($resourceMetadata as $resource) { @@ -74,9 +74,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? [Entrypoint::class => true] : []; } diff --git a/Serializer/ErrorNormalizer.php b/Serializer/ErrorNormalizer.php index 4875b28..2076a1c 100644 --- a/Serializer/ErrorNormalizer.php +++ b/Serializer/ErrorNormalizer.php @@ -32,23 +32,22 @@ public function __construct(private ?NormalizerInterface $itemNormalizer = null) /** * {@inheritdoc} */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array + public function normalize(mixed $data, ?string $format = null, array $context = []): array { - $jsonApiObject = $this->itemNormalizer->normalize($object, $format, $context); + $jsonApiObject = $this->itemNormalizer->normalize($data, $format, $context); $error = $jsonApiObject['data']['attributes'] ?? []; $error['id'] = $jsonApiObject['data']['id']; if (isset($error['type'])) { $error['links'] = ['type' => $error['type']]; } - if (!isset($error['code']) && method_exists($object, 'getId')) { - $error['code'] = $object->getId(); + if (!isset($error['code']) && method_exists($data, 'getId')) { + $error['code'] = $data->getId(); } - // TODO: change this 5.x - // if (isset($error['status'])) { - // $error['status'] = (string) $error['status']; - // } + if (isset($error['status'])) { + $error['status'] = (string) $error['status']; + } if (!isset($error['violations'])) { return ['errors' => [$error]]; @@ -81,9 +80,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { if (self::FORMAT === $format) { return [ diff --git a/Serializer/ItemDenormalizer.php b/Serializer/ItemDenormalizer.php new file mode 100644 index 0000000..8ed3a0a --- /dev/null +++ b/Serializer/ItemDenormalizer.php @@ -0,0 +1,64 @@ + + * + * 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\Serializer; + +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Serializer\AbstractItemNormalizer; +use ApiPlatform\Serializer\OperationResourceClassResolverInterface; +use ApiPlatform\Serializer\TagCollectorInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Converts JSON:API documents to objects (denormalization only). + * + * @author Kévin Dunglas + * @author Amrouche Hamza + * @author Baptiste Meyer + */ +final class ItemDenormalizer extends AbstractItemNormalizer +{ + use ItemNormalizerTrait; + + public const FORMAT = 'jsonapi'; + + public function __construct( + PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + PropertyMetadataFactoryInterface $propertyMetadataFactory, + IriConverterInterface $iriConverter, + ResourceClassResolverInterface $resourceClassResolver, + ?PropertyAccessorInterface $propertyAccessor = null, + ?NameConverterInterface $nameConverter = null, + ?ClassMetadataFactoryInterface $classMetadataFactory = null, + array $defaultContext = [], + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + ?ResourceAccessCheckerInterface $resourceAccessChecker = null, + protected ?TagCollectorInterface $tagCollector = null, + ?OperationResourceClassResolverInterface $operationResourceResolver = null, + private readonly bool $useIriAsId = true, + ) { + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return false; + } +} diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index a58a361..fbe92ff 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -13,8 +13,9 @@ namespace ApiPlatform\JsonApi\Serializer; +use ApiPlatform\JsonApi\Util\ResourceLinkageResolver; use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -23,27 +24,21 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; -use ApiPlatform\Metadata\Util\TypeHelper; +use ApiPlatform\Metadata\Util\CompositeIdentifierParser; 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; use Symfony\Component\Serializer\Exception\UnexpectedValueException; 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. + * Converts objects to JSON:API documents (normalization only). * * @author Kévin Dunglas * @author Amrouche Hamza @@ -53,45 +48,73 @@ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; use ContextTrait; + use ItemNormalizerTrait { + denormalize as private doDenormalize; + } public const FORMAT = 'jsonapi'; - private array $componentsCache = []; + /** + * Denormalization context flag enabling client-generated IDs on POST per + * https://jsonapi.org/format/#crud-creating-client-ids. Off by default to + * avoid an id-spoofing footgun on public endpoints. Set in the context or + * via the bundle configuration ("api_platform.jsonapi.allow_client_generated_id"). + */ + public const ALLOW_CLIENT_GENERATED_ID = 'allow_client_generated_id'; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) - { + private array $componentsCache = []; + private bool $useIriAsId; + private readonly ResourceLinkageResolver $resourceLinkageResolver; + + public function __construct( + PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + PropertyMetadataFactoryInterface $propertyMetadataFactory, + IriConverterInterface $iriConverter, + ResourceClassResolverInterface $resourceClassResolver, + ?PropertyAccessorInterface $propertyAccessor = null, + ?NameConverterInterface $nameConverter = null, + ?ClassMetadataFactoryInterface $classMetadataFactory = null, + array $defaultContext = [], + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + ?ResourceAccessCheckerInterface $resourceAccessChecker = null, + protected ?TagCollectorInterface $tagCollector = null, + ?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); } - /** - * {@inheritdoc} - */ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context) && !($data instanceof \Exception || $data instanceof FlattenException); } - /** - * @param string|null $format - */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; } - /** - * {@inheritdoc} - */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { - $resourceClass = $this->getObjectClass($object); + trigger_deprecation('api-platform/core', '4.4', 'Calling "denormalize()" on "%s" is deprecated, use "%s" instead.', self::class, ItemDenormalizer::class); + + return $this->doDenormalize($data, $type, $format, $context); + } + + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $resourceClass = $this->getObjectClass($data); if ($this->getOutputClass($context)) { - return parent::normalize($object, $format, $context); + return parent::normalize($data, $format, $context); } $previousResourceClass = $context['resource_class'] ?? null; if ($this->resourceClassResolver->isResourceClass($resourceClass) && (null === $previousResourceClass || $this->resourceClassResolver->isResourceClass($previousResourceClass))) { - $resourceClass = $this->resourceClassResolver->getResourceClass($object, $previousResourceClass); + $resourceClass = $this->resourceClassResolver->getResourceClass($data, $previousResourceClass); } if (($operation = $context['operation'] ?? null) && method_exists($operation, 'getItemUriTemplate')) { @@ -100,8 +123,8 @@ public function normalize(mixed $object, ?string $format = null, array $context $context = $this->initContext($resourceClass, $context); - $iri = $context['iri'] ??= $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); - $context['object'] = $object; + $iri = $context['iri'] ??= $this->iriConverter->getIriFromResource($data, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); + $context['object'] = $data; $context['format'] = $format; $context['api_normalize'] = true; @@ -109,28 +132,39 @@ public function normalize(mixed $object, ?string $format = null, array $context $context['cache_key'] = $this->isCacheKeySafe($context) ? $this->getCacheKey($format, $context) : false; } - $data = parent::normalize($object, $format, $context); - if (!\is_array($data)) { - return $data; + $normalizedData = parent::normalize($data, $format, $context); + if (!\is_array($normalizedData)) { + return $normalizedData; } - // Get and populate relations - ['relationships' => $allRelationshipsData, 'links' => $links] = $this->getComponents($object, $format, $context); + ['relationships' => $allRelationshipsData, 'links' => $links] = $this->getComponents($data, $format, $context); $populatedRelationContext = $context; - $relationshipsData = $this->getPopulatedRelations($object, $format, $populatedRelationContext, $allRelationshipsData); + $relationshipsData = $this->getPopulatedRelations($data, $format, $populatedRelationContext, $allRelationshipsData); - // Do not include primary resources - $context['api_included_resources'] = [$context['iri']]; + $id = $iri; + if (!$this->useIriAsId) { + $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($data, context: $context); + $id = $this->getIdStringFromIdentifiers($identifiers); + } - $includedResourcesData = $this->getRelatedResources($object, $format, $context, $allRelationshipsData); + $resourceShortName = $this->getResourceShortName($resourceClass); + + // Do not include primary resources — use type:id composite key to avoid cross-type collisions + $context['api_included_resources'] = [$resourceShortName.':'.$id => true]; + + $includedResourcesData = $this->getRelatedResources($data, $format, $context, $allRelationshipsData); $resourceData = [ - 'id' => $context['iri'], - 'type' => $this->getResourceShortName($resourceClass), + 'id' => $id, + 'type' => $resourceShortName, ]; - if ($data) { - $resourceData['attributes'] = $data; + if (!$this->useIriAsId) { + $resourceData['links'] = ['self' => $iri]; + } + + if ($normalizedData) { + $resourceData['attributes'] = $normalizedData; } if ($relationshipsData) { @@ -152,106 +186,12 @@ public function normalize(mixed $object, ?string $format = null, array $context return $document; } - /** - * {@inheritdoc} - */ - public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool - { - return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); - } - - /** - * {@inheritdoc} - * - * @throws NotNormalizableValueException - */ - public function denormalize(mixed $data, string $class, ?string $format = null, array $context = []): mixed - { - // When re-entering for input DTO denormalization, data has already been - // unwrapped from the JSON:API structure by the first pass. Skip extraction. - if (isset($context['api_platform_input'])) { - return parent::denormalize($data, $class, $format, $context); - } - - // Avoid issues with proxies if we populated the object - if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { - if (true !== ($context['api_allow_update'] ?? true)) { - throw new NotNormalizableValueException('Update is not allowed for this operation.'); - } - - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( - $data['data']['id'], - $context + ['fetch_data' => false] - ); - } - - // Merge attributes and relationships, into format expected by the parent normalizer - $dataToDenormalize = array_merge( - $data['data']['attributes'] ?? [], - $data['data']['relationships'] ?? [] - ); - - return parent::denormalize( - $dataToDenormalize, - $class, - $format, - $context - ); - } - - /** - * {@inheritdoc} - */ protected function getAttributes(object $object, ?string $format = null, array $context = []): array { return $this->getComponents($object, $format, $context)['attributes']; } /** - * {@inheritdoc} - */ - protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void - { - parent::setAttributeValue($object, $attribute, \is_array($value) && \array_key_exists('data', $value) ? $value['data'] : $value, $format, $context); - } - - /** - * {@inheritdoc} - * - * @see http://jsonapi.org/format/#document-resource-object-linkage - * - * @throws RuntimeException - * @throws UnexpectedValueException - */ - protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object - { - if (!\is_array($value) || !isset($value['id'], $value['type'])) { - throw new UnexpectedValueException('Only resource linkage supported currently, see: http://jsonapi.org/format/#document-resource-object-linkage.'); - } - - try { - return $this->iriConverter->getResourceFromIri($value['id'], $context + ['fetch_data' => true]); - } catch (ItemNotFoundException $e) { - if (!isset($context['not_normalizable_value_exceptions'])) { - throw new RuntimeException($e->getMessage(), $e->getCode(), $e); - } - $context['not_normalizable_value_exceptions'][] = NotNormalizableValueException::createForUnexpectedDataType( - $e->getMessage(), - $value, - [$className], - $context['deserialization_path'] ?? null, - true, - $e->getCode(), - $e - ); - - return null; - } - } - - /** - * {@inheritdoc} - * * @see http://jsonapi.org/format/#document-resource-object-linkage */ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $relatedObject, string $resourceClass, ?string $format, array $context): \ArrayObject|array|string|null @@ -278,10 +218,16 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel return $normalizedRelatedObject; } + $id = $iri; + if (!$this->useIriAsId) { + $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($relatedObject); + $id = $this->getIdStringFromIdentifiers($identifiers); + } + $context['data'] = [ 'data' => [ 'type' => $this->getResourceShortName($resourceClass), - 'id' => $iri, + 'id' => $id, ], ]; @@ -297,14 +243,6 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel return $context['data']; } - /** - * {@inheritdoc} - */ - protected function isAllowedAttribute(object|string $classOrObject, string $attribute, ?string $format = null, array $context = []): bool - { - return preg_match('/^\\w[-\\w_]*$/', $attribute) && parent::isAllowedAttribute($classOrObject, $attribute, $format, $context); - } - /** * Gets JSON API components of the resource: attributes, relationships, meta and links. */ @@ -332,105 +270,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 ($relationships as [$className, $isCollection]) { + $relation = [ + 'name' => $attribute, + 'type' => $this->getResourceShortName($className), + 'cardinality' => $isCollection ? 'many' : 'one', + ]; - foreach ($types as $type) { - $isOne = $isMany = false; + // 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']); - 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); - } + $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( + operationName: $itemUriTemplate, + httpOperation: true + ); - if (!isset($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['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; } } @@ -443,8 +312,6 @@ private function getComponents(object $object, ?string $format, array $context): } /** - * Populates relationships keys. - * * @throws UnexpectedValueException */ private function getPopulatedRelations(object $object, ?string $format, array $context, array $relationships): array @@ -465,11 +332,8 @@ private function getPopulatedRelations(object $object, ?string $format, array $c $relationshipName = $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context); } - // Many to one relationship if ('one' === $relationshipDataArray['cardinality']) { - $data[$relationshipName] = [ - 'data' => null, - ]; + $data[$relationshipName] = ['data' => null]; if (!$attributeValue) { continue; @@ -481,10 +345,7 @@ private function getPopulatedRelations(object $object, ?string $format, array $c continue; } - // Many to many relationship - $data[$relationshipName] = [ - 'data' => [], - ]; + $data[$relationshipName] = ['data' => []]; if (!$attributeValue) { continue; @@ -502,9 +363,6 @@ private function getPopulatedRelations(object $object, ?string $format, array $c return $data; } - /** - * Populates included keys. - */ private function getRelatedResources(object $object, ?string $format, array $context, array $relationships): array { if (!isset($context['api_included'])) { @@ -528,9 +386,7 @@ private function getRelatedResources(object $object, ?string $format, array $con continue; } - // Many to many relationship $attributeValues = $attributeValue; - // Many to one relationship if ('one' === $relationshipDataArray['cardinality']) { $attributeValues = [$attributeValue]; } @@ -550,21 +406,15 @@ private function getRelatedResources(object $object, ?string $format, array $con return $included; } - /** - * Add data to included array if it's not already included. - */ private function addIncluded(array $data, array &$included, array &$context): void { - if (isset($data['id']) && !\in_array($data['id'], $context['api_included_resources'], true)) { + $trackingKey = ($data['type'] ?? '').':'.($data['id'] ?? ''); + if (isset($data['id']) && !isset($context['api_included_resources'][$trackingKey])) { $included[] = $data; - // Track already included resources - $context['api_included_resources'][] = $data['id']; + $context['api_included_resources'][$trackingKey] = true; } } - /** - * Figures out if the relationship is in the api_included hash or has included nested resources (path). - */ private function shouldIncludeRelation(string $relationshipName, array $context): bool { $normalizedName = $this->nameConverter ? $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context) : $relationshipName; @@ -572,9 +422,6 @@ private function shouldIncludeRelation(string $relationshipName, array $context) return \in_array($normalizedName, $context['api_included'], true) || \count($this->getIncludedNestedResources($relationshipName, $context)) > 0; } - /** - * Returns the names of the nested resources from a path relationship. - */ private function getIncludedNestedResources(string $relationshipName, array $context): array { $normalizedName = $this->nameConverter ? $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context) : $relationshipName; @@ -584,7 +431,15 @@ private function getIncludedNestedResources(string $relationshipName, array $con return array_map(static fn (string $nested): string => substr($nested, strpos($nested, '.') + 1), $filtered); } - // TODO: this code is similar to the one used in JsonLd + private function getIdStringFromIdentifiers(array $identifiers): string + { + if (1 === \count($identifiers)) { + return (string) array_values($identifiers)[0]; + } + + return CompositeIdentifierParser::stringify($identifiers); + } + private function getResourceShortName(string $resourceClass): string { if ($this->resourceClassResolver->isResourceClass($resourceClass)) { diff --git a/Serializer/ItemNormalizerTrait.php b/Serializer/ItemNormalizerTrait.php new file mode 100644 index 0000000..e2279ca --- /dev/null +++ b/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,160 @@ + + * + * 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\Serializer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Serializer\AbstractItemNormalizer; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\RuntimeException; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; + +/** + * Shared support gates and denormalization logic for the JSON:API item (de)normalizer. + * + * @author Kévin Dunglas + * + * @internal + */ +trait ItemNormalizerTrait +{ + public function getSupportedTypes(?string $format): array + { + return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); + } + + /** + * @throws NotNormalizableValueException + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + // When re-entering for input DTO denormalization, data has already been + // unwrapped from the JSON:API structure by the first pass. Skip extraction. + if (isset($context['api_platform_input'])) { + return parent::denormalize($data, $type, $format, $context); + } + + $operation = $context['operation'] ?? null; + $isPostOperation = $operation instanceof HttpOperation && 'POST' === $operation->getMethod(); + $allowClientGeneratedId = true === ($context[ItemNormalizer::ALLOW_CLIENT_GENERATED_ID] ?? $this->defaultContext[ItemNormalizer::ALLOW_CLIENT_GENERATED_ID] ?? false); + + // Avoid issues with proxies if we populated the object + if (!isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { + if ($isPostOperation) { + if (!$allowClientGeneratedId) { + throw new NotNormalizableValueException(\sprintf('Client-generated IDs are not allowed on this operation. Set the "%s" denormalization context flag (or the bundle "allow_client_generated_id" configuration) to enable it.', ItemNormalizer::ALLOW_CLIENT_GENERATED_ID)); + } + // Fall through: client id is merged into the denormalized payload below. + } elseif (true !== ($context['api_allow_update'] ?? true)) { + throw new NotNormalizableValueException('Update is not allowed for this operation.'); + } else { + $context += ['fetch_data' => false]; + if ($this->useIriAsId) { + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['data']['id'], $context); + } elseif ($operation instanceof HttpOperation) { + $iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation); + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context); + } + } + } + + $dataToDenormalize = array_merge( + $data['data']['attributes'] ?? [], + $data['data']['relationships'] ?? [] + ); + + // Surface the client-generated id so the entity setter receives it. + if ($isPostOperation && $allowClientGeneratedId && isset($data['data']['id'])) { + $dataToDenormalize['id'] = $data['data']['id']; + } + + return parent::denormalize($dataToDenormalize, $type, $format, $context); + } + + protected function isAllowedAttribute(object|string $classOrObject, string $attribute, ?string $format = null, array $context = []): bool + { + return preg_match('/^\\w[-\\w_]*$/', $attribute) && parent::isAllowedAttribute($classOrObject, $attribute, $format, $context); + } + + protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void + { + parent::setAttributeValue($object, $attribute, \is_array($value) && \array_key_exists('data', $value) ? $value['data'] : $value, $format, $context); + } + + /** + * @see http://jsonapi.org/format/#document-resource-object-linkage + * + * @throws RuntimeException + * @throws UnexpectedValueException + */ + protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object + { + if (!\is_array($value) || !isset($value['id'], $value['type'])) { + throw new UnexpectedValueException('Only resource linkage supported currently, see: http://jsonapi.org/format/#document-resource-object-linkage.'); + } + + try { + $context += ['fetch_data' => true]; + if ($this->useIriAsId) { + return $this->iriConverter->getResourceFromIri($value['id'], $context); + } + + /** @var HttpOperation $getOperation */ + $getOperation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(httpOperation: true); + $iri = $this->reconstructIri($className, (string) $value['id'], $getOperation); + + return $this->iriConverter->getResourceFromIri($iri, $context); + } catch (ItemNotFoundException $e) { + if (!isset($context['not_normalizable_value_exceptions'])) { + throw new RuntimeException($e->getMessage(), $e->getCode(), $e); + } + $context['not_normalizable_value_exceptions'][] = NotNormalizableValueException::createForUnexpectedDataType( + $e->getMessage(), + $value, + [$className], + $context['deserialization_path'] ?? null, + true, + $e->getCode(), + $e + ); + + return null; + } + } + + /** + * Maps the id to the operation's single URI variable parameter and generates the IRI. + * Composite identifiers on a single Link work naturally since the composite string + * (e.g. "field1=val1;field2=val2") is passed as-is. + */ + private function reconstructIri(string $resourceClass, string $id, HttpOperation $operation): string + { + $uriVariables = $operation->getUriVariables() ?? []; + + if (\count($uriVariables) > 1) { + throw new UnexpectedValueException(\sprintf('JSON:API entity identifier mode requires operations with a single URI variable, operation "%s" has %d. Consider adding a NotExposed Get operation on the resource.', $operation->getName() ?? $operation->getUriTemplate(), \count($uriVariables))); + } + + $parameterName = array_key_first($uriVariables) ?? 'id'; + + return $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => [$parameterName => $id]]); + } +} diff --git a/Serializer/ObjectNormalizer.php b/Serializer/ObjectNormalizer.php index 1fd986b..5ea74b1 100644 --- a/Serializer/ObjectNormalizer.php +++ b/Serializer/ObjectNormalizer.php @@ -42,9 +42,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? $this->decorated->getSupportedTypes($format) : []; } @@ -52,16 +52,16 @@ public function getSupportedTypes($format): array /** * {@inheritdoc} */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { if (isset($context['api_resource'])) { $originalResource = $context['api_resource']; unset($context['api_resource']); } - $data = $this->decorated->normalize($object, $format, $context); - if (!\is_array($data) || isset($context['api_attribute'])) { - return $data; + $normalizedData = $this->decorated->normalize($data, $format, $context); + if (!\is_array($normalizedData) || isset($context['api_attribute'])) { + return $normalizedData; } if (isset($originalResource)) { @@ -72,13 +72,13 @@ public function normalize(mixed $object, ?string $format = null, array $context ]; } else { $resourceData = [ - 'id' => $this->iriConverter->getIriFromResource($object), - 'type' => (new \ReflectionClass($this->getObjectClass($object)))->getShortName(), + 'id' => $this->iriConverter->getIriFromResource($data), + 'type' => (new \ReflectionClass($this->getObjectClass($data)))->getShortName(), ]; } - if ($data) { - $resourceData['attributes'] = $data; + if ($normalizedData) { + $resourceData['attributes'] = $normalizedData; } return ['data' => $resourceData]; diff --git a/State/JsonApiProvider.php b/State/JsonApiProvider.php index bf86cb4..9a341fe 100644 --- a/State/JsonApiProvider.php +++ b/State/JsonApiProvider.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\Util\RequestParser; final class JsonApiProvider implements ProviderInterface { @@ -70,6 +71,12 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $filters = array_merge($pageParameter, $filters); } + foreach (['page', 'itemsPerPage', 'pagination', 'partial'] as $paginationParameter) { + if (isset($queryParameters[$paginationParameter]) && !\is_array($queryParameters[$paginationParameter]) && !isset($filters[$paginationParameter])) { + $filters[$paginationParameter] = $queryParameters[$paginationParameter]; + } + } + [$included, $properties] = $this->transformFieldsetsParameters($queryParameters, $operation->getShortName() ?? ''); if ($properties) { @@ -81,6 +88,18 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } if ($filters) { + // ReadProvider skips its raw-query fallback when _api_filters is set, + // so preserve flat custom params here too. JSON:API transforms win. + $rawParams = $request->attributes->get('_api_query_parameters'); + if (null === $rawParams) { + $queryString = RequestParser::getQueryString($request); + $rawParams = $queryString ? RequestParser::parseRequestParams($queryString) : []; + $request->attributes->set('_api_query_parameters', $rawParams); + } + // Drop keys already consumed above so the raw bracket variants don't override the hoisted values. + $consumed = array_diff_key($rawParams, array_flip(['filter', 'page', 'itemsPerPage', 'pagination', 'partial', 'include', 'fields'])); + $filters = array_replace($consumed, $filters); + $request->attributes->set('_api_filters', $filters); } diff --git a/Tests/Fixtures/OtherRelatedDummy.php b/Tests/Fixtures/OtherRelatedDummy.php new file mode 100644 index 0000000..aad2f3e --- /dev/null +++ b/Tests/Fixtures/OtherRelatedDummy.php @@ -0,0 +1,37 @@ + + * + * 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\Fixtures; + +use ApiPlatform\Metadata\ApiResource; + +/** + * A second related resource used to exercise polymorphic (union-typed) relationships. + */ +#[ApiResource] +class OtherRelatedDummy +{ + private ?int $id = null; + + public ?string $label = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id): void + { + $this->id = $id; + } +} diff --git a/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php b/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php new file mode 100644 index 0000000..a1865ab --- /dev/null +++ b/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(); + + $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/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php index 4adcb64..2c11df8 100644 --- a/Tests/JsonSchema/SchemaFactoryTest.php +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -15,13 +15,18 @@ use ApiPlatform\JsonApi\JsonSchema\SchemaFactory; use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\JsonApi\Tests\Fixtures\OtherRelatedDummy; +use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy; use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory as BaseSchemaFactory; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; @@ -29,7 +34,9 @@ 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 SchemaFactoryTest extends TestCase { @@ -49,9 +56,10 @@ protected function setUp(): void ); $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_OUTPUT])->willReturn(new PropertyNameCollection()); + $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_INPUT])->willReturn(new PropertyNameCollection()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), @@ -109,7 +117,8 @@ public function testHasRootDefinitionKeyBuildSchema(): void 'type' => 'string', ], 'attributes' => [ - '$ref' => '#/definitions/Dummy', + 'type' => 'object', + 'properties' => [], ], ], 'required' => [ @@ -152,7 +161,8 @@ public function testSchemaTypeBuildSchema(): void $this->assertArrayHasKey('data', $objectSchema['properties']); $this->assertArrayHasKey('items', $objectSchema['properties']['data']); - $this->assertArrayHasKey('$ref', $objectSchema['properties']['data']['items']['properties']['attributes']); + $this->assertArrayNotHasKey('$ref', $objectSchema['properties']['data']['items']['properties']['attributes']); + $this->assertSame('object', $objectSchema['properties']['data']['items']['properties']['attributes']['type']); $properties = $objectSchema['properties']; $this->assertArrayHasKey('data', $properties); @@ -164,4 +174,225 @@ public function testSchemaTypeBuildSchema(): void $forcedCollection = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, forceCollection: true); $this->assertEquals($resultSchema['allOf'][0]['$ref'], $forcedCollection['allOf'][0]['$ref']); } + + public function testPostInputSchemaDoesNotRequireId(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_INPUT, new Post()); + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + + $this->assertTrue(isset($definitions[$rootDefinitionKey]['properties']['data'])); + $data = $definitions[$rootDefinitionKey]['properties']['data']; + $this->assertArrayHasKey('required', $data); + $this->assertSame(['type'], $data['required']); + } + + public function testPatchInputSchemaRequiresId(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_INPUT, new Patch()); + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + + $data = $definitions[$rootDefinitionKey]['properties']['data']; + $this->assertSame(['type', 'id'], $data['required']); + } + + public function testPostOutputSchemaRequiresId(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, new Post()); + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + + $data = $definitions[$rootDefinitionKey]['properties']['data']; + $this->assertSame(['type', 'id'], $data['required']); + } + + public function testRelationIsExcludedFromAttributes(): void + { + $schemaFactory = $this->buildSchemaFactoryWithRelation(); + $resultSchema = $schemaFactory->buildSchema(Dummy::class, 'jsonapi'); + + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $dataProperties = $definitions[$rootDefinitionKey]['properties']['data']['properties']; + + $this->assertArrayHasKey('attributes', $dataProperties); + $this->assertArrayNotHasKey('$ref', $dataProperties['attributes']); + $this->assertSame('object', $dataProperties['attributes']['type']); + + $attributes = $dataProperties['attributes']['properties']; + $this->assertArrayHasKey('name', $attributes); + $this->assertArrayNotHasKey('relatedDummy', $attributes, 'relations must not be documented as attributes'); + + $this->assertArrayHasKey('_id', $attributes, 'id is exposed as _id in the JSON:API attributes'); + $this->assertArrayNotHasKey('id', $attributes); + + $this->assertArrayHasKey('relationships', $dataProperties); + $this->assertArrayHasKey('relatedDummy', $dataProperties['relationships']['properties']); + } + + public function testRelationshipLinkageRequiresTypeAndId(): void + { + $schemaFactory = $this->buildSchemaFactoryWithRelation(); + $resultSchema = $schemaFactory->buildSchema(Dummy::class, 'jsonapi'); + + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $dataProperties = $definitions[$rootDefinitionKey]['properties']['data']['properties']; + + // a resource identifier object MUST contain type and id, @see https://jsonapi.org/format/#document-resource-identifier-objects + $linkage = $dataProperties['relationships']['properties']['relatedDummy']['properties']['data']['oneOf'][1]; + $this->assertSame('object', $linkage['type']); + $this->assertSame(['type', 'id'], $linkage['required']); + } + + public function testIncludedListsAllPolymorphicRelationTargets(): void + { + $schemaFactory = $this->buildSchemaFactoryWithPolymorphicRelation(); + $resultSchema = $schemaFactory->buildSchema(Dummy::class, 'jsonapi'); + + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $included = $definitions[$rootDefinitionKey]['properties']['included']; + + $refs = array_column($included['items']['anyOf'], '$ref'); + $this->assertContains('#/definitions/RelatedDummy.jsonapi', $refs); + $this->assertContains('#/definitions/OtherRelatedDummy.jsonapi', $refs, 'every target of a polymorphic relation must be listed in included'); + } + + private function buildSchemaFactoryWithPolymorphicRelation(): SchemaFactory + { + $dummyOperation = (new Get())->withName('get')->withShortName('Dummy'); + $relatedOperation = (new Get())->withName('get')->withShortName('RelatedDummy'); + $otherRelatedOperation = (new Get())->withName('get')->withShortName('OtherRelatedDummy'); + + $resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willReturn( + new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $dummyOperation])), + ]) + ); + $resourceMetadataFactory->create(RelatedDummy::class)->willReturn( + new ResourceMetadataCollection(RelatedDummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $relatedOperation])), + ]) + ); + $resourceMetadataFactory->create(OtherRelatedDummy::class)->willReturn( + new ResourceMetadataCollection(OtherRelatedDummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $otherRelatedOperation])), + ]) + ); + + $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name', 'relatedDummy'])); + $propertyNameCollectionFactory->create(RelatedDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name'])); + $propertyNameCollectionFactory->create(OtherRelatedDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'label'])); + + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->create(Dummy::class, 'id', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer']) + ); + $propertyMetadataFactory->create(Dummy::class, 'name', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + ); + $propertyMetadataFactory->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::union(Type::object(RelatedDummy::class), Type::object(OtherRelatedDummy::class)))->withReadable(true)->withSchema(['type' => Schema::UNKNOWN_TYPE]) + ); + $propertyMetadataFactory->create(RelatedDummy::class, 'id', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer']) + ); + $propertyMetadataFactory->create(RelatedDummy::class, 'name', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + ); + $propertyMetadataFactory->create(OtherRelatedDummy::class, 'id', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer']) + ); + $propertyMetadataFactory->create(OtherRelatedDummy::class, 'label', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + ); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); + $resourceClassResolver->isResourceClass(OtherRelatedDummy::class)->willReturn(true); + + $definitionNameFactory = new DefinitionNameFactory(); + + $baseSchemaFactory = new BaseSchemaFactory( + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactory->reveal(), + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + + return new SchemaFactory( + schemaFactory: $baseSchemaFactory, + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + } + + private function buildSchemaFactoryWithRelation(): SchemaFactory + { + $dummyOperation = (new Get())->withName('get')->withShortName('Dummy'); + $relatedOperation = (new Get())->withName('get')->withShortName('RelatedDummy'); + + $resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willReturn( + new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $dummyOperation])), + ]) + ); + $resourceMetadataFactory->create(RelatedDummy::class)->willReturn( + new ResourceMetadataCollection(RelatedDummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $relatedOperation])), + ]) + ); + + $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name', 'relatedDummy'])); + $propertyNameCollectionFactory->create(RelatedDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name'])); + + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->create(Dummy::class, 'id', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer']) + ); + $propertyMetadataFactory->create(Dummy::class, 'name', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + ); + $propertyMetadataFactory->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(true)->withSchema(['type' => Schema::UNKNOWN_TYPE]) + ); + $propertyMetadataFactory->create(RelatedDummy::class, 'id', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer']) + ); + $propertyMetadataFactory->create(RelatedDummy::class, 'name', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + ); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); + + $definitionNameFactory = new DefinitionNameFactory(); + + $baseSchemaFactory = new BaseSchemaFactory( + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactory->reveal(), + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + + return new SchemaFactory( + schemaFactory: $baseSchemaFactory, + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + } } diff --git a/Tests/Serializer/ItemDenormalizerTest.php b/Tests/Serializer/ItemDenormalizerTest.php new file mode 100644 index 0000000..24956ee --- /dev/null +++ b/Tests/Serializer/ItemDenormalizerTest.php @@ -0,0 +1,82 @@ + + * + * 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\Serializer; + +use ApiPlatform\JsonApi\Serializer\ItemDenormalizer; +use ApiPlatform\JsonApi\Serializer\ItemNormalizer; +use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; + +class ItemDenormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testSupportsDenormalizationOnlyForJsonApiFormat(): void + { + $dummy = new Dummy(); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertFalse($denormalizer->supportsNormalization($dummy, ItemNormalizer::FORMAT)); + $this->assertTrue($denormalizer->supportsDenormalization($dummy, Dummy::class, ItemNormalizer::FORMAT)); + $this->assertFalse($denormalizer->supportsDenormalization($dummy, Dummy::class, 'jsonld')); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testDenormalizeOnLegacyItemNormalizerIsDeprecated(): void + { + $this->expectUserDeprecationMessage('Since api-platform/core 4.4: Calling "denormalize()" on "ApiPlatform\JsonApi\Serializer\ItemNormalizer" is deprecated, use "ApiPlatform\JsonApi\Serializer\ItemDenormalizer" instead.'); + $this->expectException(NotNormalizableValueException::class); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $normalizer->denormalize( + ['data' => ['id' => '/dummies/1']], + Dummy::class, + ItemNormalizer::FORMAT, + ['api_allow_update' => false] + ); + } +} diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index 4c5ffe9..744aefc 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -22,7 +22,9 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -30,7 +32,9 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -48,6 +52,7 @@ /** * @author Amrouche Hamza */ +#[IgnoreDeprecations] class ItemNormalizerTest extends TestCase { use ProphecyTrait; @@ -158,7 +163,7 @@ public function testCacheKeyIsFalseWhenAPropertyHasSecurity(): void $serializerProphecy = $this->prophesize(SerializerInterface::class); $serializerProphecy->willImplement(NormalizerInterface::class); - $serializerProphecy->normalize(Argument::any(), ItemNormalizer::FORMAT, Argument::type('array'))->will(fn ($args) => $args[0]); + $serializerProphecy->normalize(Argument::any(), ItemNormalizer::FORMAT, Argument::type('array'))->will(static fn ($args) => $args[0]); $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), @@ -367,6 +372,67 @@ public function testDenormalize(): void $this->assertInstanceOf(Dummy::class, $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT)); } + // https://github.com/api-platform/core/pull/7938 + public function testDenormalizeRelationUsesClassNameArgument(): void + { + $relatedDummy = new RelatedDummy(); + $relatedDummy->setId(1); + + $propertyMetadata = (new ApiProperty()) + ->withNativeType(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int())) + ->withReadable(false)->withWritable(true) + ->withReadableLink(false)->withWritableLink(false); + + $iriConverter = $this->createStub(IriConverterInterface::class); + $iriConverter->method('getIriFromResource')->willReturn('/related_dummies/1'); + $iriConverter->method('getResourceFromIri')->willReturn($relatedDummy); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturnMap([ + [RelatedDummy::class, true], + [ArrayCollection::class, false], + ]); + + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->expects($this->once()) + ->method('create') + ->with(RelatedDummy::class) + ->willReturn(new ResourceMetadataCollection(RelatedDummy::class, [ + (new ApiResource())->withOperations(new Operations([ + new Get(name: 'get', uriTemplate: '/related_dummies/{id}', uriVariables: ['id' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'])]), + ])), + ])); + + $normalizer = new ItemNormalizer( + $this->createStub(PropertyNameCollectionFactoryInterface::class), + $this->createStub(PropertyMetadataFactoryInterface::class), + $iriConverter, + $resourceClassResolver, + null, + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactory, + null, + null, + null, + null, + false, + ); + + $result = (new \ReflectionMethod(ItemNormalizer::class, 'denormalizeRelation'))->invoke( + $normalizer, + 'relatedDummies', + $propertyMetadata, + RelatedDummy::class, + ['type' => 'related-dummy', 'id' => '1'], + null, + [] + ); + + $this->assertSame($relatedDummy, $result); + } + public function testDenormalizeUpdateOperationNotAllowed(): void { $this->expectException(NotNormalizableValueException::class); @@ -636,6 +702,330 @@ public function testNormalizeWithNullToOneAndEmptyToManyRelationships(): void $this->assertSame(['data' => []], $result['data']['relationships']['relatedDummies']); } + public function testNormalizeWithEntityIdentifier(): void + { + $dummy = new Dummy(); + $dummy->setId(10); + $dummy->setName('hello'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['id', 'name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withIdentifier(true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/10'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'id')->willReturn(10); + $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('hello'); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + ])); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromItem($dummy, Argument::any(), Argument::type('array'))->willReturn(['id' => 10]); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('hello'); + $serializerProphecy->normalize(10, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(10); + $serializerProphecy->normalize(null, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(null); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactoryProphecy->reveal(), + null, + null, + null, + $identifiersExtractorProphecy->reveal(), + false, + ); + + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'data' => [ + 'id' => '10', + 'type' => 'Dummy', + 'links' => ['self' => '/dummies/10'], + 'attributes' => [ + '_id' => 10, + 'name' => 'hello', + ], + ], + ]; + + $this->assertEquals($expected, $normalizer->normalize($dummy, ItemNormalizer::FORMAT)); + } + + public function testNormalizeRelationWithEntityIdentifier(): void + { + $dummy = new Dummy(); + $dummy->setId(10); + $dummy->setName('hello'); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setId(1); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['id', 'name', 'relatedDummy'])); + + $relatedDummyType = Type::object(RelatedDummy::class); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withIdentifier(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::any())->willReturn( + (new ApiProperty())->withNativeType($relatedDummyType)->withReadable(true)->withReadableLink(false)->withWritableLink(false) + ); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/10'); + $iriConverterProphecy->getIriFromResource($relatedDummy)->willReturn('/related_dummies/1'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($relatedDummy, RelatedDummy::class)->willReturn(RelatedDummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'id')->willReturn(10); + $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('hello'); + $propertyAccessorProphecy->getValue($dummy, 'relatedDummy')->willReturn($relatedDummy); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + ])); + $resourceMetadataCollectionFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadataCollection('RelatedDummy', [ + (new ApiResource()) + ->withShortName('RelatedDummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('RelatedDummy')])), + ])); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromItem($dummy, Argument::any(), Argument::type('array'))->willReturn(['id' => 10]); + $identifiersExtractorProphecy->getIdentifiersFromItem($relatedDummy)->willReturn(['id' => 1]); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('hello'); + $serializerProphecy->normalize(10, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(10); + $serializerProphecy->normalize(null, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(null); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactoryProphecy->reveal(), + null, + null, + null, + $identifiersExtractorProphecy->reveal(), + false, + ); + + $normalizer->setSerializer($serializerProphecy->reveal()); + + $result = $normalizer->normalize($dummy, ItemNormalizer::FORMAT, [ + 'resources' => [], + 'resource_class' => Dummy::class, + ]); + + $this->assertIsArray($result); + $this->assertSame('10', $result['data']['id']); + $this->assertSame('Dummy', $result['data']['type']); + $this->assertSame('/dummies/10', $result['data']['links']['self']); + + // Verify relationship uses entity identifier (no links — resource identifier objects are spec-compliant) + $this->assertArrayHasKey('relationships', $result['data']); + $this->assertArrayHasKey('relatedDummy', $result['data']['relationships']); + $relationData = $result['data']['relationships']['relatedDummy']['data']; + $this->assertSame('1', $relationData['id']); + $this->assertSame('RelatedDummy', $relationData['type']); + $this->assertArrayNotHasKey('links', $relationData); + } + + public function testDenormalizeWithEntityIdentifier(): void + { + $data = [ + 'data' => [ + 'type' => 'dummy', + 'id' => '10', + 'attributes' => [ + 'name' => 'foo', + ], + ], + ]; + + $dummy = new Dummy(); + $dummy->setId(10); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(false)->withWritable(true)); + + $getOperation = (new Get(uriVariables: ['id' => new Link(parameterName: 'id', identifiers: ['id'], fromClass: Dummy::class)]))->withClass(Dummy::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $getOperation, Argument::that(static fn ($ctx) => ($ctx['uri_variables'] ?? null) === ['id' => '10']))->willReturn('/dummies/10'); + $iriConverterProphecy->getResourceFromIri('/dummies/10', Argument::type('array'))->willReturn($dummy); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'name', 'foo')->will(static function (): void {}); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $getOperation])), + ])); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactory->reveal(), + null, + null, + null, + null, + false, + ); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $result = $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT, [ + 'operation' => $getOperation, + ]); + + $this->assertInstanceOf(Dummy::class, $result); + } + + public function testDenormalizeRelationWithEntityIdentifier(): void + { + $data = [ + 'data' => [ + 'type' => 'dummy', + 'attributes' => [ + 'name' => 'foo', + ], + 'relationships' => [ + 'relatedDummy' => [ + 'data' => [ + 'type' => 'related-dummy', + 'id' => '1', + ], + ], + ], + ], + ]; + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setId(1); + + $relatedDummyType = Type::object(RelatedDummy::class); + $getRelatedOperation = (new Get(uriVariables: ['id' => new Link(parameterName: 'id', identifiers: ['id'], fromClass: RelatedDummy::class)]))->withClass(RelatedDummy::class); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['name', 'relatedDummy'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::any())->willReturn( + (new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) + ); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource(RelatedDummy::class, UrlGeneratorInterface::ABS_PATH, $getRelatedOperation, Argument::that(static fn ($ctx) => ($ctx['uri_variables'] ?? null) === ['id' => '1']))->willReturn('/related_dummies/1'); + $iriConverterProphecy->getResourceFromIri('/related_dummies/1', Argument::type('array'))->willReturn($relatedDummy); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'name', 'foo')->will(static function (): void {}); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'relatedDummy', $relatedDummy)->will(static function (): void {}); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, RelatedDummy::class)->willReturn(RelatedDummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([new Get(name: 'get')])), + ])); + $resourceMetadataCollectionFactory->create(RelatedDummy::class)->willReturn(new ResourceMetadataCollection(RelatedDummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $getRelatedOperation])), + ])); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactory->reveal(), + null, + null, + null, + null, + false, + ); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $result = $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT); + + $this->assertInstanceOf(Dummy::class, $result); + } + /** * Reproducer for https://github.com/api-platform/core/issues/7794. * diff --git a/Tests/State/JsonApiProviderTest.php b/Tests/State/JsonApiProviderTest.php index 5ce9426..c527615 100644 --- a/Tests/State/JsonApiProviderTest.php +++ b/Tests/State/JsonApiProviderTest.php @@ -17,24 +17,104 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\State\ProviderInterface; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\InputBag; -use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; class JsonApiProviderTest extends TestCase { public function testProvide(): void { - $request = $this->createMock(Request::class); - $request->expects($this->once())->method('getRequestFormat')->willReturn('jsonapi'); - $request->attributes = $this->createMock(ParameterBag::class); - $request->attributes->expects($this->once())->method('get')->with('_api_filters', [])->willReturn([]); - $request->attributes->method('set')->with($this->logicalOr('_api_filter_property', '_api_included', '_api_filters'), $this->logicalOr(['id', 'name', 'dummyFloat', 'relatedDummy' => ['id', 'name']], ['relatedDummy'], [])); - $request->query = new InputBag(['fields' => ['dummy' => 'id,name,dummyFloat', 'relatedDummy' => 'id,name'], 'include' => 'relatedDummy,foo']); + $request = new Request(query: ['fields' => ['dummy' => 'id,name,dummyFloat', 'relatedDummy' => 'id,name'], 'include' => 'relatedDummy,foo']); + $request->setRequestFormat('jsonapi'); $operation = new Get(class: \stdClass::class, shortName: 'dummy'); $context = ['request' => $request]; $decorated = $this->createMock(ProviderInterface::class); $provider = new JsonApiProvider($decorated); $provider->provide($operation, [], $context); + + $this->assertSame(['id', 'name', 'dummyFloat', 'relatedDummy' => ['id', 'name']], $request->attributes->get('_api_filter_property')); + $this->assertSame(['relatedDummy'], $request->attributes->get('_api_included')); + } + + public function testProvideMergesFlatPaginationWithBracketFilter(): void + { + $request = new Request(['page' => '2', 'itemsPerPage' => '5', 'pagination' => 'true', 'filter' => ['custom' => 'true']]); + $request->setRequestFormat('jsonapi'); + + $operation = new Get(class: \stdClass::class, shortName: 'dummy'); + $context = ['request' => $request]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->with($operation, [], $context); + + $provider = new JsonApiProvider($decorated); + $provider->provide($operation, [], $context); + + $this->assertSame([ + 'custom' => 'true', + 'page' => '2', + 'itemsPerPage' => '5', + 'pagination' => 'true', + ], $request->attributes->get('_api_filters')); + } + + // #8216: flat custom params must survive when _api_filters is pre-set by JSON:API. + public function testProvidePreservesFlatCustomQueryParamsWithoutBracketFilter(): void + { + $request = Request::create('/sessions?city_id=3152&order[distance]=asc&page=1'); + $request->setRequestFormat('jsonapi'); + + $operation = new Get(class: \stdClass::class, shortName: 'dummy'); + $context = ['request' => $request]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->with($operation, [], $context); + + $provider = new JsonApiProvider($decorated); + $provider->provide($operation, [], $context); + + $filters = $request->attributes->get('_api_filters'); + + $this->assertIsArray($filters); + $this->assertSame('3152', $filters['city_id'] ?? null); + $this->assertSame(['distance' => 'asc'], $filters['order'] ?? null); + $this->assertSame('1', $filters['page'] ?? null); + } + + // _api_query_parameters set by an earlier listener / ParameterProvider must be reused. + public function testProvideHonoursPrePopulatedApiQueryParameters(): void + { + $request = Request::create('/sessions?page=1'); + $request->setRequestFormat('jsonapi'); + $request->attributes->set('_api_query_parameters', ['custom_override' => 'yes', 'page' => '1']); + + $operation = new Get(class: \stdClass::class, shortName: 'dummy'); + $context = ['request' => $request]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->with($operation, [], $context); + + $provider = new JsonApiProvider($decorated); + $provider->provide($operation, [], $context); + + $filters = $request->attributes->get('_api_filters'); + + $this->assertSame('yes', $filters['custom_override'] ?? null); + $this->assertSame('1', $filters['page'] ?? null); + } + + public function testProvideDoesNotReinjectBracketPageAfterHoisting(): void + { + $request = Request::create('/dummies?page[itemsPerPage]=15'); + $request->setRequestFormat('jsonapi'); + + $operation = new Get(class: \stdClass::class, shortName: 'dummy'); + $context = ['request' => $request]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->with($operation, [], $context); + + $provider = new JsonApiProvider($decorated); + $provider->provide($operation, [], $context); + + $filters = $request->attributes->get('_api_filters'); + + $this->assertSame('15', $filters['itemsPerPage'] ?? null); + $this->assertArrayNotHasKey('page', $filters); } } diff --git a/Tests/Util/ResourceLinkageResolverTest.php b/Tests/Util/ResourceLinkageResolverTest.php new file mode 100644 index 0000000..695d5d9 --- /dev/null +++ b/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/Util/ResourceLinkageResolver.php b/Util/ResourceLinkageResolver.php new file mode 100644 index 0000000..9c47573 --- /dev/null +++ b/Util/ResourceLinkageResolver.php @@ -0,0 +1,70 @@ + + * + * 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\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 (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/composer.json b/composer.json index 41b6af0..c0226c9 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,11 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.2", - "api-platform/json-schema": "^4.2", - "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2.26", - "api-platform/state": "^4.2.4", + "api-platform/documentation": "^5.0@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", "symfony/type-info": "^7.3 || ^8.0" @@ -34,7 +34,7 @@ "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^11.5 || ^12.2", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { @@ -57,7 +57,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.3.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" @@ -72,5 +72,7 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "minimum-stability": "beta", + "prefer-stable": true }