Skip to content

Commit e3e6a0d

Browse files
refactor(serializer): child definition context creation
Also fixes the item_uri_template usage * fix(symfony): fix Symfony IriConverter with item_uri_template Co-authored-by: soyuka <[email protected]>
1 parent 07c9989 commit e3e6a0d

File tree

15 files changed

+302
-100
lines changed

15 files changed

+302
-100
lines changed

features/hydra/item_uri_template.feature

+63-2
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,7 @@ Feature: Exposing a collection of objects should use the specified operation to
131131
"""
132132

133133
Scenario: Get a collection referencing another resource for its IRI
134-
When I add "Content-Type" header equal to "application/json"
135-
And I send a "GET" request to "/item_referenced_in_collection"
134+
When I send a "GET" request to "/item_referenced_in_collection"
136135
Then the response status code should be 200
137136
And the response should be in JSON
138137
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
@@ -159,3 +158,65 @@ Feature: Exposing a collection of objects should use the specified operation to
159158
"hydra:totalItems":2
160159
}
161160
"""
161+
162+
Scenario: Get a collection referencing an itemUriTemplate
163+
When I send a "GET" request to "/issue5662/books/a/reviews"
164+
Then the response status code should be 200
165+
And the response should be in JSON
166+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
167+
And the JSON should be equal to:
168+
"""
169+
{
170+
"@context":"/contexts/Review",
171+
"@id":"/issue5662/books/a/reviews",
172+
"@type":"hydra:Collection",
173+
"hydra:member":[
174+
{
175+
"@id":"/issue5662/books/a/reviews/1",
176+
"@type":"Review",
177+
"book":"/issue5662/books/a",
178+
"id":1,
179+
"body":"Best book ever!"
180+
},
181+
{
182+
"@id":"/issue5662/books/b/reviews/2",
183+
"@type":"Review",
184+
"book":"/issue5662/books/b",
185+
"id":2,
186+
"body":"Worst book ever!"
187+
}
188+
],
189+
"hydra:totalItems":2
190+
}
191+
"""
192+
193+
Scenario: Get a collection referencing an invalid itemUriTemplate
194+
When I send a "GET" request to "/issue5662/admin/reviews"
195+
Then the response status code should be 200
196+
And the response should be in JSON
197+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
198+
And the JSON should be equal to:
199+
"""
200+
{
201+
"@context": "/contexts/Review",
202+
"@id": "/issue5662/admin/reviews",
203+
"@type": "hydra:Collection",
204+
"hydra:totalItems": 2,
205+
"hydra:member": [
206+
{
207+
"@id": "/issue5662/admin/reviews/1",
208+
"@type": "Review",
209+
"book": "/issue5662/books/a",
210+
"id": 1,
211+
"body": "Best book ever!"
212+
},
213+
{
214+
"@id": "/issue5662/admin/reviews/2",
215+
"@type": "Review",
216+
"book": "/issue5662/books/b",
217+
"id": 2,
218+
"body": "Worst book ever!"
219+
}
220+
]
221+
}
222+
"""

src/Hydra/Serializer/CollectionNormalizer.php

+1-19
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ final class CollectionNormalizer extends AbstractCollectionNormalizer
3939
self::IRI_ONLY => false,
4040
];
4141

