DEV Community

Oghenovo Usiwoma
Oghenovo Usiwoma

Posted on

A better way to create Swagger Docs for Koa APIs using decorators?

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
}

Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 };
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

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
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: {}
    };
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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!

Oldest comments (3)

Collapse
 
edwardoboh profile image
Edward Oboh

Nice one!!!
Unfortunately, I'll have to do some research to really get the context of this problem πŸ˜…

Collapse
 
crazychickendev profile image
Nwaobi Daniel

sweeeeeet

Collapse
 
amsmart profile image
Emmanuel Oluwagbemiga Adebiyi (Smart)

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.