diff --git a/CHANGELOG.md b/CHANGELOG.md index 900ea88885b..0da49629e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -187,6 +187,20 @@ Notes: * [0d5f35683](https://github.com/api-platform/core/commit/0d5f356839eb6aa9f536044abe4affa736553e76) feat(laravel): laravel component (#5882) +## v3.4.6 + +### Bug fixes + +* [17c916c3a](https://github.com/api-platform/core/commit/17c916c3a1bcc837c9bc842dc48390dbeb043450) fix(symfony): service typo fix BackedEnumProvider for autowiring (#6769) +* [216d9ccaa](https://github.com/api-platform/core/commit/216d9ccaacf7845daaaeab30f3a58bb5567430fe) fix(serializer): fetch type on normalization error when possible (#6761) +* [2f967d934](https://github.com/api-platform/core/commit/2f967d9345004779f409b9ce1b5d0cbba84c7132) fix(doctrine): throw an exception when a filter is not found in a parameter (#6767) +* [736ca045e](https://github.com/api-platform/core/commit/736ca045e6832f04aaa002ddd7b85c55df4696bb) fix(validator): allow to pass both a ConstraintViolationList and a previous exception (#6762) +* [a98332d99](https://github.com/api-platform/core/commit/a98332d99a43338fa3bc0fd6b20f82ac58d1c397) fix(metadata): name convert parameter property (#6766) +* [aa1667de1](https://github.com/api-platform/core/commit/aa1667de116fa9a40842f1480fc90ab49c7c2784) fix(state): empty result when the array paginator is out of bound (#6785) +* [ab88353a3](https://github.com/api-platform/core/commit/ab88353a32f94146b01c34bae377ec5a735846db) fix(hal): detecting and handling circular reference (#6752) +* [bba030614](https://github.com/api-platform/core/commit/bba030614b96887fea4f5c177e3137378ccae8a5) fix: properly support phpstan/phpdoc-parser 2 (#6789) +* [bec147b91](https://github.com/api-platform/core/commit/bec147b916c29e346a698b28ddd4493bf305d9a0) fix(state): do not check content type if no input (#6794) + ## v3.4.5 ### Bug fixes diff --git a/composer.json b/composer.json index 39314dc5890..290828ac782 100644 --- a/composer.json +++ b/composer.json @@ -145,7 +145,7 @@ "orchestra/testbench": "^9.1", "phpspec/prophecy-phpunit": "^2.2", "phpstan/extension-installer": "^1.1", - "phpstan/phpdoc-parser": "^1.13", + "phpstan/phpdoc-parser": "^1.13|^2.0", "phpstan/phpstan": "^1.10", "phpstan/phpstan-doctrine": "^1.0", "phpstan/phpstan-phpunit": "^1.0", diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index f128e150909..24748804177 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -13,14 +13,26 @@ namespace ApiPlatform\Hal\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\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\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Exception\CircularReferenceException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; /** * Converts between objects and array including HAL metadata. @@ -35,9 +47,25 @@ final class ItemNormalizer extends AbstractItemNormalizer public const FORMAT = 'jsonhal'; + protected const HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS = 'hal_circular_reference_limit_counters'; + private array $componentsCache = []; private array $attributesMetadataCache = []; + 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, ?TagCollectorInterface $tagCollector = null) + { + $defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] = function ($object): ?array { + $iri = $this->iriConverter->getIriFromResource($object); + if (null === $iri) { + return null; + } + + return ['_links' => ['self' => ['href' => $iri]]]; + }; + + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); + } + /** * {@inheritdoc} */ @@ -216,6 +244,10 @@ private function populateRelation(array $data, object $object, ?string $format, { $class = $this->getObjectClass($object); + if ($this->isHalCircularReference($object, $context)) { + return $this->handleHalCircularReference($object, $format, $context); + } + $attributesMetadata = \array_key_exists($class, $this->attributesMetadataCache) ? $this->attributesMetadataCache[$class] : $this->attributesMetadataCache[$class] = $this->classMetadataFactory ? $this->classMetadataFactory->getMetadataFor($class)->getAttributesMetadata() : null; @@ -319,4 +351,49 @@ private function isMaxDepthReached(array $attributesMetadata, string $class, str return false; } + + /** + * Detects if the configured circular reference limit is reached. + * + * @throws CircularReferenceException + */ + protected function isHalCircularReference(object $object, array &$context): bool + { + $objectHash = spl_object_hash($object); + + $circularReferenceLimit = $context[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT]; + if (isset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash])) { + if ($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash] >= $circularReferenceLimit) { + unset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash]); + + return true; + } + + ++$context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash]; + } else { + $context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash] = 1; + } + + return false; + } + + /** + * Handles a circular reference. + * + * If a circular reference handler is set, it will be called. Otherwise, a + * {@class CircularReferenceException} will be thrown. + * + * @final + * + * @throws CircularReferenceException + */ + protected function handleHalCircularReference(object $object, ?string $format = null, array $context = []): mixed + { + $circularReferenceHandler = $context[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER]; + if ($circularReferenceHandler) { + return $circularReferenceHandler($object, $format, $context); + } + + throw new CircularReferenceException(\sprintf('A circular reference has been detected when serializing the object of class "%s" (configured limit: %d).', get_debug_type($object), $context[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT])); + } } diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 066244428d1..f0eda744432 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -200,6 +200,10 @@ private function setDefaults(string $key, Parameter $parameter, string $resource $currentKey = $nameConvertedKey; } + if ($this->nameConverter && $property = $parameter->getProperty()) { + $parameter = $parameter->withProperty($this->nameConverter->normalize($property)); + } + if (isset($properties[$currentKey]) && ($eloquentRelation = ($properties[$currentKey]->getExtraProperties()['eloquent_relation'] ?? null)) && isset($eloquentRelation['foreign_key'])) { $parameter = $parameter->withExtraProperties(['_query_property' => $eloquentRelation['foreign_key']] + $parameter->getExtraProperties()); } diff --git a/src/Metadata/Resource/Factory/PhpDocResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/PhpDocResourceMetadataCollectionFactory.php index 2a5163f424c..b7c1378b57b 100644 --- a/src/Metadata/Resource/Factory/PhpDocResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/PhpDocResourceMetadataCollectionFactory.php @@ -25,6 +25,7 @@ use PHPStan\PhpDocParser\Parser\PhpDocParser; use PHPStan\PhpDocParser\Parser\TokenIterator; use PHPStan\PhpDocParser\Parser\TypeParser; +use PHPStan\PhpDocParser\ParserConfig; /** * Extracts descriptions from PHPDoc. @@ -58,9 +59,13 @@ public function __construct(private readonly ResourceMetadataCollectionFactoryIn } $phpDocParser = null; $lexer = null; - if (class_exists(PhpDocParser::class)) { - $phpDocParser = new PhpDocParser(new TypeParser(new ConstExprParser()), new ConstExprParser()); - $lexer = new Lexer(); + if (class_exists(PhpDocParser::class) && class_exists(ParserConfig::class)) { + $config = new ParserConfig([]); + $phpDocParser = new PhpDocParser($config, new TypeParser($config, new ConstExprParser($config)), new ConstExprParser($config)); + $lexer = new Lexer($config); + } elseif (class_exists(PhpDocParser::class)) { + $phpDocParser = new PhpDocParser(new TypeParser(new ConstExprParser()), new ConstExprParser()); // @phpstan-ignore-line + $lexer = new Lexer(); // @phpstan-ignore-line } $this->phpDocParser = $phpDocParser; $this->lexer = $lexer; diff --git a/src/State/Pagination/ArrayPaginator.php b/src/State/Pagination/ArrayPaginator.php index 3de9ab6e8f1..8c50186eb62 100644 --- a/src/State/Pagination/ArrayPaginator.php +++ b/src/State/Pagination/ArrayPaginator.php @@ -27,14 +27,15 @@ final class ArrayPaginator implements \IteratorAggregate, PaginatorInterface, Ha public function __construct(array $results, int $firstResult, int $maxResults) { - if ($maxResults > 0) { + $this->firstResult = $firstResult; + $this->maxResults = $maxResults; + $this->totalItems = \count($results); + + if ($maxResults > 0 && $firstResult < $this->totalItems) { $this->iterator = new \LimitIterator(new \ArrayIterator($results), $firstResult, $maxResults); } else { $this->iterator = new \EmptyIterator(); } - $this->firstResult = $firstResult; - $this->maxResults = $maxResults; - $this->totalItems = \count($results); } /** diff --git a/src/State/Provider/ContentNegotiationProvider.php b/src/State/Provider/ContentNegotiationProvider.php index f01b3e268d0..02f28f30121 100644 --- a/src/State/Provider/ContentNegotiationProvider.php +++ b/src/State/Provider/ContentNegotiationProvider.php @@ -92,6 +92,14 @@ private function flattenMimeTypes(array $formats): array */ private function getInputFormat(HttpOperation $operation, Request $request): ?string { + if ( + false === ($input = $operation->getInput()) + || (\is_array($input) && null === $input['class']) + || false === $operation->canDeserialize() + ) { + return null; + } + $contentType = $request->headers->get('CONTENT_TYPE'); if (null === $contentType || '' === $contentType) { return null; @@ -103,14 +111,14 @@ private function getInputFormat(HttpOperation $operation, Request $request): ?st return $format; } - $supportedMimeTypes = []; - foreach ($formats as $mimeTypes) { - foreach ($mimeTypes as $mimeType) { - $supportedMimeTypes[] = $mimeType; + if (!$request->isMethodSafe() && 'DELETE' !== $request->getMethod()) { + $supportedMimeTypes = []; + foreach ($formats as $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $supportedMimeTypes[] = $mimeType; + } } - } - if (!$request->isMethodSafe() && 'DELETE' !== $request->getMethod()) { throw new UnsupportedMediaTypeHttpException(\sprintf('The content-type "%s" is not supported. Supported MIME types are "%s".', $contentType, implode('", "', $supportedMimeTypes))); } diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index 59b9422a9df..ea88caba300 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -83,7 +83,8 @@ - + + diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceA.php b/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceA.php new file mode 100644 index 00000000000..a86482bc2ea --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceA.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\Tests\Fixtures\TestBundle\ApiResource\Issue4358; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Annotation\MaxDepth; + +#[Get(uriTemplate: 'resource_a', + formats: ['jsonhal'], + outputFormats: ['jsonhal'], + normalizationContext: ['groups' => ['ResourceA:read'], 'enable_max_depth' => true], + provider: [self::class, 'provide'])] +final class ResourceA +{ + private static ?ResourceA $resourceA = null; + + #[ApiProperty(readableLink: true)] + #[Groups(['ResourceA:read', 'ResourceB:read'])] + #[MaxDepth(6)] + public ResourceB $b; + + public function __construct(?ResourceB $b = null) + { + if (null !== $b) { + $this->b = $b; + } + } + + public static function provide(): self + { + return self::provideWithResource(); + } + + public static function provideWithResource(?ResourceB $b = null): self + { + if (!isset(self::$resourceA)) { + self::$resourceA = new self($b); + + if (null === ResourceB::getInstance()) { + self::$resourceA->b = ResourceB::provideWithResource(self::$resourceA); + } + } + + return self::$resourceA; + } + + public static function getInstance(): ?self + { + return self::$resourceA; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceB.php b/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceB.php new file mode 100644 index 00000000000..cd5ba29d3c5 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceB.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\Tests\Fixtures\TestBundle\ApiResource\Issue4358; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Annotation\MaxDepth; + +#[Get(uriTemplate: 'resource_b', + formats: ['jsonhal'], + outputFormats: ['jsonhal'], + normalizationContext: ['groups' => ['ResourceB:read'], 'enable_max_depth' => true], + provider: [self::class, 'provide'])] +final class ResourceB +{ + private static ?ResourceB $resourceB = null; + + #[ApiProperty(readableLink: true)] + #[Groups(['ResourceA:read', 'ResourceB:read'])] + #[MaxDepth(6)] + public ResourceA $a; + + public function __construct(?ResourceA $a = null) + { + if (null !== $a) { + $this->a = $a; + } + } + + public static function provide(): self + { + return self::provideWithResource(); + } + + public static function provideWithResource(?ResourceA $a = null): self + { + if (!isset(self::$resourceB)) { + self::$resourceB = new self($a); + + if (null === ResourceA::getInstance()) { + self::$resourceB->a = ResourceA::provideWithResource(self::$resourceB); + } + } + + return self::$resourceB; + } + + public static function getInstance(): ?self + { + return self::$resourceB; + } +} diff --git a/tests/Fixtures/TestBundle/Document/DummyProduct.php b/tests/Fixtures/TestBundle/Document/DummyProduct.php index 0887236029b..cdd001ca68f 100644 --- a/tests/Fixtures/TestBundle/Document/DummyProduct.php +++ b/tests/Fixtures/TestBundle/Document/DummyProduct.php @@ -23,7 +23,7 @@ /** * Dummy Product. * - * https://github.com/api-platform/core/issues/1107. + * @see https://github.com/api-platform/core/issues/1107 * * @author Antoine Bluchet */ diff --git a/tests/Fixtures/TestBundle/Entity/DummyProduct.php b/tests/Fixtures/TestBundle/Entity/DummyProduct.php index 0f34fe8174d..d6e428f8a02 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyProduct.php +++ b/tests/Fixtures/TestBundle/Entity/DummyProduct.php @@ -23,7 +23,7 @@ /** * Dummy Product. * - * https://github.com/api-platform/core/issues/1107. + * @see https://github.com/api-platform/core/issues/1107 * * @author Antoine Bluchet */ diff --git a/tests/Functional/HALCircularReference.php b/tests/Functional/HALCircularReference.php new file mode 100644 index 00000000000..eda2afffaa2 --- /dev/null +++ b/tests/Functional/HALCircularReference.php @@ -0,0 +1,33 @@ + + * + * 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\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4358\ResourceA; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4358\ResourceB; + +class HALCircularReference extends ApiTestCase +{ + public function testIssue4358(): void + { + $r1 = self::createClient()->request('GET', '/resource_a', ['headers' => ['Accept' => 'application/hal+json']]); + self::assertResponseIsSuccessful(); + self::assertEquals('{"_links":{"self":{"href":"\/resource_a"},"b":{"href":"\/resource_b"}},"_embedded":{"b":{"_links":{"self":{"href":"\/resource_b"},"a":{"href":"\/resource_a"}},"_embedded":{"a":{"_links":{"self":{"href":"\/resource_a"}}}}}}}', $r1->getContent()); + } + + public static function getResources(): array + { + return [ResourceA::class, ResourceB::class]; + } +} diff --git a/tests/State/Pagination/ArrayPaginatorTest.php b/tests/State/Pagination/ArrayPaginatorTest.php index 317ad6055e0..ba96f7bcbd3 100644 --- a/tests/State/Pagination/ArrayPaginatorTest.php +++ b/tests/State/Pagination/ArrayPaginatorTest.php @@ -41,6 +41,7 @@ public static function initializeProvider(): array 'Second of two pages of 3 items for the first page and 2 for the second' => [[0, 1, 2, 3, 4], 3, 3, 2, 5, 2, 2, false], 'Empty results' => [[], 0, 2, 0, 0, 1, 1, false], '0 for max results' => [[0, 1, 2, 3], 2, 0, 0, 4, 1, 1, false], + 'First result greater than total items' => [[0, 1], 2, 1, 0, 2, 3, 2, false], ]; } } diff --git a/tests/State/Provider/ContentNegotiationProviderTest.php b/tests/State/Provider/ContentNegotiationProviderTest.php index e4eb99ba904..dd610bd4b8d 100644 --- a/tests/State/Provider/ContentNegotiationProviderTest.php +++ b/tests/State/Provider/ContentNegotiationProviderTest.php @@ -21,6 +21,7 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; class ContentNegotiationProviderTest extends TestCase { @@ -60,4 +61,63 @@ public function testRequestWithEmptyContentType(): void $this->assertSame($expectedResult, $result); } + + public function testRequestWhenNoInput(): void + { + $expectedResult = new \stdClass(); + + $decorated = $this->prophesize(ProviderInterface::class); + $decorated->provide(Argument::cetera())->willReturn($expectedResult); + + $negotiator = new Negotiator(); + $formats = ['jsonld' => ['application/ld+json']]; + $errorFormats = ['jsonld' => ['application/ld+json']]; + + $provider = new ContentNegotiationProvider($decorated->reveal(), $negotiator, $formats, $errorFormats); + + $request = new Request( + server: [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => '/', + 'CONTENT_TYPE' => 'some-not-supported/content-type', + ], + content: '' + ); + + $operation = new Post(); + $operation = $operation->withDeserialize(false); + $context = ['request' => $request]; + + $result = $provider->provide($operation, [], $context); + + $this->assertSame($expectedResult, $result); + } + + public function testRequestWithInput(): void + { + $this->expectException(UnsupportedMediaTypeHttpException::class); + + $decorated = $this->prophesize(ProviderInterface::class); + + $negotiator = new Negotiator(); + $formats = ['jsonld' => ['application/ld+json']]; + $errorFormats = ['jsonld' => ['application/ld+json']]; + + $provider = new ContentNegotiationProvider($decorated->reveal(), $negotiator, $formats, $errorFormats); + + $request = new Request( + server: [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => '/', + 'CONTENT_TYPE' => 'some-not-supported/content-type', + ], + content: '' + ); + + $operation = new Post(); + $operation = $operation->withDeserialize(); + $context = ['request' => $request]; + + $provider->provide($operation, [], $context); + } }