Skip to content

Commit

Permalink
refactor(serializer): child definition context creation
Browse files Browse the repository at this point in the history
Also fixes the item_uri_template usage

* fix(symfony): fix Symfony IriConverter with item_uri_template

Co-authored-by: soyuka <soyuka@users.noreply.github.com>
  • Loading branch information
vincentchalamon and soyuka committed Aug 22, 2023
1 parent 07c9989 commit e3e6a0d
Show file tree
Hide file tree
Showing 15 changed files with 302 additions and 100 deletions.
65 changes: 63 additions & 2 deletions features/hydra/item_uri_template.feature
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,7 @@ Feature: Exposing a collection of objects should use the specified operation to
"""

Scenario: Get a collection referencing another resource for its IRI
When I add "Content-Type" header equal to "application/json"
And I send a "GET" request to "/item_referenced_in_collection"
When I send a "GET" request to "/item_referenced_in_collection"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
Expand All @@ -159,3 +158,65 @@ Feature: Exposing a collection of objects should use the specified operation to
"hydra:totalItems":2
}
"""

Scenario: Get a collection referencing an itemUriTemplate
When I send a "GET" request to "/issue5662/books/a/reviews"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON should be equal to:
"""
{
"@context":"/contexts/Review",
"@id":"/issue5662/books/a/reviews",
"@type":"hydra:Collection",
"hydra:member":[
{
"@id":"/issue5662/books/a/reviews/1",
"@type":"Review",
"book":"/issue5662/books/a",
"id":1,
"body":"Best book ever!"
},
{
"@id":"/issue5662/books/b/reviews/2",
"@type":"Review",
"book":"/issue5662/books/b",
"id":2,
"body":"Worst book ever!"
}
],
"hydra:totalItems":2
}
"""

Scenario: Get a collection referencing an invalid itemUriTemplate
When I send a "GET" request to "/issue5662/admin/reviews"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON should be equal to:
"""
{
"@context": "/contexts/Review",
"@id": "/issue5662/admin/reviews",
"@type": "hydra:Collection",
"hydra:totalItems": 2,
"hydra:member": [
{
"@id": "/issue5662/admin/reviews/1",
"@type": "Review",
"book": "/issue5662/books/a",
"id": 1,
"body": "Best book ever!"
},
{
"@id": "/issue5662/admin/reviews/2",
"@type": "Review",
"book": "/issue5662/books/b",
"id": 2,
"body": "Worst book ever!"
}
]
}
"""
20 changes: 1 addition & 19 deletions src/Hydra/Serializer/CollectionNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ final class CollectionNormalizer extends AbstractCollectionNormalizer
self::IRI_ONLY => false,
];

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

