‹ All posts

Automatically generate an OpenAPI specification from NodeJS servers

In this blog post, we will go through how to automatically generate an OpenAPI specification from a Koa server. OpenAPI specifications serve as fundamental guides for RESTful API design, development, and documentation, yet their manual creation is often time-consuming and error-prone.

One goal of this tutorial is to find a type-safe way to ensure the OpenAPI spec respects the server implementation. This means that we should be able to generate a valid OpenAPI specification based on the Koa server implementation and not write a single line to the OpenAPI spec ourselves.

Why? Mainly for ensuring accuracy. Product documentation and code generators rely on the spec to be correct, and failing to do so is potentially damaging to your business.

At the time of writing there don’t seem to be any libraries that directly achieve this, so we’ll have to use something indirect with a bit more setup. The most prominent one looks to be tsoa.

tsoa is an “extension” to Node.js server frameworks with the goal of providing a single source of truth for your API. It is able to generate OpenAPI specifications from various Node.js server frameworks. It uses TypeScript’s native type annotations and it’s own decorators for generating and validating the spec.

Let’s set up a basic Koa server first and then look into how tsoa works.

Setting up a Koa server

This example project has several Koa dependencies:

1
2
3
4
npm init -y
npm install @koa/router koa koa-bodyparser ts-node
npm install --save-dev @types/koa @types/koa__router @types/koa-bodyparser @types/node typescript
npm exec -- tsc --init

Note: compilerOptions.experimentalDecorators: true must be enabled in tsconfig.json.

Koa project

Our initial project has the following folder structure:

1
2
3
4
5
/coffee-shop
|-- package.json
|-- tsconfig.json
|-- /src
    |-- server.ts

All the code is in server.ts which is a simple Koa server with a router, some endpoints and a dummy database.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// server.ts
import Koa from "koa";
import KoaRouter from "@koa/router";
import bodyParser from "koa-bodyparser";

const app = new Koa();
app.use(bodyParser());

const router = new KoaRouter({ prefix: "/coffee" });

export interface Coffee {
  name: string;
  type: "Latte" | "Ice Latte" | "Cappucino" | "Flat White" | "Americano";
}

const initialCoffeeMenu: Coffee[] = [
  {
    name: "Peach Ice Latte",
    type: "Ice Latte",
  },
  {
    name: "Pumpkin Latte",
    type: "Latte",
  },
  {
    name: "Double Flat White",
    type: "Flat White",
  },
];

let coffeeDatabase = [...initialCoffeeMenu];

router.get("/", (ctx) => {
  const coffeeLimit = <string | null>ctx.query.limit;

  let mutableCoffees = [...coffeeDatabase];

  if (coffeeLimit) {
    mutableCoffees.splice(0, parseInt(coffeeLimit));
  }

  ctx.body = mutableCoffees;
});

router.post("/", (ctx) => {
  const newCoffee = <Coffee | null>ctx.request.body;

  if (!newCoffee) {
    return;
  }

  coffeeDatabase.push(newCoffee);

  ctx.body = newCoffee;
});

router.del("/:name", (ctx) => {
  coffeeDatabase = coffeeDatabase.filter(
    (coffee) => coffee.name !== ctx.params.name
  );

  ctx.status = 204;
});

app.use(router.routes()).use(router.allowedMethods());

export const server = app.listen(3000);

Koa doesn’t have a way to generate an OpenAPI spec, so let’s set up tsoa.

Setting up tsoa

  1. Add tsoa dependency with npm install tsoa
  2. Create a tsoa.json config file
1
2
3
4
5
6
7
8
{
  "entryFile": "src/server.ts",
  "noImplicitAdditionalProperties": "throw-on-extras",
  "spec": {
    "outputDirectory": "build",
    "specVersion": 3
  }
}

tsoa requires a controller class architecture for your Koa server, so refactoring the existing server to use controller classes is required.

Creating a controller class

Create a new file src/coffeeController.ts with a controller class. We will extend it with tsoa’s Controller class.

1
2
3
4
5
// src/coffeeController.ts

import { Controller } from "tsoa";

export class CoffeeController extends Controller {}

For the sake of this tutorial, let’s start the refactoring by moving the dummy database to the controller.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/coffeeController.ts

import { Controller, Route, Get, Query, Post, Body, Delete, Path, Tags, Example } from "tsoa";

export interface Coffee {
  name: string;
  type: "Latte" | "Ice Latte" | "Cappucino" | "Flat White" | "Americano";
}

