Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions src/Hal/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}
*/
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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]));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 5 additions & 4 deletions src/State/Pagination/ArrayPaginator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
20 changes: 14 additions & 6 deletions src/State/Provider/ContentNegotiationProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)));
}

Expand Down
3 changes: 2 additions & 1 deletion src/Symfony/Bundle/Resources/config/metadata/resource.xml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@
<argument type="service" id="api_platform.metadata.property.name_collection_factory" />
<argument type="service" id="api_platform.metadata.property.metadata_factory" />
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory.parameter.inner" />
<argument type="service" id="api_platform.filter_locator" />
<argument type="service" id="api_platform.filter_locator" on-invalid="ignore" />
<argument type="service" id="api_platform.name_converter" on-invalid="ignore" />
</service>

<service id="api_platform.metadata.resource.metadata_collection_factory.cached" class="ApiPlatform\Metadata\Resource\Factory\CachedResourceMetadataCollectionFactory" decorates="api_platform.metadata.resource.metadata_collection_factory" decoration-priority="-10" public="false">
Expand Down
64 changes: 64 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceA.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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;
}
}
64 changes: 64 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceB.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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;
}
}
2 changes: 1 addition & 1 deletion tests/Fixtures/TestBundle/Document/DummyProduct.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>
*/
Expand Down
2 changes: 1 addition & 1 deletion tests/Fixtures/TestBundle/Entity/DummyProduct.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>
*/
Expand Down
Loading
Loading