NestJS course Β· Module 11: Swagger and OpenAPI

API versioning - the eras of the Empire

6 min read
In this lesson6

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:

  • 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.

Code for this lesson: src/versioning.ts
1// API versioning with Swagger documentation
2import { VersioningType } from '@nestjs/common';
3import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
4
5// Versioning configuration in main.ts
6async function bootstrap() {
7  // const app = await NestFactory.create(AppModule);
8
9  // TODO: Enable URI versioning with defaultVersion '1'
10  // app.enableVersioning({
11  //   type: VersioningType.URI,
12  //   defaultVersion: '1',
13  // });
14
15  // TODO: Create DocumentBuilder for version 1
16  const configV1 = new DocumentBuilder()
17    .setTitle('Imperium API v1')
18    .setDescription('The Republic Era')
19    .setVersion('1.0')
20    .build();
21
22  // TODO: Create DocumentBuilder for version 2
23  // const configV2 = new DocumentBuilder()
24  //   .setTitle('Imperium API v2')
25  //   .setDescription('The Empire Era')
26  //   .setVersion('2.0')
27  //   .build();
28
29  // TODO: Create separate Swagger documents for each version
30  // const docV1 = SwaggerModule.createDocument(app, configV1);
31  // SwaggerModule.setup('api/v1/docs', app, docV1);
32
33  // const docV2 = SwaggerModule.createDocument(app, configV2);
34  // SwaggerModule.setup('api/v2/docs', app, docV2);
35
36  console.log("Swagger v1: http://localhost:3000/api/v1/docs");
37  console.log("Swagger v2: http://localhost:3000/api/v2/docs");
38}
39
40bootstrap();
41
42console.log("=== Versioning strategies ===");
43console.log("URI: /v1/legiones, /v2/legiones");
44console.log("Header: X-API-Version: 1");
45console.log("Media Type: Accept: application/json;v=1");
46console.log("");
47console.log("Each version can have its own Swagger document");
48console.log("deprecated: true marks a deprecated endpoint");
49

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What does the endpoint URL look like with URI Versioning using version 1?

  2. 2. How do you mark an endpoint as deprecated in @ApiOperation?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Vertical ordering

    Order the steps for creating a versioned API with separate Swagger documents:

  • Code editor

    Create a DocumentBuilder for version 1 and version 2 of the API with the appropriate settings

  • Vertical ordering

    Arrange the elements of a versioned controller declaration in the correct order:

  • Click in order

    Arrange the elements for enabling URI Versioning in main.ts:

  • Code editor

    Create a configuration with title, description, version, contact, license, servers, tags, and bearerAuth

Useful articles