diff --git a/.gitattributes b/.gitattributes index 801f208..531589d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,4 +2,5 @@ /.gitattributes export-ignore /.gitignore export-ignore /Tests export-ignore +/phpunit.baseline.xml export-ignore /phpunit.xml.dist export-ignore diff --git a/Filter/SparseFieldset.php b/Filter/SparseFieldset.php index 0c30f95..b937ea1 100644 --- a/Filter/SparseFieldset.php +++ b/Filter/SparseFieldset.php @@ -33,7 +33,7 @@ public function getSchema(MetadataParameter $parameter): array ]; } - public function getOpenApiParameters(MetadataParameter $parameter): Parameter|array|null + public function getOpenApiParameters(MetadataParameter $parameter): Parameter { return new Parameter( name: ($k = $parameter->getKey()).'[]', diff --git a/Filter/SparseFieldsetParameterProvider.php b/Filter/SparseFieldsetParameterProvider.php index 7ded8e9..1d4208f 100644 --- a/Filter/SparseFieldsetParameterProvider.php +++ b/Filter/SparseFieldsetParameterProvider.php @@ -26,7 +26,7 @@ public function provide(Parameter $parameter, array $parameters = [], array $con return null; } - $allowedProperties = $parameter->getExtraProperties()['_properties'] ?? []; + $allowedProperties = $parameter->getProperties() ?? []; $value = $parameter->getValue(); $normalizationContext = $operation->getNormalizationContext(); @@ -45,7 +45,7 @@ public function provide(Parameter $parameter, array $parameters = [], array $con } foreach (explode(',', $fields) as $f) { - if (\array_key_exists($f, $allowedProperties)) { + if (\in_array($f, $allowedProperties, true)) { $p[] = $f; } } diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 0410b4c..620572a 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -13,11 +13,16 @@ 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; use ApiPlatform\JsonSchema\Schema; 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; @@ -32,6 +37,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { use ResourceMetadataTrait; + use SchemaUriPrefixTrait; /** * As JSON:API recommends using [includes](https://jsonapi.org/format/#fetching-includes) instead of groups @@ -40,55 +46,9 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI */ public const DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS = 'disable_json_schema_serializer_groups'; - private const LINKS_PROPS = [ - 'type' => 'object', - 'properties' => [ - 'self' => [ - 'type' => 'string', - 'format' => 'iri-reference', - ], - 'first' => [ - 'type' => 'string', - 'format' => 'iri-reference', - ], - 'prev' => [ - 'type' => 'string', - 'format' => 'iri-reference', - ], - 'next' => [ - 'type' => 'string', - 'format' => 'iri-reference', - ], - 'last' => [ - 'type' => 'string', - 'format' => 'iri-reference', - ], - ], - 'example' => [ - 'self' => 'string', - 'first' => 'string', - 'prev' => 'string', - 'next' => 'string', - 'last' => 'string', - ], - ]; - private const META_PROPS = [ - 'type' => 'object', - 'properties' => [ - 'totalItems' => [ - 'type' => 'integer', - 'minimum' => 0, - ], - 'itemsPerPage' => [ - 'type' => 'integer', - 'minimum' => 0, - ], - 'currentPage' => [ - 'type' => 'integer', - 'minimum' => 0, - ], - ], - ]; + private const COLLECTION_BASE_SCHEMA_NAME = 'JsonApiCollectionBaseSchema'; + private const COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION = 'JsonApiCollectionBaseSchemaNoPagination'; + private const RELATION_PROPS = [ 'type' => 'object', 'properties' => [ @@ -100,6 +60,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI 'format' => 'iri-reference', ], ], + 'required' => ['type', 'id'], ]; private const PROPERTY_PROPS = [ 'id' => [ @@ -114,13 +75,24 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI ], ]; - public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private readonly ?DefinitionNameFactoryInterface $definitionNameFactory = null) + /** + * @var array + */ + private $builtSchema = []; + + 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(); + } if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) { $this->schemaFactory->setSchemaFactory($this); } $this->resourceClassResolver = $resourceClassResolver; $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->resourceLinkageResolver = $resourceLinkageResolver ?? new ResourceLinkageResolver($resourceClassResolver); } /** @@ -131,60 +103,174 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin if ('jsonapi' !== $format) { return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); } + + if (!$this->isResourceClass($className)) { + $operation = null; + $inputOrOutputClass = null; + $serializerContext ??= []; + } else { + $operation = $this->findOperation($className, $type, $operation, $serializerContext, $format); + $inputOrOutputClass = $this->findOutputClass($className, $type, $operation, $serializerContext); + $serializerContext ??= $this->getSerializerContext($operation, $type); + } + + if (null === $inputOrOutputClass) { + // input or output disabled + return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); + } + // We don't use the serializer context here as JSON:API doesn't leverage serializer groups for related resources. // That is done by query parameter. @see https://jsonapi.org/format/#fetching-includes - $serializerContext ??= $this->getSerializerContext($operation ?? $this->findOperation($className, $type, $operation, $serializerContext, $format), $type); - $jsonApiSerializerContext = !($serializerContext[self::DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS] ?? true) ? $serializerContext : []; - $schema = $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $jsonApiSerializerContext, $forceCollection); + $jsonApiSerializerContext = $serializerContext; + if (true === ($serializerContext[self::DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS] ?? true) && $inputOrOutputClass === $className) { + unset($jsonApiSerializerContext['groups']); + } + + $schema = $this->schemaFactory->buildSchema($className, 'json', $type, $operation, $schema, $jsonApiSerializerContext, $forceCollection); + $definitionName = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $jsonApiSerializerContext + ['schema_type' => $type]); + $prefix = $this->getSchemaUriPrefix($schema->getVersion()); + $definitions = $schema->getDefinitions(); + $collectionKey = $schema->getItemsDefinitionKey(); - if (($key = $schema->getRootDefinitionKey()) || ($key = $schema->getItemsDefinitionKey())) { - $definitions = $schema->getDefinitions(); - $properties = $definitions[$key]['properties'] ?? []; + // Already computed + if (!$collectionKey && isset($definitions[$definitionName])) { + $schema['$ref'] = $prefix.$definitionName; - if (Error::class === $className && !isset($properties['errors'])) { - $definitions[$key]['properties'] = [ - 'errors' => [ - 'type' => 'object', - 'properties' => $properties, + return $schema; + } + + $key = $schema->getRootDefinitionKey() ?? $collectionKey; + $properties = $definitions[$definitionName]['properties'] ?? []; + + if (Error::class === $className && !isset($properties['errors'])) { + $definitions[$definitionName]['properties'] = [ + 'errors' => [ + 'type' => 'array', + 'items' => [ + 'allOf' => [ + ['$ref' => $prefix.$key], + ['type' => 'object', 'properties' => ['source' => ['type' => 'object'], 'status' => ['type' => ['string', 'integer', 'null']]]], + ], ], - ]; + ], + ]; - return $schema; - } + $schema['$ref'] = $prefix.$definitionName; - // Prevent reapplying - if (isset($properties['id'], $properties['type']) || isset($properties['data']) || isset($properties['errors'])) { - return $schema; - } + return $schema; + } - $definitions[$key]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); + if (!$collectionKey) { + $definitions[$definitionName]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); + $schema['$ref'] = $prefix.$definitionName; - if ($schema->getRootDefinitionKey()) { - return $schema; - } + return $schema; } - if (($schema['type'] ?? '') === 'array') { - // data - $items = $schema['items']; - unset($schema['items']); + if (($schema['type'] ?? '') !== 'array') { + return $schema; + } - $schema['type'] = 'object'; - $schema['properties'] = [ - 'links' => self::LINKS_PROPS, - 'meta' => self::META_PROPS, - 'data' => [ - 'type' => 'array', - 'items' => $items, + if (!isset($definitions[self::COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION])) { + $definitions[self::COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION] = [ + 'type' => 'object', + 'properties' => [ + 'links' => [ + 'type' => 'object', + 'properties' => [ + 'self' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + ], + 'example' => [ + 'self' => 'string', + ], + ], + 'meta' => [ + 'type' => 'object', + 'properties' => [ + 'totalItems' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + ], + ], + 'data' => [ + 'type' => 'array', + ], ], - ]; - $schema['required'] = [ - 'data', + 'required' => ['data'], ]; - return $schema; + $definitions[self::COLLECTION_BASE_SCHEMA_NAME] = [ + 'allOf' => [ + ['$ref' => $prefix.self::COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION], + [ + 'type' => 'object', + 'properties' => [ + 'links' => [ + 'type' => 'object', + 'properties' => [ + 'first' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + 'prev' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + 'next' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + 'last' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + ], + 'example' => [ + 'first' => 'string', + 'prev' => 'string', + 'next' => 'string', + 'last' => 'string', + ], + ], + 'meta' => [ + 'type' => 'object', + 'properties' => [ + 'itemsPerPage' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + 'currentPage' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + ], + ], + ], + ], + ], + ]; } + unset($schema['items']); + unset($schema['type']); + + $properties = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); + + $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' => $properties], + ]; + return $schema; } @@ -197,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'] ?? []; @@ -212,29 +300,53 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, continue; } - $operation = $this->findOperation($relatedClassName, $type, $operation, $serializerContext); + $operation = $this->findOperation($relatedClassName, $type, null, $serializerContext); $inputOrOutputClass = $this->findOutputClass($relatedClassName, $type, $operation, $serializerContext); $serializerContext ??= $this->getSerializerContext($operation, $type); - $definitionName = $this->definitionNameFactory->create($relatedClassName, $format, $inputOrOutputClass, $operation, $serializerContext); - $ref = Schema::VERSION_OPENAPI === $schema->getVersion() ? '#/components/schemas/'.$definitionName : '#/definitions/'.$definitionName; - $refs[$ref] = '$ref'; + $definitionName = $this->definitionNameFactory->create($relatedClassName, $format, $inputOrOutputClass, $operation, $serializerContext + ['schema_type' => $type]); + + // to avoid recursion + if ($this->builtSchema[$definitionName] ?? false) { + $refs[$this->getSchemaUriPrefix($schema->getVersion()).$definitionName] = '$ref'; + continue; + } + + if (!isset($definitions[$definitionName])) { + $this->builtSchema[$definitionName] = true; + $subSchema = new Schema($schema->getVersion()); + $subSchema->setDefinitions($schema->getDefinitions()); + $subSchema = $this->buildSchema($relatedClassName, $format, $type, $operation, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); + $schema->setDefinitions($subSchema->getDefinitions()); + $definitions = $schema->getDefinitions(); + } + + $refs[$this->getSchemaUriPrefix($schema->getVersion()).$definitionName] = '$ref'; + } + // 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]; } - $relatedDefinitions[$propertyName] = array_flip($refs); + // 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'] = self::RELATION_PROPS; + $relationships[$relationshipName]['properties']['data'] = [ + 'oneOf' => [ + ['type' => 'null'], + self::RELATION_PROPS, + ], + ]; continue; } - $relationships[$propertyName]['properties']['data'] = [ + $relationships[$relationshipName]['properties']['data'] = [ 'type' => 'array', 'items' => self::RELATION_PROPS, ]; continue; } - if ('id' === $propertyName) { - $attributes['_id'] = $property; - continue; - } - $attributes[$propertyName] = $property; + + // Reserved names (id, type, links, relationships, included) are prefixed by the + // ReservedAttributeNameConverter at runtime; mirror that single source of truth here. + $attributes[ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$propertyName] ?? $propertyName] = $property; } $replacement = self::PROPERTY_PROPS; @@ -261,24 +373,15 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, ]; } - if ($required = $definitions[$key]['required'] ?? null) { - foreach ($required as $require) { - if (isset($replacement['attributes']['properties'][$require])) { - $replacement['attributes']['required'][] = $require; - continue; - } - if (isset($relationships[$require])) { - $replacement['relationships']['required'][] = $require; - } - } - unset($definitions[$key]['required']); - } + // 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; } @@ -286,29 +389,22 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, private function getRelationship(string $resourceClass, string $property, ?array $serializerContext): ?array { $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $serializerContext ?? []); - $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(); + // Share the runtime attributes/relationships split so the generated schema cannot drift from the document. + $relationships = $this->resourceLinkageResolver->getRelationships($propertyMetadata); + if ([] === $relationships) { + return null; + } + + $isOne = false; + $relatedClasses = []; + 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/CollectionNormalizer.php b/Serializer/CollectionNormalizer.php index e465b6f..1c1f362 100644 --- a/Serializer/CollectionNormalizer.php +++ b/Serializer/CollectionNormalizer.php @@ -38,7 +38,7 @@ public function __construct(ResourceClassResolverInterface $resourceClassResolve /** * {@inheritdoc} */ - protected function getPaginationData($object, array $context = []): array + protected function getPaginationData(iterable $object, array $context = []): array { [$paginator, $paginated, $currentPage, $itemsPerPage, $lastPage, $pageTotalItems, $totalItems] = $this->getPaginationConfig($object, $context); $parsed = IriHelper::parseIri($context['uri'] ?? '/', $this->pageParameterName); @@ -84,7 +84,7 @@ protected function getPaginationData($object, array $context = []): array * * @throws UnexpectedValueException */ - protected function getItemsData($object, ?string $format = null, array $context = []): array + protected function getItemsData(iterable $object, ?string $format = null, array $context = []): array { $data = [ 'data' => [], diff --git a/Serializer/ConstraintViolationListNormalizer.php b/Serializer/ConstraintViolationListNormalizer.php index 267180a..ce0c728 100644 --- a/Serializer/ConstraintViolationListNormalizer.php +++ b/Serializer/ConstraintViolationListNormalizer.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\Validator\ConstraintViolationInterface; use Symfony\Component\Validator\ConstraintViolationListInterface; @@ -35,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' => [ @@ -58,7 +59,10 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return self::FORMAT === $format && $data instanceof ConstraintViolationListInterface; } - public function getSupportedTypes($format): array + /** + * {@inheritdoc} + */ + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? [ConstraintViolationListInterface::class => true] : []; } @@ -89,8 +93,7 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio $fieldName = $this->nameConverter->normalize($fieldName, $class, self::FORMAT); } - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - if ($type && null !== $type->getClassName()) { + if ($propertyMetadata->getNativeType()?->isSatisfiedBy(static fn ($t) => $t instanceof ObjectType)) { return "data/relationships/$fieldName"; } diff --git a/Serializer/EntrypointNormalizer.php b/Serializer/EntrypointNormalizer.php index 1dd6b67..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|string|int|float|bool|\ArrayObject|null + 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) { @@ -53,7 +53,7 @@ public function normalize(mixed $object, ?string $format = null, array $context } try { - $iri = $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_URL, $operation); // @phpstan-ignore-line phpstan issue as type is CollectionOperationInterface & Operation + $iri = $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_URL, $operation); $entrypoint['links'][lcfirst($resource->getShortName())] = $iri; } catch (InvalidArgumentException) { // Ignore resources without GET operations @@ -73,7 +73,10 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return self::FORMAT === $format && $data instanceof Entrypoint; } - public function getSupportedTypes($format): array + /** + * {@inheritdoc} + */ + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? [Entrypoint::class => true] : []; } diff --git a/Serializer/ErrorNormalizer.php b/Serializer/ErrorNormalizer.php index af619b8..2076a1c 100644 --- a/Serializer/ErrorNormalizer.php +++ b/Serializer/ErrorNormalizer.php @@ -32,17 +32,21 @@ 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); - $error = $jsonApiObject['data']['attributes']; + $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(); + } + + if (isset($error['status'])) { + $error['status'] = (string) $error['status']; } if (!isset($error['violations'])) { @@ -75,7 +79,10 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return self::FORMAT === $format && ($data instanceof \Exception || $data instanceof FlattenException); } - public function getSupportedTypes($format): array + /** + * {@inheritdoc} + */ + 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 0e64eae..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,21 +24,21 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; +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\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; /** - * Converts between objects and array. + * Converts objects to JSON:API documents (normalization only). * * @author Kévin Dunglas * @author Amrouche Hamza @@ -47,42 +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) - { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); + 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); } - 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')) { @@ -91,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; @@ -100,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); + + $id = $iri; + if (!$this->useIriAsId) { + $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($data, context: $context); + $id = $this->getIdStringFromIdentifiers($identifiers); + } + + $resourceShortName = $this->getResourceShortName($resourceClass); - // Do not include primary resources - $context['api_included_resources'] = [$context['iri']]; + // 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($object, $format, $context, $allRelationshipsData); + $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) { @@ -143,100 +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 - { - // 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 @@ -263,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, ], ]; @@ -282,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. */ @@ -317,30 +270,14 @@ private function getComponents(object $object, ?string $format, array $context): ->propertyMetadataFactory ->create($context['resource_class'], $attribute, $options); - $types = $propertyMetadata->getBuiltinTypes() ?? []; - - // prevent declaring $attribute as attribute if it's already declared as relationship - $isRelationship = false; - - foreach ($types as $type) { - $isOne = $isMany = false; - - if ($type->isCollection()) { - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - $isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } else { - $isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } - - if (!isset($className) || !$isOne && !$isMany) { - // don't declare it as an attribute too quick: maybe the next type is a valid resource - continue; - } + // Shared with the JSON Schema SchemaFactory so the documented split cannot drift from this output. + $relationships = $this->resourceLinkageResolver->getRelationships($propertyMetadata); + foreach ($relationships as [$className, $isCollection]) { $relation = [ 'name' => $attribute, 'type' => $this->getResourceShortName($className), - 'cardinality' => $isOne ? 'one' : 'many', + 'cardinality' => $isCollection ? 'many' : 'one', ]; // if we specify the uriTemplate, generates its value for link definition @@ -360,11 +297,9 @@ private function getComponents(object $object, ?string $format, array $context): } $components['relationships'][] = $relation; - $isRelationship = true; } - // if all types are not relationships, declare it as an attribute - if (!$isRelationship) { + if ([] === $relationships) { $components['attributes'][] = $attribute; } } @@ -377,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 @@ -399,23 +332,25 @@ private function getPopulatedRelations(object $object, ?string $format, array $c $relationshipName = $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context); } - $data[$relationshipName] = [ - 'data' => [], - ]; + if ('one' === $relationshipDataArray['cardinality']) { + $data[$relationshipName] = ['data' => null]; - if (!$attributeValue) { - continue; - } + if (!$attributeValue) { + continue; + } - // Many to one relationship - if ('one' === $relationshipDataArray['cardinality']) { unset($attributeValue['data']['attributes']); $data[$relationshipName] = $attributeValue; continue; } - // Many to many relationship + $data[$relationshipName] = ['data' => []]; + + if (!$attributeValue) { + continue; + } + foreach ($attributeValue as $attributeValueElement) { if (!isset($attributeValueElement['data'])) { throw new UnexpectedValueException(\sprintf('The JSON API attribute \'%s\' must contain a "data" key.', $relationshipName)); @@ -428,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'])) { @@ -454,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]; } @@ -476,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; @@ -498,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; @@ -510,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 196c12a..5ea74b1 100644 --- a/Serializer/ObjectNormalizer.php +++ b/Serializer/ObjectNormalizer.php @@ -41,7 +41,10 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return self::FORMAT === $format && $this->decorated->supportsNormalization($data, $format, $context); } - public function getSupportedTypes($format): array + /** + * {@inheritdoc} + */ + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? $this->decorated->getSupportedTypes($format) : []; } @@ -49,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)) { @@ -69,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/Serializer/ReservedAttributeNameConverter.php b/Serializer/ReservedAttributeNameConverter.php index f00977d..f0537c0 100644 --- a/Serializer/ReservedAttributeNameConverter.php +++ b/Serializer/ReservedAttributeNameConverter.php @@ -14,7 +14,6 @@ namespace ApiPlatform\JsonApi\Serializer; use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; -use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -22,7 +21,7 @@ * * @author Baptiste Meyer */ -final class ReservedAttributeNameConverter implements NameConverterInterface, AdvancedNameConverterInterface +final class ReservedAttributeNameConverter implements NameConverterInterface { public const JSON_API_RESERVED_ATTRIBUTES = [ 'id' => '_id', 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/InputDto.php b/Tests/Fixtures/InputDto.php new file mode 100644 index 0000000..c6bcbe4 --- /dev/null +++ b/Tests/Fixtures/InputDto.php @@ -0,0 +1,20 @@ + + * + * 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; + +final class InputDto +{ + public string $title = ''; + public string $body = ''; +} 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/Fixtures/RelatedDummy.php b/Tests/Fixtures/RelatedDummy.php index befb25d..b0518a3 100644 --- a/Tests/Fixtures/RelatedDummy.php +++ b/Tests/Fixtures/RelatedDummy.php @@ -15,7 +15,7 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; /** * Related Dummy. 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 7a64273..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 { @@ -45,12 +52,14 @@ protected function setUp(): void (new ApiResource())->withOperations(new Operations([ 'get' => (new Get())->withName('get'), ])), - ])); + ]) + ); $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(['jsonapi' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), @@ -60,6 +69,7 @@ protected function setUp(): void ); $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); $this->schemaFactory = new SchemaFactory( schemaFactory: $baseSchemaFactory, @@ -108,8 +118,7 @@ public function testHasRootDefinitionKeyBuildSchema(): void ], 'attributes' => [ 'type' => 'object', - 'properties' => [ - ], + 'properties' => [], ], ], 'required' => [ @@ -124,58 +133,266 @@ public function testHasRootDefinitionKeyBuildSchema(): void public function testSchemaTypeBuildSchema(): void { $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, new GetCollection()); - $definitionName = 'Dummy.jsonapi'; $this->assertNull($resultSchema->getRootDefinitionKey()); - $this->assertTrue(isset($resultSchema['properties'])); - $this->assertArrayHasKey('links', $resultSchema['properties']); - $this->assertArrayHasKey('self', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('first', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('prev', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('next', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('last', $resultSchema['properties']['links']['properties']); - - $this->assertArrayHasKey('meta', $resultSchema['properties']); - $this->assertArrayHasKey('totalItems', $resultSchema['properties']['meta']['properties']); - $this->assertArrayHasKey('itemsPerPage', $resultSchema['properties']['meta']['properties']); - $this->assertArrayHasKey('currentPage', $resultSchema['properties']['meta']['properties']); - - $this->assertArrayHasKey('data', $resultSchema['properties']); - $this->assertArrayHasKey('items', $resultSchema['properties']['data']); - $this->assertArrayHasKey('$ref', $resultSchema['properties']['data']['items']); - - $properties = $resultSchema['definitions'][$definitionName]['properties']; - $this->assertArrayHasKey('data', $properties); - $this->assertArrayHasKey('properties', $properties['data']); - $this->assertArrayHasKey('id', $properties['data']['properties']); - $this->assertArrayHasKey('type', $properties['data']['properties']); - $this->assertArrayHasKey('attributes', $properties['data']['properties']); + $this->assertTrue(isset($resultSchema['allOf'][0]['$ref'])); + $this->assertEquals($resultSchema['allOf'][0]['$ref'], '#/definitions/JsonApiCollectionBaseSchema'); - $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, forceCollection: true); + $jsonApiCollectionBaseSchemaNoPagination = $resultSchema['definitions']['JsonApiCollectionBaseSchemaNoPagination']; + $this->assertTrue(isset($jsonApiCollectionBaseSchemaNoPagination['properties'])); + $this->assertArrayHasKey('links', $jsonApiCollectionBaseSchemaNoPagination['properties']); + $this->assertArrayHasKey('self', $jsonApiCollectionBaseSchemaNoPagination['properties']['links']['properties']); + $this->assertArrayHasKey('meta', $jsonApiCollectionBaseSchemaNoPagination['properties']); + $this->assertArrayHasKey('totalItems', $jsonApiCollectionBaseSchemaNoPagination['properties']['meta']['properties']); - $this->assertNull($resultSchema->getRootDefinitionKey()); - $this->assertTrue(isset($resultSchema['properties'])); - $this->assertArrayHasKey('links', $resultSchema['properties']); - $this->assertArrayHasKey('self', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('first', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('prev', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('next', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('last', $resultSchema['properties']['links']['properties']); - - $this->assertArrayHasKey('meta', $resultSchema['properties']); - $this->assertArrayHasKey('totalItems', $resultSchema['properties']['meta']['properties']); - $this->assertArrayHasKey('itemsPerPage', $resultSchema['properties']['meta']['properties']); - $this->assertArrayHasKey('currentPage', $resultSchema['properties']['meta']['properties']); - - $this->assertArrayHasKey('data', $resultSchema['properties']); - $this->assertArrayHasKey('items', $resultSchema['properties']['data']); - $this->assertArrayHasKey('$ref', $resultSchema['properties']['data']['items']); - - $properties = $resultSchema['definitions'][$definitionName]['properties']; + $jsonApiCollectionBaseSchema = $resultSchema['definitions']['JsonApiCollectionBaseSchema']; + $this->assertArrayHasKey('allOf', $jsonApiCollectionBaseSchema); + $this->assertSame(['$ref' => '#/definitions/JsonApiCollectionBaseSchemaNoPagination'], $jsonApiCollectionBaseSchema['allOf'][0]); + $this->assertArrayHasKey('first', $jsonApiCollectionBaseSchema['allOf'][1]['properties']['links']['properties']); + $this->assertArrayHasKey('prev', $jsonApiCollectionBaseSchema['allOf'][1]['properties']['links']['properties']); + $this->assertArrayHasKey('next', $jsonApiCollectionBaseSchema['allOf'][1]['properties']['links']['properties']); + $this->assertArrayHasKey('last', $jsonApiCollectionBaseSchema['allOf'][1]['properties']['links']['properties']); + + $this->assertArrayHasKey('meta', $jsonApiCollectionBaseSchema['allOf'][1]['properties']); + $this->assertArrayHasKey('itemsPerPage', $jsonApiCollectionBaseSchema['allOf'][1]['properties']['meta']['properties']); + $this->assertArrayHasKey('currentPage', $jsonApiCollectionBaseSchema['allOf'][1]['properties']['meta']['properties']); + + $objectSchema = $resultSchema['allOf'][1]; + $this->assertArrayHasKey('data', $objectSchema['properties']); + + $this->assertArrayHasKey('items', $objectSchema['properties']['data']); + $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); - $this->assertArrayHasKey('properties', $properties['data']); - $this->assertArrayHasKey('id', $properties['data']['properties']); - $this->assertArrayHasKey('type', $properties['data']['properties']); - $this->assertArrayHasKey('attributes', $properties['data']['properties']); + $this->assertArrayHasKey('items', $properties['data']); + $this->assertArrayHasKey('id', $properties['data']['items']['properties']); + $this->assertArrayHasKey('type', $properties['data']['items']['properties']); + $this->assertArrayHasKey('attributes', $properties['data']['items']['properties']); + + $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/CollectionNormalizerTest.php b/Tests/Serializer/CollectionNormalizerTest.php index 62ca83a..4a75ff5 100644 --- a/Tests/Serializer/CollectionNormalizerTest.php +++ b/Tests/Serializer/CollectionNormalizerTest.php @@ -61,8 +61,8 @@ public function testNormalizePaginator(): void $paginatorProphecy->getLastPage()->willReturn(7.); $paginatorProphecy->getItemsPerPage()->willReturn(12.); $paginatorProphecy->getTotalItems()->willReturn(1312.); - $paginatorProphecy->rewind()->will(function (): void {}); - $paginatorProphecy->next()->will(function (): void {}); + $paginatorProphecy->rewind()->will(static function (): void {}); + $paginatorProphecy->next()->will(static function (): void {}); $paginatorProphecy->current()->willReturn('foo'); $paginatorProphecy->valid()->willReturn(true, false); @@ -137,8 +137,8 @@ public function testNormalizePartialPaginator(): void $paginatorProphecy = $this->prophesize(PartialPaginatorInterface::class); $paginatorProphecy->getCurrentPage()->willReturn(3.); $paginatorProphecy->getItemsPerPage()->willReturn(12.); - $paginatorProphecy->rewind()->will(function (): void {}); - $paginatorProphecy->next()->will(function (): void {}); + $paginatorProphecy->rewind()->will(static function (): void {}); + $paginatorProphecy->next()->will(static function (): void {}); $paginatorProphecy->current()->willReturn('foo'); $paginatorProphecy->valid()->willReturn(true, false); $paginatorProphecy->count()->willReturn(1312); diff --git a/Tests/Serializer/ConstraintViolationNormalizerTest.php b/Tests/Serializer/ConstraintViolationNormalizerTest.php index d167128..5b36848 100644 --- a/Tests/Serializer/ConstraintViolationNormalizerTest.php +++ b/Tests/Serializer/ConstraintViolationNormalizerTest.php @@ -18,10 +18,11 @@ use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\ConstraintViolationListInterface; @@ -47,11 +48,12 @@ public function testSupportNormalization(): void $this->assertSame([ConstraintViolationListInterface::class => true], $normalizer->getSupportedTypes($normalizer::FORMAT)); } + #[IgnoreDeprecations] public function testNormalize(): void { $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy')->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)]))->shouldBeCalledTimes(1); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name')->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]))->shouldBeCalledTimes(1); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy')->willReturn((new ApiProperty())->withNativeType(Type::object(RelatedDummy::class)))->shouldBeCalledTimes(1); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name')->willReturn((new ApiProperty())->withNativeType(Type::string()))->shouldBeCalledTimes(1); $nameConverterProphecy = $this->prophesize(NameConverterInterface::class); $nameConverterProphecy->normalize('relatedDummy', Dummy::class, 'jsonapi')->willReturn('relatedDummy')->shouldBeCalledTimes(1); 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 284ded1..744aefc 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -17,11 +17,14 @@ use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; use ApiPlatform\JsonApi\Tests\Fixtures\CircularReference; use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\JsonApi\Tests\Fixtures\InputDto; use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy; 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; @@ -29,23 +32,27 @@ 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; use Symfony\Component\HttpFoundation\EventStreamResponse; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\TypeInfo\Type; /** * @author Amrouche Hamza */ +#[IgnoreDeprecations] class ItemNormalizerTest extends TestCase { use ProphecyTrait; @@ -156,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(), @@ -164,7 +171,7 @@ public function testCacheKeyIsFalseWhenAPropertyHasSecurity(): void $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), - null, + new ReservedAttributeNameConverter(), null, [], $resourceMetadataCollectionFactoryProphecy->reveal(), @@ -189,8 +196,7 @@ public function testNormalizeCircularReference(): void $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(CircularReference::class)->willReturn(true); $resourceClassResolverProphecy->getResourceClass($circularReferenceEntity, null)->willReturn(CircularReference::class); - $resourceClassResolverProphecy->getResourceClass($circularReferenceEntity, CircularReference::class)->willReturn(CircularReference::class); - $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true); + $resourceClassResolverProphecy->getResourceClass(null, CircularReference::class)->willReturn(CircularReference::class); $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceMetadataCollectionFactoryProphecy->create(CircularReference::class)->willReturn(new ResourceMetadataCollection('CircularReference')); @@ -217,7 +223,7 @@ public function testNormalizeCircularReference(): void $context = [ 'circular_reference_limit' => 2, 'circular_reference_limit_counters' => [$splObject => 2], - 'cache_error' => function (): void {}, + 'cache_error' => static function (): void {}, ]; $this->assertSame('/circular_references/1', $normalizer->normalize($circularReferenceEntity, ItemNormalizer::FORMAT, $context)); @@ -310,26 +316,26 @@ public function testDenormalize(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['name', 'ghost', 'relatedDummy', 'relatedDummies'])); - $relatedDummyType = new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new Type(Type::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new Type(Type::BUILTIN_TYPE_INT), $relatedDummyType); + $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int()); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'ghost', Argument::any())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::any())->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummyType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::any())->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'ghost', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::any())->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::any())->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - $getItemFromIriSecondArgCallback = fn ($arg): bool => \is_array($arg) && isset($arg['fetch_data']) && true === $arg['fetch_data']; + $getItemFromIriSecondArgCallback = static fn ($arg): bool => \is_array($arg) && isset($arg['fetch_data']) && true === $arg['fetch_data']; $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getResourceFromIri('/related_dummies/1', Argument::that($getItemFromIriSecondArgCallback))->willReturn($relatedDummy1); $iriConverterProphecy->getResourceFromIri('/related_dummies/2', Argument::that($getItemFromIriSecondArgCallback))->willReturn($relatedDummy2); $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); - $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'name', 'foo')->will(function (): void {}); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'name', 'foo')->will(static function (): void {}); $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'ghost', 'invisible')->willThrow(new NoSuchPropertyException()); - $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'relatedDummy', $relatedDummy1)->will(function (): void {}); - $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'relatedDummies', [$relatedDummy2])->will(function (): void {}); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'relatedDummy', $relatedDummy1)->will(static function (): void {}); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'relatedDummies', [$relatedDummy2])->will(static function (): void {}); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); @@ -366,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); @@ -414,9 +481,9 @@ public function testDenormalizeCollectionIsNotArray(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $type = new Type(Type::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)); + $type = Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int()); $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty()) - ->withBuiltinTypes([$type]) + ->withNativeType($type) ->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) ); @@ -469,9 +536,9 @@ public function testDenormalizeCollectionWithInvalidKey(): void $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['relatedDummies'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $type = new Type(Type::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)); + $type = Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::string()); $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn( - (new ApiProperty())->withBuiltinTypes([$type])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) + (new ApiProperty())->withNativeType($type)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -519,8 +586,7 @@ public function testDenormalizeRelationIsNotResourceLinkage(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( - (new ApiProperty())->withBuiltinTypes([ - new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class), ])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) + (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -546,4 +612,500 @@ public function testDenormalizeRelationIsNotResourceLinkage(): void $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT); } + + public function testNormalizeWithNullToOneAndEmptyToManyRelationships(): void + { + $dummy = new Dummy(); + $dummy->setId(1); + $dummy->setName('Dummy with relationships'); + + $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturn( + new PropertyNameCollection(['id', 'name', 'relatedDummy', 'relatedDummies']) + ); + + $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturnCallback(static function ($class, $property) { + return match ($property) { + 'id' => (new ApiProperty())->withReadable(true)->withIdentifier(true), + 'name' => (new ApiProperty())->withReadable(true), + 'relatedDummy' => (new ApiProperty()) + ->withReadable(true) + ->withReadableLink(true) + ->withNativeType(Type::nullable(Type::object(RelatedDummy::class))), + 'relatedDummies' => (new ApiProperty()) + ->withReadable(true) + ->withReadableLink(true) + ->withNativeType(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class))), + default => new ApiProperty(), + }; + }); + + $iriConverter = $this->createMock(IriConverterInterface::class); + $iriConverter->method('getIriFromResource')->willReturn('/dummies/1'); + + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $resourceClassResolver->method('getResourceClass')->willReturn(Dummy::class); + $resourceClassResolver->method('isResourceClass')->willReturnCallback(static fn ($class) => \in_array($class, [Dummy::class, RelatedDummy::class], true)); + + $propertyAccessor = $this->createMock(PropertyAccessorInterface::class); + $propertyAccessor->method('getValue')->willReturnCallback(static function ($object, $property) { + return match ($property) { + 'id' => 1, + 'name' => 'Dummy with relationships', + 'relatedDummy' => null, + 'relatedDummies' => [], + default => null, + }; + }); + + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->method('create')->willReturn( + new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + ]) + ); + + $serializer = $this->createStub(Serializer::class); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $iriConverter, + $resourceClassResolver, + $propertyAccessor, + null, + null, + [], + $resourceMetadataCollectionFactory, + ); + + $normalizer->setSerializer($serializer); + + $result = $normalizer->normalize($dummy, ItemNormalizer::FORMAT, [ + 'resources' => [], + 'resource_class' => Dummy::class, + ]); + + $this->assertIsArray($result); + $this->assertArrayHasKey('data', $result); + $this->assertArrayHasKey('relationships', $result['data']); + + // Verify to-one relationship with null value returns {"data": null} + $this->assertArrayHasKey('relatedDummy', $result['data']['relationships']); + $this->assertSame(['data' => null], $result['data']['relationships']['relatedDummy']); + + // Verify to-many relationship with empty array returns {"data": []} + $this->assertArrayHasKey('relatedDummies', $result['data']['relationships']); + $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. + * + * When a resource uses an input DTO, AbstractItemNormalizer::denormalize() re-enters + * the serializer with the already-unwrapped (flat) data plus an 'api_platform_input' + * context flag. Without the guard, JsonApi\ItemNormalizer::denormalize() runs a second + * time on the flat data, tries to read $data['data']['attributes'] and gets null, + * which nulls every DTO property. + */ + public function testDenormalizeInputDtoDoesNotDoubleUnwrapJsonApiStructure(): void + { + $jsonApiData = [ + 'data' => [ + 'type' => 'dummy', + 'attributes' => [ + 'title' => 'Hello', + 'body' => 'World', + ], + ], + ]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection([])); + $propertyNameCollectionFactoryProphecy->create(InputDto::class, Argument::any())->willReturn(new PropertyNameCollection(['title', 'body'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(InputDto::class, 'title', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(InputDto::class, 'body', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->setValue(Argument::type(InputDto::class), Argument::type('string'), Argument::any()) + ->will(static function ($args): void { + $args[0]->{$args[1]} = $args[2]; + }); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(InputDto::class)->willReturn(false); + $resourceClassResolverProphecy->getResourceClass(null, InputDto::class)->willReturn(InputDto::class); + + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([new Get(name: 'get')])), + ])); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactory->reveal(), + ); + + // Create a mock serializer that simulates the real serializer chain: + // when re-entering for the input DTO, it calls back into the normalizer. + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->denormalize(Argument::type('array'), InputDto::class, ItemNormalizer::FORMAT, Argument::type('array')) + ->will(static function ($args) use ($normalizer) { + // This simulates the serializer re-entering the normalizer chain + return $normalizer->denormalize($args[0], $args[1], $args[2], $args[3]); + }); + + $normalizer->setSerializer($serializerProphecy->reveal()); + + $result = $normalizer->denormalize($jsonApiData, Dummy::class, ItemNormalizer::FORMAT, [ + 'input' => ['class' => InputDto::class], + 'resource_class' => Dummy::class, + ]); + + $this->assertInstanceOf(InputDto::class, $result); + $this->assertSame('Hello', $result->title); + $this->assertSame('World', $result->body); + } } diff --git a/Tests/Serializer/ReservedAttributeNameConverterTest.php b/Tests/Serializer/ReservedAttributeNameConverterTest.php index 7c261af..b3a8697 100644 --- a/Tests/Serializer/ReservedAttributeNameConverterTest.php +++ b/Tests/Serializer/ReservedAttributeNameConverterTest.php @@ -15,6 +15,7 @@ use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; use ApiPlatform\JsonApi\Tests\Fixtures\CustomConverter; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** @@ -46,13 +47,13 @@ public static function propertiesProvider(): array ]; } - #[\PHPUnit\Framework\Attributes\DataProvider('propertiesProvider')] + #[DataProvider('propertiesProvider')] public function testNormalize(string $propertyName, string $expectedPropertyName): void { $this->assertSame($expectedPropertyName, $this->reservedAttributeNameConverter->normalize($propertyName)); } - #[\PHPUnit\Framework\Attributes\DataProvider('propertiesProvider')] + #[DataProvider('propertiesProvider')] public function testDenormalize(string $expectedPropertyName, string $propertyName): void { $this->assertSame($expectedPropertyName, $this->reservedAttributeNameConverter->denormalize($propertyName)); diff --git a/Tests/State/JsonApiProviderTest.php b/Tests/State/JsonApiProviderTest.php index 180c5d5..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']); - $operation = new Get(class: 'dummy', shortName: 'dummy'); + $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 e983ada..c0226c9 100644 --- a/composer.json +++ b/composer.json @@ -22,18 +22,20 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.1.11", - "api-platform/json-schema": "^4.1.11", - "api-platform/metadata": "^4.1.11", - "api-platform/serializer": "^4.1.30", - "api-platform/state": "^4.1.11", - "symfony/error-handler": "^6.4 || ^7.0", - "symfony/http-foundation": "^6.4.14 || ^7.0" + "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" }, "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev" + "phpunit/phpunit": "^11.5 || ^12.2", + "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { "psr-4": { @@ -55,12 +57,13 @@ }, "extra": { "branch-alias": { - "dev-main": "4.2.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" }, "symfony": { - "require": "^6.4 || ^7.0" + "require": "^6.4 || ^7.0 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", @@ -70,10 +73,6 @@ "scripts": { "test": "./vendor/bin/phpunit" }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + "minimum-stability": "beta", + "prefer-stable": true }