Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions Classes/Domain/Model/Pagination.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace SourceBroker\T3api\Domain\Model;

use SourceBroker\T3api\Exception\InvalidPaginationParameterException;
use SourceBroker\T3api\Utility\ParameterUtility;
use Symfony\Component\HttpFoundation\Request;
use TYPO3\CMS\Core\Http\ServerRequest as Typo3Request;
Expand Down Expand Up @@ -77,8 +78,30 @@ public function isEnabled(): bool
: $this->isServerEnabled();
}

/**
* Validates client parameters the same way API Platform does: page has to be an integer >= 1,
* items per page an integer >= 0 (0 returns no members, only the number of all items).
*
* @throws InvalidPaginationParameterException
*/
public function validate(): void
{
$this->getPage();

if ($this->getClientNumberOfItemsPerPage() === 0 && $this->getPage() > 1) {
throw InvalidPaginationParameterException::pageWithZeroItemsPerPage(
$this->pageParameterName,
$this->itemsPerPageParameterName
);
}
}

public function getNumberOfItemsPerPage(): int
{
if ($this->getClientNumberOfItemsPerPage() === 0) {
return 0;
}

return min(array_filter(
[$this->maximumItemsPerPage, $this->getClientNumberOfItemsPerPage() ?? $this->itemsPerPage],
static function (?int $itemsPerPage): bool {
Expand All @@ -87,9 +110,22 @@ static function (?int $itemsPerPage): bool {
));
}

/**
* @throws InvalidPaginationParameterException
*/
public function getPage(): int
{
return (isset($this->parameters[$this->pageParameterName])) ? (int)$this->parameters[$this->pageParameterName] : 1;
if (!isset($this->parameters[$this->pageParameterName])) {
return 1;
}

$page = filter_var($this->parameters[$this->pageParameterName], FILTER_VALIDATE_INT);

if ($page === false || $page < 1) {
throw InvalidPaginationParameterException::invalidPage($this->pageParameterName);
}

return $page;
}

public function getOffset(): int
Expand Down Expand Up @@ -132,10 +168,19 @@ public function isClientEnabled(): bool
return $this->clientEnabled;
}

/**
* @throws InvalidPaginationParameterException
*/
protected function getClientNumberOfItemsPerPage(): ?int
{
if ($this->clientItemsPerPage && isset($this->parameters[$this->itemsPerPageParameterName])) {
return (int)$this->parameters[$this->itemsPerPageParameterName];
$itemsPerPage = filter_var($this->parameters[$this->itemsPerPageParameterName], FILTER_VALIDATE_INT);

if ($itemsPerPage === false || $itemsPerPage < 0) {
throw InvalidPaginationParameterException::invalidItemsPerPage($this->itemsPerPageParameterName);
}

return $itemsPerPage;
}

return null;
Expand Down
56 changes: 56 additions & 0 deletions Classes/Exception/InvalidPaginationParameterException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace SourceBroker\T3api\Exception;

use SourceBroker\T3api\OpenApi\Objects\Response as OpenApiResponse;
use Symfony\Component\HttpFoundation\Response;

class InvalidPaginationParameterException extends AbstractException implements OpenApiSupportingExceptionInterface
{
public static function getOpenApiResponse(): OpenApiResponse
{
return parent::getOpenApiResponse()
->statusCode(Response::HTTP_BAD_REQUEST)
->description(self::translate('exception.invalid_pagination_parameter.title'));
}

public static function invalidPage(string $pageParameterName): self
{
return new self(
self::translate('exception.invalid_pagination_parameter.page', [$pageParameterName]),
1791043200
);
}

public static function invalidItemsPerPage(string $itemsPerPageParameterName): self
{
return new self(
self::translate('exception.invalid_pagination_parameter.items_per_page', [$itemsPerPageParameterName]),
1791043201
);
}

public static function pageWithZeroItemsPerPage(string $pageParameterName, string $itemsPerPageParameterName): self
{
return new self(
self::translate(
'exception.invalid_pagination_parameter.page_with_zero_items_per_page',
[$pageParameterName, $itemsPerPageParameterName]
),
1791043202
);
}

final public function __construct(string $description, int $code)
{
$this->title = self::translate('exception.invalid_pagination_parameter.title');
parent::__construct($description, $code);
}

public function getStatusCode(): int
{
return Response::HTTP_BAD_REQUEST;
}
}
16 changes: 15 additions & 1 deletion Classes/Response/AbstractCollectionResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace SourceBroker\T3api\Response;

use SourceBroker\T3api\Domain\Model\CollectionOperation;
use SourceBroker\T3api\Exception\InvalidPaginationParameterException;
use SourceBroker\T3api\OpenApi\Objects\Schema;
use Symfony\Component\HttpFoundation\Request;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
Expand All @@ -25,17 +26,30 @@ abstract class AbstractCollectionResponse

abstract public static function getOpenApiSchema(string $membersReference): Schema;

/**
* @throws InvalidPaginationParameterException
*/
public function __construct(CollectionOperation $operation, Request $request, QueryInterface $query)
{
$this->operation = $operation;
$this->request = $request;
$this->query = $query;

// Invalid pagination parameters are reported before any query is executed
$pagination = $this->operation->getPagination()->setParametersFromRequest($this->request);
if ($pagination->isEnabled()) {
$pagination->validate();
}
}

public function getMembers(): array
{
if ($this->membersCache === null) {
$this->membersCache = $this->applyPagination()->execute()->toArray();
$pagination = $this->operation->getPagination()->setParametersFromRequest($this->request);
// `itemsPerPage=0` requests only the number of all items, without any members
$this->membersCache = $pagination->isEnabled() && $pagination->getNumberOfItemsPerPage() === 0
? []
: $this->applyPagination()->execute()->toArray();
}

return $this->membersCache;
Expand Down
7 changes: 5 additions & 2 deletions Classes/Response/HydraCollectionResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public static function getOpenApiSchema(string $membersReference): Schema
Schema::integer('hydra:totalItems')->minimum(0),
Schema::object('hydra:view')->properties(
Schema::string('hydra:first')->description('URI to first page'),
Schema::string('hydra:last')->description('URI to first page'),
Schema::string('hydra:last')->description('URI to last page'),
Schema::string('hydra:prev')->description('URI to previous page'),
Schema::string('hydra:next')->description('URI to next page'),
Schema::array('hydra:pages')->items(Schema::string())->description('URIs to all pages'),
Expand All @@ -47,7 +47,10 @@ public function getView(): array

if ($this->operation->getPagination()->isEnabled()) {
$pagination = $this->operation->getPagination();
$lastPage = (int)ceil($this->getTotalItems() / $pagination->getNumberOfItemsPerPage());
// Empty collection and `itemsPerPage=0` (only number of all items requested) still have one page
$lastPage = $pagination->getNumberOfItemsPerPage() > 0
? max(1, (int)ceil($this->getTotalItems() / $pagination->getNumberOfItemsPerPage()))
: 1;
$viewData['hydra:first'] = $this->operation->getRoute()->getPath() . '?' .
$this->getCurrentQueryStringWithOverrideParams([
$pagination->getPageParameterName() => 1,
Expand Down
5 changes: 5 additions & 0 deletions Classes/Service/OpenApiBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use SourceBroker\T3api\Domain\Model\CollectionOperation;
use SourceBroker\T3api\Domain\Model\ItemOperation;
use SourceBroker\T3api\Domain\Model\OperationInterface;
use SourceBroker\T3api\Exception\InvalidPaginationParameterException;
use SourceBroker\T3api\Exception\OperationNotAllowedException;
use SourceBroker\T3api\Exception\ResourceNotFoundException;
use SourceBroker\T3api\Exception\ValidationException;
Expand Down Expand Up @@ -293,6 +294,10 @@ protected static function getOperationResponses(OperationInterface $operation):
$responses[] = ResourceNotFoundException::getOpenApiResponse();
}

if ($operation instanceof CollectionOperation && $operation->isMethodGet()) {
$responses[] = InvalidPaginationParameterException::getOpenApiResponse();
}

if ($operation->isMethodPatch() || $operation->isMethodPost() || $operation->isMethodPut()) {
$responses[] = ValidationException::getOpenApiResponse();
}
Expand Down
5 changes: 5 additions & 0 deletions Documentation/Pagination/ClientSide/Index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ By default client can only choose the page number. Two more options can be opene

Always set ``maximum_items_per_page`` when you enable ``pagination_client_items_per_page``. Number of items requested by the client is never higher than ``maximum_items_per_page``, so ``?itemsPerPage=100000`` returns 100 items with configuration above. Keep in mind that ``pagination_client_enabled`` allows the client to fetch the whole collection with ``?pagination=0`` regardless of ``maximum_items_per_page``.

Number of items per page has to be an integer greater than or equal to 0, otherwise ``400 Bad Request`` is returned. ``?itemsPerPage=0`` returns no items, only ``hydra:totalItems`` - useful when the client needs only the number of all items. It can not be combined with a page greater than 1.

.. admonition:: Real examples. Run "ddev restart && ddev ci 14" and try those links below.

* | Get 2 news per page, second page:
Expand All @@ -43,6 +45,9 @@ By default client can only choose the page number. Two more options can be opene
* | Get 4 news, sorted by title, from page 3 or 6 only - filters, sorting and pagination can be combined:
| `https://14.t3api.ddev.site/_api/news/news?pid[]=3&pid[]=6&order[title]=asc&itemsPerPage=4 <https://14.t3api.ddev.site/_api/news/news?pid[]=3&pid[]=6&order[title]=asc&itemsPerPage=4>`__
|
* | Get only the number of all news (``hydra:member`` is empty):
| `https://14.t3api.ddev.site/_api/news/news?itemsPerPage=0 <https://14.t3api.ddev.site/_api/news/news?itemsPerPage=0>`__
|
* | Disable pagination - ``hydra:view`` is empty and all news are returned:
| `https://14.t3api.ddev.site/_api/news/news?pagination=0 <https://14.t3api.ddev.site/_api/news/news?pagination=0>`__
|
14 changes: 14 additions & 0 deletions Documentation/Pagination/ServerSide/Index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ Now the third page is available under ``?p=3``.
.. note::

Requesting a page beyond the last one returns an empty ``hydra:member`` list (still with correct ``hydra:totalItems``), not an error.
An empty collection has one page (``hydra:first`` and ``hydra:last`` point to page 1).

The page number has to be an integer greater than or equal to 1. Any other value (``?page=0``, ``?page=-1``, ``?page=abc``) results in ``400 Bad Request``:

.. code-block:: json

{
"hydra:title": "Invalid pagination parameter",
"hydra:description": "Parameter `page` has to be an integer greater than or equal to 1",
"hydra:code": 1791043200
}

.. admonition:: Real examples. Run "ddev restart && ddev ci 14" and try those links below.

Expand All @@ -56,3 +67,6 @@ Now the third page is available under ``?p=3``.
* | Get page which does not exist - ``hydra:member`` is empty:
| `https://14.t3api.ddev.site/_api/news/news?page=5 <https://14.t3api.ddev.site/_api/news/news?page=5>`__
|
* | Invalid page number - ``400 Bad Request``:
| `https://14.t3api.ddev.site/_api/news/news?page=0 <https://14.t3api.ddev.site/_api/news/news?page=0>`__
|
12 changes: 12 additions & 0 deletions Resources/Private/Language/locallang.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@
<trans-unit id="exception.operation_not_allowed.description">
<source>You are not allowed to access operation `%s`</source>
</trans-unit>
<trans-unit id="exception.invalid_pagination_parameter.title">
<source>Invalid pagination parameter</source>
</trans-unit>
<trans-unit id="exception.invalid_pagination_parameter.page">
<source>Parameter `%s` has to be an integer greater than or equal to 1</source>
</trans-unit>
<trans-unit id="exception.invalid_pagination_parameter.items_per_page">
<source>Parameter `%s` has to be an integer greater than or equal to 0</source>
</trans-unit>
<trans-unit id="exception.invalid_pagination_parameter.page_with_zero_items_per_page">
<source>Parameter `%s` can not be greater than 1 if parameter `%s` is 0</source>
</trans-unit>
</body>
</file>
</xliff>
69 changes: 69 additions & 0 deletions Tests/Functional/Dispatcher/CollectionPaginationDispatcherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace SourceBroker\T3api\Tests\Functional\Dispatcher;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use SourceBroker\T3api\Exception\InvalidPaginationParameterException;

/**
* Full-cycle functional tests for collection pagination and query modifiers, run against the
Expand Down Expand Up @@ -156,6 +158,73 @@ public function orderedUidsFilterMatchingNothingReturnsEmptyCollection(): void
self::assertSame(0, $response['hydra:totalItems']);
}

#[Test]
public function emptyCollectionHasSingleFirstPageInView(): void
{
$response = $this->dispatchProductsGet(['fixedOrder' => 'none']);

self::assertSame('/_api/products?fixedOrder=none&page=1', $response['hydra:view']['hydra:first']);
self::assertSame('/_api/products?fixedOrder=none&page=1', $response['hydra:view']['hydra:last']);
self::assertSame(['/_api/products?fixedOrder=none&page=1'], $response['hydra:view']['hydra:pages']);
self::assertSame(1, $response['hydra:view']['hydra:page']);
self::assertArrayNotHasKey('hydra:next', $response['hydra:view']);
self::assertArrayNotHasKey('hydra:prev', $response['hydra:view']);
}

public static function invalidPaginationParameters(): array
{
return [
'page zero' => [['page' => '0'], 1791043200],
'page negative' => [['page' => '-1'], 1791043200],
'page not a number' => [['page' => 'abc'], 1791043200],
'page not an integer' => [['page' => '1.5'], 1791043200],
'items per page negative' => [['itemsPerPage' => '-5'], 1791043201],
'items per page not a number' => [['itemsPerPage' => 'abc'], 1791043201],
'page greater than 1 with zero items per page' => [['itemsPerPage' => '0', 'page' => '2'], 1791043202],
];
}

#[Test]
#[DataProvider('invalidPaginationParameters')]
public function invalidPaginationParameterIsRejected(array $queryParams, int $expectedExceptionCode): void
{
$this->expectException(InvalidPaginationParameterException::class);
$this->expectExceptionCode($expectedExceptionCode);

$this->dispatchProductsGet($queryParams);
}

#[Test]
public function zeroItemsPerPageReturnsOnlyNumberOfAllItems(): void
{
$response = $this->dispatchProductsGet(['itemsPerPage' => 0]);

self::assertSame([], $response['hydra:member']);
self::assertSame(300, $response['hydra:totalItems']);
self::assertSame(['/_api/products?itemsPerPage=0&page=1'], $response['hydra:view']['hydra:pages']);
self::assertArrayNotHasKey('hydra:next', $response['hydra:view']);
}

#[Test]
public function zeroItemsPerPageReturnsOnlyNumberOfAllItemsForStatementBackedQuery(): void
{
$response = $this->dispatchProductsGet(['fixedOrder' => '11,2,9,4,7,6', 'itemsPerPage' => 0]);

self::assertSame([], $response['hydra:member']);
self::assertSame(6, $response['hydra:totalItems']);
}

#[Test]
public function statementBackedQueryKeepsTotalItemsOnLastPage(): void
{
$response = $this->dispatchProductsGet(['fixedOrder' => '11,2,9,4,7,6', 'itemsPerPage' => 4, 'page' => 2]);

self::assertSame([7, 6], $this->memberUids($response));
self::assertSame(6, $response['hydra:totalItems']);
self::assertSame(2, $response['hydra:view']['hydra:page']);
self::assertStringEndsWith('page=2', $response['hydra:view']['hydra:last']);
}

private function dispatchProductsGet(array $queryParams): array
{
return json_decode(
Expand Down
Loading