const initialCoffeeMenu: Coffee[] = [
  {
    name: "Peach Ice Latte",
    type: "Ice Latte",
  },
  {
    name: "Pumpkin Latte",
    type: "Latte",
  },
  {
    name: "Double Flat White",
    type: "Flat White",
  },
];

let coffeeDatabase: Coffee[] = [...initialCoffeeMenu];

@Route("coffee")                             // <-- Define root of the route
export class CoffeeController extends Controller {}

Notice the Route decorator. It tells tsoa that this is a route it should be interested in. tsoa makes good use of TypeScript’s decorators to add context to your server.

Next, we will add the list endpoint.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/coffeeController.ts
...

@Route("coffee")
export class CoffeeController extends Controller {
  ...

  @Get()                                    // <-- Define REST method as GET
  public async getAll(
    @Query() limit?: number                 // <-- Define query parameters
  ): Promise<Coffee[]> {
    this.setStatus(200);
    let mutableCoffees = [...coffeeDatabase];

    if (limit) {
      mutableCoffees.splice(0, limit);
    }

    return mutableCoffees;
  }
}

The function description for getAll does not include any Koa related context, and instead query parameters are defined with the Query decorator. This separation of concerns allows tsoa to plug into various server frameworks such as koa, express and hapi. The Get decorator is used to define that this is a GET endpoint at the root of the router, GET “/coffee”. Let’s add the rest of the methods.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// src/coffeeController.ts

...

@Route("coffee")
export class CoffeeController extends Controller {
  ...

  @Post()
  public async add(
    @Body() newCoffee: Coffee               // <-- Define request body
  ): Promise<Coffee> {
    this.setStatus(201);
    coffeeDatabase.push(newCoffee);

    return newCoffee;
  }

  @Delete("{name}")
  public async delete(
    @Path() name: string                    // <-- Define path parameter
  ): Promise<void> {
    this.setStatus(204);
    coffeeDatabase = coffeeDatabase.filter((coffee) => coffee.name !== name);
  };
}

Similarly to Get, we can use Post and Delete to define paths in the router. The Body and Path decorators can be used to define request body and path parameters.

Configuring tsoa routes

Now that we have our controller set up, let’s take a look at tsoa.json again. In order for tsoa to recognize our new controller, we’ll add a routes object and define the controllerPathGlobs.

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "entryFile": "src/server.ts",
  "noImplicitAdditionalProperties": "throw-on-extras",
  "controllerPathGlobs": ["src/**/*Controller.ts"],
  "spec": {
    "specVersion": 3,
    "outputDirectory": "tsoa-output"
  },
  "routes": {
    "routesDir": "tsoa-output",
    "middleware": "koa"
  }
}

The routes object instructs tsoa on how to construct routes for the specified server framework. We use koa, but it could be express or hapi, too. controllerPathGlobs defines where to look for our controllers, in this case all files in the src folder which end in “Controller.ts”. Concretely speaking, tsoa will auto-generate a routes.ts file at routesDir, which contains the route specification for the targeted framework.

Try to run the generator and have a look at the file it generated at tsoa-output/routes.ts.

1
npm exec tsoa routes

If you’ve used Koa, it should look familiar. tsoa does most of the work related to koa (or other framework of your choice).

Let’s take a look at server.ts again. This time around, it will look much simpler.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// server.ts
import Koa from "koa";
import KoaRouter from "@koa/router";
import bodyParser from "koa-bodyparser";
import { RegisterRoutes } from "../tsoa-output/routes";

const app = new Koa();
app.use(bodyParser());

const router = new KoaRouter();

RegisterRoutes(router); // <-- register the controllers defined in tsoa.json

app.use(router.routes()).use(router.allowedMethods());

export const server = app.listen(3000);

Now we’re done with setting up tsoa and you can run your server normally. Next let’s look why the setup is so powerful and worth it.

Building on tsoa

tsoa offers several powerful features:

  • generate OpenAPI specification directly from your server
  • document your APIs next to code
  • perform runtime request validation

Generating OpenAPI specification

Now that we have our server using tsoa, generating an OpenAPI specification is easy. Run the generator for your server.

1
npm exec tsoa spec

You’ll notice that a json file was created in the /tsoa-output folder:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
{
  "paths": {
    "/coffee": {
      "get": {
        "operationId": "GetAll",
        "responses": {
          "200": {
            "description": "Ok",
            "content": {
              "application/json": {
                "schema": {
                  "items": {
                    "$ref": "#/components/schemas/Coffee"
                  },
                  "type": "array"
                }
              }
            }
          }
        },
        "security": [],
        "parameters": [
          {
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "format": "double",
              "type": "number"
            }
          }
        ]
      }
    }
  }
}

