📜 ⬆️ ⬇️

Self-documenting REST server (Node.JS, TypeScript, Koa, Joi, Swagger)


About the advantages and disadvantages of REST quite a few articles have already been written (and even more in the comments to them)). And if it so happened that you have to develop a service in which this particular architecture is to be applied, then you will definitely encounter its documentation. After all, by creating each method, we certainly understand that other programmers will apply to these methods. Therefore, the documentation should be exhaustive, and most importantly - relevant.

Welcome under the cut, where I describe how we solved this problem in our team.

A bit of context.

Our team was tasked to issue a backend product on Node.js of medium difficulty in a short time. Frontend programmers and mobile workers should interact with this product.
')
After some reflection, we decided to try using TypeScript as PL . Properly tuned TSLint and Prettier helped us to achieve the same code style and hard checking it at the coding / assembly stage (and husky even at the commit stage). Strong typing led everyone to clearly describe the interfaces and types of all objects. It has become easy to read and understand what exactly this function accepts as an input parameter, what it will eventually return, and which of the object properties are mandatory and which are not. The code began to resemble Java quite strongly). And of course TypeDoc on each function adds readability.

This is how the code looked like:

/** * Interface of all responses */ export interface IResponseData<T> { nonce: number; code: number; message?: string; data?: T; } /** * Utils helper */ export class TransferObjectUtils { /** * Compose all data to result response package * * @param responseCode - 200 | 400 | 500 * @param message - any info text message * @param data - response data object * * @return ready object for REST response */ public static createResponseObject<T = object>(responseCode: number, message: string, data: T): IResponseData<T> { const result: IResponseData<T> = { code: responseCode || 200, nonce: Date.now() }; if (message) { result.message = message; } if (data) { result.data = data; } return result; } } 

We thought about the descendants, it will not be difficult to maintain our code, it is time to think about the users of our REST server.

Since everything was done rather quickly, we understood that it would be very difficult to write the code separately and the documentation to it separately. Especially to add additional parameters to the answers or requests for the requirements of the front-runners or mobile workers and not to forget to warn others about it. This is where a clear requirement appeared: the code with the documentation should always be synchronized . This meant that the human factor should be excluded and the documentation should influence the code, and the code on the documentation.

Here I went deep into the search for suitable tools for this. Fortunately, the NPM repository is just a storehouse of all kinds of ideas and solutions.

Requirements for the tool were as follows:


I had to write a REST service using many different packages, the most popular of which are: tsoa, ​​swagger-node-express, express-openapi, swagger-codegen.



But in some there was no support for TypeScript, in some packet validation, and some were able to generate code based on the documentation, but they didn’t provide further synchronization.

This is where I stumbled upon joi-to-swagger. An excellent package that can turn the scheme described in Joi into a swagger documentation and with TypeScript support. All items are completed except for synchronization. Rush for some time, I found an abandoned repository of a Chinese who used the joi-to-swagger in conjunction with the Koa framework. Since there were no prejudices against Koa in our team, and there was no reason to blindly follow the Express trend either, we decided to try to take off on this stack.

I forked this repository, fixed the bugs, completed some things, and then my first contribution to OpenSource Koa-Joi-Swagger-TS came out. That project we successfully passed and after it there were already several others. REST services began to be written and maintained very conveniently, and users of these services do not need anything other than a link to Swagger online documentation. After them, it became clear where it was possible to develop this package and it underwent several more improvements.

Now let's see how using Koa-Joi-Swagger-TS you can write a self-documenting REST server. I put the ready code here .

Since this project is a demo, I simplified and merged several files into one. In general, it is good if the index initializes the application and calls the app.ts file, which in turn will read resources, make connections to the database, etc. The very last command should start the server (just what will be described below).

So, first create index.ts with the following content:

index.ts
 import * as Koa from "koa"; import { BaseContext } from "koa"; import * as bodyParser from "koa-bodyparser"; import * as Router from "koa-router"; const SERVER_PORT = 3002; (async () => { const app = new Koa(); const router = new Router(); app.use(bodyParser()); router.get("/", (ctx: BaseContext, next: Function) => { console.log("Root loaded!") }); app .use(router.routes()) .use(router.allowedMethods()); app.listen(SERVER_PORT); console.log(`Server listening on http://localhost:${SERVER_PORT} ...`); })(); 



When you start this service will be raised REST server, which so far can not do anything. Now a little about the architecture of the project. Since I switched to Node.JS from Java, I tried to build a service with the same layers here.


