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.
| Request | Response |
|---|---|
/api/v1/users?id=7 | 200 {"id":"7","name":"Ada Lovelace"} with Deprecation: true and a Link to v2 |
/api/v2/users?id=7 | 200 {"id":"7","first_name":"Ada","last_name":"Lovelace"} |
/api/v3/users?id=7 | 404 Unknown api endpoint |
/api/v1/openapi.json | only the v1 endpoints, v1 marked deprecated |
Version 1
deprecated() returns true, which OpenAPI shows. The headers come from Nette\Http\IResponse.
<?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
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.
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
#!/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.
What do the Deprecation and Link headers do?
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/....