Skip to content

Commit

Permalink
fix(hydra): hydra:view with absolute iris (#6208)
Browse files Browse the repository at this point in the history
* fix(hydra): hydra:view with absolute iris

fixes #6096
  • Loading branch information
soyuka committed Mar 12, 2024
1 parent 98f4b8f commit 2819d56
Show file tree
Hide file tree
Showing 6 changed files with 50 additions and 23 deletions.
2 changes: 1 addition & 1 deletion features/doctrine/multiple_filter.feature
Expand Up @@ -38,7 +38,7 @@ Feature: Multiple filters on collections
"hydra:view": {
"type": "object",
"properties": {
"@id": {"pattern": "^/dummies\\?dummyDate%5Bafter%5D=2015-04-28&dummyBoolean=1$"},
"@id": {"pattern": "^/dummies\\?dummyBoolean=1&dummyDate%5Bafter%5D=2015-04-28$"},
"@type": {"pattern": "^hydra:PartialCollectionView$"}
}
}
Expand Down
4 changes: 2 additions & 2 deletions features/elasticsearch/match_filter.feature
Expand Up @@ -209,7 +209,7 @@ Feature: Match filter on collections from Elasticsearch
"hydra:view": {
"type": "object",
"properties": {
"@id": {"pattern": "^/tweets\\?message%5B%5D=Good%20job&message%5B%5D=run&author.firstName=Caroline$"},
"@id": {"pattern": "^/tweets\\?author.firstName=Caroline&message%5B%5D=Good%20job&message%5B%5D=run$"},
"@type": {"pattern": "^hydra:PartialCollectionView$"}
}
}
Expand Down Expand Up @@ -422,7 +422,7 @@ Feature: Match filter on collections from Elasticsearch
"hydra:view": {
"type": "object",
"properties": {
"@id": {"pattern": "^/books\\?message%5B%5D=Good%20job&message%5B%5D=run&library.firstName=Caroline$"},
"@id": {"pattern": "^/books\\?library.firstName=Caroline&message%5B%5D=Good%20job&message%5B%5D=run$"},
"@type": {"pattern": "^hydra:PartialCollectionView$"}
}
}
Expand Down
23 changes: 23 additions & 0 deletions features/hydra/absolute.feature
@@ -0,0 +1,23 @@
Feature: Collections with absolute IRIs support
In order to retrieve large collections of resources
As a client software developer
I need to retrieve paged collections respecting the Hydra specification and with absolute iris

@createSchema
Scenario: Retrieve third page of collection with absolute iris
Given there are 30 absoluteUrlDummy objects with a related absoluteUrlRelationDummy
When I send a "GET" request to "/absolute_url_dummies?page=3"
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 node "hydra:view" should be equal to:
"""
{
"@id": "http://example.com/absolute_url_dummies?page=3",
"@type": "hydra:PartialCollectionView",
"hydra:first": "http://example.com/absolute_url_dummies?page=1",
"hydra:last": "http://example.com/absolute_url_dummies?page=10",
"hydra:previous": "http://example.com/absolute_url_dummies?page=2",
"hydra:next": "http://example.com/absolute_url_dummies?page=4"
}
"""
6 changes: 3 additions & 3 deletions features/hydra/collection.feature
Expand Up @@ -576,10 +576,10 @@ Feature: Collections support
"hydra:view": {
"type": "object",
"properties": {
"@id": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Bgt%5D=10$"},
"@id": {"pattern": "^/so_manies\\?id%5Bgt%5D=10&order%5Bid%5D=desc$"},
"@type": {"pattern": "^hydra:PartialCollectionView$"},
"hydra:previous": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Bgt%5D=13$"},
"hydra:next": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Blt%5D=10$"}
"hydra:previous": {"pattern": "^/so_manies\\?id%5Bgt%5D=13&order%5Bid%5D=desc$"},
"hydra:next": {"pattern": "^/so_manies\\?id%5Blt%5D=10&order%5Bid%5D=desc$"}
},
"additionalProperties": false
}
Expand Down
37 changes: 20 additions & 17 deletions src/Hydra/Serializer/PartialCollectionViewNormalizer.php
Expand Up @@ -15,6 +15,7 @@

use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use ApiPlatform\Serializer\CacheableSupportsMethodInterface;
use ApiPlatform\State\Pagination\PaginatorInterface;
use ApiPlatform\State\Pagination\PartialPaginatorInterface;
Expand All @@ -37,7 +38,7 @@ final class PartialCollectionViewNormalizer implements NormalizerInterface, Norm
{
private readonly PropertyAccessorInterface $propertyAccessor;

public function __construct(private readonly NormalizerInterface $collectionNormalizer, private readonly string $pageParameterName = 'page', private string $enabledParameterName = 'pagination', private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ?PropertyAccessorInterface $propertyAccessor = null)
public function __construct(private readonly NormalizerInterface $collectionNormalizer, private readonly string $pageParameterName = 'page', private string $enabledParameterName = 'pagination', private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ?PropertyAccessorInterface $propertyAccessor = null, private readonly int $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH)
{
$this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor();
}
Expand Down Expand Up @@ -72,7 +73,7 @@ public function normalize(mixed $object, ?string $format = null, array $context
// TODO: This needs to be changed as well as I wrote in the CollectionFiltersNormalizer
// We should not rely on the request_uri but instead rely on the UriTemplate
// This needs that we implement the RFC and that we do more parsing before calling the serialization (MainController)
$parsed = IriHelper::parseIri($context['request_uri'] ?? '/', $this->pageParameterName);
$parsed = IriHelper::parseIri($context['uri'] ?? $context['request_uri'] ?? '/', $this->pageParameterName);
$appliedFilters = $parsed['parameters'];
unset($appliedFilters[$this->enabledParameterName]);

Expand All @@ -82,22 +83,24 @@ public function normalize(mixed $object, ?string $format = null, array $context

$isPaginatedWithCursor = false;
$cursorPaginationAttribute = null;
if ($this->resourceMetadataFactory && isset($context['resource_class']) && $paginated) {
/** @var HttpOperation $operation */
$operation = $context['operation'] ?? null;
if (!$operation && $this->resourceMetadataFactory && isset($context['resource_class']) && $paginated) {
$operation = $this->resourceMetadataFactory->create($context['resource_class'])->getOperation($context['operation_name'] ?? null);
$isPaginatedWithCursor = [] !== $cursorPaginationAttribute = ($operation->getPaginationViaCursor() ?? []);
}

$cursorPaginationAttribute = $operation instanceof HttpOperation ? $operation->getPaginationViaCursor() : null;
$isPaginatedWithCursor = (bool) $cursorPaginationAttribute;

$data['hydra:view'] = ['@id' => null, '@type' => 'hydra:PartialCollectionView'];

if ($isPaginatedWithCursor) {
return $this->populateDataWithCursorBasedPagination($data, $parsed, $object, $cursorPaginationAttribute);
return $this->populateDataWithCursorBasedPagination($data, $parsed, $object, $cursorPaginationAttribute, $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy);
}

$data['hydra:view']['@id'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null);
$data['hydra:view']['@id'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy);

if ($paginated) {
return $this->populateDataWithPagination($data, $parsed, $currentPage, $lastPage, $itemsPerPage, $pageTotalItems);
return $this->populateDataWithPagination($data, $parsed, $currentPage, $lastPage, $itemsPerPage, $pageTotalItems, $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy);
}

return $data;
Expand Down Expand Up @@ -165,38 +168,38 @@ private function cursorPaginationFields(array $fields, int $direction, $object):
return $paginationFilters;
}

private function populateDataWithCursorBasedPagination(array $data, array $parsed, \Traversable $object, ?array $cursorPaginationAttribute): array
private function populateDataWithCursorBasedPagination(array $data, array $parsed, \Traversable $object, ?array $cursorPaginationAttribute, ?int $urlGenerationStrategy): array
{
$objects = iterator_to_array($object);
$firstObject = current($objects);
$lastObject = end($objects);

$data['hydra:view']['@id'] = IriHelper::createIri($parsed['parts'], $parsed['parameters']);
$data['hydra:view']['@id'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], urlGenerationStrategy: $urlGenerationStrategy);

if (false !== $lastObject && \is_array($cursorPaginationAttribute)) {
$data['hydra:view']['hydra:next'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, 1, $lastObject)));
$data['hydra:view']['hydra:next'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, 1, $lastObject)), urlGenerationStrategy: $urlGenerationStrategy);
}