The OpenAPI json file contains for example the GET /coffee route which corresponds to our getAll method. It is still rather plain with just the basics of OpenAPI so let’s look at how to start documenting your code with further use of tsoa’s decorators.

Documentation in your code

OpenAPI has a plethora of ways to add context to your API document: examples, descriptions, authentication to name a few. With tsoa, these are added through decorators and jsDoc. Let’s add some descriptions and examples to the getAll method.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
  /**
   * List all coffees on the menu.                  // <-- Add a description
   * @returns A list of all coffees on the menu.
   */
  @Example<Coffee[]>([                              // <-- Add examples
    {
      name: "Pumpkin Spiced Latte",
      type: "Latte"
    },
    {
      name: "Choco Grance Ice",
      type: "Ice Latte"
    }
  ])
  @Get()
  @Tags("Coffee")                                   // <-- Tag your route
  public async getAll(
    /**                                             // <-- Descriptions for
     * Limit the number of coffees on the result    // <-- parameters
     */
    @Query() limit?: number
  ): Promise<Coffee[]> {
    this.setStatus(200);
    let mutableCoffees = [...coffeeDatabase];

    if (limit) {
      mutableCoffees.splice(0, limit);
    }

    return mutableCoffees;
  }

If you run the OpenAPI generator again, you should see a more precise document being generated.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
{
  "paths": {
    "/coffee": {
      "get": {
        "operationId": "GetAll",
        "responses": {
          "200": {
            "description": "A list of all coffees on the menu.",
            "content": {
              "application/json": {
                "schema": {
                  "items": {
                    "$ref": "#/components/schemas/Coffee"
                  },
                  "type": "array"
                },
                "examples": {
                  "Example 1": {
                    "value": [
                      {
                        "name": "Pumpkin Spiced Latte",
                        "type": "Latte"
                      },
                      {
                        "name": "Choco Grance Ice",
                        "type": "Ice Latte"
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "description": "List all coffees on the menu.",
        "tags": ["Coffee"],
        "security": [],
        "parameters": [
          {
            "description": "Limit the number of coffees on the result",
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "format": "double",
              "type": "number"
            }
          }
        ]
      }
    }
  }
}

Beyond this basic example, you can go further for example by adding and documenting authentication for your server. Read more at tsoa’s documentation.

Runtime validation

tsoa offers runtime validation out-of-the-box. If you try to send your server something incorrect, you will receive an error message. tsoa offers ways of providing further validation to your schema. Let’s add some validation to our Coffee interface.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...

export interface Coffee {
  /**
   * The coffee's name on the menu. Acts as the coffee's identifier
   * @minLength 5 Coffee's name must be greater than or equal to 5 characters.
   * @maxLength 15 Coffee's name must be lower than or equal to 15 characters.
   */
  name: string;
  /**
   * The coffee's type. Used to categorize coffees on the menu.
   */
  type: "Latte" | "Ice Latte" | "Cappucino" | "Flat White" | "Americano";
}

...

Here we define that the name of the coffee must be within 5 and 15 characters of length. Make sure to run npm exec tsoa routes again for the new validation to take effect. Let’s test it out!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ npm exec tsoa routes

$ curl http://localhost:3000/coffee -X POST \
  -H "Content-Type: application/json" \
  -d '{"name": "Cap", "type": "Cappucino"}'

{
  "fields": {
    "newCoffee.name": {
      "message": "Coffee's name must be greater than or equal to 5 characters.",
      "value": "Cap"
    }
  }
}

As you see, we get an error stating the name of the coffee is too short. Describing the boundaries of our schema directly in the type is great as it will also be reflected to our OpenAPI specification automatically. Read more about annotating interfaces.

Wrapping up

To summarize, we set up

  • A basic Koa server with some endpoints and integrated it with tsoa
  • Generated an OpenAPI specification of our server
  • Added documentation and boundaries to our server

Maintaining an OpenAPI specification of your server manually by writing the document yourself can be a tedious task: it is error-prone and hard to maintain when it grows in size. Using a framework like tsoa makes it easier by keeping the API description in small pieces and close to your code.

Create world-class API portals with Doctave

Build beautiful API documentation portals directly from your OpenAPI specification with Doctave.

Get analytics, preview environments, user feedback, Markdown support, and much more, all out of the box.

Articles about documentation, technical writing, and Doctave into your inbox every month.