Proper documentation is the key for comfortable work with your code. And the best documentation is the one you generate, not write.
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);
}
}
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));
});
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);
…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}`));
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
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"
}
]
}
}
}
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, withoutputDirectory
pointing at where we want to haveswagger.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();
}
// ...
}
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();
}
// ...
}
Okay, we’ve been doing this for a while, but does it even work? Let’s try!
~ tsoa spec
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);
}
// ...
}
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);
}
}
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
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;
Now, we need to enable JSON imports in our TypeScript config:
{
"compilerOptions": {
"resolveJsonModule": true
}
}
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}`));
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"
}
}
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
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 });
// ...
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;
}
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
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,
},
// ...
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);
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!