A better way to create Swagger Docs for Koa APIs using decorators?
Tags: node, koa, swagger, typescript
Published: 2022-06-20 10:35:06

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](https://www.npmjs.com/package/swagger-jsdoc) tool to extract. As a developer who has used [NestJS](https://nestjs.com/) 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](https://www.npmjs.com/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](https://swagger.io/docs/specification/2-0/basic-structure/) 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](https://www.typescriptlang.org/docs/handbook/decorators.htm) 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](https://www.typescriptlang.org/docs/handbook/decorators.html#decorator-evaluation) 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](https://www.npmjs.com/package/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](https://gist.github.com/Eunovo/8b3ef97b1c88dcf0ae6d773ab713dbe3). 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](https://www.npmjs.com/package/swagger2-koa) which accepts any object following the [swagger specification](https://swagger.io/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](https://gist.github.com/Eunovo/8b3ef97b1c88dcf0ae6d773ab713dbe3).

Thank you for reading!