DEV Community

Yehor Kardash
Yehor Kardash

Posted on

1

Backward compatibility in NestJS

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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"
        }
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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'}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

If we start the app now, and navigate to localhost:3000/api, we should see a Swagger page and the annotated DTO

Image description

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();
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

package.json

"scripts": {
"generate:schema": "nest start -- --generate-schema",
}
Enter fullscreen mode Exit fullscreen mode

If we run the command, we should get a swagger.json file

npm run generate:schema
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

We should get the output like this:

Image description

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
Enter fullscreen mode Exit fullscreen mode

And when breaking changes are detected, the action will write a comment:

Image description

Link to repository

AWS Security LIVE!

Tune in for AWS Security LIVE!

Join AWS Security LIVE! for expert insights and actionable tips to protect your organization and keep security teams prepared.

Learn More

Top comments (0)

nextjs tutorial video

Youtube Tutorial Series πŸ“Ί

So you built a Next.js app, but you need a clear view of the entire operation flow to be able to identify performance bottlenecks before you launch. But how do you get started? Get the essentials on tracing for Next.js from @nikolovlazar in this video series πŸ‘€

Watch the Youtube series