diff --git a/Classes/Domain/Model/Pagination.php b/Classes/Domain/Model/Pagination.php index 45bdde2e..0ca34436 100644 --- a/Classes/Domain/Model/Pagination.php +++ b/Classes/Domain/Model/Pagination.php @@ -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; @@ -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 { @@ -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 @@ -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; diff --git a/Classes/Exception/InvalidPaginationParameterException.php b/Classes/Exception/InvalidPaginationParameterException.php new file mode 100644 index 00000000..06d76725 --- /dev/null +++ b/Classes/Exception/InvalidPaginationParameterException.php @@ -0,0 +1,56 @@ +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; + } +} diff --git a/Classes/Response/AbstractCollectionResponse.php b/Classes/Response/AbstractCollectionResponse.php index 0ec5f227..12261838 100644 --- a/Classes/Response/AbstractCollectionResponse.php +++ b/Classes/Response/AbstractCollectionResponse.php @@ -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; @@ -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; diff --git a/Classes/Response/HydraCollectionResponse.php b/Classes/Response/HydraCollectionResponse.php index 288607d2..7ecd8c67 100644 --- a/Classes/Response/HydraCollectionResponse.php +++ b/Classes/Response/HydraCollectionResponse.php @@ -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'), @@ -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, diff --git a/Classes/Service/OpenApiBuilder.php b/Classes/Service/OpenApiBuilder.php index 7704d3fd..1eecb744 100644 --- a/Classes/Service/OpenApiBuilder.php +++ b/Classes/Service/OpenApiBuilder.php @@ -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; @@ -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(); } diff --git a/Documentation/Pagination/ClientSide/Index.rst b/Documentation/Pagination/ClientSide/Index.rst index 1a17d808..4118fe6e 100644 --- a/Documentation/Pagination/ClientSide/Index.rst +++ b/Documentation/Pagination/ClientSide/Index.rst @@ -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: @@ -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 `__ | + * | Get only the number of all news (``hydra:member`` is empty): + | `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 `__ | diff --git a/Documentation/Pagination/ServerSide/Index.rst b/Documentation/Pagination/ServerSide/Index.rst index 388b8f29..ee06abe5 100644 --- a/Documentation/Pagination/ServerSide/Index.rst +++ b/Documentation/Pagination/ServerSide/Index.rst @@ -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. @@ -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 `__ | + * | Invalid page number - ``400 Bad Request``: + | `https://14.t3api.ddev.site/_api/news/news?page=0 `__ + | diff --git a/Resources/Private/Language/locallang.xlf b/Resources/Private/Language/locallang.xlf index 6b0405de..22fc2378 100644 --- a/Resources/Private/Language/locallang.xlf +++ b/Resources/Private/Language/locallang.xlf @@ -33,6 +33,18 @@ You are not allowed to access operation `%s` + + Invalid pagination parameter + + + Parameter `%s` has to be an integer greater than or equal to 1 + + + Parameter `%s` has to be an integer greater than or equal to 0 + + + Parameter `%s` can not be greater than 1 if parameter `%s` is 0 + diff --git a/Tests/Functional/Dispatcher/CollectionPaginationDispatcherTest.php b/Tests/Functional/Dispatcher/CollectionPaginationDispatcherTest.php index 3f9ea907..f03abd92 100644 --- a/Tests/Functional/Dispatcher/CollectionPaginationDispatcherTest.php +++ b/Tests/Functional/Dispatcher/CollectionPaginationDispatcherTest.php @@ -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 @@ -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(