From ece64b9f86f62fc01eecc89373688a8700a6d879 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 27 Jan 2025 17:16:57 +0100 Subject: [PATCH 01/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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) : [];