Skip to content

Commit

Permalink
Merge 4.0
Browse files Browse the repository at this point in the history
  • Loading branch information
soyuka committed Nov 15, 2024
2 parents 8a4218b + ffadacc commit fb47197
Show file tree
Hide file tree
Showing 20 changed files with 503 additions and 36 deletions.
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## v4.0.9

### Bug fixes

* [417fef5da](https://github.com/api-platform/core/commit/417fef5da9b1f3f16e323a193dec141f13b1ebc5) fix(laravel): overlaping route format (#6782)
* [81099065e](https://github.com/api-platform/core/commit/81099065e30eb0356881907bd52095b85b9cae3d) fix(laravel): declare normalizer list as a service (#6786)
* [dc8c09b1e](https://github.com/api-platform/core/commit/dc8c09b1e1ac15a7bcd4961fc3e80b06bec82e77) fix(laravel) graphQl Relationship loading (#6792)

Also contains [v3.4.6 changes](#v346).

### Features

## v4.0.8

### Bug fixes

* [dddb97075](https://github.com/api-platform/core/commit/dddb97075af9c6e2517e1881b803c9d31a1913cf) fix(symfony): default formats order (#6780)

### Features

## v4.0.7

### Bug fixes
Expand Down Expand Up @@ -179,6 +199,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]));
}
}
19 changes: 11 additions & 8 deletions src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -432,12 +432,12 @@ public function register(): void
$this->app->singleton(ItemProvider::class, function (Application $app) {
$tagged = iterator_to_array($app->tagged(LinksHandlerInterface::class));

return new ItemProvider(new LinksHandler($app), new ServiceLocator($tagged));
return new ItemProvider(new LinksHandler($app, $app->make(ResourceMetadataCollectionFactoryInterface::class)), new ServiceLocator($tagged));
});
$this->app->singleton(CollectionProvider::class, function (Application $app) {
$tagged = iterator_to_array($app->tagged(LinksHandlerInterface::class));

return new CollectionProvider($app->make(Pagination::class), new LinksHandler($app), $app->tagged(QueryExtensionInterface::class), new ServiceLocator($tagged));
return new CollectionProvider($app->make(Pagination::class), new LinksHandler($app, $app->make(ResourceMetadataCollectionFactoryInterface::class)), $app->tagged(QueryExtensionInterface::class), new ServiceLocator($tagged));
});
$this->app->tag([ItemProvider::class, CollectionProvider::class], ProviderInterface::class);

Expand Down Expand Up @@ -973,11 +973,7 @@ public function register(): void
);
});

