Skip to content

Example: pagination ​

GET /api/v1/products?page=2&per_page=5 returns one page of 23 products with links to the previous and next page.

Open the diagram at full size
RequestResponse
/api/v1/products200, page 1, 10 items, next link
?page=3&per_page=10200, 3 items, no next
?per_page=7400 Field contains not available value(s)
?page=0 or ?page=abc400 from the handler
?page=9404, last page is 3

The handler ​

php
<?php

declare(strict_types=1);

namespace App\Api;

use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\Output\JsonOutput;
use Tomaj\NetteApi\Params\GetInputParam;
use Tomaj\NetteApi\Validation\InputType;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ResponseInterface;

final class ProductListHandler extends BaseHandler
{
    private const TOTAL = 23;

    private const ERROR_SCHEMA = '{"type":"object","required":["status","message"],"properties":{"status":{"const":"error"},"message":{"type":"string"}}}';

    public function summary(): string
    {
        return 'List products page by page';
    }

    public function params(): array
    {
        return [
            // InputType::INTEGER turns "2" into 2 before the handler sees it
            (new GetInputParam('page', InputType::INTEGER))->setDefault(1)->setDescription('From 1'),
            // only these values pass, anything else answers 400
            (new GetInputParam('per_page', InputType::INTEGER))->setDefault(10)->setAvailableValues([5, 10, 20]),
        ];
    }

    public function outputs(): array
    {
        // checked after handle() unless APP_ENV is production, see EnvConfigurator in the config
        return [
            new JsonOutput(200, (string) json_encode([
                'type' => 'object',
                'required' => ['items', 'page', 'per_page', 'total', 'links'],
                'properties' => [
                    'items' => ['type' => 'array', 'items' => ['type' => 'object', 'required' => ['id', 'name']]],
                    'page' => ['type' => 'integer', 'minimum' => 1],
                    'per_page' => ['type' => 'integer'],
                    'total' => ['type' => 'integer'],
                    'links' => ['type' => 'object'],
                ],
            ])),
            // every code the handler returns needs an output, else the validation answers 500
            new JsonOutput(400, self::ERROR_SCHEMA),
            new JsonOutput(404, self::ERROR_SCHEMA),
        ];
    }

    public function handle(array $params): ResponseInterface
    {
        // a value that is not a number arrives as null, a range check is up to the handler
        $page = $params['page'];
        $perPage = $params['per_page'];
        if (!is_int($page) || $page < 1) {
            return new JsonApiResponse(400, ['status' => 'error', 'message' => 'page must be a whole number from 1']);
        }
        $pages = max(1, (int) ceil(self::TOTAL / $perPage));
        if ($page > $pages) {
            return new JsonApiResponse(404, ['status' => 'error', 'message' => "Page $page does not exist, last page is $pages"]);
        }

        $items = [];
        for ($id = ($page - 1) * $perPage + 1; $id <= min(self::TOTAL, $page * $perPage); $id++) {
            $items[] = ['id' => $id, 'name' => "Product $id"];
        }

        $links = ['self' => $this->link($page, $perPage)];
        if ($page > 1) {
            $links['prev'] = $this->link($page - 1, $perPage);
        }
        if ($page < $pages) {
            $links['next'] = $this->link($page + 1, $perPage);
        }

        return new JsonApiResponse(200, [
            'items' => $items,
            'page' => $page,
            'per_page' => $perPage,
            'total' => self::TOTAL,
            'links' => $links,
        ]);
    }

    private function link(int $page, int $perPage): string
    {
        return '/api/v1/products?' . http_build_query(['page' => $page, 'per_page' => $perPage]);
    }
}

The services ​

yaml
application:
	mapping:
		Api: Tomaj\NetteApi\Presenters\*Presenter

services:
	router: App\RouterFactory::create
	apiPresenter:
		factory: Tomaj\NetteApi\Presenters\ApiPresenter
	# validates output schemas unless APP_ENV is production
	- Tomaj\NetteApi\Output\Configurator\EnvConfigurator
	- Tomaj\NetteApi\Error\DefaultErrorHandler

	apiDecider:
		factory: Tomaj\NetteApi\ApiDecider
		setup:
			# GET /api/v1/products?page=2&per_page=5
			- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'products'), App\Api\ProductListHandler(), Tomaj\NetteApi\Authorization\NoAuthorization())

The test ​

bash
#!/usr/bin/env bash
# End-to-end check: typed query params, allowed values, prev/next links and an output schema.
set -euo pipefail
cd "$(dirname "$0")"
[[ -f vendor/autoload.php ]] || { echo "fail: vendor/ is missing, run composer install first" >&2; exit 1; }
source ../ci/lib.sh
serve 8093

request "$BASE/api/v1/products"
expect_status 200 "defaults"
expect_body '"page":1,"per_page":10,"total":23' "defaults"
expect_body '"links":{"self":"/api/v1/products?page=1&per_page=10","next":"/api/v1/products?page=2&per_page=10"}' "defaults"
ok "without params the defaults apply and only a next link is given"

request "$BASE/api/v1/products?page=3&per_page=10"
expect_body '"items":[{"id":21,"name":"Product 21"},{"id":22,"name":"Product 22"},{"id":23,"name":"Product 23"}]' "last page"
expect_body '"prev":"/api/v1/products?page=2&per_page=10"}' "last page"
ok "the last page has three items and only a prev link"

request "$BASE/api/v1/products?page=2&per_page=5"
expect_body '{"id":6,"name":"Product 6"}' "per_page 5"
ok "the string 5 from the URL arrives as the integer 5"

request "$BASE/api/v1/products?per_page=7"
expect_status 400 "per_page not allowed"
expect_body '"per_page":["Field contains not available value(s)"]' "per_page not allowed"
ok "a per_page outside 5, 10, 20 answers 400"

request "$BASE/api/v1/products?page=0"
expect_status 400 "page 0"
expect_body 'page must be a whole number from 1' "page 0"
request "$BASE/api/v1/products?page=abc"
expect_status 400 "page abc"
ok "page 0 or abc answers 400 from the handler"

request "$BASE/api/v1/products?page=9"
expect_status 404 "past the end"
expect_body 'last page is 3' "past the end"
ok "a page past the end answers 404"

FAQ ​

Why does page=abc reach the handler? ​

A value that is not a number becomes null for an INTEGER param in 3.x, and the default does not apply then. The handler checks it. See Input params.

Why does every status code have an output? ​

Output validation accepts a response only when an output matches its code and schema. Without the 400 and 404 outputs, those responses would turn into 500 in development. See Outputs.

Are the allowed values compared as numbers? ​

setAvailableValues([5, 10, 20]) checks the value that arrived in the query, so per_page=7 answers 400 before the handler runs.

Both work. A body is easier to read in the console; for a header, inject Nette\Http\IResponse as in the versions example.

How do I paginate a database? ​

Use per_page as LIMIT and (page - 1) * per_page as OFFSET, and one COUNT(*) for the total.

Released under the MIT License.