We use cookies to enhance your experience on the site
CodeWorlds

API versioning - the eras of the Empire

A change to an API several clients depend on has one unpleasant property: it cannot be withdrawn from other people's applications. You remove a field nobody - you believe - uses, and that same afternoon you learn that an integrator at the other end of the country was using it.

Rome managed this through eras. The law of the Republic did not vanish when the Empire came - it went on governing matters begun earlier, while the new rules ran alongside. API versioning is the same: the old version lives as long as somebody uses it, and changes go into the new one.

Three strategies

NestJS knows three ways of stating a version:

  • URI Versioning - the version in the path, as in
    /v1/legiones
    . The most commonly used, because it shows in the logs, in the browser and in bookmarks.
  • Header Versioning - the version in an HTTP header, for example
    X-Version: 1
    . The address stays clean, but the version cannot be seen without inspecting the request.
  • Media Type Versioning - the version in the
    Content-Type
    header. The most faithful to the spirit of HTTP and the rarest in practice.

With URI Versioning at version one the address looks like this:

/v1/legiones
. Not
/legiones?version=1
- that would be a query parameter, not a version. Not
/legiones/v1
- there
v1
would look like a resource identifier. And not
/legiones
with an
X-Version
header - that is already a different strategy.

Enabling versioning

Versioning is switched on once, in

main.ts
:

1async function bootstrap() {
2  const app = await NestFactory.create(AppModule);
3
4  app.enableVersioning({
5    type: VersioningType.URI,
6    defaultVersion: '1',
7  });
8
9  await app.listen(3000);
10}

The notation has four parts in a fixed order:

app.enableVersioning({
opens the configuration,
type: VersioningType.URI,
chooses the strategy,
defaultVersion: '1'
sets the version for controllers that do not state one, and
});
closes it.

defaultVersion
is worth setting straight away. Without it, every controller lacking an explicit version stops answering - and when you switch versioning on in an existing project that usually means all of them at once.

A versioned controller

A controller declares its version in the same decorator that gives its path:

1@Controller({
2  path: 'legiones', version: '1' })
3export class LegionV1Controller {}

The written order is fixed:

@Controller({
opens the configuration object,
path: 'legiones', version: '1' })
supplies the path and the version, and
export class LegionV1Controller {}
declares the class itself.

Note that

@Controller('legiones')
with a string becomes
@Controller({ path, version })
with an object - the same function, in the variant that accepts more options. Individual methods can be versioned separately with the
@Version('2')
decorator, when only one of them has changed.

The class name -

LegionV1Controller
- means nothing to the routing; the version is settled by the
version
field alone. It is still worth naming it after the version, because in a project with two eras, two
LegionController
classes in different files soon become a source of mistakes.

Separate documentation for each version

One Swagger document with the versions mixed together is less useful than two separate ones. The process has four steps, in this order:

  1. app.enableVersioning()
    - the application must first be able to tell versions apart at all.
  2. A
    DocumentBuilder
    for each version
    - a separate configuration with its own title and number.
  3. SwaggerModule.createDocument
    with the
    include
    option
    - building a document from the chosen modules only.
  4. SwaggerModule.setup
    for each version
    - serving each document at its own address.
1const configV1 = new DocumentBuilder()
2  .setTitle('Legion API v1')
3  .setDescription('The first era of the Empire')
4  .setVersion('1.0')
5  .setContact('Chancery', 'https://imperium.rome', 'chancery@imperium.rome')
6  .setLicense('MIT', 'https://opensource.org/licenses/MIT')
7  .addServer('https://api.imperium.rome')
8  .addTag('Legions')
9  .addBearerAuth(undefined, 'JWT-auth')
10  .build();
11
12const documentV1 = SwaggerModule.createDocument(app, configV1, {
13  include: [LegionV1Module],
14});
15
16SwaggerModule.setup('api/v1', app, documentV1);

A

DocumentBuilder
is assembled from a chain of methods, each adding one element of the description:
setTitle
,
setDescription
and
setVersion
are the basics,
setContact
says whom to write to,
setLicense
gives the licence,
addServer
the address at which the API actually runs,
addTag
declares a group of endpoints, and
addBearerAuth
switches on the token field.

The

include
option includes only selected modules in the documentation. It does not add CSS files to Swagger UI, does not import external OpenAPI specifications, and does not enable additional decorators. It is what makes the
v1
document contain only the first era's endpoints - without it both documents would be identical and would show the whole API.

Finally

SwaggerModule.setup('api/v1', ...)
serves the document at
/api/v1
. The second version is built the same way: a
configV2
with its own title and number,
include: [LegionV2Module]
, and
setup('api/v2', ...)
at the end.

Deprecating endpoints

Version two does not annul version one overnight. Endpoints that are to disappear are first marked as deprecated:

1@ApiOperation({
2  summary: 'List of legions (old version)',
3  deprecated: true,
4})
5@Get()
6findAllLegacy() {
7  return this.legionService.findAll();
8}

You mark an endpoint as deprecated with the

deprecated: true
property in
@ApiOperation
. Not
removed
, not
obsolete
, not
disabled
- those three names sound sensible, but they do not exist in OpenAPI; the specification knows only
deprecated
.

In Swagger UI such an endpoint is struck through and carries a warning, but it still works. That is the point: the marking is an announcement, not a switch-off. It gives integrators time to move across before the endpoint truly disappears - and without that transition period versioning is pointless, since the change breaks other people's applications anyway.

Summary

The law of the Republic holds while matters begun under the Republic remain, @name:

  • three strategies: URI (version in the path), Header (in a header, e.g.
    X-Version
    ), Media Type (in
    Content-Type
    ),
  • with URI Versioning the address looks like
    /v1/legiones
    - not
    /legiones?version=1
    , not
    /legiones/v1
    ,
  • enabling it in
    main.ts
    :
    app.enableVersioning({
    type: VersioningType.URI,
    defaultVersion: '1'
    });
    ,
  • defaultVersion
    protects controllers that state no version,
  • a versioned controller:
    @Controller({
    path: 'legiones', version: '1' })
    export class LegionV1Controller {}
    ,
  • separate documentation in four steps:
    app.enableVersioning()
    → a
    DocumentBuilder
    per version →
    createDocument
    with
    include
    SwaggerModule.setup
    per version,
  • DocumentBuilder
    :
    setTitle
    ,
    setDescription
    ,
    setVersion
    ,
    setContact
    ,
    setLicense
    ,
    addServer
    ,
    addTag
    ,
    addBearerAuth
    ,
  • the
    include
    option includes only selected modules
    - it adds no CSS, imports no external specifications, enables no decorators,
  • deprecated: true
    in
    @ApiOperation
    marks an endpoint as deprecated - not
    removed
    , not
    obsolete
    , not
    disabled
    ; the endpoint still works.

In the next lesson we gather the whole module into one project - the complete documentation of the Imperium API. For now remember: versioning is not there to let you make changes. It is there so that a change does not ruin somebody's day.

Go to CodeWorlds