Versioning is an important part of API design. It's also one of those project aspects that is not given enough thought upfront, and it often happens that it comes into play late in the game, when it's difficult to introduce breaking changes (and introducing versioning can sometimes be a breaking change). In this blog post, we will describe the various versioning strategies that you can implement in NestJS, with a special focus on the highest-matching version selection. This is a strategy that you might consider when you want to minimize the amount of changes needed to upgrade your API-level versions.
Types of versioning
In NestJS, there are four different types of versioning that can be implemented:
- URI versioning
- The version will be passed within the URI of the request. For example, if a request comes in to
/api/v1/users
, thenv1
marks the version of the API. - This is the default in NestJS.
- The version will be passed within the URI of the request. For example, if a request comes in to
- Custom header versioning
- A custom request header will specify the version. For example,
X-API-Version: 1
in a request to/api/users
will request v1 version of the API.
- A custom request header will specify the version. For example,
- Media type versioning
- Similar to custom header versioning, a header will specify the version. Only, this time, the standard media accept header is used. For example:
Accept: application/json;v=2
- Similar to custom header versioning, a header will specify the version. Only, this time, the standard media accept header is used. For example:
- Custom versioning
- Any aspect of the request may be used to specify the version(s). A custom function is provided to extract said version(s).
- For example, you can implement query parameter versioning using this mechanism.
URI versioning and custom header versioning are the most common choices when implementing versioning.
Before deciding which type of versioning you want to use, it's also important to define the versioning strategy. Do you want to version on the API level? Or on the endpoint level?
If you want to go with the endpoint-versioning approach, this gives you more fine-grained control over your endpoints, without needing to reversion the entire API. The downside of this approach, is that it may get difficult to track endpoint versions. How would an API client know which version is the latest, or which endpoints are compatible with each other? There would need to be a discovery mechanism for this, or just very well maintained documentation.
API-level versioning is more common, though. With API-level versioning, every time you introduce a breaking change, you deliver a new version of the entire API, even though internally, most of the code is unchanged. There are some strategies to mitigate this, and we will focus on one in particular in this blog post. But first, let's see how we can enable versioning on our API.
Applying versions to your endpoints
The first step is to enable versioning on the NestJS application:
app.enableVersioning({
type: VersioningType.URI,
});
With URI versioning enabled, to apply a version on an endpoint, you'd either provide the version on the @Controller
decorator to apply the version to all endpoints under the controller, or you'd apply the version to a route in the controller with the @Version
decorator.
In the below example, we use endpoint versioning on the findAll()
method.
import { Controller, Get, Param, Version } from '@nestjs/common';
@Controller('users')
export class UsersController {
@Get()
@Version('1')
findAll() {
return 'findAll()';
}
@Get(':id')
findOne(@Param('id') id: string) {
return `findOne(${id})`;
}
}
We can invoke findAll()
using curl:
β nestjs-versioning-strategies git:(main) β curl http://localhost:3000/api/v1/users
findAll()%
How can we invoke findOne()
, though? Since only findAll()
is versioned, invoking findOne()
needs to be without a version. When you request an endpoint without a version, NestJS will try to find so-called "version-neutral" endpoints, which are the endpoints that are not annotated with any version.
In our case, this would mean the URI we use will not contain v1
or any other version in the path:
β nestjs-versioning-strategies git:(main) β curl http://localhost:3000/api/users/1
findOne(1)%
This happens because implicitly, NestJS considers the "version-neutral" version to be the default version if no version is requested by the API client. The default version is the version that is applied to all controllers/routes that don't have a version specified via the decorators. The versioning configuration we wrote earlier could have easily been written as:
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: VERSION_NEUTRAL,
});
Meaning, any controllers/routes without a version (such as findAll()
above), will be given the "version-neutral" version by default.
If we don't want to use version-neutral endpoints, then we can specify some other version as the default version.
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
});
The findOne()
endpoint will now return a 404, unless you call it with an explicit version. This is because we no longer have any "version-neutral" versions defined anywhere (the controllers/routes or the defaultVersion
property).
β nestjs-versioning-strategies git:(main) β curl http://localhost:3000/api/users/1
{"statusCode":404,"message":"Cannot GET /api/users/1","error":"Not Found"}%
Multiple versions
Multiple versions can be applied to a controller/route by setting the version to be an array.
import { Controller, Get, Param, Version } from '@nestjs/common';
@Controller('users')
export class UsersController {
@Get()
@Version(['1', '2'])
findAll() {
return 'findAll()';
}
@Get(':id')
findOne(@Param('id') id: string) {
return `findOne(${id})`;
}
}
Invoking /api/v1/users
or /api/v2/users
will both land on the same method findAll()
in the controller.
Multiple versions can also be set in the defaultVersion
of the versioning configuration:
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: ['1', '2'],
});
This simply means that controllers/routes without a version decorator will be applied to both version 1 and version 2.
Selection of highest-matching version
Imagine the following scenario: You've decided to use API-level versioning, but you don't want to update all of your controllers/routes every time you increase a version of the API. You only want to do it on those that had breaking changes. Other controllers/routes should remain at whatever version they are currently.
Currently, in NestJS, there is no way of accomplishing this with just a configuration option. But fortunately, the versioning config allows you to define a custom version extractor. A version extractor is simply a function that will tell NestJS which versions the client is requesting, in order of preference. For example, if the version extractor returns an array such as ['3', '2', '1']
. This means the client is requesting version 3, or version 2 if 3 is not available, or version 1 if neither 2 nor 3 is available.
This kind of highest-matching version selection does have a caveat, though. It does not reliably work with the Express server, so we need to switch to the Fastify server instead. Fortunately, that is easy in NestJS. Install the Fastify adapter first:
npm i --save @nestjs/platform-fastify
Next, provide the FastifyAdapter
to the NestFactory
:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { VersioningType } from '@nestjs/common';
import { FastifyAdapter } from '@nestjs/platform-fastify';
async function bootstrap() {
const app = await NestFactory.create(AppModule, new FastifyAdapter());
app.setGlobalPrefix('api');
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
});
await app.listen(3000);
}
bootstrap();
And that's it. Now we can proceed onto writing the version extractor:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { VersioningType } from '@nestjs/common';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import { FastifyRequest } from 'fastify';
const DEFAULT_VERSION = '1';
const extractor = (request: FastifyRequest): string | string[] => {
const requestedVersion =
<string>request.headers['x-api-version'] ?? DEFAULT_VERSION;
// If requested version is N, then this generates an array like: ['N', 'N-1', 'N-2', ... , '1']
return Array.from(
{ length: parseInt(requestedVersion) },
(_, i) => `${i + 1}`,
).reverse();
};
async function bootstrap() {
const app = await NestFactory.create(AppModule, new FastifyAdapter());
app.setGlobalPrefix('api');
app.enableVersioning({
type: VersioningType.CUSTOM,
extractor,
defaultVersion: DEFAULT_VERSION,
});
await app.listen(3000);
}
bootstrap();
The version extractor uses the x-api-version
header to extract the requested version and then returns an array of all possible versions up to and including the requested version. The reason why we chose to use header-based versioning in this example is that it would be too complex to implement URI-based versioning using a version extractor.
First of all, the version extractor gets an instance of FastifyRequest
. This instance does not provide any properties or methods for obtaining parts of the URL. You only get the URL path in the request.url
property. You would need to parse this yourself if you wanted to extract a route token or a query parameter. Secondly, you would also need to handle the routing based on the version requested.
Now, if we add multiple versions to our controller, we will always be getting the highest supported version:
import { Controller, Get, Param, Version } from '@nestjs/common';
@Controller('users')
export class UsersController {
@Get()
@Version('2')
findAll2() {
return 'findAll2()';
}
@Get()
@Version('1')
findAll1() {
return 'findAll1()';
}
@Get(':id')
findOne(@Param('id') id: string) {
return `findOne(${id})`;
}
}
Let's test this:
β ~ curl http://localhost:3000/api/users/1 --header "X-Api-Version: 1"
findOne(1)%
β ~ curl http://localhost:3000/api/users/1 --header "X-Api-Version: 2"
findOne(1)%
β ~ curl http://localhost:3000/api/users --header "X-Api-Version: 2"
findAll2()%
β ~ curl http://localhost:3000/api/users --header "X-Api-Version: 1"
findAll1()%
We have only one findOne()
implementation, which doesn't have any explicit version applied. However, since the default version is 1 (as configured in the versioning config), this means that version 1 applies to the findOne()
endpoint. Now, if a client requested version 2 of our API, the version extractor would tell NestJS to first try version 2 of the endpoint if exists, or to try version 1 if it doesn't exist.
Unlike findOne()
, findAll1()
and findAll2()
have explicit versions applied: version 1 and version 2, respectively. That's why the third and the fourth calls will return the versions that were explicitly requested by the client.
Conclusion
This was an overview of the tools you have at your disposal for implementing various versioning strategies in NestJS, with a special focus on API-level versioning and highest-matching version selection. As you can see, NestJS provides a very robust way of implementing various strategies. But some come with caveats, so it is always good to know them upfront before deciding which versioning strategy to use in your project.
The entire source code for this mini-project is available on GitHub, with the code related to highest-matching version implementation being in the highest-matching-version-selection
branch.