As APIs evolve, maintaining backward compatibility is crucial to prevent breaking changes that could disrupt existing clients. One of the common mistakes is adding a new required field to an existing DTO, which breaks compatibility on old clients, since they don't know about this field.
In this article, I will show you how to create an automated OpenAPI schema validation to ensure your API stays backwards compatible.
We will use NestJS CLI plugin to generate a schema and oasdiff to validate it.
Let's start with creating a basic NestJS application
nest new hello-world
cd hello-world
npm install --save @nestjs/swagger class-validator class-transformer
After that we should configure DTO validation and Swagger in our app.
main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { createApiDocument } from './open-api/create-api-document';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// configure Swagger
SwaggerModule.setup('api', app, createApiDocument(app));
// configure validation
app.useGlobalPipes(
new ValidationPipe({
transform: true,
}),
);
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();
open-api/create-api-document.ts
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
/*A helper function for creating Swagger document*/
export const createApiDocument = (app: INestApplication) => {
const config = new DocumentBuilder()
.setTitle('Example')
.setDescription('Description')
.setVersion('1.0.0')
.build();
const document = SwaggerModule.createDocument(app, config);
return document;
};
nest-cli.json
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"introspectComments": true,
"controllerKeyOfComment": "summary"
}
}
]
}
}
Now we configured validation and Swagger, and we can move to creating an example endpoint.
hello.dto.ts
import { IsOptional, IsString } from 'class-validator';
export class HelloDto {
@IsString()
@IsOptional()
name?: string;
}
app.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { HelloDto } from './hello.dto';
@Controller()
export class AppController {
constructor() {}
@Post('hello')
getHello(@Body() dto: HelloDto): string {
return `Hello ${dto.name ?? 'World'}`;
}
}
If we start the app now, and navigate to localhost:3000/api, we should see a Swagger page and the annotated DTO
Now let's move to generating the schema as a file. The problem is that we can't get the schema without starting the app first. Most likely, your app uses environment variables, connects to databases, etc., which we don't need to create a schema file. Fortunately, we can start the app in "preview" mode, to skip intialization of services.
main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { boostrapDocsGenerator } from './open-api/bootstrap';
import { createApiDocument } from './open-api/create-api-document';
async function bootstrap() {
if (process.argv.includes('--generate-schema')) {
console.log('Generating OpenAPI schema');
return await boostrapDocsGenerator();
}
const app = await NestFactory.create(AppModule);
SwaggerModule.setup('api', app, createApiDocument(app));
app.useGlobalPipes(
new ValidationPipe({
transform: true,
}),
);
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();
open-api/bootstrap.ts
import { NestFactory } from '@nestjs/core';
import * as fs from 'fs/promises';
import { AppModule } from 'src/app.module';
import { createApiDocument } from './create-api-document';
export async function boostrapDocsGenerator() {
const app = await NestFactory.create(AppModule, {
preview: true, // to avoid initializing services
});
const document = createApiDocument(app);
await fs.writeFile('swagger.json', JSON.stringify(document));
await app.close();
}
package.json
"scripts": {
"generate:schema": "nest start -- --generate-schema",
}
If we run the command, we should get a swagger.json
file
npm run generate:schema
Let's rename the generated file to swagger-old.json
, update our DTO, and generate a new schema.
hello.dto.ts
import { IsString } from 'class-validator';
export class HelloDto {
@IsString()
name: string;
}
Now that we have files, we can install oasdiff and try it out.
curl -fsSL https://raw.githubusercontent.com/tufin/oasdiff/main/install.sh | sh
oasdiff breaking ./swagger-old.json ./swagger.json
We should get the output like this:
We can put schema validation in a nice GitHub workflow, that will run for each PR.
name: Detect breaking changes in API
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
jobs:
oasdiff-check:
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v4
with:
path: pr
- name: Checkout base branch (target of PR)
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref }}
path: base
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install oasdiff
run: |
curl -fsSL https://raw.githubusercontent.com/tufin/oasdiff/main/install.sh | sh
- name: Generate schema for base branch
run: |
cd base
npm install
npm run generate:schema
cp swagger.json ~/swagger-base.json
- name: Generate schema for PR branch
run: |
cd pr
npm install
npm run generate:schema
cp swagger.json ~/swagger-pr.json
- name: Run OASDiff
id: oasdiff
continue-on-error: true
run: |
oasdiff breaking --format markup --fail-on WARN ~/swagger-base.json ~/swagger-pr.json | tee ~/oasdiff-result.txt
result_code=${PIPESTATUS[0]}
exit $result_code
- name: Comment on PR if there are breaking changes
if: ${{ steps.oasdiff.outcome == 'failure' }}
uses: mshick/add-pr-comment@v2
with:
message-path: ~/oasdiff-result.txt
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Fail if breaking changes are detected
if: ${{ steps.oasdiff.outcome == 'failure' }}
run: exit 1
And when breaking changes are detected, the action will write a comment:
Top comments (0)