DEV Community

Tomasz Buszewski
Tomasz Buszewski

Posted on

How to Add OpenAPI to your Express App?

Proper documentation is the key for comfortable work with your code. And the
best documentation is the one you generate, not write.

This is a repost from my blog.

You can also watch the video on YouTube.

Structure and Preparations

Let’s say we have a simple Express application written in TypeScript. It
consists of only one area – “chores”. It’s glorified todo app, yes. It has:

  • main.ts as the app’s entry point;
  • app.ts for the app initialization;
  • chore/ as the actual business unit.

For the logic, it’s not really that important. What matters is that we have
controller as classes.

// ./chore/controller.ts

import { ChoreService } from "./service";
import { BaseChore, Chore, ChoreUpdate } from "./types";

export class ChoreController {
  private service: ChoreService;

  constructor(service: typeof ChoreService = ChoreService) {
    this.service = new service();
  }

  public getAllChores(): Chore[] {
    return this.service.getAllChores();
  }

  public createChore(chore: BaseChore): Chore[] {
    return this.service.createChore(chore);
  }

  public updateChore(id: string, chore: ChoreUpdate): Chore[] {
    return this.service.updateChore(id, chore);
  }

  public deleteChore(id: string): Chore[] {
    return this.service.deleteChore(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

It uses a service, but that’s really beyond the point. We could (we shouldn’t,
but we could) dump all the logic in the controller and it wouldn’t matter for
this example.

There’s also routes.ts file that basically takes URLs and payloads and pushes
it further:

// ./chore/routes.ts

import { type Request, type Response, Router } from "express";
import { ChoreController } from "./controller";
import { ChoreService } from "./service";
import { BaseChore, ChoreRequest } from "./types";

export const router = Router();

const controller = new ChoreController(ChoreService);

router.get("/", (_req: Request, res: Response): Response => {
  return res.json(controller.getAllChores());
});

router.post(
  "/",
  (req: Request<undefined, BaseChore>, res: Response): Response => {
    if (!req.body.name || !req.body.description) {
      return res.status(400).send("Invalid chore");
    }

    try {
      return res.status(201).json(controller.createChore(req.body));
    } catch (e) {
      console.log(e);
      return res.status(500).send(JSON.stringify(e));
    }
  },
);

router.put(
  "/:id",
  (req: Request<ChoreRequest, BaseChore>, res: Response): Response => {
    if (!req.params.id) {
      return res.status(400).send("Invalid chore");
    }

    return res.json(controller.updateChore(req.params.id, req.body));
  },
);

router.delete("/:id", (req: Request<ChoreRequest>, res: Response): Response => {
  return res.json(controller.deleteChore(req.params.id));
});
Enter fullscreen mode Exit fullscreen mode

And it is exported and thrown into the express app in app.ts:

// app.ts

import express, { Express } from "express";

import chores from "./chore";

export const app: Express = express();

app.use(express.json());
app.use("/", chores);
Enter fullscreen mode Exit fullscreen mode

…which in return is taken by main.ts:

// main.ts

import express from "express";
import { app } from "./app";

const port = process.env.PORT || 3000;

app.use(express.json());

app.listen(port, () => console.log(`http://localhost:${port}`));
Enter fullscreen mode Exit fullscreen mode

That’s it for preparations. This is a fully working app that accepts GET, POST,
PUT and DELETE. But to know it, you have to go through the code. This is where
OpenAPI comes in.

Adding TSOA for generating docs

In order to enhance the controller with OpenAPI markers, we will use
TSOA. It’s battle-tested library built
exactly for this purpose. If you’ve worked with NestJS or TsED, you might’ve
seen decorators like Get() or Post() for controllers. That’s TSOA.

Let’s start by adding the library:

~ npm install tsoa
Enter fullscreen mode Exit fullscreen mode

It comes fully typed, so there’s no need to add anything more.

After it is installed, let’s create tsoa.json file in the root of our project.
It’s the config file for the generator, it tells it where to look for
controllers and where to save the docs. It looks like this:

{
  "entryFile": "src/main.ts",
  "controllerPathGlobs": ["./src/**/controller.ts"],
  "spec": {
    "outputDirectory": "./docs",
    "specVersion": 3,
    "spec": {
      "servers": [
        {
          "url": "http://localhost:3000",
          "description": "Local server"
        }
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Going from the top,

  • entryFile is for which file is the main one;
  • controllerPathGlobs is a glob for where to look for controllers;
  • spec is the config for documentation that will be generated, with outputDirectory pointing at where we want to have swagger.json. specVersion defined whether we want OpenAPI standard (3) or Swagger (2). I highly recommend the former.

Expanding the Controller

As mentioned earlier, TSOA works with controllers. So we need to enhance what we
currently have to make it readable.

Let’s start with marking the controller as Route, tag it and make it extend
the Controller class from TSOA:

// ./chore/controller.ts
import { Route, Tags, Controller } from "tsoa";

@Route()
@Tags("Chore")
export class ChoreController extends Controller {
  // ...

  constructor(service: typeof ChoreService = ChoreService) {
    super();

    this.service = new service();
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

From the top, Route allows the library to detect that it’s actually a
controller that takes care of traffic. Tags serves as the name (otherwise it
defaults to "default") and Controller is a thin class giving us few
additional methods, like setting status codes and headers.

All right, let’s start adding some routes! First, we have getAllChores, which
is the easiest. Just add Get() and you’re golden:

// ./chore/controller.ts

export class ChoreController extends Controller {
  // ...

  @Get()
  public getAllChores(): Chore[] {
    return this.service.getAllChores();
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Okay, we’ve been doing this for a while, but does it even work? Let’s try!

~ tsoa spec
Enter fullscreen mode Exit fullscreen mode

And we have a new directory – ./docs – created, with a swagger.json inside.
Okay, let’s keep the momentum going!

The most complex is definitely the POST verb, because it requires defining the
body. Let’s try:

// ./chore/controller.ts

@Route()
@Tags("Chore")
export class ChoreController extends Controller {
  // ...

  @SuccessResponse("201", "Created")
  @Post()
  public createChore(@Body() chore: BaseChore): Chore[] {
    this.setStatus(201);
    return this.service.createChore(chore);
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Okay, it wasn’t that hard, right? The biggest thing is this.setStatus method,
coming from the TSOA Controller class. Without this, we’d get 200, default
success status. It’s not a mistake, but good status codes just nice and makes
the work easier. What’s left are the PUT and DELETE ones, with path params.
Let’s go:

// ./chore/controller.ts

@Route()
@Tags("Chore")
export class ChoreController extends Controller {
  // ...

  @Put("{id}")
  public updateChore(
    @Path("id") id: string,
    @Body() chore: ChoreUpdate,
  ): Chore[] {
    return this.service.updateChore(id, chore);
  }

  @Delete("{id}")
  public deleteChore(@Path("id") id: string): Chore[] {
    return this.service.deleteChore(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

Important thing is to match the param names, so if we have Put("{id}"), we
want to retain this id everywhere in the method.

Right, so it seems we’re done? Let’s try.

~ tsoa spec
Enter fullscreen mode Exit fullscreen mode

And we’re golden. Docs are generated, so we can now have certainty that
everything we say in the Swagger matches the reality.

Bonus #1: Exposing the Swagger docs

Having docs as the JSON file is the most important, but can we also present it
somehow? Sure we can. Enter Swagger UI! And I am not talking about the
Swagger Editor, but our very own instance!

First, let’s install the dependencies:

~ npm i swagger-ui-express;
~ npm i -D @types/swagger-ui-express;
Enter fullscreen mode Exit fullscreen mode

Now, we need to enable JSON imports in our TypeScript config:

{
  "compilerOptions": {
    "resolveJsonModule": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Having this will allow TS to import JSON files just like any other modules.

Now let’s import the dependencies in our main file:

// main.ts

import express from "express";
import Swagger from "swagger-ui-express";
import { app } from "./app";
import swaggerDocument from "../docs/swagger.json";

const port = process.env.PORT || 3000;

app.use(express.json());
app.use("/docs", Swagger.serve, Swagger.setup(swaggerDocument));

app.listen(port, () => console.log(`http://localhost:${port}`));
Enter fullscreen mode Exit fullscreen mode

That’s it! Now, after we start out server and access
http://localhost:3000/docs, we should have our
docs ready!

Bonus #2: Generated Routing and Validation

But wait, there’s more!

No, really, there’s a bit more TSOA can do.

As you can see, there’s no validation to be found, and all the routes are made
by hand. That's a bit of a problem, because we define routes in routes.ts
file, and then in TSOA annotations in the controller. And all the validation
requires us to jump in and write it manually. These two issues can be solved in
one fell swoop.

First, let’s go back to the config file and add routes entry:

{
  "entryFile": "src/main.ts",
  "controllerPathGlobs": ["./src/**/controller.ts"],
  "spec": {
    "outputDirectory": "./docs",
    "specVersion": 3,
    "spec": {
      "servers": [
        {
          "url": "http://localhost:3000",
          "description": "Local server"
        }
      ]
    }
  },
  "routes": {
    "routesDir": "./src/api"
  }
}
Enter fullscreen mode Exit fullscreen mode

routesDir defines where the routes should be written. I put it in a general
location, because TSOA will take all controllers and combine them into one file.
Right, so let’s try to generate this.

~ tsoa routes
Enter fullscreen mode Exit fullscreen mode

All right! We have routes.ts file created, which… frankly, looks messy. But
all generated code does, so don’t worry. If we dive a bit deeper, there’s
validation added, basing on our routes as well! For example, app.delete checks
for the id in path:

// ./src/api/routes.ts

const args: Record<string, TsoaRoute.ParameterSchema> = {
  id: {"in":"path","name":"id","required":true,"dataType":"string"},
};

// ...

try {
    validatedArgs = templateService.getValidatedArgs({ args, request, response });

// ...
Enter fullscreen mode Exit fullscreen mode

That’s great, but what if we want to have more strict validation? Like, input
lengths?

No problem!

Let’s say we want to make sure that, when we add a chore, name is at least 3
characters long. To do so, we need to modify our type a bit:

// ./src/chore/types.ts

export interface BaseChore {
  /**
   * @minLength 3
   */
  name: string;
  /**
   * @minLength 3
   */
  description: string;
}
Enter fullscreen mode Exit fullscreen mode

Yes, I know these aren't the most appealing, but adding these comments will
allow the generator to create validation clauses. Let’s try!

~ tsoa routes
Enter fullscreen mode Exit fullscreen mode

And in the routes, we have our models updated:

// ./src/api/routes.ts

const models: TsoaRoute.Models = {
    "Chore": {
        "dataType": "refObject",
        "properties": {
            "name": {"dataType":"string","required":true,"validators":{"minLength":{"value":3}}},
            "description": {"dataType":"string","required":true,"validators":{"minLength":{"value":3}}},
            "id": {"dataType":"string","required":true},
            "done": {"dataType":"boolean","required":true},
        },
        "additionalProperties": false,
    },
// ...
Enter fullscreen mode Exit fullscreen mode

Great!

Now all there’s left is to register these routes.

The generated routes.ts exposes RegisterRoutes function, which accepts the
Express app, so it’s just a formality:

// app.ts

import express, { Express } from "express";

import { RegisterRoutes } from "./api/routes";

export const app: Express = express();

app.use(express.json());
RegisterRoutes(app);
Enter fullscreen mode Exit fullscreen mode

That’s it!

We now have a fully working application that generated its documentation and
routes. In the future, I’ll show you how to utilize the Swagger file to generate
a fully typed API client for the frontend.

Happy coding!


Top comments (0)