From ece64b9f86f62fc01eecc89373688a8700a6d879 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 27 Jan 2025 17:16:57 +0100 Subject: [PATCH 01/84] feat(openapi): document error outputs using json-schemas (#6923) --- JsonSchema/SchemaFactory.php | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 6cd4d7e..0410b4c 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\State\ApiResource\Error; /** * Decorator factory which adds JSON:API properties to the JSON Schema document. @@ -31,6 +32,14 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { use ResourceMetadataTrait; + + /** + * As JSON:API recommends using [includes](https://jsonapi.org/format/#fetching-includes) instead of groups + * this flag allows to force using groups to generate the JSON:API JSONSchema. Defaults to true, use it in + * a serializer context. + */ + public const DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS = 'disable_json_schema_serializer_groups'; + private const LINKS_PROPS = [ 'type' => 'object', 'properties' => [ @@ -124,14 +133,27 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin } // 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 - $schema = $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, [], $forceCollection); + $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); if (($key = $schema->getRootDefinitionKey()) || ($key = $schema->getItemsDefinitionKey())) { $definitions = $schema->getDefinitions(); $properties = $definitions[$key]['properties'] ?? []; + if (Error::class === $className && !isset($properties['errors'])) { + $definitions[$key]['properties'] = [ + 'errors' => [ + 'type' => 'object', + 'properties' => $properties, + ], + ]; + + return $schema; + } + // Prevent reapplying - if (isset($properties['id'], $properties['type']) || isset($properties['data'])) { + if (isset($properties['id'], $properties['type']) || isset($properties['data']) || isset($properties['errors'])) { return $schema; } From 2c42c3dc54d90c8ba0d659a5f0102cb01d908db4 Mon Sep 17 00:00:00 2001 From: Tac Tacelosky Date: Mon, 10 Feb 2025 04:23:32 -0500 Subject: [PATCH 02/84] chore: remove AdvancedNameConverterInterface usage (#6956) --- Tests/Fixtures/CustomConverter.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/Fixtures/CustomConverter.php b/Tests/Fixtures/CustomConverter.php index 2dd8b97..2d046fe 100644 --- a/Tests/Fixtures/CustomConverter.php +++ b/Tests/Fixtures/CustomConverter.php @@ -13,7 +13,6 @@ namespace ApiPlatform\JsonApi\Tests\Fixtures; -use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -21,7 +20,7 @@ * Custom converter that will only convert a property named "nameConverted" * with the same logic as Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter. */ -class CustomConverter implements AdvancedNameConverterInterface +class CustomConverter implements NameConverterInterface { private NameConverterInterface $nameConverter; From ab5d0228a8d470e846339f23903c801beabaad01 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 28 Feb 2025 11:08:08 +0100 Subject: [PATCH 03/84] chore: dependency constraints (#6988) --- composer.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 60e5fd2..371ffd8 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,11 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^3.4 || ^4.0", - "api-platform/json-schema": "^3.4 || ^4.0", - "api-platform/metadata": "^3.4 || ^4.0", - "api-platform/serializer": "^3.4 || ^4.0", - "api-platform/state": "^3.4 || ^4.0", + "api-platform/documentation": "^4.1", + "api-platform/json-schema": "^4.1", + "api-platform/metadata": "^4.1", + "api-platform/serializer": "^4.1", + "api-platform/state": "^4.1", "symfony/error-handler": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || ^7.0" }, @@ -55,7 +55,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.0.x-dev", + "dev-main": "4.2.x-dev", "dev-3.4": "3.4.x-dev" }, "symfony": { From 691780b7f0c808151900b94921fbaf7e781878cd Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 11 Apr 2025 11:32:56 +0200 Subject: [PATCH 04/84] chore: phpunit missing deprecation triggers (#7059) --- phpunit.xml.dist | 3 +++ 1 file changed, 3 insertions(+) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5c65d38..1d97727 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -10,6 +10,9 @@ + + trigger_deprecation + ./ From 9d5cafab0f197016885a1456098227a0f6ac83c5 Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Wed, 16 Apr 2025 21:29:35 +0200 Subject: [PATCH 05/84] feat: Use `Type` of `TypeInfo` instead of `PropertyInfo` (#6979) Co-authored-by: soyuka scopes: metadata, doctrine, json-schema --- composer.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 371ffd8..086e396 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^11.2" + "phpunit/phpunit": "^11.2", + "symfony/type-info": "^7.3-dev" }, "autoload": { "psr-4": { @@ -68,5 +69,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } From 6991f1a88353b679a74ad2bbabd51f4bb27cd228 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 17 Apr 2025 15:04:44 +0200 Subject: [PATCH 06/84] feat(jsonapi): use `TypeInfo`'s `Type` (#7100) Co-authored-by: Mathias Arlaud --- JsonSchema/SchemaFactory.php | 74 ++++++++- .../ConstraintViolationListNormalizer.php | 25 ++- Serializer/ItemNormalizer.php | 143 +++++++++++++----- .../ConstraintViolationNormalizerTest.php | 6 +- Tests/Serializer/ItemNormalizerTest.php | 25 ++- composer.json | 3 +- 6 files changed, 212 insertions(+), 64 deletions(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 0410b4c..8602cc7 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -23,6 +23,12 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\State\ApiResource\Error; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; /** * Decorator factory which adds JSON:API properties to the JSON Schema document. @@ -286,21 +292,73 @@ 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() ?? []; + + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $types = $propertyMetadata->getBuiltinTypes() ?? []; + $isRelationship = false; + $isOne = $isMany = false; + $relatedClasses = []; + + foreach ($types as $type) { + if ($type->isCollection()) { + $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; + $isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); + } else { + $isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className); + } + if (!isset($className) || (!$isOne && !$isMany)) { + continue; + } + $isRelationship = true; + $resourceMetadata = $this->resourceMetadataFactory->create($className); + $operation = $resourceMetadata->getOperation(); + // @see https://github.com/api-platform/core/issues/5501 + // @see https://github.com/api-platform/core/pull/5722 + $relatedClasses[$className] = $operation->canRead(); + } + + return $isRelationship ? [$isOne, $relatedClasses] : null; + } + + if (null === $type = $propertyMetadata->getNativeType()) { + return null; + } + $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); + /** @var class-string|null $className */ + $className = null; + + $typeIsResourceClass = function (Type $type) use (&$typeIsResourceClass, &$className): bool { + return match (true) { + $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass), + $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass), + default => $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()), + }; + }; + + $collectionValueIsResourceClass = function (Type $type) use (&$typeIsResourceClass): bool { + return match (true) { + $type instanceof CollectionType => $type->getCollectionValueType()->isSatisfiedBy($typeIsResourceClass), + $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass), + $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass), + default => false, + }; + }; + + foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { + if ($t->isSatisfiedBy($collectionValueIsResourceClass)) { + $isMany = true; + } elseif ($t->isSatisfiedBy($typeIsResourceClass)) { + $isOne = true; } - if (!isset($className) || (!$isOne && !$isMany)) { + + if (!$className || (!$isOne && !$isMany)) { continue; } + $isRelationship = true; $resourceMetadata = $this->resourceMetadataFactory->create($className); $operation = $resourceMetadata->getOperation(); diff --git a/Serializer/ConstraintViolationListNormalizer.php b/Serializer/ConstraintViolationListNormalizer.php index 28604a9..18ad788 100644 --- a/Serializer/ConstraintViolationListNormalizer.php +++ b/Serializer/ConstraintViolationListNormalizer.php @@ -14,8 +14,13 @@ namespace ApiPlatform\JsonApi\Serializer; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; use Symfony\Component\Validator\ConstraintViolationInterface; use Symfony\Component\Validator\ConstraintViolationListInterface; @@ -83,9 +88,23 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio $fieldName = $this->nameConverter->normalize($fieldName, $class, self::FORMAT); } - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - if ($type && null !== $type->getClassName()) { - return "data/relationships/$fieldName"; + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; + if ($type && null !== $type->getClassName()) { + return "data/relationships/$fieldName"; + } + } else { + $typeIsObject = static function (Type $type) use (&$typeIsObject): bool { + return match (true) { + $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsObject), + $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsObject), + default => $type instanceof ObjectType, + }; + }; + + if ($propertyMetadata->getNativeType()?->isSatisfiedBy($typeIsObject)) { + return "data/relationships/$fieldName"; + } } return "data/attributes/$fieldName"; diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index d1e5ba7..7e274ef 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -29,6 +29,7 @@ use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\RuntimeException; @@ -36,6 +37,11 @@ use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; /** * Converts between objects and array. @@ -319,50 +325,115 @@ 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 (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $types = $propertyMetadata->getBuiltinTypes() ?? []; - 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); - } + foreach ($types as $type) { + $isOne = $isMany = false; - if (!isset($className) || !$isOne && !$isMany) { - // don't declare it as an attribute too quick: maybe the next type is a valid resource - continue; - } + 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; + } + + $relation = [ + 'name' => $attribute, + 'type' => $this->getResourceShortName($className), + 'cardinality' => $isOne ? 'one' : 'many', + ]; + + // if we specify the uriTemplate, generates its value for link definition + // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content + if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); + $childContext = $this->createChildContext($context, $attribute, $format); + unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); + + $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( + operationName: $itemUriTemplate, + httpOperation: true + ); + + $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); + } - $relation = [ - 'name' => $attribute, - 'type' => $this->getResourceShortName($className), - 'cardinality' => $isOne ? 'one' : 'many', - ]; - - // if we specify the uriTemplate, generates its value for link definition - // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content - if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - $childContext = $this->createChildContext($context, $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); - - $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( - operationName: $itemUriTemplate, - httpOperation: true - ); - - $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); + $components['relationships'][] = $relation; + $isRelationship = true; } + } else { + if ($type = $propertyMetadata->getNativeType()) { + /** @var class-string|null $className */ + $className = null; + + $typeIsResourceClass = function (Type $type) use (&$typeIsResourceClass, &$className): bool { + return match (true) { + $type instanceof ObjectType => $this->resourceClassResolver->isResourceClass($className = $type->getClassName()), + $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass), + $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass), + default => false, + }; + }; + + $collectionValueIsResourceClass = function (Type $type) use ($typeIsResourceClass, &$collectionValueIsResourceClass): bool { + return match (true) { + $type instanceof CollectionType => $type->getCollectionValueType()->isSatisfiedBy($typeIsResourceClass), + $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($collectionValueIsResourceClass), + $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($collectionValueIsResourceClass), + default => false, + }; + }; + + foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { + $isOne = $isMany = false; + + if ($t->isSatisfiedBy($collectionValueIsResourceClass)) { + $isMany = true; + } elseif ($t->isSatisfiedBy($typeIsResourceClass)) { + $isOne = true; + } + + if (!$className || (!$isOne && !$isMany)) { + // don't declare it as an attribute too quick: maybe the next type is a valid resource + continue; + } - $components['relationships'][] = $relation; - $isRelationship = true; + $relation = [ + 'name' => $attribute, + 'type' => $this->getResourceShortName($className), + 'cardinality' => $isOne ? 'one' : 'many', + ]; + + // if we specify the uriTemplate, generates its value for link definition + // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content + if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); + $childContext = $this->createChildContext($context, $attribute, $format); + unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); + + $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( + operationName: $itemUriTemplate, + httpOperation: true + ); + + $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); + } + + $components['relationships'][] = $relation; + $isRelationship = true; + } + } } // if all types are not relationships, declare it as an attribute diff --git a/Tests/Serializer/ConstraintViolationNormalizerTest.php b/Tests/Serializer/ConstraintViolationNormalizerTest.php index f587321..d8b6201 100644 --- a/Tests/Serializer/ConstraintViolationNormalizerTest.php +++ b/Tests/Serializer/ConstraintViolationNormalizerTest.php @@ -20,8 +20,8 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; 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; @@ -50,8 +50,8 @@ public function testSupportNormalization(): void 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/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index 9065dfd..9c5c772 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -35,13 +35,13 @@ use Prophecy\PhpUnit\ProphecyTrait; 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\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\TypeInfo\Type; /** * @author Amrouche Hamza @@ -259,14 +259,14 @@ 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']; @@ -363,9 +363,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', [])->willReturn((new ApiProperty()) - ->withBuiltinTypes([$type]) + ->withNativeType($type) ->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) ); @@ -418,9 +418,9 @@ public function testDenormalizeCollectionWithInvalidKey(): void $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->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', [])->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); @@ -468,8 +468,7 @@ public function testDenormalizeRelationIsNotResourceLinkage(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', [])->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); diff --git a/composer.json b/composer.json index 086e396..e9251bb 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ "api-platform/serializer": "^4.1", "api-platform/state": "^4.1", "symfony/error-handler": "^6.4 || ^7.0", - "symfony/http-foundation": "^6.4 || ^7.0" + "symfony/http-foundation": "^6.4 || ^7.0", + "symfony/type-info": "^7.2" }, "require-dev": { "phpspec/prophecy": "^1.19", From c6579e17facc69979a1a5475c98a70c5d845c5f6 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 18 Apr 2025 10:39:51 +0200 Subject: [PATCH 07/84] ci: patch phpunit deprecations inside component (#7103) --- composer.json | 10 ++++++++-- phpunit.xml.dist | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 371ffd8..0b3aa7d 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^11.2" + "phpunit/phpunit": "11.5.x-dev" }, "autoload": { "psr-4": { @@ -68,5 +68,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/soyuka/phpunit" + } + ] } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 1d97727..e686806 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,7 +9,7 @@ - + trigger_deprecation From 2a2512a295c3561971cd8892881e7da228d353da Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 24 Apr 2025 14:35:49 +0200 Subject: [PATCH 08/84] feat(serializer): type info (#7104) Co-authored-by: Mathias Arlaud --- JsonSchema/SchemaFactory.php | 22 ++++------------------ Serializer/ItemNormalizer.php | 23 ++++------------------- 2 files changed, 8 insertions(+), 37 deletions(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 8602cc7..312e647 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -22,13 +22,12 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\State\ApiResource\Error; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; use Symfony\Component\TypeInfo\Type\ObjectType; -use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; /** * Decorator factory which adds JSON:API properties to the JSON Schema document. @@ -331,25 +330,12 @@ private function getRelationship(string $resourceClass, string $property, ?array /** @var class-string|null $className */ $className = null; - $typeIsResourceClass = function (Type $type) use (&$typeIsResourceClass, &$className): bool { - return match (true) { - $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass), - $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass), - default => $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()), - }; - }; - - $collectionValueIsResourceClass = function (Type $type) use (&$typeIsResourceClass): bool { - return match (true) { - $type instanceof CollectionType => $type->getCollectionValueType()->isSatisfiedBy($typeIsResourceClass), - $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass), - $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass), - default => false, - }; + $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 ($t->isSatisfiedBy($collectionValueIsResourceClass)) { + if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) { $isMany = true; } elseif ($t->isSatisfiedBy($typeIsResourceClass)) { $isOne = true; diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index 7e274ef..179462f 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -23,6 +23,7 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; @@ -38,10 +39,8 @@ use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; use Symfony\Component\TypeInfo\Type\ObjectType; -use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; /** * Converts between objects and array. @@ -376,28 +375,14 @@ private function getComponents(object $object, ?string $format, array $context): /** @var class-string|null $className */ $className = null; - $typeIsResourceClass = function (Type $type) use (&$typeIsResourceClass, &$className): bool { - return match (true) { - $type instanceof ObjectType => $this->resourceClassResolver->isResourceClass($className = $type->getClassName()), - $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass), - $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass), - default => false, - }; - }; - - $collectionValueIsResourceClass = function (Type $type) use ($typeIsResourceClass, &$collectionValueIsResourceClass): bool { - return match (true) { - $type instanceof CollectionType => $type->getCollectionValueType()->isSatisfiedBy($typeIsResourceClass), - $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($collectionValueIsResourceClass), - $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($collectionValueIsResourceClass), - default => false, - }; + $typeIsResourceClass = function (Type $type) use (&$className): bool { + return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); }; foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { $isOne = $isMany = false; - if ($t->isSatisfiedBy($collectionValueIsResourceClass)) { + if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) { $isMany = true; } elseif ($t->isSatisfiedBy($typeIsResourceClass)) { $isOne = true; From aaa7696f4eede4148f598dd42476ce1919c078af Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 5 May 2025 13:22:11 +0200 Subject: [PATCH 09/84] refactor: detect collection type using TypeHelper --- Serializer/ConstraintViolationListNormalizer.php | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/Serializer/ConstraintViolationListNormalizer.php b/Serializer/ConstraintViolationListNormalizer.php index 18ad788..b153b65 100644 --- a/Serializer/ConstraintViolationListNormalizer.php +++ b/Serializer/ConstraintViolationListNormalizer.php @@ -17,10 +17,7 @@ use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; use Symfony\Component\TypeInfo\Type\ObjectType; -use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; use Symfony\Component\Validator\ConstraintViolationInterface; use Symfony\Component\Validator\ConstraintViolationListInterface; @@ -94,15 +91,7 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio return "data/relationships/$fieldName"; } } else { - $typeIsObject = static function (Type $type) use (&$typeIsObject): bool { - return match (true) { - $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsObject), - $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsObject), - default => $type instanceof ObjectType, - }; - }; - - if ($propertyMetadata->getNativeType()?->isSatisfiedBy($typeIsObject)) { + if ($propertyMetadata->getNativeType()?->isSatisfiedBy(fn ($t) => $t instanceof ObjectType)) { return "data/relationships/$fieldName"; } } From e9f5f932456fc89ba3d86baabe35bc4c30bc460b Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 5 May 2025 13:26:52 +0200 Subject: [PATCH 10/84] chore: symfony/type-info 7.3.0-BETA1 --- composer.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 384a4a1..acc471c 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "api-platform/state": "^4.1", "symfony/error-handler": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || ^7.0", - "symfony/type-info": "^7.2" + "symfony/type-info": "v7.3.0-BETA1" }, "require-dev": { "phpspec/prophecy": "^1.19", @@ -72,10 +72,6 @@ "test": "./vendor/bin/phpunit" }, "repositories": [ - { - "type": "vcs", - "url": "https://github.com/symfony/type-info" - }, { "type": "vcs", "url": "https://github.com/soyuka/phpunit" From 5e396ec483d18b49ada5322429ce1bb9ce691641 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 5 May 2025 13:30:27 +0200 Subject: [PATCH 11/84] test: property info deprecation --- Tests/Serializer/ConstraintViolationNormalizerTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/Serializer/ConstraintViolationNormalizerTest.php b/Tests/Serializer/ConstraintViolationNormalizerTest.php index d8b6201..e629f86 100644 --- a/Tests/Serializer/ConstraintViolationNormalizerTest.php +++ b/Tests/Serializer/ConstraintViolationNormalizerTest.php @@ -18,6 +18,7 @@ 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\Serializer\NameConverter\NameConverterInterface; @@ -47,6 +48,7 @@ public function testSupportNormalization(): void $this->assertSame([ConstraintViolationListInterface::class => true], $normalizer->getSupportedTypes($normalizer::FORMAT)); } + #[IgnoreDeprecations] public function testNormalize(): void { $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); From 5c7af61ce18d0dfdf2c96a541d7c7b7271f5d091 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 13 May 2025 16:36:41 +0200 Subject: [PATCH 12/84] fix(json-schema): share invariable sub-schemas --- JsonSchema/SchemaFactory.php | 177 +++++++++++++++++-------- Tests/JsonSchema/SchemaFactoryTest.php | 89 +++++-------- 2 files changed, 155 insertions(+), 111 deletions(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 312e647..1189c34 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -13,11 +13,13 @@ namespace ApiPlatform\JsonApi\JsonSchema; +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\Operation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -37,6 +39,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 @@ -45,6 +48,8 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI */ public const DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS = 'disable_json_schema_serializer_groups'; + private const COLLECTION_BASE_SCHEMA_NAME = 'JsonApiCollectionBaseSchema'; + private const LINKS_PROPS = [ 'type' => 'object', 'properties' => [ @@ -119,8 +124,16 @@ 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 = []; + + public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) { + if (!$definitionNameFactory) { + $this->definitionNameFactory = new DefinitionNameFactory(); + } if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) { $this->schemaFactory->setSchemaFactory($this); } @@ -136,60 +149,105 @@ 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); - - if (($key = $schema->getRootDefinitionKey()) || ($key = $schema->getItemsDefinitionKey())) { - $definitions = $schema->getDefinitions(); - $properties = $definitions[$key]['properties'] ?? []; - - if (Error::class === $className && !isset($properties['errors'])) { - $definitions[$key]['properties'] = [ - 'errors' => [ - 'type' => 'object', - 'properties' => $properties, - ], - ]; - - return $schema; - } + $jsonApiSerializerContext = $serializerContext; + if (true === ($serializerContext[self::DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS] ?? true) && $inputOrOutputClass === $className) { + unset($jsonApiSerializerContext['groups']); + } - // Prevent reapplying - if (isset($properties['id'], $properties['type']) || isset($properties['data']) || isset($properties['errors'])) { - return $schema; - } + $schema = $this->schemaFactory->buildSchema($className, 'json', $type, $operation, $schema, $jsonApiSerializerContext, $forceCollection); + $definitionName = $this->definitionNameFactory->create($inputOrOutputClass, $format, $className, $operation, $jsonApiSerializerContext); + $prefix = $this->getSchemaUriPrefix($schema->getVersion()); + $definitions = $schema->getDefinitions(); + $collectionKey = $schema->getItemsDefinitionKey(); - $definitions[$key]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); + // Already computed + if (!$collectionKey && isset($definitions[$definitionName])) { + $schema['$ref'] = $prefix.$definitionName; - if ($schema->getRootDefinitionKey()) { - return $schema; - } + return $schema; } - if (($schema['type'] ?? '') === 'array') { - // data - $items = $schema['items']; - unset($schema['items']); + $key = $schema->getRootDefinitionKey() ?? $collectionKey; + $properties = $definitions[$definitionName]['properties'] ?? []; - $schema['type'] = 'object'; - $schema['properties'] = [ - 'links' => self::LINKS_PROPS, - 'meta' => self::META_PROPS, - 'data' => [ + if (Error::class === $className && !isset($properties['errors'])) { + $definitions[$definitionName]['properties'] = [ + 'errors' => [ 'type' => 'array', - 'items' => $items, + 'items' => [ + 'allOf' => [ + ['$ref' => $prefix.$key], + ['type' => 'object', 'properties' => ['source' => ['type' => 'object'], 'status' => ['type' => 'string']]], + ], + ], ], ]; - $schema['required'] = [ - 'data', - ]; + + $schema['$ref'] = $prefix.$definitionName; + + return $schema; + } + + if (!$collectionKey) { + $definitions[$definitionName]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); + $schema['$ref'] = $prefix.$definitionName; return $schema; } + if (($schema['type'] ?? '') !== 'array') { + return $schema; + } + + if (!isset($definitions[self::COLLECTION_BASE_SCHEMA_NAME])) { + $definitions[self::COLLECTION_BASE_SCHEMA_NAME] = [ + 'type' => 'object', + 'properties' => [ + 'links' => self::LINKS_PROPS, + 'meta' => self::META_PROPS, + 'data' => [ + 'type' => 'array', + ], + ], + 'required' => ['data'], + ]; + } + + unset($schema['items']); + unset($schema['type']); + + $properties = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); + $properties['data']['properties']['attributes']['$ref'] = $prefix.$key; + + $schema['description'] = "$definitionName collection."; + $schema['allOf'] = [ + ['$ref' => $prefix.self::COLLECTION_BASE_SCHEMA_NAME], + ['type' => 'object', 'properties' => [ + 'data' => [ + 'type' => 'array', + 'items' => $properties['data'], + ], + ]], + ]; + return $schema; } @@ -217,12 +275,27 @@ 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'; + + // 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'; } $relatedDefinitions[$propertyName] = array_flip($refs); if ($isOne) { @@ -235,15 +308,18 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, ]; continue; } + if ('id' === $propertyName) { + // should probably be renamed "lid" and moved to the above node $attributes['_id'] = $property; continue; } $attributes[$propertyName] = $property; } + $currentRef = $this->getSchemaUriPrefix($schema->getVersion()).$schema->getRootDefinitionKey(); $replacement = self::PROPERTY_PROPS; - $replacement['attributes']['properties'] = $attributes; + $replacement['attributes'] = ['$ref' => $currentRef]; $included = []; if (\count($relationships) > 0) { @@ -266,19 +342,6 @@ 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']); - } - return [ 'data' => [ 'type' => 'object', diff --git a/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php index 7a64273..37d1b2a 100644 --- a/Tests/JsonSchema/SchemaFactoryTest.php +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -45,12 +45,13 @@ 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()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), @@ -60,6 +61,7 @@ protected function setUp(): void ); $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); $this->schemaFactory = new SchemaFactory( schemaFactory: $baseSchemaFactory, @@ -107,9 +109,7 @@ public function testHasRootDefinitionKeyBuildSchema(): void 'type' => 'string', ], 'attributes' => [ - 'type' => 'object', - 'properties' => [ - ], + '$ref' => '#/definitions/Dummy', ], ], 'required' => [ @@ -124,58 +124,39 @@ 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->assertTrue(isset($resultSchema['allOf'][0]['$ref'])); + $this->assertEquals($resultSchema['allOf'][0]['$ref'], '#/definitions/JsonApiCollectionBaseSchema'); + + $jsonApiCollectionBaseSchema = $resultSchema['definitions']['JsonApiCollectionBaseSchema']; + $this->assertTrue(isset($jsonApiCollectionBaseSchema['properties'])); + $this->assertArrayHasKey('links', $jsonApiCollectionBaseSchema['properties']); + $this->assertArrayHasKey('self', $jsonApiCollectionBaseSchema['properties']['links']['properties']); + $this->assertArrayHasKey('first', $jsonApiCollectionBaseSchema['properties']['links']['properties']); + $this->assertArrayHasKey('prev', $jsonApiCollectionBaseSchema['properties']['links']['properties']); + $this->assertArrayHasKey('next', $jsonApiCollectionBaseSchema['properties']['links']['properties']); + $this->assertArrayHasKey('last', $jsonApiCollectionBaseSchema['properties']['links']['properties']); + + $this->assertArrayHasKey('meta', $jsonApiCollectionBaseSchema['properties']); + $this->assertArrayHasKey('totalItems', $jsonApiCollectionBaseSchema['properties']['meta']['properties']); + $this->assertArrayHasKey('itemsPerPage', $jsonApiCollectionBaseSchema['properties']['meta']['properties']); + $this->assertArrayHasKey('currentPage', $jsonApiCollectionBaseSchema['properties']['meta']['properties']); + + $objectSchema = $resultSchema['allOf'][1]; + $this->assertArrayHasKey('data', $objectSchema['properties']); + + $this->assertArrayHasKey('items', $objectSchema['properties']['data']); + $this->assertArrayHasKey('$ref', $objectSchema['properties']['data']['items']['properties']['attributes']); + + $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']); - - $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, forceCollection: true); + $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']); - $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']); + $forcedCollection = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, forceCollection: true); + $this->assertEquals($resultSchema['allOf'][0]['$ref'], $forcedCollection['allOf'][0]['$ref']); } } From 8eae922542eceffb974cbb8db1fc222fe2f150e1 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 15:13:30 +0200 Subject: [PATCH 13/84] chore: bump patch dependencies --- composer.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 0b3aa7d..4023758 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,11 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.1", - "api-platform/json-schema": "^4.1", - "api-platform/metadata": "^4.1", - "api-platform/serializer": "^4.1", - "api-platform/state": "^4.1", + "api-platform/documentation": "^4.1.11", + "api-platform/json-schema": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/serializer": "^4.1.11", + "api-platform/state": "^4.1.11", "symfony/error-handler": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || ^7.0" }, From 01f454213620d5185257e7736bc590265dcfb0dd Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 27 May 2025 15:41:14 +0200 Subject: [PATCH 14/84] chore: type-info v7.3.0-RC1 #7177 (#7178) --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 700aa38..38b6a48 100644 --- a/composer.json +++ b/composer.json @@ -29,13 +29,13 @@ "api-platform/state": "^4.1.11", "symfony/error-handler": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || ^7.0", - "symfony/type-info": "v7.3.0-BETA1" + "symfony/type-info": "v7.3.0-RC1" }, "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "11.5.x-dev", - "symfony/type-info": "^7.3-dev" + "symfony/type-info": "v7.3.0-RC1" }, "autoload": { "psr-4": { From e6e93b68095dc75d11e0e5f458e68824de73ba76 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 28 May 2025 10:03:08 +0200 Subject: [PATCH 15/84] ci: prefer-lowest to avoid bumping inter components dependencies (#7169) --- composer.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 4023758..7ad25ea 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,8 @@ "extra": { "branch-alias": { "dev-main": "4.2.x-dev", - "dev-3.4": "3.4.x-dev" + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev" }, "symfony": { "require": "^6.4 || ^7.0" @@ -74,5 +75,6 @@ "type": "vcs", "url": "https://github.com/soyuka/phpunit" } - ] + ], + "version": "4.1.12" } From d3349c07b86ea2f7cd8ec680ccee265b896b5558 Mon Sep 17 00:00:00 2001 From: Maxime Helias Date: Mon, 2 Jun 2025 16:15:04 +0200 Subject: [PATCH 16/84] chore: remove 3.4 deprecation (#7188) --- Serializer/ErrorNormalizerTrait.php | 57 ------------------------- Tests/Serializer/ItemNormalizerTest.php | 24 ++++------- 2 files changed, 8 insertions(+), 73 deletions(-) delete mode 100644 Serializer/ErrorNormalizerTrait.php diff --git a/Serializer/ErrorNormalizerTrait.php b/Serializer/ErrorNormalizerTrait.php deleted file mode 100644 index 8af5cc5..0000000 --- a/Serializer/ErrorNormalizerTrait.php +++ /dev/null @@ -1,57 +0,0 @@ - - * - * 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\Exception\ErrorCodeSerializableInterface; -use Symfony\Component\ErrorHandler\Exception\FlattenException; -use Symfony\Component\HttpFoundation\Response; - -/** - * @deprecated - */ -trait ErrorNormalizerTrait -{ - private function getErrorMessage($object, array $context, bool $debug = false): string - { - $message = $object->getMessage(); - - if ($debug) { - return $message; - } - - if ($object instanceof FlattenException) { - $statusCode = $context['statusCode'] ?? $object->getStatusCode(); - if ($statusCode >= 500 && $statusCode < 600) { - $message = Response::$statusTexts[$statusCode] ?? Response::$statusTexts[Response::HTTP_INTERNAL_SERVER_ERROR]; - } - } - - return $message; - } - - private function getErrorCode(object $object): ?string - { - if ($object instanceof FlattenException) { - $exceptionClass = $object->getClass(); - } else { - $exceptionClass = $object::class; - } - - if (is_a($exceptionClass, ErrorCodeSerializableInterface::class, true)) { - return $exceptionClass::getErrorCode(); - } - - return null; - } -} diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index 9065dfd..610b264 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -33,12 +33,12 @@ 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\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; @@ -153,21 +153,13 @@ public function testNormalizeCircularReference(): void $normalizer->setSerializer($this->prophesize(SerializerInterface::class)->reveal()); - $circularReferenceLimit = 2; - if (!interface_exists(AdvancedNameConverterInterface::class) && method_exists($normalizer, 'setCircularReferenceLimit')) { - $normalizer->setCircularReferenceLimit($circularReferenceLimit); - - $context = [ - 'circular_reference_limit' => [spl_object_hash($circularReferenceEntity) => 2], - 'cache_error' => function (): void {}, - ]; - } else { - $context = [ - 'circular_reference_limit' => $circularReferenceLimit, - 'circular_reference_limit_counters' => [spl_object_hash($circularReferenceEntity) => 2], - 'cache_error' => function (): void {}, - ]; - } + // Symfony >= 7.3 + $splObject = class_exists(EventStreamResponse::class) ? spl_object_id($circularReferenceEntity) : spl_object_hash($circularReferenceEntity); + $context = [ + 'circular_reference_limit' => 2, + 'circular_reference_limit_counters' => [$splObject => 2], + 'cache_error' => function (): void {}, + ]; $this->assertSame('/circular_references/1', $normalizer->normalize($circularReferenceEntity, ItemNormalizer::FORMAT, $context)); } From ba9fda20b64c7608a1ba719cc0200fd7ffadd8a1 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 2 Jun 2025 16:22:51 +0200 Subject: [PATCH 17/84] chore: use type-info:^7.3 (#7185) --- Tests/Serializer/ItemNormalizerTest.php | 10 +++++++--- composer.json | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index 9c5c772..b6fe995 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -133,14 +133,16 @@ 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')); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(CircularReference::class, [])->willReturn(new PropertyNameCollection()); + $normalizer = new ItemNormalizer( - $this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), $this->prophesize(PropertyMetadataFactoryInterface::class)->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), @@ -158,11 +160,13 @@ public function testNormalizeCircularReference(): void $normalizer->setCircularReferenceLimit($circularReferenceLimit); $context = [ + 'api_empty_resource_as_iri' => true, 'circular_reference_limit' => [spl_object_hash($circularReferenceEntity) => 2], 'cache_error' => function (): void {}, ]; } else { $context = [ + 'api_empty_resource_as_iri' => true, 'circular_reference_limit' => $circularReferenceLimit, 'circular_reference_limit_counters' => [spl_object_hash($circularReferenceEntity) => 2], 'cache_error' => function (): void {}, diff --git a/composer.json b/composer.json index 38b6a48..8019a3c 100644 --- a/composer.json +++ b/composer.json @@ -29,13 +29,13 @@ "api-platform/state": "^4.1.11", "symfony/error-handler": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || ^7.0", - "symfony/type-info": "v7.3.0-RC1" + "symfony/type-info": "^7.3" }, "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "11.5.x-dev", - "symfony/type-info": "v7.3.0-RC1" + "symfony/type-info": "^7.3" }, "autoload": { "psr-4": { From 8a6c89a434a8938ab2439a2618ae30b11f8288bd Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 4 Jun 2025 12:13:07 +0200 Subject: [PATCH 18/84] ci: symfony 7.3 deprecations (#7192) --- phpunit.baseline.xml | 8 ++++++++ phpunit.xml.dist | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 phpunit.baseline.xml diff --git a/phpunit.baseline.xml b/phpunit.baseline.xml new file mode 100644 index 0000000..e3ef619 --- /dev/null +++ b/phpunit.baseline.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e686806..27bf85c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,7 +9,7 @@ - + trigger_deprecation From 6cdacca7eadef403609220117db3af82d73a06ba Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Jun 2025 16:02:15 +0200 Subject: [PATCH 19/84] fix: bump composer.json version nodes --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7ad25ea..6c88e04 100644 --- a/composer.json +++ b/composer.json @@ -76,5 +76,5 @@ "url": "https://github.com/soyuka/phpunit" } ], - "version": "4.1.12" + "version": "4.1.14" } From eaf2b651dfc28df41224d32d18a0a3b53628630a Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Jun 2025 16:18:03 +0200 Subject: [PATCH 20/84] chore: missing "v" prefix in composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6c88e04..b7cbc9a 100644 --- a/composer.json +++ b/composer.json @@ -76,5 +76,5 @@ "url": "https://github.com/soyuka/phpunit" } ], - "version": "4.1.14" + "version": "v4.1.15" } From 838589c0567f9b4451754d367a3dc5359798a3e9 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Jun 2025 16:56:47 +0200 Subject: [PATCH 21/84] ci: remove version from composer to avoid release side effects (#7196) --- composer.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/composer.json b/composer.json index b7cbc9a..228da72 100644 --- a/composer.json +++ b/composer.json @@ -75,6 +75,5 @@ "type": "vcs", "url": "https://github.com/soyuka/phpunit" } - ], - "version": "v4.1.15" + ] } From 98d5cbf4b272940a38685fcc98b2b591fc6f88e8 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 20 Jun 2025 15:00:58 +0200 Subject: [PATCH 22/84] ci: bump lowest dependencies (#7237) --- Tests/JsonSchema/SchemaFactoryTest.php | 2 +- composer.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php index 37d1b2a..28b9c3a 100644 --- a/Tests/JsonSchema/SchemaFactoryTest.php +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -51,7 +51,7 @@ protected function setUp(): void $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_OUTPUT])->willReturn(new PropertyNameCollection()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory(); + $definitionNameFactory = new DefinitionNameFactory(null); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), diff --git a/composer.json b/composer.json index 63b12dd..3db7cca 100644 --- a/composer.json +++ b/composer.json @@ -23,8 +23,8 @@ "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/json-schema": "4.2.x-dev as dev-main", + "api-platform/metadata": "4.2.x-dev as dev-main", "api-platform/serializer": "^4.1.11", "api-platform/state": "^4.1.11", "symfony/error-handler": "^6.4 || ^7.0", From 57ab8a9ac402c2f244bb5b80d7857751fea8314d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 27 Jun 2025 15:30:43 +0200 Subject: [PATCH 23/84] refactor(metadata): cascade resource to operation (#7246) --- Serializer/EntrypointNormalizer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Serializer/EntrypointNormalizer.php b/Serializer/EntrypointNormalizer.php index 1dd6b67..7d6dd6a 100644 --- a/Serializer/EntrypointNormalizer.php +++ b/Serializer/EntrypointNormalizer.php @@ -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 From 91b61902fea6c175e0f91200bd77588f52cdac86 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 30 Jun 2025 14:41:47 +0200 Subject: [PATCH 24/84] chore: solve some phpstan issues (#7249) --- Filter/SparseFieldset.php | 2 +- Serializer/EntrypointNormalizer.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/Serializer/EntrypointNormalizer.php b/Serializer/EntrypointNormalizer.php index 7d6dd6a..f49411d 100644 --- a/Serializer/EntrypointNormalizer.php +++ b/Serializer/EntrypointNormalizer.php @@ -39,7 +39,7 @@ 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 $object, ?string $format = null, array $context = []): array { $entrypoint = ['links' => ['self' => $this->urlGenerator->generate('api_entrypoint', [], UrlGeneratorInterface::ABS_URL)]]; From 0e1c28adbd90de453bc3de84fb19b5de90c8a36c Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 25 Jul 2025 11:37:01 +0200 Subject: [PATCH 25/84] feat(metadata): class is now class-string (#7307) --- Tests/State/JsonApiProviderTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/State/JsonApiProviderTest.php b/Tests/State/JsonApiProviderTest.php index 180c5d5..5ce9426 100644 --- a/Tests/State/JsonApiProviderTest.php +++ b/Tests/State/JsonApiProviderTest.php @@ -31,7 +31,7 @@ public function testProvide(): void $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'); + $operation = new Get(class: \stdClass::class, shortName: 'dummy'); $context = ['request' => $request]; $decorated = $this->createMock(ProviderInterface::class); $provider = new JsonApiProvider($decorated); From 7ea9bbe5f801f58b3f78730f6e6cd4b168b450d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20St=C3=B6hr?= Date: Wed, 6 Aug 2025 09:56:58 +0200 Subject: [PATCH 26/84] fix(jsonapi): handle type error when handling validation errors (#7330) --- .../ConstraintViolationListNormalizer.php | 8 ++- .../ConstraintViolationNormalizerTest.php | 57 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/Serializer/ConstraintViolationListNormalizer.php b/Serializer/ConstraintViolationListNormalizer.php index 28604a9..267180a 100644 --- a/Serializer/ConstraintViolationListNormalizer.php +++ b/Serializer/ConstraintViolationListNormalizer.php @@ -71,7 +71,13 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio return 'data'; } - $class = $violation->getRoot()::class; + $root = $violation->getRoot(); + + if (!\is_object($root)) { + return "data/attributes/$fieldName"; + } + + $class = $root::class; $propertyMetadata = $this->propertyMetadataFactory ->create( // Im quite sure this requires some thought in case of validations over relationships diff --git a/Tests/Serializer/ConstraintViolationNormalizerTest.php b/Tests/Serializer/ConstraintViolationNormalizerTest.php index f587321..d167128 100644 --- a/Tests/Serializer/ConstraintViolationNormalizerTest.php +++ b/Tests/Serializer/ConstraintViolationNormalizerTest.php @@ -91,4 +91,61 @@ public function testNormalize(): void (new ConstraintViolationListNormalizer($propertyMetadataFactoryProphecy->reveal(), $nameConverterProphecy->reveal()))->normalize($constraintViolationList) ); } + + public function testNormalizeWithStringRoot(): void + { + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + // Create a violation with a string root (simulating query parameter validation) + $constraintViolationList = new ConstraintViolationList([ + new ConstraintViolation('Invalid page value.', 'Invalid page value.', [], 'page', 'page', 'invalid'), + ]); + + $normalizer = new ConstraintViolationListNormalizer($propertyMetadataFactoryProphecy->reveal()); + + $result = $normalizer->normalize($constraintViolationList); + + $this->assertEquals( + [ + 'errors' => [ + [ + 'detail' => 'Invalid page value.', + 'source' => [ + 'pointer' => 'data/attributes/page', + ], + ], + ], + ], + $result + ); + } + + public function testNormalizeWithNullRoot(): void + { + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + // Create a violation with a null root + $constraintViolationList = new ConstraintViolationList([ + new ConstraintViolation('Invalid value.', 'Invalid value.', [], null, 'field', 'invalid'), + ]); + + $normalizer = new ConstraintViolationListNormalizer($propertyMetadataFactoryProphecy->reveal()); + + // This should not throw a TypeError and should handle the null root gracefully + $result = $normalizer->normalize($constraintViolationList); + + $this->assertEquals( + [ + 'errors' => [ + [ + 'detail' => 'Invalid value.', + 'source' => [ + 'pointer' => 'data/attributes/field', + ], + ], + ], + ], + $result + ); + } } From e876202df2ace287ad59509fe736880a05a39fea Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 18 Aug 2025 15:34:44 +0200 Subject: [PATCH 27/84] chore: add param type (#7313) --- Serializer/CollectionNormalizer.php | 4 ++-- Serializer/ConstraintViolationListNormalizer.php | 3 +++ Serializer/EntrypointNormalizer.php | 3 +++ Serializer/ErrorNormalizer.php | 3 +++ Serializer/ItemNormalizer.php | 3 +++ Serializer/ObjectNormalizer.php | 3 +++ 6 files changed, 17 insertions(+), 2 deletions(-) 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 b153b65..53ed7e8 100644 --- a/Serializer/ConstraintViolationListNormalizer.php +++ b/Serializer/ConstraintViolationListNormalizer.php @@ -60,6 +60,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return self::FORMAT === $format && $data instanceof ConstraintViolationListInterface; } + /** + * @param string|null $format + */ public function getSupportedTypes($format): array { return self::FORMAT === $format ? [ConstraintViolationListInterface::class => true] : []; diff --git a/Serializer/EntrypointNormalizer.php b/Serializer/EntrypointNormalizer.php index f49411d..394bcb3 100644 --- a/Serializer/EntrypointNormalizer.php +++ b/Serializer/EntrypointNormalizer.php @@ -73,6 +73,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return self::FORMAT === $format && $data instanceof Entrypoint; } + /** + * @param string|null $format + */ public function getSupportedTypes($format): array { return self::FORMAT === $format ? [Entrypoint::class => true] : []; diff --git a/Serializer/ErrorNormalizer.php b/Serializer/ErrorNormalizer.php index af619b8..a9eba14 100644 --- a/Serializer/ErrorNormalizer.php +++ b/Serializer/ErrorNormalizer.php @@ -75,6 +75,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return self::FORMAT === $format && ($data instanceof \Exception || $data instanceof FlattenException); } + /** + * @param string|null $format + */ public function getSupportedTypes($format): array { if (self::FORMAT === $format) { diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index 179462f..47a5879 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -72,6 +72,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context) && !($data instanceof \Exception || $data instanceof FlattenException); } + /** + * @param string|null $format + */ public function getSupportedTypes($format): array { return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; diff --git a/Serializer/ObjectNormalizer.php b/Serializer/ObjectNormalizer.php index 196c12a..1fd986b 100644 --- a/Serializer/ObjectNormalizer.php +++ b/Serializer/ObjectNormalizer.php @@ -41,6 +41,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return self::FORMAT === $format && $this->decorated->supportsNormalization($data, $format, $context); } + /** + * @param string|null $format + */ public function getSupportedTypes($format): array { return self::FORMAT === $format ? $this->decorated->getSupportedTypes($format) : []; From d33f71b8ccd5ad741d8192ace260d23bb03ea916 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 19 Aug 2025 10:04:29 +0200 Subject: [PATCH 28/84] chore: 4.2 branch alias --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3db7cca..dcd96c8 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,8 @@ }, "extra": { "branch-alias": { - "dev-main": "4.2.x-dev", + "dev-main": "4.3.x-dev", + "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, From 21901d2421eec2bd43e3290ddeb9c9de257d5f34 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 15 Sep 2025 11:57:10 +0200 Subject: [PATCH 29/84] fix(metadata): compute isWritable during updates (#7383) fixes #7382 --- Tests/Serializer/ItemNormalizerTest.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index 610b264..bb1c010 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -61,7 +61,7 @@ public function testNormalize(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->willReturn((new ApiProperty())->withReadable(true)->withIdentifier(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withIdentifier(true)); $propertyMetadataFactoryProphecy->create(Dummy::class, '\bad_property', Argument::any())->willReturn((new ApiProperty())->withReadable(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -351,12 +351,12 @@ public function testDenormalizeCollectionIsNotArray(): void ]; $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['relatedDummies'])); + $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_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', [])->willReturn((new ApiProperty()) + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty()) ->withBuiltinTypes([$type]) ->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) ); @@ -407,11 +407,11 @@ public function testDenormalizeCollectionWithInvalidKey(): void ]; $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['relatedDummies'])); + $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)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', [])->willReturn( + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn( (new ApiProperty())->withBuiltinTypes([$type])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) ); @@ -456,10 +456,10 @@ public function testDenormalizeRelationIsNotResourceLinkage(): void ]; $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['relatedDummy'])); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['relatedDummy'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', [])->willReturn( + $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) ); From e8da698d55fb1702b25c63d7c821d1760159912e Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 16 Sep 2025 14:12:59 +0200 Subject: [PATCH 30/84] chore: lowest fixes --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index dcd96c8..d02efac 100644 --- a/composer.json +++ b/composer.json @@ -23,8 +23,8 @@ "require": { "php": ">=8.2", "api-platform/documentation": "^4.1.11", - "api-platform/json-schema": "4.2.x-dev as dev-main", - "api-platform/metadata": "4.2.x-dev as dev-main", + "api-platform/json-schema": "^4.2@beta", + "api-platform/metadata": "^4.2@beta", "api-platform/serializer": "^4.1.11", "api-platform/state": "^4.1.11", "symfony/error-handler": "^6.4 || ^7.0", From 65a9ae462eca1875c275c7818ea2529e8639b0ff Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 31 Oct 2025 17:12:05 +0100 Subject: [PATCH 31/84] chore: bump deps --- composer.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index d02efac..6217d1f 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,11 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.1.11", - "api-platform/json-schema": "^4.2@beta", - "api-platform/metadata": "^4.2@beta", - "api-platform/serializer": "^4.1.11", - "api-platform/state": "^4.1.11", + "api-platform/documentation": "^4.2", + "api-platform/json-schema": "^4.2", + "api-platform/metadata": "^4.2", + "api-platform/serializer": "^4.2", + "api-platform/state": "^4.2", "symfony/error-handler": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || ^7.0", "symfony/type-info": "^7.3" From 0aa236ad6286d0f63ffc69559e5cd14a4369c04c Mon Sep 17 00:00:00 2001 From: aaa2000 Date: Thu, 13 Nov 2025 08:44:34 +0100 Subject: [PATCH 32/84] fix(json-schema): pagination less schema with disabled pagination (#7506) --- JsonSchema/SchemaFactory.php | 130 +++++++++++++++---------- Tests/JsonSchema/SchemaFactoryTest.php | 29 +++--- 2 files changed, 93 insertions(+), 66 deletions(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 1189c34..24a50e8 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -49,56 +49,8 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI public const DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS = 'disable_json_schema_serializer_groups'; private const COLLECTION_BASE_SCHEMA_NAME = 'JsonApiCollectionBaseSchema'; + private const COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION = 'JsonApiCollectionBaseSchemaNoPagination'; - 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 RELATION_PROPS = [ 'type' => 'object', 'properties' => [ @@ -217,18 +169,88 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin return $schema; } - if (!isset($definitions[self::COLLECTION_BASE_SCHEMA_NAME])) { - $definitions[self::COLLECTION_BASE_SCHEMA_NAME] = [ + if (!isset($definitions[self::COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION])) { + $definitions[self::COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION] = [ 'type' => 'object', 'properties' => [ - 'links' => self::LINKS_PROPS, - 'meta' => self::META_PROPS, + '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', ], ], 'required' => ['data'], ]; + + $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']); @@ -239,7 +261,7 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin $schema['description'] = "$definitionName collection."; $schema['allOf'] = [ - ['$ref' => $prefix.self::COLLECTION_BASE_SCHEMA_NAME], + ['$ref' => $prefix.(false === $operation->getPaginationEnabled() ? self::COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION : self::COLLECTION_BASE_SCHEMA_NAME)], ['type' => 'object', 'properties' => [ 'data' => [ 'type' => 'array', diff --git a/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php index 28b9c3a..4adcb64 100644 --- a/Tests/JsonSchema/SchemaFactoryTest.php +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -129,19 +129,24 @@ public function testSchemaTypeBuildSchema(): void $this->assertTrue(isset($resultSchema['allOf'][0]['$ref'])); $this->assertEquals($resultSchema['allOf'][0]['$ref'], '#/definitions/JsonApiCollectionBaseSchema'); + $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']); + $jsonApiCollectionBaseSchema = $resultSchema['definitions']['JsonApiCollectionBaseSchema']; - $this->assertTrue(isset($jsonApiCollectionBaseSchema['properties'])); - $this->assertArrayHasKey('links', $jsonApiCollectionBaseSchema['properties']); - $this->assertArrayHasKey('self', $jsonApiCollectionBaseSchema['properties']['links']['properties']); - $this->assertArrayHasKey('first', $jsonApiCollectionBaseSchema['properties']['links']['properties']); - $this->assertArrayHasKey('prev', $jsonApiCollectionBaseSchema['properties']['links']['properties']); - $this->assertArrayHasKey('next', $jsonApiCollectionBaseSchema['properties']['links']['properties']); - $this->assertArrayHasKey('last', $jsonApiCollectionBaseSchema['properties']['links']['properties']); - - $this->assertArrayHasKey('meta', $jsonApiCollectionBaseSchema['properties']); - $this->assertArrayHasKey('totalItems', $jsonApiCollectionBaseSchema['properties']['meta']['properties']); - $this->assertArrayHasKey('itemsPerPage', $jsonApiCollectionBaseSchema['properties']['meta']['properties']); - $this->assertArrayHasKey('currentPage', $jsonApiCollectionBaseSchema['properties']['meta']['properties']); + $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']); From 5f67fd9b29e9d54fa33b84811c8de8c036856cca Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 13 Nov 2025 15:23:22 +0100 Subject: [PATCH 33/84] chore: symfony/http-foundation:^6.4.14 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6217d1f..f39d3d6 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "api-platform/serializer": "^4.2", "api-platform/state": "^4.2", "symfony/error-handler": "^6.4 || ^7.0", - "symfony/http-foundation": "^6.4 || ^7.0", + "symfony/http-foundation": "^6.4.14 || ^7.0", "symfony/type-info": "^7.3" }, "require-dev": { From d8ab83b8323471702114f3dd73efeced7af7b7fa Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 13 Nov 2025 16:19:51 +0100 Subject: [PATCH 34/84] chore: bump self dependencies to avoid installing lowest --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index f39d3d6..50de99d 100644 --- a/composer.json +++ b/composer.json @@ -25,8 +25,8 @@ "api-platform/documentation": "^4.2", "api-platform/json-schema": "^4.2", "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2", - "api-platform/state": "^4.2", + "api-platform/serializer": "^4.2.4", + "api-platform/state": "^4.2.4", "symfony/error-handler": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4.14 || ^7.0", "symfony/type-info": "^7.3" From 0562d3f08dc44f4bc1efd5e227d2c99500740a9f Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 13 Nov 2025 16:57:14 +0100 Subject: [PATCH 35/84] chore: http-foundation:^6.4.14 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 228da72..1c1e287 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "api-platform/serializer": "^4.1.11", "api-platform/state": "^4.1.11", "symfony/error-handler": "^6.4 || ^7.0", - "symfony/http-foundation": "^6.4 || ^7.0" + "symfony/http-foundation": "^6.4.14 || ^7.0" }, "require-dev": { "phpspec/prophecy": "^1.19", From f7a0680c1183795c46bc2e55a69acb94735cfbe9 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 30 Nov 2025 13:55:42 +0100 Subject: [PATCH 36/84] chore: support symfony 8 (#7561) --- Serializer/ReservedAttributeNameConverter.php | 3 +-- Tests/Fixtures/RelatedDummy.php | 2 +- composer.json | 10 +++++----- 3 files changed, 7 insertions(+), 8 deletions(-) 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/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/composer.json b/composer.json index 50de99d..1e41909 100644 --- a/composer.json +++ b/composer.json @@ -27,15 +27,15 @@ "api-platform/metadata": "^4.2", "api-platform/serializer": "^4.2.4", "api-platform/state": "^4.2.4", - "symfony/error-handler": "^6.4 || ^7.0", - "symfony/http-foundation": "^6.4.14 || ^7.0", - "symfony/type-info": "^7.3" + "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", - "symfony/type-info": "^7.3" + "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { "psr-4": { @@ -63,7 +63,7 @@ "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", From 4e2ea985b77e04e67a70701f7ad6e9192af1dd82 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 30 Nov 2025 17:14:56 +0100 Subject: [PATCH 37/84] fix(json-schema): schema type in serializer context for schema factory (#7557) --- JsonSchema/SchemaFactory.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 24a50e8..cc2ba9d 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -125,7 +125,7 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin } $schema = $this->schemaFactory->buildSchema($className, 'json', $type, $operation, $schema, $jsonApiSerializerContext, $forceCollection); - $definitionName = $this->definitionNameFactory->create($inputOrOutputClass, $format, $className, $operation, $jsonApiSerializerContext); + $definitionName = $this->definitionNameFactory->create($inputOrOutputClass, $format, $className, $operation, $jsonApiSerializerContext + ['schema_type' => $type]); $prefix = $this->getSchemaUriPrefix($schema->getVersion()); $definitions = $schema->getDefinitions(); $collectionKey = $schema->getItemsDefinitionKey(); @@ -300,7 +300,7 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, $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); + $definitionName = $this->definitionNameFactory->create($relatedClassName, $format, $inputOrOutputClass, $operation, $serializerContext + ['schema_type' => $type]); // to avoid recursion if ($this->builtSchema[$definitionName] ?? false) { From 0b5a7c14cc97daae2b720cf0e888059944e106df Mon Sep 17 00:00:00 2001 From: Nathan Pesneau <129308244+NathanPesneau@users.noreply.github.com> Date: Thu, 4 Dec 2025 15:20:26 +0100 Subject: [PATCH 38/84] refactor(state): replace :property placeholder with properties (#7547) fixes #7478 --- Filter/SparseFieldsetParameterProvider.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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; } } From 2bb93263f900401c41476b93bcf03c386c9500d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20St=C3=B6hr?= Date: Fri, 19 Dec 2025 15:13:59 +0100 Subject: [PATCH 39/84] fix(jsonapi): handle missing attributes in ErrorNormalizer (#7569) Co-authored-by: soyuka --- Serializer/ErrorNormalizer.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Serializer/ErrorNormalizer.php b/Serializer/ErrorNormalizer.php index a9eba14..4875b28 100644 --- a/Serializer/ErrorNormalizer.php +++ b/Serializer/ErrorNormalizer.php @@ -35,7 +35,7 @@ public function __construct(private ?NormalizerInterface $itemNormalizer = null) public function normalize(mixed $object, ?string $format = null, array $context = []): array { $jsonApiObject = $this->itemNormalizer->normalize($object, $format, $context); - $error = $jsonApiObject['data']['attributes']; + $error = $jsonApiObject['data']['attributes'] ?? []; $error['id'] = $jsonApiObject['data']['id']; if (isset($error['type'])) { $error['links'] = ['type' => $error['type']]; @@ -45,6 +45,11 @@ public function normalize(mixed $object, ?string $format = null, array $context $error['code'] = $object->getId(); } + // TODO: change this 5.x + // if (isset($error['status'])) { + // $error['status'] = (string) $error['status']; + // } + if (!isset($error['violations'])) { return ['errors' => [$error]]; } From 86f93ac31f20faeeca5cacd74d1318dc273e6b93 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 27 Dec 2025 22:56:46 +0100 Subject: [PATCH 40/84] ci: upgrade to phpunit 12 Remove soyuka/phpunit fork from all composer.json files and upgrade to PHPUnit 12.2. Update CI workflow to install PHPUnit before other steps and configure MongoDB conditional execution. Migrate tests from Prophecy to PHPUnit native mocking in FieldsBuilderTest and Symfony event listener tests. Remove unused dataprovider and fix warnings. --- composer.json | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 1e41909..c1fa548 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { @@ -72,11 +72,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } From 102c2c7896b564788a6f1cc4c554c41fcc46281c Mon Sep 17 00:00:00 2001 From: Maxcastel <92802347+Maxcastel@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:12:21 +0100 Subject: [PATCH 41/84] refactor: make method parameter names match interfaces (#7643) --- .../ConstraintViolationListNormalizer.php | 8 ++--- Serializer/EntrypointNormalizer.php | 8 ++--- Serializer/ErrorNormalizer.php | 12 +++---- Serializer/ItemNormalizer.php | 36 +++++++++---------- Serializer/ObjectNormalizer.php | 20 +++++------ 5 files changed, 42 insertions(+), 42 deletions(-) diff --git a/Serializer/ConstraintViolationListNormalizer.php b/Serializer/ConstraintViolationListNormalizer.php index fc87afc..3d25656 100644 --- a/Serializer/ConstraintViolationListNormalizer.php +++ b/Serializer/ConstraintViolationListNormalizer.php @@ -37,10 +37,10 @@ public function __construct(private readonly PropertyMetadataFactoryInterface $p /** * {@inheritdoc} */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array + public function normalize(mixed $data, ?string $format = null, array $context = []): array { $violations = []; - foreach ($object as $violation) { + foreach ($data as $violation) { $violations[] = [ 'detail' => $violation->getMessage(), 'source' => [ @@ -61,9 +61,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? [ConstraintViolationListInterface::class => true] : []; } diff --git a/Serializer/EntrypointNormalizer.php b/Serializer/EntrypointNormalizer.php index 394bcb3..a7a6f67 100644 --- a/Serializer/EntrypointNormalizer.php +++ b/Serializer/EntrypointNormalizer.php @@ -39,11 +39,11 @@ public function __construct(private readonly ResourceMetadataCollectionFactoryIn /** * {@inheritdoc} */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array + public function normalize(mixed $data, ?string $format = null, array $context = []): array { $entrypoint = ['links' => ['self' => $this->urlGenerator->generate('api_entrypoint', [], UrlGeneratorInterface::ABS_URL)]]; - foreach ($object->getResourceNameCollection() as $resourceClass) { + foreach ($data->getResourceNameCollection() as $resourceClass) { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); foreach ($resourceMetadata as $resource) { @@ -74,9 +74,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? [Entrypoint::class => true] : []; } diff --git a/Serializer/ErrorNormalizer.php b/Serializer/ErrorNormalizer.php index 4875b28..3b6d2fb 100644 --- a/Serializer/ErrorNormalizer.php +++ b/Serializer/ErrorNormalizer.php @@ -32,17 +32,17 @@ public function __construct(private ?NormalizerInterface $itemNormalizer = null) /** * {@inheritdoc} */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array + public function normalize(mixed $data, ?string $format = null, array $context = []): array { - $jsonApiObject = $this->itemNormalizer->normalize($object, $format, $context); + $jsonApiObject = $this->itemNormalizer->normalize($data, $format, $context); $error = $jsonApiObject['data']['attributes'] ?? []; $error['id'] = $jsonApiObject['data']['id']; if (isset($error['type'])) { $error['links'] = ['type' => $error['type']]; } - if (!isset($error['code']) && method_exists($object, 'getId')) { - $error['code'] = $object->getId(); + if (!isset($error['code']) && method_exists($data, 'getId')) { + $error['code'] = $data->getId(); } // TODO: change this 5.x @@ -81,9 +81,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { if (self::FORMAT === $format) { return [ diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index 47a5879..12be479 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -73,9 +73,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; } @@ -83,16 +83,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 { - $resourceClass = $this->getObjectClass($object); + $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')) { @@ -101,8 +101,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; @@ -110,28 +110,28 @@ public function normalize(mixed $object, ?string $format = null, array $context $context['cache_key'] = $this->getCacheKey($format, $context); } - $data = parent::normalize($object, $format, $context); - if (!\is_array($data)) { - return $data; + $normalizedData = parent::normalize($data, $format, $context); + if (!\is_array($normalizedData)) { + return $normalizedData; } // Get and populate relations - ['relationships' => $allRelationshipsData, 'links' => $links] = $this->getComponents($object, $format, $context); + ['relationships' => $allRelationshipsData, 'links' => $links] = $this->getComponents($data, $format, $context); $populatedRelationContext = $context; - $relationshipsData = $this->getPopulatedRelations($object, $format, $populatedRelationContext, $allRelationshipsData); + $relationshipsData = $this->getPopulatedRelations($data, $format, $populatedRelationContext, $allRelationshipsData); // Do not include primary resources $context['api_included_resources'] = [$context['iri']]; - $includedResourcesData = $this->getRelatedResources($object, $format, $context, $allRelationshipsData); + $includedResourcesData = $this->getRelatedResources($data, $format, $context, $allRelationshipsData); $resourceData = [ 'id' => $context['iri'], 'type' => $this->getResourceShortName($resourceClass), ]; - if ($data) { - $resourceData['attributes'] = $data; + if ($normalizedData) { + $resourceData['attributes'] = $normalizedData; } if ($relationshipsData) { @@ -166,7 +166,7 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form * * @throws NotNormalizableValueException */ - public function denormalize(mixed $data, string $class, ?string $format = null, array $context = []): mixed + public function denormalize(mixed $data, string $type, ?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'])) { @@ -188,7 +188,7 @@ public function denormalize(mixed $data, string $class, ?string $format = null, return parent::denormalize( $dataToDenormalize, - $class, + $type, $format, $context ); diff --git a/Serializer/ObjectNormalizer.php b/Serializer/ObjectNormalizer.php index 1fd986b..5ea74b1 100644 --- a/Serializer/ObjectNormalizer.php +++ b/Serializer/ObjectNormalizer.php @@ -42,9 +42,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? $this->decorated->getSupportedTypes($format) : []; } @@ -52,16 +52,16 @@ public function getSupportedTypes($format): array /** * {@inheritdoc} */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { if (isset($context['api_resource'])) { $originalResource = $context['api_resource']; unset($context['api_resource']); } - $data = $this->decorated->normalize($object, $format, $context); - if (!\is_array($data) || isset($context['api_attribute'])) { - return $data; + $normalizedData = $this->decorated->normalize($data, $format, $context); + if (!\is_array($normalizedData) || isset($context['api_attribute'])) { + return $normalizedData; } if (isset($originalResource)) { @@ -72,13 +72,13 @@ public function normalize(mixed $object, ?string $format = null, array $context ]; } else { $resourceData = [ - 'id' => $this->iriConverter->getIriFromResource($object), - 'type' => (new \ReflectionClass($this->getObjectClass($object)))->getShortName(), + 'id' => $this->iriConverter->getIriFromResource($data), + 'type' => (new \ReflectionClass($this->getObjectClass($data)))->getShortName(), ]; } - if ($data) { - $resourceData['attributes'] = $data; + if ($normalizedData) { + $resourceData['attributes'] = $normalizedData; } return ['data' => $resourceData]; From f761dfd5ce19f2fcd0f187cee931eb315e32e119 Mon Sep 17 00:00:00 2001 From: Gregor Harlan Date: Mon, 26 Jan 2026 07:26:55 +0100 Subject: [PATCH 42/84] chore: add `phpunit.baseline.xml` to .gitattributes (#7692) --- .gitattributes | 1 + 1 file changed, 1 insertion(+) 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 From bc9f4f862edec62ad75615604d046da51da5ec95 Mon Sep 17 00:00:00 2001 From: elfin-sbreuers Date: Mon, 26 Jan 2026 09:31:38 +0100 Subject: [PATCH 43/84] fix(jsonapi): output null on a to-one relationship (#7686) Co-authored-by: soyuka --- JsonSchema/SchemaFactory.php | 7 +- Serializer/ItemNormalizer.php | 24 ++++--- Tests/Serializer/ItemNormalizerTest.php | 89 +++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index cc2ba9d..3228d17 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -321,7 +321,12 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, } $relatedDefinitions[$propertyName] = array_flip($refs); if ($isOne) { - $relationships[$propertyName]['properties']['data'] = self::RELATION_PROPS; + $relationships[$propertyName]['properties']['data'] = [ + 'oneOf' => [ + ['type' => 'null'], + self::RELATION_PROPS, + ], + ]; continue; } $relationships[$propertyName]['properties']['data'] = [ diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index 47a5879..4b3084d 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -460,16 +460,16 @@ private function getPopulatedRelations(object $object, ?string $format, array $c $relationshipName = $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context); } - $data[$relationshipName] = [ - 'data' => [], - ]; - - if (!$attributeValue) { - continue; - } - // Many to one relationship if ('one' === $relationshipDataArray['cardinality']) { + $data[$relationshipName] = [ + 'data' => null, + ]; + + if (!$attributeValue) { + continue; + } + unset($attributeValue['data']['attributes']); $data[$relationshipName] = $attributeValue; @@ -477,6 +477,14 @@ private function getPopulatedRelations(object $object, ?string $format, array $c } // 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)); diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index d6bea46..6775d23 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -488,4 +488,93 @@ 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(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(fn ($class) => \in_array($class, [Dummy::class, RelatedDummy::class], true)); + + $propertyAccessor = $this->createMock(PropertyAccessorInterface::class); + $propertyAccessor->method('getValue')->willReturnCallback(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']); + } } From 32ca38f977203f8a59f6efee9637261ae4651c29 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 26 Jan 2026 16:38:30 +0100 Subject: [PATCH 44/84] cs: static fn fixes --- Serializer/ConstraintViolationListNormalizer.php | 2 +- Tests/Serializer/CollectionNormalizerTest.php | 8 ++++---- Tests/Serializer/ItemNormalizerTest.php | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Serializer/ConstraintViolationListNormalizer.php b/Serializer/ConstraintViolationListNormalizer.php index fc87afc..081ccb6 100644 --- a/Serializer/ConstraintViolationListNormalizer.php +++ b/Serializer/ConstraintViolationListNormalizer.php @@ -100,7 +100,7 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio return "data/relationships/$fieldName"; } } else { - if ($propertyMetadata->getNativeType()?->isSatisfiedBy(fn ($t) => $t instanceof ObjectType)) { + if ($propertyMetadata->getNativeType()?->isSatisfiedBy(static fn ($t) => $t instanceof ObjectType)) { return "data/relationships/$fieldName"; } } 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/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index 6775d23..a9af4cf 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -160,7 +160,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)); @@ -262,17 +262,17 @@ public function testDenormalize(): void $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); @@ -501,7 +501,7 @@ public function testNormalizeWithNullToOneAndEmptyToManyRelationships(): void ); $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactory->method('create')->willReturnCallback(function ($class, $property) { + $propertyMetadataFactory->method('create')->willReturnCallback(static function ($class, $property) { return match ($property) { 'id' => (new ApiProperty())->withReadable(true)->withIdentifier(true), 'name' => (new ApiProperty())->withReadable(true), @@ -522,10 +522,10 @@ public function testNormalizeWithNullToOneAndEmptyToManyRelationships(): void $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); $resourceClassResolver->method('getResourceClass')->willReturn(Dummy::class); - $resourceClassResolver->method('isResourceClass')->willReturnCallback(fn ($class) => \in_array($class, [Dummy::class, RelatedDummy::class], true)); + $resourceClassResolver->method('isResourceClass')->willReturnCallback(static fn ($class) => \in_array($class, [Dummy::class, RelatedDummy::class], true)); $propertyAccessor = $this->createMock(PropertyAccessorInterface::class); - $propertyAccessor->method('getValue')->willReturnCallback(function ($object, $property) { + $propertyAccessor->method('getValue')->willReturnCallback(static function ($object, $property) { return match ($property) { 'id' => 1, 'name' => 'Dummy with relationships', From ab9b1484e175ce57806b74a21f8a23e364f6026b Mon Sep 17 00:00:00 2001 From: Maxcastel <92802347+Maxcastel@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:07:33 +0100 Subject: [PATCH 45/84] refactor: use imported DataProvider instead of FQCN (#7753) Co-authored-by: Antoine Bluchet --- Tests/Serializer/ReservedAttributeNameConverterTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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)); From 6c5b5b83f693667371b7b31a65a50925e10c6d46 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 13 Feb 2026 18:30:49 +0100 Subject: [PATCH 46/84] fix(serializer): prevent context leakage with service-based entity resolution (#7756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(serializer): prevent context leakage with service-based entity resolution (#7733) | Q | A | ------------- | --- | Branch? | 4.2 | Tickets | Fixes #7733 | License | MIT | Doc PR | ∅ * Create OperationResourceResolverInterface service to validate entity-to-resource mappings * Add framework-specific decorators (Doctrine, Eloquent) to handle stateOptions validation * Remove force_resource_class propagation to nested objects preventing DateTimeImmutable issues --- Serializer/ItemNormalizer.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index 4b3084d..3ecb096 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -27,6 +27,7 @@ use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; +use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -59,9 +60,9 @@ final class ItemNormalizer extends AbstractItemNormalizer private array $componentsCache = []; - 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) + 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) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); } /** From 72f092f83e00cd9c6820a66cdf21f3386ad37576 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 27 Feb 2026 15:41:06 +0100 Subject: [PATCH 47/84] fix(jsonapi): prevent double unwrapping of data.attributes with input DTOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Q | A | ------------- | --- | Branch? | 4.2 | Tickets | Fixes #7794 | License | MIT | Doc PR | ∅ * When re-entering the serializer for input DTO denormalization, the data has already been unwrapped from the JSON:API `data.attributes` structure. * Without this guard, JsonApi\ItemNormalizer::denormalize() runs a second time on flat data, reads `$data['data']['attributes']` → null, and nulls every DTO property. * Skip JSON:API extraction when `api_platform_input` context flag is set. Co-Authored-By: Claude Opus 4.6 --- Serializer/ItemNormalizer.php | 6 ++ Tests/Fixtures/InputDto.php | 20 ++++++ Tests/Serializer/ItemNormalizerTest.php | 85 +++++++++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 Tests/Fixtures/InputDto.php diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index 3ecb096..f59a49b 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -169,6 +169,12 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form */ public function denormalize(mixed $data, string $class, ?string $format = null, array $context = []): mixed { + // When re-entering for input DTO denormalization, data has already been + // unwrapped from the JSON:API structure by the first pass. Skip extraction. + if (isset($context['api_platform_input'])) { + return parent::denormalize($data, $class, $format, $context); + } + // Avoid issues with proxies if we populated the object if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { if (true !== ($context['api_allow_update'] ?? true)) { 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/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index a9af4cf..f11d7c3 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -17,6 +17,7 @@ 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; @@ -38,6 +39,7 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; 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; @@ -577,4 +579,87 @@ public function testNormalizeWithNullToOneAndEmptyToManyRelationships(): void $this->assertArrayHasKey('relatedDummies', $result['data']['relationships']); $this->assertSame(['data' => []], $result['data']['relationships']['relatedDummies']); } + + /** + * 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); + } } From d28b51d78c50451e6714ed7a0c673ec6d9070900 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 27 Feb 2026 17:02:33 +0100 Subject: [PATCH 48/84] chore: depend on last serializer --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c1fa548..65ba9d5 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "api-platform/documentation": "^4.2", "api-platform/json-schema": "^4.2", "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2.4", + "api-platform/serializer": "^4.2.18", "api-platform/state": "^4.2.4", "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", From 5368f7ba520f67d4ddcef0040465a10491c4bd42 Mon Sep 17 00:00:00 2001 From: elfin-sbreuers Date: Sat, 28 Feb 2026 08:55:20 +0100 Subject: [PATCH 49/84] feat(jsonapi): support entity identifiers instead of IRIs as resource id --- Serializer/ItemNormalizer.php | 138 ++++++++-- Tests/Serializer/ItemNormalizerTest.php | 327 ++++++++++++++++++++++++ 2 files changed, 446 insertions(+), 19 deletions(-) diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index 046c0ee..bad4d61 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -15,6 +15,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -23,6 +25,7 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Metadata\Util\CompositeIdentifierParser; use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\CacheKeyTrait; @@ -59,10 +62,26 @@ final class ItemNormalizer extends AbstractItemNormalizer public const FORMAT = 'jsonapi'; private array $componentsCache = []; - - 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 bool $useIriAsId; + + 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, + ) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); + $this->useIriAsId = $useIriAsId; } /** @@ -121,16 +140,29 @@ public function normalize(mixed $data, ?string $format = null, array $context = $populatedRelationContext = $context; $relationshipsData = $this->getPopulatedRelations($data, $format, $populatedRelationContext, $allRelationshipsData); - // Do not include primary resources - $context['api_included_resources'] = [$context['iri']]; + $id = $iri; + if (!$this->useIriAsId) { + $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($data, context: $context); + $id = $this->getIdStringFromIdentifiers($identifiers); + } + + $resourceShortName = $this->getResourceShortName($resourceClass); + + // Do not include primary resources — use type:id composite key to avoid cross-type collisions + $context['api_included_resources'] = [$resourceShortName.':'.$id => true]; $includedResourcesData = $this->getRelatedResources($data, $format, $context, $allRelationshipsData); $resourceData = [ - 'id' => $context['iri'], - 'type' => $this->getResourceShortName($resourceClass), + 'id' => $id, + 'type' => $resourceShortName, ]; + // TODO: consider always adding links.self — it's valid per the JSON:API spec even when id is the IRI + if (!$this->useIriAsId) { + $resourceData['links'] = ['self' => $iri]; + } + if ($normalizedData) { $resourceData['attributes'] = $normalizedData; } @@ -175,10 +207,19 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a 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] - ); + $context += ['fetch_data' => false]; + if ($this->useIriAsId) { + $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( + $data['data']['id'], + $context + ); + } else { + $operation = $context['operation'] ?? null; + if ($operation instanceof HttpOperation) { + $iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation); + $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context); + } + } } // Merge attributes and relationships, into format expected by the parent normalizer @@ -226,7 +267,29 @@ protected function denormalizeRelation(string $attributeName, ApiProperty $prope } try { - return $this->iriConverter->getResourceFromIri($value['id'], $context + ['fetch_data' => true]); + $context += ['fetch_data' => true]; + if ($this->useIriAsId) { + return $this->iriConverter->getResourceFromIri($value['id'], $context); + } + + $targetClass = null; + $nativeType = $propertyMetadata->getNativeType(); + + if ($nativeType) { + $nativeType->isSatisfiedBy(function (Type $type) use (&$targetClass): bool { + return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($targetClass = $type->getClassName()); + }); + } + + if (null === $targetClass) { + throw new ItemNotFoundException(\sprintf('Cannot determine target class for property "%s".', $attributeName)); + } + + /** @var HttpOperation $getOperation */ + $getOperation = $this->resourceMetadataCollectionFactory->create($targetClass)->getOperation(httpOperation: true); + $iri = $this->reconstructIri($targetClass, (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); @@ -274,11 +337,19 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel return $normalizedRelatedObject; } + $id = $iri; + if (!$this->useIriAsId) { + $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($relatedObject); + $id = $this->getIdStringFromIdentifiers($identifiers); + } + + $relationData = [ + 'type' => $this->getResourceShortName($resourceClass), + 'id' => $id, + ]; + $context['data'] = [ - 'data' => [ - 'type' => $this->getResourceShortName($resourceClass), - 'id' => $iri, - ], + 'data' => $relationData, ]; $context['iri'] = $iri; @@ -551,10 +622,10 @@ private function getRelatedResources(object $object, ?string $format, array $con */ 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; } } @@ -580,6 +651,35 @@ private function getIncludedNestedResources(string $relationshipName, array $con return array_map(static fn (string $nested): string => substr($nested, strpos($nested, '.') + 1), $filtered); } + private function getIdStringFromIdentifiers(array $identifiers): string + { + if (1 === \count($identifiers)) { + return (string) array_values($identifiers)[0]; + } + + return CompositeIdentifierParser::stringify($identifiers); + } + + /** + * Reconstructs an IRI from a resource class and a raw JSON:API id string. + * + * Maps the id to the operation's single URI variable parameter name and generates + * the IRI via IriConverter. 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]]); + } + // TODO: this code is similar to the one used in JsonLd private function getResourceShortName(string $resourceClass): string { diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index a9af4cf..a11d4d2 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -21,7 +21,9 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -29,6 +31,7 @@ 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\TestCase; use Prophecy\Argument; @@ -577,4 +580,328 @@ public function testNormalizeWithNullToOneAndEmptyToManyRelationships(): void $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); + } } From f3414f09a702ad9e40c1a1e0c0aa56400c111b15 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 3 Mar 2026 10:41:40 +0100 Subject: [PATCH 50/84] fix(jsonapi): wrong variable name during merge (#7816) --- Serializer/ItemNormalizer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index 83f83b8..4d9d6e2 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -204,7 +204,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a // When re-entering for input DTO denormalization, data has already been // unwrapped from the JSON:API structure by the first pass. Skip extraction. if (isset($context['api_platform_input'])) { - return parent::denormalize($data, $class, $format, $context); + return parent::denormalize($data, $type, $format, $context); } // Avoid issues with proxies if we populated the object From 6247a0949a363c3fa7c9a9fe52381e19630e12f0 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Mar 2026 10:30:10 +0100 Subject: [PATCH 51/84] chore: bump sub-components dependencies to ^4.3 (#7820) --- composer.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 65ba9d5..323c675 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,11 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.2", - "api-platform/json-schema": "^4.2", - "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2.18", - "api-platform/state": "^4.2.4", + "api-platform/documentation": "^4.3", + "api-platform/json-schema": "^4.3", + "api-platform/metadata": "^4.3", + "api-platform/serializer": "^4.3", + "api-platform/state": "^4.3", "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" @@ -72,5 +72,7 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "minimum-stability": "beta", + "prefer-stable": true } From 1acd05c4d2defa165d3e8eb1139add9e545551a6 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Mar 2026 16:07:49 +0100 Subject: [PATCH 52/84] chore: switch dev-main branch alias to 4.4 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 323c675..7a83368 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.3.x-dev", + "dev-main": "4.4.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" From bbbe30a91dc061e0299281e8a31c67e686b4ee8e Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 13 Mar 2026 11:46:07 +0100 Subject: [PATCH 53/84] fix(jsonapi): swap arguments in DefinitionNameFactory::create() call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Q | A | ------------- | --- | Branch? | 4.2 | Tickets | ∅ | License | MIT | Doc PR | ∅ The first and third arguments were reversed compared to every other call site, causing schema definitions to be registered under a wrong name and producing unresolved $ref errors in OpenAPI linters. --- JsonSchema/SchemaFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 3228d17..17f3e17 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -125,7 +125,7 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin } $schema = $this->schemaFactory->buildSchema($className, 'json', $type, $operation, $schema, $jsonApiSerializerContext, $forceCollection); - $definitionName = $this->definitionNameFactory->create($inputOrOutputClass, $format, $className, $operation, $jsonApiSerializerContext + ['schema_type' => $type]); + $definitionName = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $jsonApiSerializerContext + ['schema_type' => $type]); $prefix = $this->getSchemaUriPrefix($schema->getVersion()); $definitions = $schema->getDefinitions(); $collectionKey = $schema->getItemsDefinitionKey(); From 9666ca4ddd482157f56ce568fac100ba9f743eec Mon Sep 17 00:00:00 2001 From: Maxcastel <92802347+Maxcastel@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:25:05 +0200 Subject: [PATCH 54/84] ci: openapi errors (#7923) --- JsonSchema/SchemaFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 17f3e17..8e5b066 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -147,7 +147,7 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin 'items' => [ 'allOf' => [ ['$ref' => $prefix.$key], - ['type' => 'object', 'properties' => ['source' => ['type' => 'object'], 'status' => ['type' => 'string']]], + ['type' => 'object', 'properties' => ['source' => ['type' => 'object'], 'status' => ['type' => ['string', 'integer', 'null']]]], ], ], ], From 21682f0595d206ea85ed345c80b9ae424a3b3623 Mon Sep 17 00:00:00 2001 From: elfin-sbreuers Date: Thu, 30 Apr 2026 11:26:48 +0200 Subject: [PATCH 55/84] fix(jsonapi): use parent-resolved class in denormalizeRelation Co-authored-by: soyuka --- Serializer/ItemNormalizer.php | 17 +------ Tests/Serializer/ItemNormalizerTest.php | 61 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index 4d9d6e2..b97c141 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -278,22 +278,9 @@ protected function denormalizeRelation(string $attributeName, ApiProperty $prope return $this->iriConverter->getResourceFromIri($value['id'], $context); } - $targetClass = null; - $nativeType = $propertyMetadata->getNativeType(); - - if ($nativeType) { - $nativeType->isSatisfiedBy(function (Type $type) use (&$targetClass): bool { - return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($targetClass = $type->getClassName()); - }); - } - - if (null === $targetClass) { - throw new ItemNotFoundException(\sprintf('Cannot determine target class for property "%s".', $attributeName)); - } - /** @var HttpOperation $getOperation */ - $getOperation = $this->resourceMetadataCollectionFactory->create($targetClass)->getOperation(httpOperation: true); - $iri = $this->reconstructIri($targetClass, (string) $value['id'], $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) { diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index 683ed89..fd4b2ea 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -314,6 +314,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); From 30e399ea2266403d04fd93df83c6983cf0a30e5d Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 30 Apr 2026 14:21:24 +0200 Subject: [PATCH 56/84] chore: allow phpunit ^11.5 for PHP 8.2 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHPUnit 12 requires PHP 8.3+. Widen the constraint in the root and each component composer.json so PHP 8.2 resolves to 11.5 (LTS) and 8.3+ to 12.x. Removes the per-job "Force PHPUnit 11.5" workaround steps from CI. | Q | A | ------------- | --- | Branch? | 4.3 | Tickets | ∅ | License | MIT | Doc PR | ∅ --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7a83368..56d2c52 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^11.5 || ^12.2", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { From d62b7c2f100c989b0b2e0e4339fe2131829f08d3 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 7 May 2026 13:56:31 +0200 Subject: [PATCH 57/84] refactor: split normalizer/denormalizer (#7713) --- Serializer/ItemDenormalizer.php | 64 +++++++ Serializer/ItemNormalizer.php | 213 ++-------------------- Serializer/ItemNormalizerTrait.php | 149 +++++++++++++++ Tests/Serializer/ItemDenormalizerTest.php | 82 +++++++++ Tests/Serializer/ItemNormalizerTest.php | 2 + 5 files changed, 315 insertions(+), 195 deletions(-) create mode 100644 Serializer/ItemDenormalizer.php create mode 100644 Serializer/ItemNormalizerTrait.php create mode 100644 Tests/Serializer/ItemDenormalizerTest.php 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 b97c141..cd40cae 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -14,8 +14,6 @@ namespace ApiPlatform\JsonApi\Serializer; use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\Exception\ItemNotFoundException; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -36,8 +34,6 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Exception\LogicException; -use Symfony\Component\Serializer\Exception\NotNormalizableValueException; -use Symfony\Component\Serializer\Exception\RuntimeException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -47,7 +43,7 @@ use Symfony\Component\TypeInfo\Type\ObjectType; /** - * Converts between objects and array. + * Converts objects to JSON:API documents (normalization only). * * @author Kévin Dunglas * @author Amrouche Hamza @@ -58,11 +54,13 @@ final class ItemNormalizer extends AbstractItemNormalizer use CacheKeyTrait; use ClassInfoTrait; use ContextTrait; + use ItemNormalizerTrait { + denormalize as private doDenormalize; + } public const FORMAT = 'jsonapi'; private array $componentsCache = []; - private bool $useIriAsId; public function __construct( PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, @@ -78,31 +76,28 @@ public function __construct( protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null, private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null, - bool $useIriAsId = true, + private readonly bool $useIriAsId = true, ) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); - $this->useIriAsId = $useIriAsId; } - /** - * {@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); } - /** - * {@inheritdoc} - */ public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; } - /** - * {@inheritdoc} - */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + 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); @@ -135,7 +130,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $normalizedData; } - // Get and populate relations ['relationships' => $allRelationshipsData, 'links' => $links] = $this->getComponents($data, $format, $context); $populatedRelationContext = $context; $relationshipsData = $this->getPopulatedRelations($data, $format, $populatedRelationContext, $allRelationshipsData); @@ -158,7 +152,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = 'type' => $resourceShortName, ]; - // TODO: consider always adding links.self — it's valid per the JSON:API spec even when id is the IRI if (!$this->useIriAsId) { $resourceData['links'] = ['self' => $iri]; } @@ -186,62 +179,6 @@ public function normalize(mixed $data, ?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 $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); - } - - // 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 += ['fetch_data' => false]; - if ($this->useIriAsId) { - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( - $data['data']['id'], - $context - ); - } else { - $operation = $context['operation'] ?? null; - if ($operation instanceof HttpOperation) { - $iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation); - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context); - } - } - } - - // Merge attributes and relationships, into format expected by the parent normalizer - $dataToDenormalize = array_merge( - $data['data']['attributes'] ?? [], - $data['data']['relationships'] ?? [] - ); - - return parent::denormalize( - $dataToDenormalize, - $type, - $format, - $context - ); - } - /** * {@inheritdoc} */ @@ -251,59 +188,6 @@ protected function getAttributes(object $object, ?string $format = null, array $ } /** - * {@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 { - $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; - } - } - - /** - * {@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 @@ -336,13 +220,11 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel $id = $this->getIdStringFromIdentifiers($identifiers); } - $relationData = [ - 'type' => $this->getResourceShortName($resourceClass), - 'id' => $id, - ]; - $context['data'] = [ - 'data' => $relationData, + 'data' => [ + 'type' => $this->getResourceShortName($resourceClass), + 'id' => $id, + ], ]; $context['iri'] = $iri; @@ -357,14 +239,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. */ @@ -392,7 +266,6 @@ private function getComponents(object $object, ?string $format, array $context): ->propertyMetadataFactory ->create($context['resource_class'], $attribute, $options); - // prevent declaring $attribute as attribute if it's already declared as relationship $isRelationship = false; if (!method_exists(PropertyInfoExtractor::class, 'getType')) { @@ -409,7 +282,6 @@ private function getComponents(object $object, ?string $format, array $context): } if (!isset($className) || !$isOne && !$isMany) { - // don't declare it as an attribute too quick: maybe the next type is a valid resource continue; } @@ -419,8 +291,6 @@ private function getComponents(object $object, ?string $format, array $context): 'cardinality' => $isOne ? 'one' : 'many', ]; - // if we specify the uriTemplate, generates its value for link definition - // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { $attributeValue = $this->propertyAccessor->getValue($object, $attribute); $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); @@ -457,7 +327,6 @@ private function getComponents(object $object, ?string $format, array $context): } if (!$className || (!$isOne && !$isMany)) { - // don't declare it as an attribute too quick: maybe the next type is a valid resource continue; } @@ -467,8 +336,6 @@ private function getComponents(object $object, ?string $format, array $context): 'cardinality' => $isOne ? 'one' : 'many', ]; - // if we specify the uriTemplate, generates its value for link definition - // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { $attributeValue = $this->propertyAccessor->getValue($object, $attribute); $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); @@ -489,7 +356,6 @@ private function getComponents(object $object, ?string $format, array $context): } } - // if all types are not relationships, declare it as an attribute if (!$isRelationship) { $components['attributes'][] = $attribute; } @@ -503,8 +369,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 @@ -525,11 +389,8 @@ private function getPopulatedRelations(object $object, ?string $format, array $c $relationshipName = $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context); } - // Many to one relationship if ('one' === $relationshipDataArray['cardinality']) { - $data[$relationshipName] = [ - 'data' => null, - ]; + $data[$relationshipName] = ['data' => null]; if (!$attributeValue) { continue; @@ -541,10 +402,7 @@ private function getPopulatedRelations(object $object, ?string $format, array $c continue; } - // Many to many relationship - $data[$relationshipName] = [ - 'data' => [], - ]; + $data[$relationshipName] = ['data' => []]; if (!$attributeValue) { continue; @@ -562,9 +420,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'])) { @@ -588,9 +443,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]; } @@ -610,9 +463,6 @@ 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 { $trackingKey = ($data['type'] ?? '').':'.($data['id'] ?? ''); @@ -622,9 +472,6 @@ private function addIncluded(array $data, array &$included, array &$context): vo } } - /** - * 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; @@ -632,9 +479,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; @@ -653,27 +497,6 @@ private function getIdStringFromIdentifiers(array $identifiers): string return CompositeIdentifierParser::stringify($identifiers); } - /** - * Reconstructs an IRI from a resource class and a raw JSON:API id string. - * - * Maps the id to the operation's single URI variable parameter name and generates - * the IRI via IriConverter. 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]]); - } - - // TODO: this code is similar to the one used in JsonLd 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..5b00aa1 --- /dev/null +++ b/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,149 @@ + + * + * 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); + } + + // Avoid issues with proxies if we populated the object + if (!isset($context[AbstractItemNormalizer::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 += ['fetch_data' => false]; + if ($this->useIriAsId) { + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['data']['id'], $context); + } else { + $operation = $context['operation'] ?? null; + if ($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'] ?? [] + ); + + 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/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 fd4b2ea..2809e2f 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -34,6 +34,7 @@ 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; @@ -51,6 +52,7 @@ /** * @author Amrouche Hamza */ +#[IgnoreDeprecations] class ItemNormalizerTest extends TestCase { use ProphecyTrait; From 3a562e7f1bb1bc802e58eff674a20b78fe107275 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 22 May 2026 13:06:32 +0200 Subject: [PATCH 58/84] fix(jsonapi): merge flat page/itemsPerPage params with bracket filter (#8193) --- State/JsonApiProvider.php | 6 ++++++ Tests/State/JsonApiProviderTest.php | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/State/JsonApiProvider.php b/State/JsonApiProvider.php index bf86cb4..0976d9c 100644 --- a/State/JsonApiProvider.php +++ b/State/JsonApiProvider.php @@ -70,6 +70,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) { diff --git a/Tests/State/JsonApiProviderTest.php b/Tests/State/JsonApiProviderTest.php index 5ce9426..e1478a7 100644 --- a/Tests/State/JsonApiProviderTest.php +++ b/Tests/State/JsonApiProviderTest.php @@ -37,4 +37,25 @@ public function testProvide(): void $provider = new JsonApiProvider($decorated); $provider->provide($operation, [], $context); } + + 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')); + } } From b275e45653f693427e38b53b195a6cdab890418b Mon Sep 17 00:00:00 2001 From: Francisco Silva <116388885+Will-thom@users.noreply.github.com> Date: Mon, 25 May 2026 03:50:22 -0300 Subject: [PATCH 59/84] fix(openapi): include jsonapi collection schema (#8190) --- JsonSchema/SchemaFactory.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 8e5b066..3f71cf0 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -259,15 +259,15 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin $properties = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); $properties['data']['properties']['attributes']['$ref'] = $prefix.$key; + $properties['data'] = [ + 'type' => 'array', + 'items' => $properties['data'], + ]; + $schema['description'] = "$definitionName collection."; $schema['allOf'] = [ ['$ref' => $prefix.(false === $operation->getPaginationEnabled() ? self::COLLECTION_BASE_SCHEMA_NAME_NO_PAGINATION : self::COLLECTION_BASE_SCHEMA_NAME)], - ['type' => 'object', 'properties' => [ - 'data' => [ - 'type' => 'array', - 'items' => $properties['data'], - ], - ]], + ['type' => 'object', 'properties' => $properties], ]; return $schema; From 43ceb9b4c60b98d2c076ac6524dcc4b199a95c39 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 25 May 2026 17:24:43 +0200 Subject: [PATCH 60/84] fix(serializer): gate cache_key in JsonApi and Hal with isCacheKeySafe `#[ApiProperty(security: ...)]` makes allowed attributes per-user, but the local `componentsCache` in JsonApi and Hal item normalizers is not user-aware: a cached component map can leak attributes across users. Move `isCacheKeySafe()` from `GraphQl/Serializer/ItemNormalizer` into `Serializer/AbstractItemNormalizer` so it is shared across all formats, and gate `$context['cache_key']` with it in JsonApi and Hal. Promote `CacheKeyTrait::getCacheKey()` from `private` to `protected` so subclasses can reuse the inherited trait method. Supersedes #7854. --- Serializer/ItemNormalizer.php | 4 +- Tests/Serializer/ItemNormalizerTest.php | 58 ++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index f59a49b..a58a361 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -25,7 +25,6 @@ use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\Serializer\AbstractItemNormalizer; -use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; @@ -52,7 +51,6 @@ */ final class ItemNormalizer extends AbstractItemNormalizer { - use CacheKeyTrait; use ClassInfoTrait; use ContextTrait; @@ -108,7 +106,7 @@ public function normalize(mixed $object, ?string $format = null, array $context $context['api_normalize'] = true; if (!isset($context['cache_key'])) { - $context['cache_key'] = $this->getCacheKey($format, $context); + $context['cache_key'] = $this->isCacheKeySafe($context) ? $this->getCacheKey($format, $context) : false; } $data = parent::normalize($object, $format, $context); diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index f11d7c3..4c5ffe9 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -123,6 +123,62 @@ public function testNormalize(): void $this->assertEquals($expected, $normalizer->normalize($dummy, ItemNormalizer::FORMAT)); } + public function testCacheKeyIsFalseWhenAPropertyHasSecurity(): void + { + $dummy = new Dummy(); + $dummy->setId(11); + $dummy->setName('hello'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withIdentifier(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/11'); + + $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(11); + $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')])), + ])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize(Argument::any(), ItemNormalizer::FORMAT, Argument::type('array'))->will(fn ($args) => $args[0]); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactoryProphecy->reveal(), + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $normalizer->normalize($dummy, ItemNormalizer::FORMAT); + + $componentsCacheRef = new \ReflectionProperty(ItemNormalizer::class, 'componentsCache'); + $this->assertSame([], $componentsCacheRef->getValue($normalizer), 'componentsCache must not be populated when a property has security set'); + } + public function testNormalizeCircularReference(): void { $circularReferenceEntity = new CircularReference(); @@ -141,7 +197,7 @@ public function testNormalizeCircularReference(): void $resourceMetadataCollectionFactoryProphecy->create(CircularReference::class)->willReturn(new ResourceMetadataCollection('CircularReference')); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(CircularReference::class, [])->willReturn(new PropertyNameCollection()); + $propertyNameCollectionFactoryProphecy->create(CircularReference::class, Argument::type('array'))->willReturn(new PropertyNameCollection()); $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), From b01b37e75ba32506ba39d5ca18df1bb9803a9885 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 26 May 2026 08:29:35 +0200 Subject: [PATCH 61/84] fix(serializer): bump min serializer dep and fix phpstan probe typing - Bump api-platform/serializer min to ^4.3.7 in JsonApi, Hal and GraphQl composer.json so they pull a serializer release that exposes the new protected isCacheKeySafe() method on AbstractItemNormalizer. - Change createCacheKeySafeProbe() return type to object so PHPStan can resolve probeIsCacheKeySafe() on the anonymous AbstractItemNormalizer subclass. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 65ba9d5..5d71bb5 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "api-platform/documentation": "^4.2", "api-platform/json-schema": "^4.2", "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2.18", + "api-platform/serializer": "^4.2.25", "api-platform/state": "^4.2.4", "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", From 870aefe6b5b9a87c1443fc82e0460dbb957c6af6 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 25 May 2026 17:24:43 +0200 Subject: [PATCH 62/84] fix(serializer): gate cache_key in JsonApi and Hal with isCacheKeySafe `#[ApiProperty(security: ...)]` makes allowed attributes per-user, but the local `componentsCache` in JsonApi and Hal item normalizers is not user-aware: a cached component map can leak attributes across users. Move `isCacheKeySafe()` from `GraphQl/Serializer/ItemNormalizer` into `Serializer/AbstractItemNormalizer` so it is shared across all formats, and gate `$context['cache_key']` with it in JsonApi and Hal. Promote `CacheKeyTrait::getCacheKey()` from `private` to `protected` so subclasses can reuse the inherited trait method. Supersedes #7854. --- Serializer/ItemNormalizer.php | 4 +- Tests/Serializer/ItemNormalizerTest.php | 61 ++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index d1e5ba7..0e64eae 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -24,7 +24,6 @@ use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Serializer\AbstractItemNormalizer; -use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\ErrorHandler\Exception\FlattenException; @@ -46,7 +45,6 @@ */ final class ItemNormalizer extends AbstractItemNormalizer { - use CacheKeyTrait; use ClassInfoTrait; use ContextTrait; @@ -99,7 +97,7 @@ public function normalize(mixed $object, ?string $format = null, array $context $context['api_normalize'] = true; if (!isset($context['cache_key'])) { - $context['cache_key'] = $this->getCacheKey($format, $context); + $context['cache_key'] = $this->isCacheKeySafe($context) ? $this->getCacheKey($format, $context) : false; } $data = parent::normalize($object, $format, $context); diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index bb1c010..63534fe 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -121,6 +121,62 @@ public function testNormalize(): void $this->assertEquals($expected, $normalizer->normalize($dummy, ItemNormalizer::FORMAT)); } + public function testCacheKeyIsFalseWhenAPropertyHasSecurity(): void + { + $dummy = new Dummy(); + $dummy->setId(11); + $dummy->setName('hello'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withIdentifier(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/11'); + + $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(11); + $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')])), + ])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize(Argument::any(), ItemNormalizer::FORMAT, Argument::type('array'))->will(fn ($args) => $args[0]); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactoryProphecy->reveal(), + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $normalizer->normalize($dummy, ItemNormalizer::FORMAT); + + $componentsCacheRef = new \ReflectionProperty(ItemNormalizer::class, 'componentsCache'); + $this->assertSame([], $componentsCacheRef->getValue($normalizer), 'componentsCache must not be populated when a property has security set'); + } + public function testNormalizeCircularReference(): void { $circularReferenceEntity = new CircularReference(); @@ -139,8 +195,11 @@ public function testNormalizeCircularReference(): void $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceMetadataCollectionFactoryProphecy->create(CircularReference::class)->willReturn(new ResourceMetadataCollection('CircularReference')); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(CircularReference::class, Argument::type('array'))->willReturn(new PropertyNameCollection()); + $normalizer = new ItemNormalizer( - $this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), $this->prophesize(PropertyMetadataFactoryInterface::class)->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), From 0202a5de940424c7219c05b9179e31b63df28a2c Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 26 May 2026 08:29:35 +0200 Subject: [PATCH 63/84] fix(serializer): bump min serializer dep and fix phpstan probe typing - Bump api-platform/serializer min to ^4.3.7 in JsonApi, Hal and GraphQl composer.json so they pull a serializer release that exposes the new protected isCacheKeySafe() method on AbstractItemNormalizer. - Change createCacheKeySafeProbe() return type to object so PHPStan can resolve probeIsCacheKeySafe() on the anonymous AbstractItemNormalizer subclass. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 1c1e287..ae9924b 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "api-platform/documentation": "^4.1.11", "api-platform/json-schema": "^4.1.11", "api-platform/metadata": "^4.1.11", - "api-platform/serializer": "^4.1.11", + "api-platform/serializer": "^4.1.29", "api-platform/state": "^4.1.11", "symfony/error-handler": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4.14 || ^7.0" From d23a2e5ca129c48d883168a4b6213344f64c6223 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 27 May 2026 07:55:17 +0200 Subject: [PATCH 64/84] fix(serializer): adapt tests for 4.1 (createMock for HAL, drop name converter) --- Tests/Serializer/ItemNormalizerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index 63534fe..284ded1 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -164,7 +164,7 @@ public function testCacheKeyIsFalseWhenAPropertyHasSecurity(): void $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), - new ReservedAttributeNameConverter(), + null, null, [], $resourceMetadataCollectionFactoryProphecy->reveal(), From f5270cbca004dacea9659a8482035f20916bf7ca Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 29 May 2026 15:58:43 +0200 Subject: [PATCH 65/84] fix(tests): symfony 8.1 compat (#8210) --- Tests/State/JsonApiProviderTest.php | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/Tests/State/JsonApiProviderTest.php b/Tests/State/JsonApiProviderTest.php index e1478a7..6e5999b 100644 --- a/Tests/State/JsonApiProviderTest.php +++ b/Tests/State/JsonApiProviderTest.php @@ -17,25 +17,22 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\State\ProviderInterface; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpFoundation\InputBag; -use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; class JsonApiProviderTest extends TestCase { public function testProvide(): void { - $request = $this->createMock(Request::class); - $request->expects($this->once())->method('getRequestFormat')->willReturn('jsonapi'); - $request->attributes = $this->createMock(ParameterBag::class); - $request->attributes->expects($this->once())->method('get')->with('_api_filters', [])->willReturn([]); - $request->attributes->method('set')->with($this->logicalOr('_api_filter_property', '_api_included', '_api_filters'), $this->logicalOr(['id', 'name', 'dummyFloat', 'relatedDummy' => ['id', 'name']], ['relatedDummy'], [])); - $request->query = new InputBag(['fields' => ['dummy' => 'id,name,dummyFloat', 'relatedDummy' => 'id,name'], 'include' => 'relatedDummy,foo']); + $request = new Request(query: ['fields' => ['dummy' => 'id,name,dummyFloat', 'relatedDummy' => 'id,name'], 'include' => 'relatedDummy,foo']); + $request->setRequestFormat('jsonapi'); $operation = new Get(class: \stdClass::class, shortName: 'dummy'); $context = ['request' => $request]; $decorated = $this->createMock(ProviderInterface::class); $provider = new JsonApiProvider($decorated); $provider->provide($operation, [], $context); + + $this->assertSame(['id', 'name', 'dummyFloat', 'relatedDummy' => ['id', 'name']], $request->attributes->get('_api_filter_property')); + $this->assertSame(['relatedDummy'], $request->attributes->get('_api_included')); } public function testProvideMergesFlatPaginationWithBracketFilter(): void From cd75deb64638e2963a32fe673256d473935a5693 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 1 Jun 2026 11:42:04 +0200 Subject: [PATCH 66/84] fix(jsonapi): keep flat custom params with flat page (#8216) (#8217) fixes #8216 --- State/JsonApiProvider.php | 11 ++++++++ Tests/State/JsonApiProviderTest.php | 43 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/State/JsonApiProvider.php b/State/JsonApiProvider.php index 0976d9c..c09c2fd 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 { @@ -87,6 +88,16 @@ 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); + } + $filters = array_replace($rawParams, $filters); + $request->attributes->set('_api_filters', $filters); } diff --git a/Tests/State/JsonApiProviderTest.php b/Tests/State/JsonApiProviderTest.php index 6e5999b..e315e8d 100644 --- a/Tests/State/JsonApiProviderTest.php +++ b/Tests/State/JsonApiProviderTest.php @@ -55,4 +55,47 @@ public function testProvideMergesFlatPaginationWithBracketFilter(): void '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); + } } From aa1208d8efc32f3ee6b454c990ce656cae4a5542 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 2 Jun 2026 15:20:29 +0200 Subject: [PATCH 67/84] fix(jsonapi): drop consumed pagination keys before raw-param replace 37e361339 reinjected the raw bracket-style page array after JsonApiProvider had already hoisted its contents into _api_filters, so _api_filters['page'] surfaced as an array (e.g. ['itemsPerPage' => 15] for ?page[itemsPerPage]=15) and downstream providers expecting a scalar threw "Page must be a positive integer". Strip filter/page/itemsPerPage/pagination/partial/include/fields from $rawParams before array_replace; those were already processed above. Refs #8216 --- State/JsonApiProvider.php | 4 +++- Tests/State/JsonApiProviderTest.php | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/State/JsonApiProvider.php b/State/JsonApiProvider.php index c09c2fd..9a341fe 100644 --- a/State/JsonApiProvider.php +++ b/State/JsonApiProvider.php @@ -96,7 +96,9 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $rawParams = $queryString ? RequestParser::parseRequestParams($queryString) : []; $request->attributes->set('_api_query_parameters', $rawParams); } - $filters = array_replace($rawParams, $filters); + // 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/State/JsonApiProviderTest.php b/Tests/State/JsonApiProviderTest.php index e315e8d..c527615 100644 --- a/Tests/State/JsonApiProviderTest.php +++ b/Tests/State/JsonApiProviderTest.php @@ -98,4 +98,23 @@ public function testProvideHonoursPrePopulatedApiQueryParameters(): void $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); + } } From 670d31494eebbd7081cd7dfac680f3dbe0eb5781 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 25 May 2026 17:24:43 +0200 Subject: [PATCH 68/84] fix(serializer): gate cache_key in JsonApi and Hal with isCacheKeySafe `#[ApiProperty(security: ...)]` makes allowed attributes per-user, but the local `componentsCache` in JsonApi and Hal item normalizers is not user-aware: a cached component map can leak attributes across users. Move `isCacheKeySafe()` from `GraphQl/Serializer/ItemNormalizer` into `Serializer/AbstractItemNormalizer` so it is shared across all formats, and gate `$context['cache_key']` with it in JsonApi and Hal. Promote `CacheKeyTrait::getCacheKey()` from `private` to `protected` so subclasses can reuse the inherited trait method. Supersedes #7854. --- Serializer/ItemNormalizer.php | 4 +- Tests/Serializer/ItemNormalizerTest.php | 58 ++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index b97c141..d3e59b2 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -28,7 +28,6 @@ use ApiPlatform\Metadata\Util\CompositeIdentifierParser; use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\Serializer\AbstractItemNormalizer; -use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; @@ -55,7 +54,6 @@ */ final class ItemNormalizer extends AbstractItemNormalizer { - use CacheKeyTrait; use ClassInfoTrait; use ContextTrait; @@ -127,7 +125,7 @@ public function normalize(mixed $data, ?string $format = null, array $context = $context['api_normalize'] = true; if (!isset($context['cache_key'])) { - $context['cache_key'] = $this->getCacheKey($format, $context); + $context['cache_key'] = $this->isCacheKeySafe($context) ? $this->getCacheKey($format, $context) : false; } $normalizedData = parent::normalize($data, $format, $context); diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index fd4b2ea..c6c1f35 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -126,6 +126,62 @@ public function testNormalize(): void $this->assertEquals($expected, $normalizer->normalize($dummy, ItemNormalizer::FORMAT)); } + public function testCacheKeyIsFalseWhenAPropertyHasSecurity(): void + { + $dummy = new Dummy(); + $dummy->setId(11); + $dummy->setName('hello'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withIdentifier(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/11'); + + $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(11); + $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')])), + ])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize(Argument::any(), ItemNormalizer::FORMAT, Argument::type('array'))->will(fn ($args) => $args[0]); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactoryProphecy->reveal(), + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $normalizer->normalize($dummy, ItemNormalizer::FORMAT); + + $componentsCacheRef = new \ReflectionProperty(ItemNormalizer::class, 'componentsCache'); + $this->assertSame([], $componentsCacheRef->getValue($normalizer), 'componentsCache must not be populated when a property has security set'); + } + public function testNormalizeCircularReference(): void { $circularReferenceEntity = new CircularReference(); @@ -144,7 +200,7 @@ public function testNormalizeCircularReference(): void $resourceMetadataCollectionFactoryProphecy->create(CircularReference::class)->willReturn(new ResourceMetadataCollection('CircularReference')); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(CircularReference::class, [])->willReturn(new PropertyNameCollection()); + $propertyNameCollectionFactoryProphecy->create(CircularReference::class, Argument::type('array'))->willReturn(new PropertyNameCollection()); $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), From fc8173c8333f0bbdedf5720867781e6003a98afd Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 26 May 2026 08:16:12 +0200 Subject: [PATCH 69/84] fix(jsonapi): cs-fixer static closure --- Tests/Serializer/ItemNormalizerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Serializer/ItemNormalizerTest.php b/Tests/Serializer/ItemNormalizerTest.php index c6c1f35..374675a 100644 --- a/Tests/Serializer/ItemNormalizerTest.php +++ b/Tests/Serializer/ItemNormalizerTest.php @@ -161,7 +161,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(), From b182df4e4b551bdb13757c5918fe643a60ea723f Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 26 May 2026 08:29:35 +0200 Subject: [PATCH 70/84] fix(serializer): bump min serializer dep and fix phpstan probe typing - Bump api-platform/serializer min to ^4.3.7 in JsonApi, Hal and GraphQl composer.json so they pull a serializer release that exposes the new protected isCacheKeySafe() method on AbstractItemNormalizer. - Change createCacheKeySafeProbe() return type to object so PHPStan can resolve probeIsCacheKeySafe() on the anonymous AbstractItemNormalizer subclass. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 56d2c52..0b6c7b3 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "api-platform/documentation": "^4.3", "api-platform/json-schema": "^4.3", "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3", + "api-platform/serializer": "^4.3.7", "api-platform/state": "^4.3", "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", From c1b70755ef8aaeeb1d6901035a0db88d344114eb Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 4 Jun 2026 15:34:52 +0200 Subject: [PATCH 71/84] fix(serializer): bump api-platform/serializer to ^4.3.8 and cover Hal in CI The cache-key gate `isCacheKeySafe()` moved into `Serializer/AbstractItemNormalizer` after `api-platform/serializer v4.3.7` was tagged, so `^4.3.7` resolves to a release that still lacks the method. The api-platform/json-api lowest CI job blew up with "Call to undefined method ItemNormalizer::isCacheKeySafe()". Bump the constraint to `^4.3.8` in JsonApi, Hal and GraphQl so lowest installs pick up the published release that exposes the method. api-platform/hal was missing from the phpunit components matrix, so the same lowest-deps regression silently passed for it. Add it to the matrix and migrate `ItemNormalizerTest.php` to local Hal fixtures so the suite runs standalone: - Add missing `phpspec/prophecy-phpunit` dev dep used by the suite. - Enrich `Tests/Fixtures/Dummy` with alias/description and make `relatedDummy` nullable so the legacy assertions still hold. - Make `Tests/Fixtures/MaxDepthDummy::\$child` nullable to avoid the "must not be accessed before initialization" error. - Correct the stale `getSupportedTypes` assertion that was never executed in CI. - Skip two pre-existing `SchemaFactoryTest` cases whose assertions drifted from the current factory output; tracked for follow-up. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 0b6c7b3..c638ec1 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "api-platform/documentation": "^4.3", "api-platform/json-schema": "^4.3", "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3.7", + "api-platform/serializer": "^4.3.8", "api-platform/state": "^4.3", "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", From be694c4d793031c6fa49404ae918ee6261e2b415 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 5 Jun 2026 13:42:57 +0200 Subject: [PATCH 72/84] fix(jsonapi): do not require id in input schema for post operations (#8252) Fixes #6738 --- JsonSchema/SchemaFactory.php | 6 +++++- Tests/JsonSchema/SchemaFactoryTest.php | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 3f71cf0..f940b44 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -20,6 +20,7 @@ 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; @@ -369,11 +370,14 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, ]; } + // https://jsonapi.org/format/#crud-creating — clients MAY supply an id when creating a resource. + $required = $operation instanceof HttpOperation && 'POST' === $operation->getMethod() ? ['type'] : ['type', 'id']; + return [ 'data' => [ 'type' => 'object', 'properties' => $replacement, - 'required' => ['type', 'id'], + 'required' => $required, ], ] + $included; } diff --git a/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php index 4adcb64..9ca0d9c 100644 --- a/Tests/JsonSchema/SchemaFactoryTest.php +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -22,6 +22,8 @@ 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; @@ -49,6 +51,7 @@ protected function setUp(): void ); $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_OUTPUT])->willReturn(new PropertyNameCollection()); + $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_INPUT])->willReturn(new PropertyNameCollection()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); $definitionNameFactory = new DefinitionNameFactory(null); @@ -164,4 +167,26 @@ public function testSchemaTypeBuildSchema(): void $forcedCollection = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, forceCollection: true); $this->assertEquals($resultSchema['allOf'][0]['$ref'], $forcedCollection['allOf'][0]['$ref']); } + + public function testPostInputSchemaDoesNotRequireId(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_INPUT, new Post()); + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + + $this->assertTrue(isset($definitions[$rootDefinitionKey]['properties']['data'])); + $data = $definitions[$rootDefinitionKey]['properties']['data']; + $this->assertArrayHasKey('required', $data); + $this->assertSame(['type'], $data['required']); + } + + public function testPatchInputSchemaRequiresId(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_INPUT, new Patch()); + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + + $data = $definitions[$rootDefinitionKey]['properties']['data']; + $this->assertSame(['type', 'id'], $data['required']); + } } From 2e1773bc4b098119531c59de14124afa0cb693a2 Mon Sep 17 00:00:00 2001 From: Abderrahim GHAZALI Date: Fri, 5 Jun 2026 17:17:34 +0200 Subject: [PATCH 73/84] fix(jsonapi): allow opt-in client-generated IDs on POST per spec (#7930) Co-authored-by: soyuka --- JsonSchema/SchemaFactory.php | 5 +++- Serializer/ItemNormalizer.php | 41 +++++++++++++++++++------- Tests/JsonSchema/SchemaFactoryTest.php | 10 +++++++ 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index f940b44..2baa105 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -283,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'] ?? []; @@ -371,7 +373,8 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, } // https://jsonapi.org/format/#crud-creating — clients MAY supply an id when creating a resource. - $required = $operation instanceof HttpOperation && 'POST' === $operation->getMethod() ? ['type'] : ['type', 'id']; + // 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' => [ diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index d3e59b2..ee2b7bf 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -59,6 +59,14 @@ final class ItemNormalizer extends AbstractItemNormalizer public const FORMAT = 'jsonapi'; + /** + * 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'; + private array $componentsCache = []; private bool $useIriAsId; @@ -205,21 +213,27 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return parent::denormalize($data, $type, $format, $context); } + $operation = $context['operation'] ?? null; + $isPostOperation = $operation instanceof HttpOperation && 'POST' === $operation->getMethod(); + $allowClientGeneratedId = true === ($context[self::ALLOW_CLIENT_GENERATED_ID] ?? $this->defaultContext[self::ALLOW_CLIENT_GENERATED_ID] ?? false); + // 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)) { + 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.', self::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.'); - } - - $context += ['fetch_data' => false]; - if ($this->useIriAsId) { - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( - $data['data']['id'], - $context - ); } else { - $operation = $context['operation'] ?? null; - if ($operation instanceof HttpOperation) { + $context += ['fetch_data' => false]; + if ($this->useIriAsId) { + $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( + $data['data']['id'], + $context + ); + } elseif ($operation instanceof HttpOperation) { $iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation); $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context); } @@ -232,6 +246,11 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a $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, diff --git a/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php index 9ca0d9c..d588075 100644 --- a/Tests/JsonSchema/SchemaFactoryTest.php +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -189,4 +189,14 @@ public function testPatchInputSchemaRequiresId(): void $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']); + } } From 10f17b600f3d32cacc9bd066428f6874fcace30d Mon Sep 17 00:00:00 2001 From: Alexis Lefebvre <2071331+alexisLefebvre@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:51:17 +0200 Subject: [PATCH 74/84] chore: fix php-cs-fixer (#8269) --- Serializer/ItemNormalizerTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Serializer/ItemNormalizerTrait.php b/Serializer/ItemNormalizerTrait.php index b307caa..e2279ca 100644 --- a/Serializer/ItemNormalizerTrait.php +++ b/Serializer/ItemNormalizerTrait.php @@ -62,7 +62,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a 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. + // 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 { From f9e4bc473dec64c4bd28545228696b3fee0c3768 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 3 Jun 2026 13:41:18 +0200 Subject: [PATCH 75/84] fix(serializer): validate IRI target class on relation denormalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AbstractItemNormalizer's relation IRI denormalization loaded the resource without checking it matched the declared relation class, since it never passed an operation to IriConverter::getResourceFromIri. A writable relation declared as `Foo` with no PHP type hint (legacy `@var`-only style) silently accepted a `/bars/1` IRI and Symfony's PropertyAccessor could not block it — CWE-843 type confusion. Add an is_a guard at both inline call sites so a mismatched IRI throws InvalidArgumentException, mirroring the IriConverter operation check. GHSA-9rjg-x2p2-h68h --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ae9924b..e983ada 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "api-platform/documentation": "^4.1.11", "api-platform/json-schema": "^4.1.11", "api-platform/metadata": "^4.1.11", - "api-platform/serializer": "^4.1.29", + "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" From 516cb0d715a0a6f4a79d0aef0bd01470074fc234 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 11:25:11 +0200 Subject: [PATCH 76/84] fix(jsonapi): exclude relations from openapi attributes schema (#8313) Fixes #8308 --- JsonSchema/SchemaFactory.php | 4 +- Tests/JsonSchema/SchemaFactoryTest.php | 95 +++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index 2baa105..eccfb67 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -258,7 +258,6 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin unset($schema['type']); $properties = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); - $properties['data']['properties']['attributes']['$ref'] = $prefix.$key; $properties['data'] = [ 'type' => 'array', @@ -347,9 +346,8 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, $attributes[$propertyName] = $property; } - $currentRef = $this->getSchemaUriPrefix($schema->getVersion()).$schema->getRootDefinitionKey(); $replacement = self::PROPERTY_PROPS; - $replacement['attributes'] = ['$ref' => $currentRef]; + $replacement['attributes']['properties'] = $attributes; $included = []; if (\count($relationships) > 0) { diff --git a/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php index d588075..5c64724 100644 --- a/Tests/JsonSchema/SchemaFactoryTest.php +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -15,9 +15,11 @@ use ApiPlatform\JsonApi\JsonSchema\SchemaFactory; use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +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; @@ -31,7 +33,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 { @@ -112,7 +116,8 @@ public function testHasRootDefinitionKeyBuildSchema(): void 'type' => 'string', ], 'attributes' => [ - '$ref' => '#/definitions/Dummy', + 'type' => 'object', + 'properties' => [], ], ], 'required' => [ @@ -155,7 +160,8 @@ public function testSchemaTypeBuildSchema(): void $this->assertArrayHasKey('data', $objectSchema['properties']); $this->assertArrayHasKey('items', $objectSchema['properties']['data']); - $this->assertArrayHasKey('$ref', $objectSchema['properties']['data']['items']['properties']['attributes']); + $this->assertArrayNotHasKey('$ref', $objectSchema['properties']['data']['items']['properties']['attributes']); + $this->assertSame('object', $objectSchema['properties']['data']['items']['properties']['attributes']['type']); $properties = $objectSchema['properties']; $this->assertArrayHasKey('data', $properties); @@ -199,4 +205,89 @@ public function testPostOutputSchemaRequiresId(): void $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']); + } + + 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(null); + + $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, + ); + } } From dfa98bd7a3abd7921a01b82b0a941e5dfe5e8225 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 14:59:50 +0200 Subject: [PATCH 77/84] fix(jsonapi): correct relationship schemas in generated json schema (#8321) --- JsonSchema/SchemaFactory.php | 6 +- Tests/Fixtures/OtherRelatedDummy.php | 37 +++++++++ Tests/JsonSchema/SchemaFactoryTest.php | 105 +++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 Tests/Fixtures/OtherRelatedDummy.php diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index eccfb67..e86aedf 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -63,6 +63,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI 'format' => 'iri-reference', ], ], + 'required' => ['type', 'id'], ]; private const PROPERTY_PROPS = [ 'id' => [ @@ -321,7 +322,10 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, $refs[$this->getSchemaUriPrefix($schema->getVersion()).$definitionName] = '$ref'; } - $relatedDefinitions[$propertyName] = array_flip($refs); + // keep one entry per related definition: a polymorphic relation targets several resource classes, all of which may appear in "included" + foreach (array_keys($refs) as $ref) { + $relatedDefinitions[$ref] = ['$ref' => $ref]; + } if ($isOne) { $relationships[$propertyName]['properties']['data'] = [ 'oneOf' => [ diff --git a/Tests/Fixtures/OtherRelatedDummy.php b/Tests/Fixtures/OtherRelatedDummy.php new file mode 100644 index 0000000..aad2f3e --- /dev/null +++ b/Tests/Fixtures/OtherRelatedDummy.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonApi\Tests\Fixtures; + +use ApiPlatform\Metadata\ApiResource; + +/** + * A second related resource used to exercise polymorphic (union-typed) relationships. + */ +#[ApiResource] +class OtherRelatedDummy +{ + private ?int $id = null; + + public ?string $label = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id): void + { + $this->id = $id; + } +} diff --git a/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php index 5c64724..648cfd8 100644 --- a/Tests/JsonSchema/SchemaFactoryTest.php +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -15,6 +15,7 @@ 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; @@ -230,6 +231,110 @@ public function testRelationIsExcludedFromAttributes(): void $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(null); + + $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'); From 30f70ddc6d865e9c36d99c0255bb1f407c4d4258 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 20:14:46 +0200 Subject: [PATCH 78/84] refactor(jsonapi): single source of truth for the attribute/relationship split (#8325) --- JsonSchema/SchemaFactory.php | 89 +++---------- Serializer/ItemNormalizer.php | 124 ++++-------------- ...ReservedAttributeNameSchemaFactoryTest.php | 107 +++++++++++++++ Tests/Util/ResourceLinkageResolverTest.php | 81 ++++++++++++ Util/ResourceLinkageResolver.php | 90 +++++++++++++ 5 files changed, 326 insertions(+), 165 deletions(-) create mode 100644 Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php create mode 100644 Tests/Util/ResourceLinkageResolverTest.php create mode 100644 Util/ResourceLinkageResolver.php diff --git a/JsonSchema/SchemaFactory.php b/JsonSchema/SchemaFactory.php index e86aedf..620572a 100644 --- a/JsonSchema/SchemaFactory.php +++ b/JsonSchema/SchemaFactory.php @@ -13,6 +13,8 @@ namespace ApiPlatform\JsonApi\JsonSchema; +use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; +use ApiPlatform\JsonApi\Util\ResourceLinkageResolver; use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\DefinitionNameFactoryInterface; use ApiPlatform\JsonSchema\ResourceMetadataTrait; @@ -25,12 +27,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\State\ApiResource\Error; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; -use Symfony\Component\TypeInfo\Type\ObjectType; /** * Decorator factory which adds JSON:API properties to the JSON Schema document. @@ -83,7 +80,9 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI */ private $builtSchema = []; - public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) + private readonly ResourceLinkageResolver $resourceLinkageResolver; + + public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null, ?ResourceLinkageResolver $resourceLinkageResolver = null) { if (!$definitionNameFactory) { $this->definitionNameFactory = new DefinitionNameFactory(); @@ -93,6 +92,7 @@ public function __construct(private readonly SchemaFactoryInterface $schemaFacto } $this->resourceClassResolver = $resourceClassResolver; $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->resourceLinkageResolver = $resourceLinkageResolver ?? new ResourceLinkageResolver($resourceClassResolver); } /** @@ -326,8 +326,10 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, foreach (array_keys($refs) as $ref) { $relatedDefinitions[$ref] = ['$ref' => $ref]; } + // A relationship literally named "relationships"/"included" is prefixed at runtime too. + $relationshipName = ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$propertyName] ?? $propertyName; if ($isOne) { - $relationships[$propertyName]['properties']['data'] = [ + $relationships[$relationshipName]['properties']['data'] = [ 'oneOf' => [ ['type' => 'null'], self::RELATION_PROPS, @@ -335,19 +337,16 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, ]; continue; } - $relationships[$propertyName]['properties']['data'] = [ + $relationships[$relationshipName]['properties']['data'] = [ 'type' => 'array', 'items' => self::RELATION_PROPS, ]; continue; } - if ('id' === $propertyName) { - // should probably be renamed "lid" and moved to the above node - $attributes['_id'] = $property; - continue; - } - $attributes[$propertyName] = $property; + // Reserved names (id, type, links, relationships, included) are prefixed by the + // ReservedAttributeNameConverter at runtime; mirror that single source of truth here. + $attributes[ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$propertyName] ?? $propertyName] = $property; } $replacement = self::PROPERTY_PROPS; @@ -391,67 +390,21 @@ private function getRelationship(string $resourceClass, string $property, ?array { $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $serializerContext ?? []); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - $isRelationship = false; - $isOne = $isMany = false; - $relatedClasses = []; - - foreach ($types as $type) { - if ($type->isCollection()) { - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - $isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } else { - $isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } - if (!isset($className) || (!$isOne && !$isMany)) { - continue; - } - $isRelationship = true; - $resourceMetadata = $this->resourceMetadataFactory->create($className); - $operation = $resourceMetadata->getOperation(); - // @see https://github.com/api-platform/core/issues/5501 - // @see https://github.com/api-platform/core/pull/5722 - $relatedClasses[$className] = $operation->canRead(); - } - - return $isRelationship ? [$isOne, $relatedClasses] : null; - } - - if (null === $type = $propertyMetadata->getNativeType()) { + // Share the runtime attributes/relationships split so the generated schema cannot drift from the document. + $relationships = $this->resourceLinkageResolver->getRelationships($propertyMetadata); + if ([] === $relationships) { return null; } - $isRelationship = false; - $isOne = $isMany = false; + $isOne = false; $relatedClasses = []; - - /** @var class-string|null $className */ - $className = null; - - $typeIsResourceClass = function (Type $type) use (&$className): bool { - return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); - }; - - foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { - if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) { - $isMany = true; - } elseif ($t->isSatisfiedBy($typeIsResourceClass)) { - $isOne = true; - } - - if (!$className || (!$isOne && !$isMany)) { - continue; - } - - $isRelationship = true; - $resourceMetadata = $this->resourceMetadataFactory->create($className); - $operation = $resourceMetadata->getOperation(); + foreach ($relationships as [$className, $isCollection]) { + $isOne = $isOne || !$isCollection; // @see https://github.com/api-platform/core/issues/5501 // @see https://github.com/api-platform/core/pull/5722 - $relatedClasses[$className] = $operation->canRead(); + $relatedClasses[$className] = $this->resourceMetadataFactory->create($className)->getOperation()->canRead(); } - return $isRelationship ? [$isOne, $relatedClasses] : null; + return [$isOne, $relatedClasses]; } } diff --git a/Serializer/ItemNormalizer.php b/Serializer/ItemNormalizer.php index ee2b7bf..b3bcd2c 100644 --- a/Serializer/ItemNormalizer.php +++ b/Serializer/ItemNormalizer.php @@ -13,6 +13,7 @@ namespace ApiPlatform\JsonApi\Serializer; +use ApiPlatform\JsonApi\Util\ResourceLinkageResolver; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\HttpOperation; @@ -26,14 +27,12 @@ use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\CompositeIdentifierParser; -use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\RuntimeException; @@ -41,9 +40,6 @@ use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; -use Symfony\Component\TypeInfo\Type\ObjectType; /** * Converts between objects and array. @@ -69,6 +65,7 @@ final class ItemNormalizer extends AbstractItemNormalizer private array $componentsCache = []; private bool $useIriAsId; + private readonly ResourceLinkageResolver $resourceLinkageResolver; public function __construct( PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, @@ -85,9 +82,11 @@ public function __construct( ?OperationResourceClassResolverInterface $operationResourceResolver = null, private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null, bool $useIriAsId = true, + ?ResourceLinkageResolver $resourceLinkageResolver = null, ) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); $this->useIriAsId = $useIriAsId; + $this->resourceLinkageResolver = $resourceLinkageResolver ?? new ResourceLinkageResolver($resourceClassResolver); } /** @@ -409,105 +408,36 @@ private function getComponents(object $object, ?string $format, array $context): ->propertyMetadataFactory ->create($context['resource_class'], $attribute, $options); - // prevent declaring $attribute as attribute if it's already declared as relationship - $isRelationship = false; + // Shared with the JSON Schema SchemaFactory so the documented split cannot drift from this output. + $relationships = $this->resourceLinkageResolver->getRelationships($propertyMetadata); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - - foreach ($types as $type) { - $isOne = $isMany = false; - - if ($type->isCollection()) { - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - $isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } else { - $isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } - - if (!isset($className) || !$isOne && !$isMany) { - // don't declare it as an attribute too quick: maybe the next type is a valid resource - continue; - } + foreach ($relationships as [$className, $isCollection]) { + $relation = [ + 'name' => $attribute, + 'type' => $this->getResourceShortName($className), + 'cardinality' => $isCollection ? 'many' : 'one', + ]; - $relation = [ - 'name' => $attribute, - 'type' => $this->getResourceShortName($className), - 'cardinality' => $isOne ? 'one' : 'many', - ]; - - // if we specify the uriTemplate, generates its value for link definition - // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content - if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - $childContext = $this->createChildContext($context, $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); - - $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( - operationName: $itemUriTemplate, - httpOperation: true - ); - - $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); - } + // if we specify the uriTemplate, generates its value for link definition + // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content + if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); + $childContext = $this->createChildContext($context, $attribute, $format); + unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); + + $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( + operationName: $itemUriTemplate, + httpOperation: true + ); - $components['relationships'][] = $relation; - $isRelationship = true; + $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); } - } else { - if ($type = $propertyMetadata->getNativeType()) { - /** @var class-string|null $className */ - $className = null; - - $typeIsResourceClass = function (Type $type) use (&$className): bool { - return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); - }; - foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { - $isOne = $isMany = false; - - if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) { - $isMany = true; - } elseif ($t->isSatisfiedBy($typeIsResourceClass)) { - $isOne = true; - } - - if (!$className || (!$isOne && !$isMany)) { - // don't declare it as an attribute too quick: maybe the next type is a valid resource - continue; - } - - $relation = [ - 'name' => $attribute, - 'type' => $this->getResourceShortName($className), - 'cardinality' => $isOne ? 'one' : 'many', - ]; - - // if we specify the uriTemplate, generates its value for link definition - // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content - if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - $childContext = $this->createChildContext($context, $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); - - $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( - operationName: $itemUriTemplate, - httpOperation: true - ); - - $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); - } - - $components['relationships'][] = $relation; - $isRelationship = true; - } - } + $components['relationships'][] = $relation; } - // if all types are not relationships, declare it as an attribute - if (!$isRelationship) { + if ([] === $relationships) { $components['attributes'][] = $attribute; } } diff --git a/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php b/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php new file mode 100644 index 0000000..0b0d46b --- /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(null); + + $baseSchemaFactory = new BaseSchemaFactory( + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactory->reveal(), + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); + + $this->schemaFactory = new SchemaFactory( + schemaFactory: $baseSchemaFactory, + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + } + + public function testReservedAttributeNamesAreRenamedLikeTheResponse(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $attributes = $resultSchema['definitions'][$rootDefinitionKey]['properties']['data']['properties']['attributes']['properties']; + + // Every reserved name must be documented under the prefixed key the ReservedAttributeNameConverter emits. + $this->assertArrayHasKey('_id', $attributes); + $this->assertArrayHasKey('_type', $attributes); + $this->assertArrayHasKey('_links', $attributes); + $this->assertArrayHasKey('_relationships', $attributes); + $this->assertArrayHasKey('_included', $attributes); + $this->assertArrayHasKey('name', $attributes); + + // The bare reserved names must never leak: the response never emits them. + $this->assertArrayNotHasKey('id', $attributes); + $this->assertArrayNotHasKey('type', $attributes); + $this->assertArrayNotHasKey('links', $attributes); + $this->assertArrayNotHasKey('relationships', $attributes); + $this->assertArrayNotHasKey('included', $attributes); + } +} diff --git a/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..73cacb9 --- /dev/null +++ b/Util/ResourceLinkageResolver.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonApi\Util; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\TypeHelper; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; + +/** + * Decides whether a property is a JSON:API relationship and with which related resource(s). + * + * Single source of truth for the attributes/relationships split, shared by the runtime + * {@see \ApiPlatform\JsonApi\Serializer\ItemNormalizer} and the doc-time + * {@see \ApiPlatform\JsonApi\JsonSchema\SchemaFactory} so the generated schema cannot drift + * from the emitted document. + * + * @author Antoine Bluchet + * + * @internal + */ +final class ResourceLinkageResolver +{ + public function __construct(private readonly ResourceClassResolverInterface $resourceClassResolver) + { + } + + /** + * Returns the related resource classes a property points to, in declaration order. + * + * @return list ordered [relatedClass, isCollection] pairs; empty when the property is a plain attribute + */ + public function getRelationships(ApiProperty $propertyMetadata): array + { + $relationships = []; + + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + foreach ($propertyMetadata->getBuiltinTypes() ?? [] as $type) { + if ($type->isCollection()) { + $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; + if ($collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) { + $relationships[] = [$className, true]; + } + + continue; + } + + if (($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) { + $relationships[] = [$className, false]; + } + } + + return $relationships; + } + + if (null === $type = $propertyMetadata->getNativeType()) { + return $relationships; + } + + /** @var class-string|null $className */ + $className = null; + $typeIsResourceClass = function (Type $type) use (&$className): bool { + return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); + }; + + foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { + if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) { + $relationships[] = [$className, true]; + } elseif ($t->isSatisfiedBy($typeIsResourceClass)) { + $relationships[] = [$className, false]; + } + } + + return $relationships; + } +} From 92b0380f024914a0ccb884a2866bb8e435219b1c Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 25 Jun 2026 17:18:03 +0200 Subject: [PATCH 79/84] chore: open 5.0 development line Bump dev-main branch alias 4.4.x-dev -> 5.0.x-dev across root and all 21 subpackage composer.json files. Update COMPOSER_ROOT_VERSION to 5.0.x-dev (was stale at 4.3.x-dev). The 4.4 branch was cut from the prior 4.4.x-dev HEAD before this bump to carry the 4.4 maintenance line. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ef3ba82..ad9e949 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.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" From d346a4b2d39244260b9e7ea50de8667fbe138b75 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 25 Jun 2026 18:15:00 +0200 Subject: [PATCH 80/84] chore: require ^4.4@alpha for inter-package dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7115 added cross-package calls new in 4.4 — getStateOptionsRepositoryMethod() in api-platform/state and the repositoryMethod constructor argument in api-platform/doctrine-common — that 4.4 provider code invokes. The old ^4.2/^4.3 sibling floors let `composer update --prefer-lowest` pull releases lacking these symbols, so the per-component lowest CI jobs failed with "undefined method" / "unknown named parameter". Floor all api-platform/* inter-package constraints at ^4.4. The @alpha stability flag is required because the subpackages set minimum-stability:beta, under which a plain ^4.4 would not match the 4.4.0-alpha prereleases. Revert to plain ^4.4 once 4.4.0 stable ships. --- composer.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index ef3ba82..4b61971 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,11 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.3", - "api-platform/json-schema": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3.12", - "api-platform/state": "^4.3", + "api-platform/documentation": "^4.4@alpha", + "api-platform/json-schema": "^4.4@alpha", + "api-platform/metadata": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha", + "api-platform/state": "^4.4@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" From 28b69c29a69f15a2c8ba08e51c185d0094f3df2c Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 29 Jun 2026 13:22:12 +0200 Subject: [PATCH 81/84] chore: bump inter-package constraints to ^5.0@alpha Completes 22ece5199 (open 5.0 dev line): root is 5.0.x-dev but the self-referential api-platform/* constraints were left at ^4.4@alpha, which is unresolvable in the component split-tests (published 4.4 alphas require metadata ^4.4, conflicting with the 5.0 root). --- composer.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index e7d490c..c0226c9 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,11 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.4@alpha", - "api-platform/json-schema": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "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" From 61b7cb78c77ace950447955e81030b93df3be34d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 30 Jun 2026 17:13:00 +0200 Subject: [PATCH 82/84] feat!: core 5.0 cleanups (getProperties interface, json:api status string) (#8366) --- Serializer/ErrorNormalizer.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Serializer/ErrorNormalizer.php b/Serializer/ErrorNormalizer.php index 3b6d2fb..2076a1c 100644 --- a/Serializer/ErrorNormalizer.php +++ b/Serializer/ErrorNormalizer.php @@ -45,10 +45,9 @@ public function normalize(mixed $data, ?string $format = null, array $context = $error['code'] = $data->getId(); } - // TODO: change this 5.x - // if (isset($error['status'])) { - // $error['status'] = (string) $error['status']; - // } + if (isset($error['status'])) { + $error['status'] = (string) $error['status']; + } if (!isset($error['violations'])) { return ['errors' => [$error]]; From 355e54e7f434b350515056b8770ff377afc618eb Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 30 Jun 2026 19:15:47 +0200 Subject: [PATCH 83/84] feat!: remove legacy PropertyInfo Type system (#8364) --- .../ConstraintViolationListNormalizer.php | 12 ++--------- Util/ResourceLinkageResolver.php | 20 ------------------- 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/Serializer/ConstraintViolationListNormalizer.php b/Serializer/ConstraintViolationListNormalizer.php index c20fedf..ce0c728 100644 --- a/Serializer/ConstraintViolationListNormalizer.php +++ b/Serializer/ConstraintViolationListNormalizer.php @@ -14,7 +14,6 @@ namespace ApiPlatform\JsonApi\Serializer; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -94,15 +93,8 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio $fieldName = $this->nameConverter->normalize($fieldName, $class, self::FORMAT); } - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - if ($type && null !== $type->getClassName()) { - return "data/relationships/$fieldName"; - } - } else { - if ($propertyMetadata->getNativeType()?->isSatisfiedBy(static fn ($t) => $t instanceof ObjectType)) { - return "data/relationships/$fieldName"; - } + if ($propertyMetadata->getNativeType()?->isSatisfiedBy(static fn ($t) => $t instanceof ObjectType)) { + return "data/relationships/$fieldName"; } return "data/attributes/$fieldName"; diff --git a/Util/ResourceLinkageResolver.php b/Util/ResourceLinkageResolver.php index 73cacb9..9c47573 100644 --- a/Util/ResourceLinkageResolver.php +++ b/Util/ResourceLinkageResolver.php @@ -16,7 +16,6 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\TypeHelper; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -48,25 +47,6 @@ public function getRelationships(ApiProperty $propertyMetadata): array { $relationships = []; - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - foreach ($propertyMetadata->getBuiltinTypes() ?? [] as $type) { - if ($type->isCollection()) { - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - if ($collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) { - $relationships[] = [$className, true]; - } - - continue; - } - - if (($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) { - $relationships[] = [$className, false]; - } - } - - return $relationships; - } - if (null === $type = $propertyMetadata->getNativeType()) { return $relationships; } From 45b4196426918442299c88b0e3eccaaef40f0144 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 1 Jul 2026 09:50:13 +0200 Subject: [PATCH 84/84] feat!: remove deprecated APIs scheduled for 5.0 (#8367) --- Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php | 2 +- Tests/JsonSchema/SchemaFactoryTest.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php b/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php index 0b0d46b..a1865ab 100644 --- a/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php +++ b/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php @@ -62,7 +62,7 @@ protected function setUp(): void ); } - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), diff --git a/Tests/JsonSchema/SchemaFactoryTest.php b/Tests/JsonSchema/SchemaFactoryTest.php index 648cfd8..2c11df8 100644 --- a/Tests/JsonSchema/SchemaFactoryTest.php +++ b/Tests/JsonSchema/SchemaFactoryTest.php @@ -59,7 +59,7 @@ protected function setUp(): void $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_INPUT])->willReturn(new PropertyNameCollection()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), @@ -316,7 +316,7 @@ private function buildSchemaFactoryWithPolymorphicRelation(): SchemaFactory $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); $resourceClassResolver->isResourceClass(OtherRelatedDummy::class)->willReturn(true); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), @@ -377,7 +377,7 @@ private function buildSchemaFactoryWithRelation(): SchemaFactory $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(),