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
- Add tsoa dependency with
npm install tsoa
- 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.