$this->app->bind(SerializerInterface::class, Serializer::class);
$this->app->bind(NormalizerInterface::class, Serializer::class);
$this->app->singleton(Serializer::class, function (Application $app) {
/** @var ConfigRepository */
$config = $app['config'];
$this->app->singleton('api_platform_normalizer_list', function (Application $app) {
$list = new \SplPriorityQueue();
$list->insert($app->make(HydraEntrypointNormalizer::class), -800);
$list->insert($app->make(HydraPartialCollectionViewNormalizer::class), -800);
Expand Down Expand Up @@ -1011,14 +1007,20 @@ public function register(): void
$list->insert($app->make(GraphQlRuntimeExceptionNormalizer::class), -780);
}

return $list;
});

$this->app->bind(SerializerInterface::class, Serializer::class);
$this->app->bind(NormalizerInterface::class, Serializer::class);
$this->app->singleton(Serializer::class, function (Application $app) {
// TODO: unused + implement hal/jsonapi ?
// $list->insert($dataUriNormalizer, -920);
// $list->insert($unwrappingDenormalizer, 1000);
// $list->insert($jsonserializableNormalizer, -900);
// $list->insert($uuidDenormalizer, -895); //Todo ramsey uuid support ?
return new Serializer(
iterator_to_array($list),
iterator_to_array($app->make('api_platform_normalizer_list')),
[
new JsonEncoder('json'),
$app->make(JsonEncoder::class),
Expand Down Expand Up @@ -1342,6 +1344,7 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect
// _format is read by the middleware
$uriTemplate = $operation->getRoutePrefix().str_replace('{._format}', '{_format?}', $uriTemplate);
$route = (new Route([$operation->getMethod()], $uriTemplate, [ApiPlatformController::class, '__invoke']))
->where('_format', '^\.[a-zA-Z]+')
->name($operation->getName())
->setDefaults(['_api_operation_name' => $operation->getName(), '_api_resource_class' => $operation->getClass()]);

Expand Down
73 changes: 62 additions & 11 deletions src/Laravel/Eloquent/State/LinksHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@

namespace ApiPlatform\Laravel\Eloquent\State;

use ApiPlatform\Metadata\Exception\OperationNotFoundException;
use ApiPlatform\Metadata\GraphQl\Operation;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
Expand All @@ -25,6 +30,7 @@ final class LinksHandler implements LinksHandlerInterface
{
public function __construct(
private readonly Application $application,
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory,
) {
}

Expand All @@ -34,27 +40,72 @@ public function handleLinks(Builder $builder, array $uriVariables, array $contex

if ($operation instanceof HttpOperation) {
foreach (array_reverse($operation->getUriVariables() ?? []) as $uriVariable => $link) {
$identifier = $uriVariables[$uriVariable];
$builder = $this->buildQuery($builder, $link, $uriVariables[$uriVariable]);
}

if ($to = $link->getToProperty()) {
$builder = $builder->where($builder->getModel()->{$to}()->getQualifiedForeignKeyName(), $identifier);
return $builder;
}

continue;
}
if (!($linkClass = $context['linkClass'] ?? false)) {
return $builder;
}

if ($from = $link->getFromProperty()) {
$relation = $this->application->make($link->getFromClass());
$builder = $builder->getModel()->where($relation->{$from}()->getQualifiedForeignKeyName(), $identifier);
$newLink = null;
$linkedOperation = null;
$linkProperty = $context['linkProperty'] ?? null;

continue;
try {
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($linkClass);
$linkedOperation = $resourceMetadataCollection->getOperation($operation->getName());
} catch (OperationNotFoundException) {
// Instead, we'll look for the first Query available.
foreach ($resourceMetadataCollection as $resourceMetadata) {
foreach ($resourceMetadata->getGraphQlOperations() as $op) {
if ($op instanceof Query) {
$linkedOperation = $op;
}
}
}
}

if (!$linkedOperation instanceof Operation) {
return $builder;
}

$builder->where($builder->getModel()->qualifyColumn($link->getIdentifiers()[0]), $identifier);
$resourceClass = $builder->getModel()::class;
foreach ($linkedOperation->getLinks() ?? [] as $link) {
if ($resourceClass === $link->getToClass() && $linkProperty === $link->getFromProperty()) {
$newLink = $link;
break;
}
}

if (!$newLink) {
return $builder;
}

return $builder;
return $this->buildQuery($builder, $newLink, $uriVariables[$newLink->getIdentifiers()[0]]);
}

/**
* @param Builder<Model> $builder
*
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*
* @return Builder<Model> $builder
*/
private function buildQuery(Builder $builder, Link $link, mixed $identifier): Builder
{
if ($to = $link->getToProperty()) {
return $builder->where($builder->getModel()->{$to}()->getQualifiedForeignKeyName(), $identifier);
}

if ($from = $link->getFromProperty()) {
$relation = $this->application->make($link->getFromClass());

return $builder->getModel()->where($relation->{$from}()->getQualifiedForeignKeyName(), $identifier);
}

return $builder->where($builder->getModel()->qualifyColumn($link->getIdentifiers()[0]), $identifier);
}
}
11 changes: 11 additions & 0 deletions src/Laravel/Tests/JsonLdTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ protected function defineEnvironment($app): void
{
tap($app['config'], function (Repository $config): void {
$config->set('app.debug', true);
$config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]);
});
}

Expand Down Expand Up @@ -337,4 +338,14 @@ public function testRelationWithGroups(): void
$this->assertArrayHasKey('relation', $content);
$this->assertArrayHasKey('name', $content['relation']);
}

/**
* @see https://github.com/api-platform/core/issues/6779
*/
public function testSimilarRoutesWithFormat(): void
{
$response = $this->get('/api/staff_position_histories?page=1', ['accept' => 'application/ld+json']);
$response->assertStatus(200);
$this->assertSame('/api/staff_position_histories', $response->json()['@id']);
}
}
25 changes: 25 additions & 0 deletions src/Laravel/workbench/app/ApiResource/Staff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?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 Workbench\App\ApiResource;

use ApiPlatform\Metadata\ApiResource;

#[ApiResource(provider: [self::class, 'provide'])]
class Staff
{
public static function provide()
{
return [];
}
}
Loading

0 comments on commit fb47197

Please sign in to comment.