Skip to content

Example: API versions ​

GET /api/v1/users returns name, GET /api/v2/users returns first_name and last_name. v1 still works and tells clients to move.

Open the diagram at full size
RequestResponse
/api/v1/users?id=7200 {"id":"7","name":"Ada Lovelace"} with Deprecation: true and a Link to v2
/api/v2/users?id=7200 {"id":"7","first_name":"Ada","last_name":"Lovelace"}
/api/v3/users?id=7404 Unknown api endpoint
/api/v1/openapi.jsononly the v1 endpoints, v1 marked deprecated

Version 1 ​

deprecated() returns true, which OpenAPI shows. The headers come from Nette\Http\IResponse.

php
<?php

declare(strict_types=1);

namespace App\Api;

use Nette\Http\IResponse;
use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\Params\GetInputParam;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ResponseInterface;

final class UserV1Handler extends BaseHandler
{
    public function __construct(private IResponse $httpResponse)
    {
        parent::__construct();
    }

    public function summary(): string
    {
        return 'User detail, one name field';
    }

    // OpenAPI marks the endpoint deprecated: true, the console shows it crossed out
    public function deprecated(): bool
    {
        return true;
    }

    public function params(): array
    {
        return [(new GetInputParam('id'))->setRequired()];
    }

    public function handle(array $params): ResponseInterface
    {
        // deprecated() only changes the docs, clients learn about it from these headers
        $this->httpResponse->setHeader('Deprecation', 'true');
        $this->httpResponse->setHeader('Link', '</api/v2/users?id=' . rawurlencode($params['id']) . '>; rel="successor-version"');

        return new JsonApiResponse(200, ['id' => $params['id'], 'name' => 'Ada Lovelace']);
    }
}

Version 2 ​

php
<?php

declare(strict_types=1);

namespace App\Api;

use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\Params\GetInputParam;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ResponseInterface;

final class UserV2Handler extends BaseHandler
{
    public function summary(): string
    {
        return 'User detail, name split in two fields';
    }

    public function params(): array
    {
        return [(new GetInputParam('id'))->setRequired()];
    }

    public function handle(array $params): ResponseInterface
    {
        // the breaking change: name is split, so it is a new version, v1 stays as it was
        return new JsonApiResponse(200, ['id' => $params['id'], 'first_name' => 'Ada', 'last_name' => 'Lovelace']);
    }
}

The services ​

The version is the second argument of EndpointIdentifier.

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

services:
	router: App\RouterFactory::create
	apiPresenter:
		factory: Tomaj\NetteApi\Presenters\ApiPresenter
	- Tomaj\NetteApi\Output\Configurator\EnvConfigurator
	- Tomaj\NetteApi\Error\DefaultErrorHandler
	- Tomaj\NetteApi\Link\ApiLink
	# created when /api/vN/openapi is called, it lists only the endpoints of version N
	- Tomaj\NetteApi\Handlers\OpenApiHandler(initData: [info: [title: 'Users API']])

	apiDecider:
		factory: Tomaj\NetteApi\ApiDecider
		setup:
			# the same package and action in two versions, each with its own handler
			- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'users'), App\Api\UserV1Handler(), Tomaj\NetteApi\Authorization\NoAuthorization())
			- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 2, 'users'), App\Api\UserV2Handler(), Tomaj\NetteApi\Authorization\NoAuthorization())
			- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'openapi'), Tomaj\NetteApi\Handlers\OpenApiHandler, Tomaj\NetteApi\Authorization\NoAuthorization())
			- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 2, 'openapi'), Tomaj\NetteApi\Handlers\OpenApiHandler, Tomaj\NetteApi\Authorization\NoAuthorization())

The test ​

bash
#!/usr/bin/env bash
# End-to-end check: two versions of one endpoint, a deprecated v1 and OpenAPI per version.
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 8094

request "$BASE/api/v1/users?id=7"
expect_status 200 "v1"
expect_body '{"id":"7","name":"Ada Lovelace"}' "v1"
expect_header 'Deprecation: true' "v1"
expect_header 'Link: </api/v2/users?id=7>; rel="successor-version"' "v1"
ok "v1 still answers the old shape and points to v2"

request "$BASE/api/v2/users?id=7"
expect_status 200 "v2"
expect_body '{"id":"7","first_name":"Ada","last_name":"Lovelace"}' "v2"
no_header 'Deprecation' "v2"
ok "v2 answers the new shape"

request "$BASE/api/v3/users?id=7"
expect_status 404 "v3"
ok "a version that is not registered answers 404"

request "$BASE/api/v1/openapi"
expect_body '"deprecated":true' "openapi v1"
expect_body '"/api/v1/users"' "openapi v1"
request "$BASE/api/v2/openapi"
expect_body '"/api/v2/users"' "openapi v2"
if [[ "$BODY" == *'"deprecated"'* || "$BODY" == *'/api/v1/'* ]]; then echo "fail: openapi v2 lists a deprecated or v1 endpoint" >&2; exit 1; fi
ok "each version has its own OpenAPI, v1 marks users deprecated"

FAQ ​

Where is the version in the URL? ​

The route api/v<version>/<package>[/<apiAction>] puts it in the path; ApiDecider matches it with the method and package. See ApiDecider and endpoints.

Can v2 reuse v1 code? ​

Yes, both handlers can use the same service; only the shape of the response differs here.

They tell a client, or a gateway, that v1 is deprecated and where the successor is, without breaking it.

Why one OpenAPI document per version? ​

OpenApiHandler lists the endpoints of its own version. Clients of v2 do not see v1. See OpenAPI.

Does a string version work? ​

Yes, EndpointIdentifier takes a string such as '1.1', and the default route pattern accepts the dot in /api/v1.1/....

Released under the MIT License.