Skip to content

Commit 9df6518

Browse files
committed
Merge 4.3
2 parents 1d7695d + f533810 commit 9df6518

11 files changed

Lines changed: 331 additions & 18 deletions

File tree

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@
117117
"symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0",
118118
"symfony/property-access": "^6.4 || ^7.0 || ^8.0",
119119
"symfony/property-info": "^6.4 || ^7.1 || ^8.0",
120-
"symfony/serializer": "^6.4 || ^7.0 || ^8.0",
120+
"symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9",
121121
"symfony/translation-contracts": "^3.3",
122122
"symfony/type-info": "^7.4 || ^8.0",
123123
"symfony/validator": "^6.4.11 || ^7.1 || ^8.0",

src/GraphQl/Type/FieldsBuilder.php

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -512,20 +512,16 @@ private function getParameterArgs(Operation $operation, array $args = []): array
512512
$name = key($leafs);
513513

514514
$filterLeafs = [];
515-
if (($filterId = $parameter->getFilter()) && $this->filterLocator->has($filterId)) {
516-
$filter = $this->filterLocator->get($filterId);
517-
518-
if ($filter instanceof FilterInterface) {
519-
$property = $parameter->getProperty() ?? $name;
520-
$property = str_replace('.', $this->nestingSeparator, $property);
521-
$description = $filter->getDescription($operation->getClass());
522-
523-
foreach ($description as $descKey => $descValue) {
524-
$descKey = str_replace('.', $this->nestingSeparator, $descKey);
525-
parse_str($descKey, $descValues);
526-
if (isset($descValues[$property]) && \is_array($descValues[$property])) {
527-
$filterLeafs = array_merge($filterLeafs, $descValues[$property]);
528-
}
515+
if ($filter = $this->resolveFilter($parameter->getFilter())) {
516+
$property = $parameter->getProperty() ?? $name;
517+
$property = str_replace('.', $this->nestingSeparator, $property);
518+
$description = $filter->getDescription($operation->getClass());
519+
520+
foreach ($description as $descKey => $descValue) {
521+
$descKey = str_replace('.', $this->nestingSeparator, $descKey);
522+
parse_str($descKey, $descValues);
523+
if (isset($descValues[$property]) && \is_array($descValues[$property])) {
524+
$filterLeafs = array_merge($filterLeafs, $descValues[$property]);
529525
}
530526
}
531527
}
@@ -612,12 +608,12 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root
612608
}
613609

614610
foreach ($resourceOperation->getFilters() ?? [] as $filterId) {
615-
if (!$this->filterLocator->has($filterId)) {
611+
if (!($filter = $this->resolveFilter($filterId))) {
616612
continue;
617613
}
618614

619615
$entityClass = $this->getStateOptionsClass($resourceOperation, $resourceOperation->getClass());
620-
foreach ($this->filterLocator->get($filterId)->getDescription($entityClass) as $key => $description) {
616+
foreach ($filter->getDescription($entityClass) as $key => $description) {
621617
$filterType = \in_array($description['type'], TypeIdentifier::values(), true) ? Type::builtin($description['type']) : Type::object($description['type']);
622618
if (!($description['required'] ?? false)) {
623619
$filterType = Type::nullable($filterType);
@@ -751,4 +747,24 @@ private function normalizePropertyName(string $property, string $resourceClass):
751747

752748
return $this->nameConverter->normalize($property, $resourceClass);
753749
}
750+
751+
/**
752+
* Resolves a filter reference to a {@see FilterInterface} instance, supporting
753+
* both a string service id (legacy/locator path) and an object form
754+
* (`new QueryParameter(filter: new SortFilter())`).
755+
*/
756+
private function resolveFilter(mixed $filter): ?FilterInterface
757+
{
758+
if ($filter instanceof FilterInterface) {
759+
return $filter;
760+
}
761+
762+
if (\is_string($filter) && $this->filterLocator->has($filter)) {
763+
$resolved = $this->filterLocator->get($filter);
764+
765+
return $resolved instanceof FilterInterface ? $resolved : null;
766+
}
767+
768+
return null;
769+
}
754770
}

src/Serializer/AbstractItemNormalizer.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
use ApiPlatform\Metadata\UrlGeneratorInterface;
2929
use ApiPlatform\Metadata\Util\ClassInfoTrait;
3030
use ApiPlatform\Metadata\Util\CloneTrait;
31+
use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException as PropertyAccessInvalidArgumentException;
32+
use Symfony\Component\PropertyAccess\Exception\InvalidTypeException;
3133
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
3234
use Symfony\Component\PropertyAccess\PropertyAccess;
3335
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
@@ -554,6 +556,8 @@ protected function setAttributeValue(object $object, string $attribute, mixed $v
554556
if (!isset($context['not_normalizable_value_exceptions'])) {
555557
throw $exception;
556558
}
559+
} catch (PropertyAccessInvalidArgumentException $exception) {
560+
throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Failed to denormalize attribute "%s" value for class "%s": %s', $attribute, $object::class, $exception->getMessage()), $value, $exception instanceof InvalidTypeException ? [$exception->expectedType] : ['unknown'], $context['deserialization_path'] ?? null, false, $exception->getCode(), $exception);
557561
}
558562
}
559563

src/Serializer/composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"api-platform/state": "^4.3",
2828
"symfony/property-access": "^6.4 || ^7.0 || ^8.0",
2929
"symfony/property-info": "^6.4 || ^7.1 || ^8.0",
30-
"symfony/serializer": "^6.4 || ^7.0 || ^8.0",
30+
"symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9",
3131
"symfony/validator": "^6.4.11 || ^7.0 || ^8.0"
3232
},
3333
"require-dev": {

src/Symfony/Bundle/ApiPlatformBundle.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\FilterPass;
2222
use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlResolverPass;
2323
use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlTypePass;
24+
use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\JsonStreamerTransformerPass;
2425
use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MetadataAwareNameConverterPass;
2526
use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MutatorPass;
2627
use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\SerializerMappingLoaderPass;
@@ -59,5 +60,7 @@ public function build(ContainerBuilder $container): void
5960
$container->addCompilerPass(new AuthenticatorManagerPass());
6061
$container->addCompilerPass(new SerializerMappingLoaderPass());
6162
$container->addCompilerPass(new MutatorPass());
63+
// Must run after Symfony's TransformerPass so we can rely on the value_object_transformer tag being processed.
64+
$container->addCompilerPass(new JsonStreamerTransformerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -10);
6265
}
6366
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler;
15+
16+
use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument;
17+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
18+
use Symfony\Component\DependencyInjection\ContainerBuilder;
19+
use Symfony\Component\DependencyInjection\Reference;
20+
use Symfony\Component\JsonStreamer\Transformer\ValueObjectTransformerInterface;
21+
22+
/**
23+
* Builds a transformers locator merging "json_streamer.property_value_transformer",
24+
* "json_streamer.value_transformer" (legacy) and "json_streamer.value_object_transformer"
25+
* services, and assigns it to API Platform's custom JSON-LD stream reader/writer.
26+
*
27+
* FrameworkBundle's own TransformerPass only touches the standard json_streamer.stream_reader/writer
28+
* services, not API Platform's JSON-LD-scoped ones; see https://github.com/symfony/symfony/pull/64190
29+
* for a proposed upstream fix that would make this pass obsolete.
30+
*
31+
* @internal
32+
*/
33+
final class JsonStreamerTransformerPass implements CompilerPassInterface
34+
{
35+
public function process(ContainerBuilder $container): void
36+
{
37+
if (!interface_exists(ValueObjectTransformerInterface::class)) {
38+
return;
39+
}
40+
41+
if (!$container->hasDefinition('api_platform.jsonld.json_streamer.stream_reader')
42+
&& !$container->hasDefinition('api_platform.jsonld.json_streamer.stream_writer')) {
43+
return;
44+
}
45+
46+
$map = [];
47+
48+
foreach (['json_streamer.property_value_transformer', 'json_streamer.value_transformer'] as $tagName) {
49+
foreach ($container->findTaggedServiceIds($tagName, true) as $id => $_) {
50+
$map[$id] ??= new Reference($id);
51+
}
52+
}
53+
54+
foreach ($container->findTaggedServiceIds('json_streamer.value_object_transformer', true) as $id => $_) {
55+
$class = $container->getParameterBag()->resolveValue($container->getDefinition($id)->getClass());
56+
if (!\is_string($class) || !method_exists($class, 'getValueObjectClassName')) {
57+
continue;
58+
}
59+
60+
$map[$class::getValueObjectClassName()] = new Reference($id);
61+
}
62+
63+
$argument = new ServiceLocatorArgument($map);
64+
65+
foreach (['api_platform.jsonld.json_streamer.stream_reader', 'api_platform.jsonld.json_streamer.stream_writer'] as $serviceId) {
66+
if ($container->hasDefinition($serviceId)) {
67+
$container->getDefinition($serviceId)->replaceArgument(0, $argument);
68+
}
69+
}
70+
}
71+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7966;
15+
16+
use ApiPlatform\Doctrine\Orm\Filter\SortFilter;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\GraphQl\QueryCollection;
19+
use ApiPlatform\Metadata\QueryParameter;
20+
21+
#[ApiResource(
22+
operations: [],
23+
graphQlOperations: [
24+
new QueryCollection(
25+
provider: [self::class, 'provide'],
26+
paginationEnabled: false,
27+
parameters: [
28+
'order[:property]' => new QueryParameter(filter: new SortFilter()),
29+
],
30+
),
31+
],
32+
)]
33+
final class SortFilterParameterDummy
34+
{
35+
public ?string $id = null;
36+
public ?string $name = null;
37+
38+
public static function provide(): array
39+
{
40+
return [];
41+
}
42+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullOnNonNullableProperty;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\Get;
18+
use ApiPlatform\Metadata\Post;
19+
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
20+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
21+
22+
#[Get(
23+
shortName: 'NullOnNonNullableResource',
24+
uriTemplate: '/null_on_non_nullable_resources/{id}',
25+
provider: [self::class, 'provide'],
26+
)]
27+
#[Post(
28+
shortName: 'NullOnNonNullableResource',
29+
uriTemplate: '/null_on_non_nullable_resources',
30+
processor: [self::class, 'process'],
31+
denormalizationContext: [AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true],
32+
)]
33+
#[Post(
34+
shortName: 'NullOnNonNullableResource',
35+
uriTemplate: '/null_on_non_nullable_resources_collect',
36+
processor: [self::class, 'process'],
37+
denormalizationContext: [
38+
AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true,
39+
DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true,
40+
],
41+
)]
42+
class NullOnNonNullableResource
43+
{
44+
#[ApiProperty(identifier: true)]
45+
public int $id = 1;
46+
47+
public string $name;
48+
49+
public static function provide(): self
50+
{
51+
$r = new self();
52+
$r->name = 'foo';
53+
54+
return $r;
55+
}
56+
57+
public static function process(self $data): self
58+
{
59+
return $data;
60+
}
61+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional\GraphQl;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7966\SortFilterParameterDummy;
18+
use ApiPlatform\Tests\SetupClassResourcesTrait;
19+
20+
/**
21+
* Object-form filter (FilterInterface instance) combined with a bracketed parameter
22+
* key crashed the GraphQL schema build because `filterLocator->has()` was called
23+
* with the instance rather than a string service id.
24+
*
25+
* @see https://github.com/api-platform/core/issues/7966
26+
*/
27+
final class Issue7966Test extends ApiTestCase
28+
{
29+
use SetupClassResourcesTrait;
30+
31+
protected static ?bool $alwaysBootKernel = false;
32+
33+
/**
34+
* @return class-string[]
35+
*/
36+
public static function getResources(): array
37+
{
38+
return [SortFilterParameterDummy::class];
39+
}
40+
41+
public function testSchemaBuildsWithObjectFormFilterAndBracketedKey(): void
42+
{
43+
$response = self::createClient()->request('POST', '/graphql', ['json' => [
44+
'query' => '{ __type(name: "SortFilterParameterDummy") { name } }',
45+
]]);
46+
47+
$this->assertResponseIsSuccessful();
48+
$json = $response->toArray(false);
49+
$this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null));
50+
$this->assertSame('SortFilterParameterDummy', $json['data']['__type']['name']);
51+
}
52+
}

0 commit comments

Comments
 (0)