Hello there!๐ I just did something to ease documentation for Koa APIs and I'm going to share it with you. If you are a fan of typescript's decorators or you are figuring out how to use them then you'll probably love this.
So, I had to setup swagger docs for a Koa API recently and I had to cram a lot of information and definitions into comments for the swagger-jsdoc tool to extract. As a developer who has used NestJS and is familiar with the ease at which you can create Swagger docs, I disliked this experience. I did check for some alternatives and found one notable package koa-swagger-decorator but why not re-invent the wheel ๐? I just wanted to code this myself... Thankfully, I got something usable without too much effort but this could easily have turned into a bad situation where I just wasted time and effort instead of using an existing solution.
Alright, let's get started!
So, I wanted something similar to what NestJS offers; I wanted to create classes to represent my various definitions and I wanted to use decorators to add swagger specific information to it's properties, Piece of cake...
This is an example of what I had in mind for Definitions...
@Definition()
export class CreateUser {
@ApiProperty({
required: true,
type: 'string'
})
createdBy!: string
@ApiProperty({
required: true,
type: 'string'
})
username!: string
@ApiProperty({
required: true,
type: 'string'
})
city!: string
}
We will have to do some work at the Controller level too but let's start here.
Creating the decorators is easy enough, you only need peruse the Typescript documentation but I mostly skipped that step and that came back to haunt me later but let's proceed.
How Decorators work
A decorator is a function that can be attached to classes, methods, properties etc, and get's called at runtime with details about the declaration it is attached to(let's call this the decorated entity). You can also modify said decorated entity at runtime. A couple of things to note about decorators;
When you have multiple decorators in a class, the parameter decorators, the method/property decorators, and the class decorators are evaluated serially in that order
When you have multiple decorators attached to the same entity, they are evaluated top-to-bottom and the results are passed bottom-to-top
A bit oversimplified but checkout Decorator composition for more information.
Creating the "ApiProperty" and "Definition" decorators
We need to store information like required fields, property types, examples if any for each Definition. I decided that a single "@ApiProperty" will suffice for this and "@Definition" will be added to the class to compile all the collected information into one definition and added to our definitions list... See the code snippet below.
export const DEFINITIONS: any = {}; // to hold all definitions
let DEFINITION: any = {}; // current definition details
// class decorator
export function Definition() {
return function <T extends { new(...args: any[]): {} }>(constructor: T) {
DEFINITIONS[constructor] = {
name: constructor.name,
type: "object",
...DEFINITION
};
DEFINITION = {}; // prepare for next class
}
}
Why am I using the class constructor as key for the Definition object? well, we will see that in the next section...
export interface ApiPropertyProps {
required?: boolean
type: string
example?: string
items?: { $ref?: any }
}
// A function that returns the actual decorator, A decorator factory
export function ApiProperty(props: ApiPropertyProps) {
return function (_target: any, propertyKey: string) {
if (!DEFINITION.required) DEFINITION.required = [];
if (!DEFINITION.properties) DEFINITION.properties = {};
if (props.required) DEFINITION.required.push(propertyKey);
if (props.items?.$ref) props.items.$ref = toSwaggerRef(props.items.$ref); // convert ref to swagger ref format
DEFINITION.properties = { ...DEFINITION.properties, [propertyKey]: props };
}
}
The Controllers
Now, we can't just define routes using koa-router because we can only use decorators in classes. So, we need to make Controller classes and also create decorators to add path, parameters and response definitions. I ended with something this..
class UserController {
@ApiParameter({ in: 'body', schema: { $ref: CreateUser } })
@ApiResponse({ status: 200, type: 'application/json', schema: { $ref: CreateUser } })
@ApiOperation({ path: '/user/create', method: 'post' })
async createUser(ctx: Context) {
const body: CreateGroup = ctx.request.body;
console.log(body);
}
}
If you are worried about adding middleware, it's easy enough to create a "Middleware" decorator for this purpose.
Notice here, that $ref
points to the actual CreateUser
class. I did this to ensure that the decorators applied to CreateUser
actually get executed at runtime. Without this limitation, I'd have to find other ways to make sure CreateUser
actually gets added to the Definitions
The toSwaggerRef
function as is shown below will be responsible for converting these class references to "#/definitions/CreateUser" strings for swagger to interpret.
function toSwaggerRef(ref: any) {
if (ref.charAt) return ref; // quick check if ref is a string
const definition = DEFINITIONS[ref];
return `#/definitions/${definition.name}`;
}
The code for the "ApiParameter" and "ApiResponse" decorators are pretty standard and you can take a look at them in the github gist. For "@ApiOperation", I modified the decorated method's instance a little bit to make it easier to add the routes to koa using koa-router.
export interface ApiOperationProps {
path: string, // Api Path
method: Methods, // Http Methods
description?: string
consumes?: string[]
}
export function ApiOperation(props: ApiOperationProps) {
const swaggerPath = props.path.split('/')
.map(token => {
if (!token.startsWith(':')) return token;
return `{${token.slice(1)}}`;
})
.join('/'); // convert all ':param' to '{param}' for swagger
PATHS[swaggerPath] = {
[props.method]: {
description: props.description,
consumes: props.consumes,
parameters: PARAMETERS,
responses: RESPONSES
}
}
PARAMETERS = [];
RESPONSES = {};
return (target: any, propertyKey: string, _descriptor: PropertyDescriptor) => {
// target is the instance with decorated property
if (!target._paths) target._paths = [];
target._paths.push({
path: props.path,
method: props.method, // method as in Http Method
propertyKey
});
}
}
Putting it all together
So, let's add our routes to koa and then generate our swagger doc...
export function applyRoutes(controller: any, router: Router) {
if (!controller._paths) return;
// Remember the paths we added in the @ApiOperation decorator?
controller._paths.forEach((pathObj: any) => {
const { path, method, propertyKey } = pathObj;
router[method as Methods](path, controller[propertyKey]); // Register route
});
}
In our controller file, after defining our controller class, we just need to do this...
const router = new Router();
const users = new UserController();
applyRoutes(users, router);
export default router; // add this to the koa app
To get our swagger page, I used this tool, swagger2-koa which accepts any object following the swagger specification...
The swaggerDoc
function compiles the paths and definitions into one object following the swagger specification.
export interface SwaggerProps {
info: {
title: string,
version: string,
description: string
}
}
export function swaggerDoc(props: SwaggerProps) {
const definitions = getDefinitions(); // Parse our DEFINITIONS object into the swagger format
return {
swagger: "2.0",
info: props.info,
paths: PATHS,
definitions,
responses: {},
parameters: {},
securityDefinitions: {},
tags: {}
};
}
and finally...
import { ui } from 'swagger2-koa';
import { swaggerDoc } from './utils/swagger';
let swaggerSpec: any = swaggerDoc({
info: {
title: `Test API`,
version: '1.0.0',
description: `Test API`
}
});
const swagger = ui(swaggerSpec, "/swagger");
// add to koa app
app.use(swagger);
Conclusion
This was mostly fun... I like to do things like this from time-to-time to prove that I'm still an "okay" programmer ๐. The full code is available here.
Thank you for reading!
Top comments (3)
sweeeeeet
Nice one!!!
Unfortunately, I'll have to do some research to really get the context of this problem ๐
Nice one! Will consider Koa.js sometime. Using Swagger with express.js so far felt very subpar compared with what I had in ASP.NET Core.