if (false !== $firstObject && \is_array($cursorPaginationAttribute)) {
$data['hydra:view']['hydra:previous'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, -1, $firstObject)));
$data['hydra:view']['hydra:previous'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, -1, $firstObject)), urlGenerationStrategy: $urlGenerationStrategy);
}

return $data;
}

private function populateDataWithPagination(array $data, array $parsed, ?float $currentPage, ?float $lastPage, ?float $itemsPerPage, ?float $pageTotalItems): array
private function populateDataWithPagination(array $data, array $parsed, ?float $currentPage, ?float $lastPage, ?float $itemsPerPage, ?float $pageTotalItems, ?int $urlGenerationStrategy): array
{
if (null !== $lastPage) {
$data['hydra:view']['hydra:first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1.);
$data['hydra:view']['hydra:last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage);
$data['hydra:view']['hydra:first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy);
$data['hydra:view']['hydra:last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy);
}

if (1. !== $currentPage) {
$data['hydra:view']['hydra:previous'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1.);
$data['hydra:view']['hydra:previous'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy);
}

if ((null !== $lastPage && $currentPage < $lastPage) || (null === $lastPage && $pageTotalItems >= $itemsPerPage)) {
$data['hydra:view']['hydra:next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1.);
$data['hydra:view']['hydra:next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy);
}

return $data;
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Bundle/Resources/config/hydra.xml
Expand Up @@ -70,6 +70,7 @@
<argument>%api_platform.collection.pagination.enabled_parameter_name%</argument>
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
<argument type="service" id="api_platform.property_accessor" />
<argument>%api_platform.url_generation_strategy%</argument>
</service>

<service id="api_platform.hydra.normalizer.collection_filters" class="ApiPlatform\Hydra\Serializer\CollectionFiltersNormalizer" decorates="api_platform.hydra.normalizer.collection" public="false">
Expand Down

0 comments on commit 2819d56

Please sign in to comment.