Let's start connecting Koa-Joi-Swagger-TS . Naturally install it.

 npm install koa-joi-swagger-ts --save 

Create a folder “controllers” and in it a folder “schemas” . In the controllers folder, create our first base.controller.ts controller:

base.controller.ts
 import { BaseContext } from "koa"; import { controller, description, get, response, summary, tag } from "koa-joi-swagger-ts"; import { ApiInfoResponseSchema } from "./schemas/apiInfo.response.schema"; @controller("/api/v1") export abstract class BaseController { @get("/") @response(200, { $ref: ApiInfoResponseSchema }) @tag("GET") @description("Returns text info about version of API") @summary("Show API index page") public async index(ctx: BaseContext, next: Function): Promise<void> { console.log("GET /api/v1/"); ctx.status = 200; ctx.body = { code: 200, data: { appVersion: "1.0.0", build: "1001", apiVersion: 1, reqHeaders: ctx.request.headers, apiDoc: "/api/v1/swagger.json" } } }; } 


As can be seen from the decorators (annotations in Java), this class will be associated with the path “/ api / v1”, all methods inside will be relative to this path.

This method has a description of the response format, which is described in the file "./schemas/apiInfo.response.schema":

apiInfo.response.schema
 import * as Joi from "joi"; import { definition } from "koa-joi-swagger-ts"; import { BaseAPIResponseSchema } from "./baseAPI.response.schema"; @definition("ApiInfo", "Information data about current application and API version") export class ApiInfoResponseSchema extends BaseAPIResponseSchema { public data = Joi.object({ appVersion: Joi.string() .description("Current version of application") .required(), build: Joi.string().description("Current build version of application"), apiVersion: Joi.number() .positive() .description("Version of current REST api") .required(), reqHeaders: Joi.object().description("Request headers"), apiDoc: Joi.string() .description("URL path to swagger document") .required() }).required(); } 


The possibilities of such a description of the scheme in Joi are very extensive and are described in more detail here: www.npmjs.com/package/joi-to-swagger

But the ancestor of the described class (in fact, this is the base class for all the answers of our service):

baseAPI.response.schema
 import * as Joi from "joi"; import { definition } from "koa-joi-swagger-ts"; @definition("BaseAPIResponse", "Base response entity with base fields") export class BaseAPIResponseSchema { public code = Joi.number() .required() .strict() .only(200, 400, 500) .example(200) .description("Code of operation result"); public message = Joi.string().description("message will be filled in some causes"); } 


Now we will register these schemes and controllers in the Koa-Joi-Swagger-TS system.
Next to index.ts, create another routing.ts file:

routing.ts
 import { KJSRouter } from "koa-joi-swagger-ts"; import { BaseController } from "./controllers/base.controller"; import { BaseAPIResponseSchema } from "./controllers/schemas/baseAPI.response.schema"; import { ApiInfoResponseSchema } from "./controllers/schemas/apiInfo.response.schema"; const SERVER_PORT = 3002; export const loadRoutes = () => { const router = new KJSRouter({ swagger: "2.0", info: { version: "1.0.0", title: "simple-rest" }, host: `localhost:${SERVER_PORT}`, basePath: "/api/v1", schemes: ["http"], paths: {}, definitions: {} }); router.loadDefinition(ApiInfoResponseSchema); router.loadDefinition(BaseAPIResponseSchema); router.loadController(BaseController); router.setSwaggerFile("swagger.json"); router.loadSwaggerUI("/api/docs"); return router.getRouter(); }; 


Here we create an instance of the KJSRouter class, which is essentially a Koa-router, but already with added middlewares and handlers in them.

Therefore, in the index.ts file, we simply change

 const router = new Router(); 

on

 const router = loadRoutes(); 

Well, delete the already unnecessary handler:

index.ts
 import * as Koa from "koa"; import * as bodyParser from "koa-bodyparser"; import { loadRoutes } from "./routing"; const SERVER_PORT = 3002; (async () => { const app = new Koa(); const router = loadRoutes(); app.use(bodyParser()); app .use(router.routes()) .use(router.allowedMethods()); app.listen(SERVER_PORT); console.log(`Server listening on http://localhost:${SERVER_PORT} ...`); })(); 


When starting this service, 3 routes are available to us:
1. / api / v1 - documented route
Which in my case shows:

http: // localhost: 3002 / api / v1
 { code: 200, data: { appVersion: "1.0.0", build: "1001", apiVersion: 1, reqHeaders: { host: "localhost:3002", connection: "keep-alive", cache-control: "max-age=0", upgrade-insecure-requests: "1", user-agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36", accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3", accept-encoding: "gzip, deflate, br", accept-language: "uk-UA,uk;q=0.9,ru;q=0.8,en-US;q=0.7,en;q=0.6" }, apiDoc: "/api/v1/swagger.json" } } 


And two service routes:

2. /api/v1/swagger.json

swagger.json
 { swagger: "2.0", info: { version: "1.0.0", title: "simple-rest" }, host: "localhost:3002", basePath: "/api/v1", schemes: [ "http" ], paths: { /: { get: { tags: [ "GET" ], summary: "Show API index page", description: "Returns text info about version of API", consumes: [ "application/json" ], produces: [ "application/json" ], responses: { 200: { description: "Information data about current application and API version", schema: { type: "object", $ref: "#/definitions/ApiInfo" } } }, security: [ ] } } }, definitions: { BaseAPIResponse: { type: "object", required: [ "code" ], properties: { code: { type: "number", format: "float", enum: [ 200, 400, 500 ], description: "Code of operation result", example: { value: 200 } }, message: { type: "string", description: "message will be filled in some causes" } } }, ApiInfo: { type: "object", required: [ "code", "data" ], properties: { code: { type: "number", format: "float", enum: [ 200, 400, 500 ], description: "Code of operation result", example: { value: 200 } }, message: { type: "string", description: "message will be filled in some causes" }, data: { type: "object", required: [ "appVersion", "apiVersion", "apiDoc" ], properties: { appVersion: { type: "string", description: "Current version of application" }, build: { type: "string", description: "Current build version of application" }, apiVersion: { type: "number", format: "float", minimum: 1, description: "Version of current REST api" }, reqHeaders: { type: "object", properties: { }, description: "Request headers" }, apiDoc: { type: "string", description: "URL path to swagger document" } } } } } } } 


3. / api / docs

This page with Swagger UI is a very convenient visual representation of the Swagger scheme, in which, besides being convenient to see, you can even generate requests and get real answers from the server.



This UI requires access to the swagger.json file, which is why the previous route was included.

Well, everything seems to be there and everything works, but! ..

After a while, we became aware that in such an implementation we get quite a lot of code duplication. In the case when the controllers need to perform the same actions. It is because of this that I modified the package later and added the ability to describe the “wrapper” for the controllers.

Consider an example of such a service.

Suppose that we have a “Users” controller with several methods.

Get all users
  @get("/") @response(200, { $ref: UsersResponseSchema }) @response(400, { $ref: BaseAPIResponseSchema }) @response(500, { $ref: BaseAPIResponseSchema }) @tag("User") @description("Returns list of all users") @summary("Get all users") public async getAllUsers(ctx: BaseContext): Promise<void> { console.log("GET /api/v1/users"); let message = "Get all users error"; let code = 400; let data = null; try { let serviceResult = await getAllUsers(); if (serviceResult) { data = serviceResult; code = 200; message = null; } } catch (e) { console.log("Error while getting users list"); code = 500; } ctx.status = code; ctx.body = TransferObjectUtils.createResponseObject(code, message, data); }; 


Update user
  @post("/") @parameter("body", { $ref: UsersRequestSchema }, ENUM_PARAM_IN.body) @response(200, { $ref: BaseAPIResponseSchema }) @response(400, { $ref: BaseAPIResponseSchema }) @response(500, { $ref: BaseAPIResponseSchema }) @tag("User") @description("Update user data") @summary("Update user data") public async updateUser(ctx: BaseContext): Promise<void> { console.log("POST /api/v1/users"); let message = "Update user data error"; let code = 400; let data = null; try { let serviceResult = await updateUser(ctx.request.body.data); if (serviceResult) { code = 200; message = null; } } catch (e) { console.log("Error while updating user"); code = 500; } ctx.status = code; ctx.body = TransferObjectUtils.createResponseObject(code, message, data); }; 