42-
public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, array $defaultContext = [])
42+
public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, array $defaultContext = [])
4343
{
4444
$this->defaultContext = array_merge($this->defaultContext, $defaultContext);
4545

@@ -80,26 +80,8 @@ protected function getItemsData(iterable $object, string $format = null, array $
8080
{
8181
$data = [];
8282
$data['hydra:member'] = [];
83-
8483
$iriOnly = $context[self::IRI_ONLY] ?? $this->defaultContext[self::IRI_ONLY];
8584

86-
if (($operation = $context['operation'] ?? null) && method_exists($operation, 'getItemUriTemplate')) {
87-
$context['item_uri_template'] = $operation->getItemUriTemplate();
88-
}
89-
90-
// We need to keep this operation for serialization groups for later
91-
if (isset($context['operation'])) {
92-
$context['root_operation'] = $context['operation'];
93-
}
94-
95-
if (isset($context['operation_name'])) {
96-
$context['root_operation_name'] = $context['operation_name'];
97-
}
98-
99-
// We need to unset the operation to ensure a proper IRI generation inside items
100-
unset($context['operation']);
101-
unset($context['operation_name'], $context['uri_variables']);
102-
10385
foreach ($object as $obj) {
10486
if ($iriOnly) {
10587
$data['hydra:member'][] = $this->iriConverter->getIriFromResource($obj);

src/JsonLd/Serializer/ItemNormalizer.php

-4
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,6 @@ public function normalize(mixed $object, string $format = null, array $context =
9999
unset($context['operation'], $context['operation_name']);
100100
}
101101

102-
if (($operation = $context['operation'] ?? null) && method_exists($operation, 'getItemUriTemplate') && ($itemUriTemplate = $operation->getItemUriTemplate())) {
103-
$context['item_uri_template'] = $itemUriTemplate;
104-
}
105-
106102
if ($iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context)) {
107103
$context['iri'] = $iri;
108104
$metadata['@id'] = $iri;

src/Serializer/AbstractCollectionNormalizer.php

+5-19
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ abstract class AbstractCollectionNormalizer implements NormalizerInterface, Norm
3434
initContext as protected;
3535
}
3636
use NormalizerAwareTrait;
37+
use OperationContextTrait;
3738

3839
/**
3940
* This constant must be overridden in the child class.
@@ -96,27 +97,12 @@ public function normalize(mixed $object, string $format = null, array $context =
9697
}
9798

9899
$resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class']);
99-
$context = $this->initContext($resourceClass, $context);
100+
$collectionContext = $this->initContext($resourceClass, $context);
100101
$data = [];
101-
$paginationData = $this->getPaginationData($object, $context);
102+
$paginationData = $this->getPaginationData($object, $collectionContext);
102103

103-
if (($operation = $context['operation'] ?? null) && method_exists($operation, 'getItemUriTemplate')) {
104-
$context['item_uri_template'] = $operation->getItemUriTemplate();
105-
}
106-
107-
// We need to keep this operation for serialization groups for later
108-
if (isset($context['operation'])) {
109-
$context['root_operation'] = $context['operation'];
110-
}
111-
112-
if (isset($context['operation_name'])) {
113-
$context['root_operation_name'] = $context['operation_name'];
114-
}
115-
116-
unset($context['operation']);
117-
unset($context['operation_type'], $context['operation_name']);
118-
119-
$itemsData = $this->getItemsData($object, $format, $context);
104+
$childContext = $this->createOperationContext($collectionContext, $resourceClass);
105+
$itemsData = $this->getItemsData($object, $format, $childContext);
120106

121107
return array_merge_recursive($data, $paginationData, $itemsData);
122108
}

src/Serializer/AbstractItemNormalizer.php

+5-36
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
use ApiPlatform\Exception\ItemNotFoundException;
2121
use ApiPlatform\Metadata\ApiProperty;
2222
use ApiPlatform\Metadata\CollectionOperationInterface;
23-
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
2423
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2524
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2625
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
@@ -56,6 +55,7 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer
5655
use CloneTrait;
5756
use ContextTrait;
5857
use InputOutputMetadataTrait;
58+
use OperationContextTrait;
5959

6060
protected PropertyAccessorInterface $propertyAccessor;
6161
protected array $localCache = [];
@@ -134,6 +134,8 @@ public function normalize(mixed $object, string $format = null, array $context =
134134
return $this->serializer->normalize($object, $format, $context);
135135
}
136136

137+
// Never remove this, with `application/json` we don't use our AbstractCollectionNormalizer and we need
138+
// to remove the collection operation from our context or we'll introduce security issues
137139
if (isset($context['operation']) && $context['operation'] instanceof CollectionOperationInterface) {
138140
unset($context['operation_name']);
139141
unset($context['operation']);
@@ -586,14 +588,7 @@ protected function getFactoryOptions(array $context): array
586588
// This is a hot spot
587589
if (isset($context['resource_class'])) {
588590
// Note that the groups need to be read on the root operation
589-
$operation = $context['root_operation'] ?? $context['operation'] ?? null;
590-
591-
if (!$operation && $this->resourceMetadataCollectionFactory && $this->resourceClassResolver->isResourceClass($context['resource_class'])) {
592-
$resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); // fix for abstract classes and interfaces
593-
$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($context['root_operation_name'] ?? $context['operation_name'] ?? null);
594-
}
595-
596-
if ($operation) {
591+
if ($operation = ($context['root_operation'] ?? null)) {
597592
$options['normalization_groups'] = $operation->getNormalizationContext()['groups'] ?? null;
598593
$options['denormalization_groups'] = $operation->getDenormalizationContext()['groups'] ?? null;
599594
$options['operation_name'] = $operation->getName();
@@ -716,8 +711,7 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel
716711
throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
717712
}
718713

719-
$relatedContext = $context;
720-
unset($relatedContext['force_resource_class']);
714+
$relatedContext = $this->createOperationContext($context, $resourceClass);
721715
$normalizedRelatedObject = $this->serializer->normalize($relatedObject, $format, $relatedContext);
722716
if (!\is_string($normalizedRelatedObject) && !\is_array($normalizedRelatedObject) && !$normalizedRelatedObject instanceof \ArrayObject && null !== $normalizedRelatedObject) {
723717
throw new UnexpectedValueException('Expected normalized relation to be an IRI, array, \ArrayObject or null');
@@ -883,29 +877,4 @@ private function setValue(object $object, string $attributeName, mixed $value):
883877
// Properties not found are ignored
884878
}
885879
}
886-
887-
private function createOperationContext(array $context, string $resourceClass = null): array
888-
{
889-
if (isset($context['operation']) && !isset($context['root_operation'])) {
890-
$context['root_operation'] = $context['operation'];
891-
$context['root_operation_name'] = $context['operation_name'];
892-
}
893-
894-
unset($context['iri'], $context['uri_variables']);
895-
if (!$resourceClass) {
896-
return $context;
897-
}
898-
899-
unset($context['operation'], $context['operation_name']);
900-
$context['resource_class'] = $resourceClass;
901-
if ($this->resourceMetadataCollectionFactory) {
902-
try {
903-
$context['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation();
904-
$context['operation_name'] = $context['operation']->getName();
905-
} catch (OperationNotFoundException) {
906-
}
907-
}
908-
909-
return $context;
910-
}
911880
}
+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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\Serializer;
15+
16+
/**
17+
* @internal
18+
*/
19+
trait OperationContextTrait
20+
{
21+
/**
22+
* This context is created when working on a relation context or items of a collection. It cleans the previously given
23+
* context as the operation changes.
24+
*/
25+
protected function createOperationContext(array $context, string $resourceClass = null): array
26+
{
27+
if (isset($context['operation']) && !isset($context['root_operation'])) {
28+
$context['root_operation'] = $context['operation'];
29+
}
30+
31+
if (isset($context['operation_name']) || isset($context['graphql_operation_name'])) {
32+
$context['root_operation_name'] = $context['operation_name'] ?? $context['graphql_operation_name'];
33+
}
34+
35+
unset($context['iri'], $context['uri_variables'], $context['item_uri_template'], $context['force_resource_class']);
36+
37+
if (!$resourceClass) {
38+
return $context;
39+
}
40+
41+
if (($operation = $context['operation'] ?? null) && method_exists($operation, 'getItemUriTemplate')) {
42+
$context['item_uri_template'] = $operation->getItemUriTemplate();
43+
}
44+
45+
unset($context['operation'], $context['operation_name']);
46+
$context['resource_class'] = $resourceClass;
47+
48+
return $context;
49+
}
50+
}

src/Serializer/SerializerContextBuilder.php

+6
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Serializer;
1515

1616
use ApiPlatform\Exception\RuntimeException;
17+
use ApiPlatform\Metadata\CollectionOperationInterface;
1718
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
1819
use ApiPlatform\Util\RequestAttributesExtractor;
1920
use Symfony\Component\HttpFoundation\Request;
@@ -53,6 +54,11 @@ public function createFromRequest(Request $request, bool $normalization, array $
5354
$context['input'] = $operation->getInput();
5455
$context['output'] = $operation->getOutput();
5556

57+
// Special case as this is usually handled by our OperationContextTrait, here we want to force the IRI in the response
58+
if (!$operation instanceof CollectionOperationInterface && method_exists($operation, 'getItemUriTemplate') && $operation->getItemUriTemplate()) {
59+
$context['item_uri_template'] = $operation->getItemUriTemplate();
60+
}
61+
5662
if ($operation->getTypes()) {
5763
$context['types'] = $operation->getTypes();
5864
}

src/Symfony/Routing/IriConverter.php

+4-5
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ public function getIriFromResource(object|string $resource, int $referenceType =
106106
{
107107
$resourceClass = $context['force_resource_class'] ?? (\is_string($resource) ? $resource : $this->getObjectClass($resource));
108108

109+
if ($this->operationMetadataFactory && isset($context['item_uri_template'])) {
110+
$operation = $this->operationMetadataFactory->create($context['item_uri_template']);
111+
}
112+
109113
$localOperationCacheKey = ($operation?->getName() ?? '').$resourceClass.(\is_string($resource) ? '_c' : '_i');
110114
if ($operation && isset($this->localOperationCache[$localOperationCacheKey])) {
111115
return $this->generateSymfonyRoute($resource, $referenceType, $this->localOperationCache[$localOperationCacheKey], $context, $this->localIdentifiersExtractorOperationCache[$localOperationCacheKey] ?? null);
@@ -130,11 +134,6 @@ public function getIriFromResource(object|string $resource, int $referenceType =
130134
}
131135

132136
$identifiersExtractorOperation = $operation;
133-
if ($this->operationMetadataFactory && isset($context['item_uri_template'])) {
134-
$identifiersExtractorOperation = null;
135-
$operation = $this->operationMetadataFactory->create($context['item_uri_template']);
136-
}
137-
138137
// In symfony the operation name is the route name, try to find one if none provided
139138
if (
140139
!$operation->getName()

tests/Fixtures/TestBundle/ApiResource/Issue5396/CompositeKeyWithDifferentType.php

+6-2
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,16 @@ class CompositeKeyWithDifferentType
2626
#[ApiProperty(identifier: true)]
2727
public ?string $verificationKey;
2828

29-
public static function provide(Operation $operation, array $uriVariables = [], array $context = []): array
29+
public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self
3030
{
3131
if (!\is_string($uriVariables['verificationKey'])) {
3232
throw new \RuntimeException('verificationKey should be a string.');
3333
}
3434

35-
return $context;
35+
$t = new self();
36+
$t->id = $uriVariables['id'];
37+
$t->verificationKey = $uriVariables['verificationKey'];
38+
39+
return $t;
3640
}
3741
}

tests/Fixtures/TestBundle/ApiResource/Issue5648/DummyResource.php

+2-1
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@
1919
use ApiPlatform\Metadata\ApiResource;
2020
use ApiPlatform\Metadata\Get;
2121
use ApiPlatform\Metadata\GetCollection;
22+
use ApiPlatform\Metadata\Link;
2223
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy;
2324

2425
#[ApiResource(
2526
operations: [
2627
new GetCollection(uriTemplate: '/dummy_resource_with_custom_filter', itemUriTemplate: '/dummy_resource_with_custom_filter/{id}'),
27-
new Get(uriTemplate: '/dummy_resource_with_custom_filter/{id}', uriVariables: ['id']),
28+
new Get(uriTemplate: '/dummy_resource_with_custom_filter/{id}', uriVariables: ['id' => new Link(fromClass: Dummy::class)]),
2829
],
2930
stateOptions: new Options(entityClass: Dummy::class)
3031
)]

0 commit comments

Comments
 (0)