Expand Down Expand Up @@ -80,26 +80,8 @@ protected function getItemsData(iterable $object, string $format = null, array $
{
$data = [];
$data['hydra:member'] = [];

$iriOnly = $context[self::IRI_ONLY] ?? $this->defaultContext[self::IRI_ONLY];

if (($operation = $context['operation'] ?? null) && method_exists($operation, 'getItemUriTemplate')) {
$context['item_uri_template'] = $operation->getItemUriTemplate();
}

// We need to keep this operation for serialization groups for later
if (isset($context['operation'])) {
$context['root_operation'] = $context['operation'];
}

if (isset($context['operation_name'])) {
$context['root_operation_name'] = $context['operation_name'];
}

// We need to unset the operation to ensure a proper IRI generation inside items
unset($context['operation']);
unset($context['operation_name'], $context['uri_variables']);

foreach ($object as $obj) {
if ($iriOnly) {
$data['hydra:member'][] = $this->iriConverter->getIriFromResource($obj);
Expand Down
4 changes: 0 additions & 4 deletions src/JsonLd/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,6 @@ public function normalize(mixed $object, string $format = null, array $context =
unset($context['operation'], $context['operation_name']);
}

if (($operation = $context['operation'] ?? null) && method_exists($operation, 'getItemUriTemplate') && ($itemUriTemplate = $operation->getItemUriTemplate())) {
$context['item_uri_template'] = $itemUriTemplate;
}

if ($iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context)) {
$context['iri'] = $iri;
$metadata['@id'] = $iri;
Expand Down
24 changes: 5 additions & 19 deletions src/Serializer/AbstractCollectionNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ abstract class AbstractCollectionNormalizer implements NormalizerInterface, Norm
initContext as protected;
}
use NormalizerAwareTrait;
use OperationContextTrait;

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

$resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class']);
$context = $this->initContext($resourceClass, $context);
$collectionContext = $this->initContext($resourceClass, $context);
$data = [];
$paginationData = $this->getPaginationData($object, $context);
$paginationData = $this->getPaginationData($object, $collectionContext);

if (($operation = $context['operation'] ?? null) && method_exists($operation, 'getItemUriTemplate')) {
$context['item_uri_template'] = $operation->getItemUriTemplate();
}

// We need to keep this operation for serialization groups for later
if (isset($context['operation'])) {
$context['root_operation'] = $context['operation'];
}

if (isset($context['operation_name'])) {
$context['root_operation_name'] = $context['operation_name'];
}

unset($context['operation']);
unset($context['operation_type'], $context['operation_name']);

$itemsData = $this->getItemsData($object, $format, $context);
$childContext = $this->createOperationContext($collectionContext, $resourceClass);
$itemsData = $this->getItemsData($object, $format, $childContext);

return array_merge_recursive($data, $paginationData, $itemsData);
}
Expand Down
41 changes: 5 additions & 36 deletions src/Serializer/AbstractItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
use ApiPlatform\Exception\ItemNotFoundException;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
Expand Down Expand Up @@ -56,6 +55,7 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer
use CloneTrait;
use ContextTrait;
use InputOutputMetadataTrait;
use OperationContextTrait;

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

// Never remove this, with `application/json` we don't use our AbstractCollectionNormalizer and we need
// to remove the collection operation from our context or we'll introduce security issues
if (isset($context['operation']) && $context['operation'] instanceof CollectionOperationInterface) {
unset($context['operation_name']);
unset($context['operation']);
Expand Down Expand Up @@ -586,14 +588,7 @@ protected function getFactoryOptions(array $context): array
// This is a hot spot
if (isset($context['resource_class'])) {
// Note that the groups need to be read on the root operation
$operation = $context['root_operation'] ?? $context['operation'] ?? null;

if (!$operation && $this->resourceMetadataCollectionFactory && $this->resourceClassResolver->isResourceClass($context['resource_class'])) {
$resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); // fix for abstract classes and interfaces
$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($context['root_operation_name'] ?? $context['operation_name'] ?? null);
}

if ($operation) {
if ($operation = ($context['root_operation'] ?? null)) {
$options['normalization_groups'] = $operation->getNormalizationContext()['groups'] ?? null;
$options['denormalization_groups'] = $operation->getDenormalizationContext()['groups'] ?? null;
$options['operation_name'] = $operation->getName();
Expand Down Expand Up @@ -716,8 +711,7 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel
throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
}

$relatedContext = $context;
unset($relatedContext['force_resource_class']);
$relatedContext = $this->createOperationContext($context, $resourceClass);
$normalizedRelatedObject = $this->serializer->normalize($relatedObject, $format, $relatedContext);
if (!\is_string($normalizedRelatedObject) && !\is_array($normalizedRelatedObject) && !$normalizedRelatedObject instanceof \ArrayObject && null !== $normalizedRelatedObject) {
throw new UnexpectedValueException('Expected normalized relation to be an IRI, array, \ArrayObject or null');
Expand Down Expand Up @@ -883,29 +877,4 @@ private function setValue(object $object, string $attributeName, mixed $value):
// Properties not found are ignored
}
}

private function createOperationContext(array $context, string $resourceClass = null): array
{
if (isset($context['operation']) && !isset($context['root_operation'])) {
$context['root_operation'] = $context['operation'];
$context['root_operation_name'] = $context['operation_name'];
}

unset($context['iri'], $context['uri_variables']);
if (!$resourceClass) {
return $context;
}

unset($context['operation'], $context['operation_name']);
$context['resource_class'] = $resourceClass;
if ($this->resourceMetadataCollectionFactory) {
try {
$context['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation();
$context['operation_name'] = $context['operation']->getName();
} catch (OperationNotFoundException) {
}
}

return $context;
}
}
50 changes: 50 additions & 0 deletions src/Serializer/OperationContextTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Serializer;

/**
* @internal
*/
trait OperationContextTrait
{
/**
* This context is created when working on a relation context or items of a collection. It cleans the previously given
* context as the operation changes.
*/
protected function createOperationContext(array $context, string $resourceClass = null): array
{
if (isset($context['operation']) && !isset($context['root_operation'])) {
$context['root_operation'] = $context['operation'];
}

if (isset($context['operation_name']) || isset($context['graphql_operation_name'])) {
$context['root_operation_name'] = $context['operation_name'] ?? $context['graphql_operation_name'];
}

unset($context['iri'], $context['uri_variables'], $context['item_uri_template'], $context['force_resource_class']);

if (!$resourceClass) {
return $context;
}

if (($operation = $context['operation'] ?? null) && method_exists($operation, 'getItemUriTemplate')) {
$context['item_uri_template'] = $operation->getItemUriTemplate();
}

unset($context['operation'], $context['operation_name']);
$context['resource_class'] = $resourceClass;

return $context;
}
}
6 changes: 6 additions & 0 deletions src/Serializer/SerializerContextBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace ApiPlatform\Serializer;

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

// Special case as this is usually handled by our OperationContextTrait, here we want to force the IRI in the response
if (!$operation instanceof CollectionOperationInterface && method_exists($operation, 'getItemUriTemplate') && $operation->getItemUriTemplate()) {
$context['item_uri_template'] = $operation->getItemUriTemplate();
}

if ($operation->getTypes()) {
$context['types'] = $operation->getTypes();
}
Expand Down
9 changes: 4 additions & 5 deletions src/Symfony/Routing/IriConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ public function getIriFromResource(object|string $resource, int $referenceType =
{
$resourceClass = $context['force_resource_class'] ?? (\is_string($resource) ? $resource : $this->getObjectClass($resource));

if ($this->operationMetadataFactory && isset($context['item_uri_template'])) {
$operation = $this->operationMetadataFactory->create($context['item_uri_template']);
}

$localOperationCacheKey = ($operation?->getName() ?? '').$resourceClass.(\is_string($resource) ? '_c' : '_i');
if ($operation && isset($this->localOperationCache[$localOperationCacheKey])) {
return $this->generateSymfonyRoute($resource, $referenceType, $this->localOperationCache[$localOperationCacheKey], $context, $this->localIdentifiersExtractorOperationCache[$localOperationCacheKey] ?? null);
Expand All @@ -130,11 +134,6 @@ public function getIriFromResource(object|string $resource, int $referenceType =
}

$identifiersExtractorOperation = $operation;
if ($this->operationMetadataFactory && isset($context['item_uri_template'])) {
$identifiersExtractorOperation = null;
$operation = $this->operationMetadataFactory->create($context['item_uri_template']);
}

// In symfony the operation name is the route name, try to find one if none provided
if (
!$operation->getName()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,16 @@ class CompositeKeyWithDifferentType
#[ApiProperty(identifier: true)]
public ?string $verificationKey;

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

return $context;
$t = new self();
$t->id = $uriVariables['id'];
$t->verificationKey = $uriVariables['verificationKey'];

return $t;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy;

#[ApiResource(
operations: [
new GetCollection(uriTemplate: '/dummy_resource_with_custom_filter', itemUriTemplate: '/dummy_resource_with_custom_filter/{id}'),
new Get(uriTemplate: '/dummy_resource_with_custom_filter/{id}', uriVariables: ['id']),
new Get(uriTemplate: '/dummy_resource_with_custom_filter/{id}', uriVariables: ['id' => new Link(fromClass: Dummy::class)]),
],
stateOptions: new Options(entityClass: Dummy::class)
)]
Expand Down
Loading

0 comments on commit e3e6a0d

Please sign in to comment.