Insert user
  @put("/") @parameter("body", { $ref: UsersRequestSchema }, ENUM_PARAM_IN.body) @response(200, { $ref: BaseAPIResponseSchema }) @response(400, { $ref: BaseAPIResponseSchema }) @response(500, { $ref: BaseAPIResponseSchema }) @tag("User") @description("Insert new user") @summary("Insert new user") public async insertUser(ctx: BaseContext): Promise<void> { console.log("PUT /api/v1/users"); let message = "Insert new user error"; let code = 400; let data = null; try { let serviceResult = await insertUser(ctx.request.body.data); if (serviceResult) { code = 200; message = null; } } catch (e) { console.log("Error while inserting user"); code = 500; } ctx.status = code; ctx.body = TransferObjectUtils.createResponseObject(code, message, data); }; 


As you can see, the three controller methods contain duplicate code. For such cases, we now use this opportunity.

First, create a wrapper function, for example, directly in the routing.ts file.

 const controllerDecorator = async (controller: Function, ctx: BaseContext, next: Function, summary: string): Promise<void> => { console.log(`${ctx.request.method} ${ctx.request.url}`); ctx.body = null; ctx.status = 400; ctx.statusMessage = `Error while executing '${summary}'`; try { await controller(ctx); } catch (e) { console.log(e, `Error while executing '${summary}'`); ctx.status = 500; } ctx.body = TransferObjectUtils.createResponseObject(ctx.status, ctx.statusMessage, ctx.body); }; 

Then connect it to our controller.

Replace

 router.loadController(UserController); 

on

 router.loadController(UserController, controllerDecorator); 

Well, simplify our controller methods.

User controller
  @get("/") @response(200, { $ref: UsersResponseSchema }) @response(400, { $ref: BaseAPIResponseSchema }) @response(500, { $ref: BaseAPIResponseSchema }) @tag("User") @description("Returns list of all users") @summary("Get all users") public async getAllUsers(ctx: BaseContext): Promise<void> { let serviceResult = await getAllUsers(); if (serviceResult) { ctx.body = serviceResult; ctx.status = 200; ctx.statusMessage = null; } }; @post("/") @parameter("body", { $ref: UsersRequestSchema }, ENUM_PARAM_IN.body) @response(200, { $ref: BaseAPIResponseSchema }) @response(400, { $ref: BaseAPIResponseSchema }) @response(500, { $ref: BaseAPIResponseSchema }) @tag("User") @description("Update user data") @summary("Update user data") public async updateUser(ctx: BaseContext): Promise<void> { let serviceResult = await updateUser(ctx.request.body.data); if (serviceResult) { ctx.status = 200; ctx.statusMessage = null; } }; @put("/") @parameter("body", { $ref: UsersRequestSchema }, ENUM_PARAM_IN.body) @response(200, { $ref: BaseAPIResponseSchema }) @response(400, { $ref: BaseAPIResponseSchema }) @response(500, { $ref: BaseAPIResponseSchema }) @tag("User") @description("Insert new user") @summary("Insert new user") public async insertUser(ctx: BaseContext): Promise<void> { let serviceResult = await insertUser(ctx.request.body.data); if (serviceResult) { ctx.status = 200; ctx.statusMessage = null; } }; 


In this controllerDecorator, you can add any logic checks or detailed logs of inputs / outputs.

I put the ready code here .

Now we have almost CRUD ready. Delete can be written by analogy. In essence, now for writing a new controller, we should:

  1. Create controller file
  2. Add it to routing.ts
  3. Describe methods
  4. In each method, use input / output circuits
  5. Describe these schemes
  6. Connect these schemes to routing.ts

If the incoming packet does not match the scheme, the user of our REST service will receive an error 400 describing what is wrong. If the outgoing packet is invalid, then error 500 will be generated.

Well, as a nice little thing. In Swagger UI, you can use the “ Try it out ” functionality on any method. A request will be generated via curl for your running service, and of course you can immediately see the result. And it is precisely for this that it is very convenient to describe the parameter “ example ” in the scheme. Because the request will be generated immediately with a ready package based on the described examps.



findings


A very convenient and useful thing turned out. At first, they did not want to validate outgoing packets, but then with the help of this validation they caught several significant bugs on their side. Of course, you cannot fully use all the features of Joi (since we are limited to joi-to-swagger), but also those that are quite enough.

Now the documentation with us is always online and always strictly complies with the code - and this is the main thing.
What other ideas are there? ..

Is it possible to add express support?
Just read it .

It would really be cool to describe entities once in one place. Because now it is necessary to edit both circuits and interfaces.

Maybe you will have some interesting ideas. And even better Pull Requests :)
Welcome to contributors.

Source: https://habr.com/ru/post/449906/


All Articles