Servidor REST autodocumentado (Node.JS, TypeScript, Koa, Joi, Swagger)


Ya se han escrito muchos artículos sobre las ventajas y desventajas de REST (y aún más en los comentarios que se les hacen). Y si resulta que tiene que desarrollar un servicio en el que se debe aplicar esta arquitectura, entonces seguramente encontrará su documentación. Después de todo, al crear cada método, ciertamente entendemos que otros programadores se referirán a estos métodos. Por lo tanto, la documentación debe ser exhaustiva y, lo más importante, relevante.

Bienvenido al gato, donde describiré cómo resolvimos este problema en nuestro equipo.

Un poco de contexto.

Nuestro equipo tuvo la tarea de emitir un producto back-end en Node.js de complejidad media en poco tiempo. Se suponía que los programadores y movilizadores frontend interactuarían con este producto.

Después de pensarlo un poco, decidimos intentar usar TypeScript como YaP . TSLint y Prettier bien ajustados nos ayudaron a lograr el mismo estilo de código y una verificación estricta en la etapa de codificación / ensamblaje (y ronca incluso en la etapa de confirmación). La escritura fuerte llevó a todos a describir claramente las interfaces y los tipos de todos los objetos. Se ha vuelto fácil de leer y comprender exactamente qué toma esta función como parámetro de entrada, qué devolverá eventualmente y cuáles de las propiedades del objeto son obligatorias y cuáles no. El código comenzó a parecerse a Java más o menos). Y, por supuesto, TypeDoc agregó legibilidad a cada función.

Así es como comenzó a verse el código:

/** * 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; } } 

Pensamos en los descendientes, no será difícil mantener nuestro código, es hora de pensar en los usuarios de nuestro servidor REST.

Como todo se hizo con bastante rapidez, entendimos que escribir código por separado y documentación por separado sería muy difícil. Especialmente agregue parámetros adicionales a las respuestas o solicitudes de acuerdo con los requisitos de front-end o mobilchiki y no olvide advertir a otros sobre esto. Aquí es donde apareció un requisito claro: el código con la documentación siempre debe estar sincronizado . Esto significaba que el factor humano debería ser excluido y la documentación debería influir en el código, y el código debería afectar la documentación.

Aquí profundicé en la búsqueda de herramientas adecuadas para esto. Afortunadamente, el repositorio de NPM es solo un almacén de todo tipo de ideas y soluciones.

Los requisitos para la herramienta fueron los siguientes:

  • Documentación de sincronización con el código;
  • Soporte de TypeScript;
  • Validación de paquetes entrantes / salientes;
  • Paquete en vivo y compatible.

Tuve que escribir en un servicio REST usando muchos paquetes diferentes, los más populares son: tsoa, ​​swagger-node-express, express-openapi, swagger-codegen.



Pero en algunos no había soporte para TypeScript, en algunos de validación de paquetes, y algunos pudieron generar código basado en la documentación, pero no proporcionaron sincronización adicional.

Aquí es donde me encontré con joi-to-swagger. Un gran paquete que puede convertir lo descrito en el esquema Joi en documentación más elegante e incluso con soporte TypeScript. Todos los elementos se ejecutan, excepto la sincronización. Apresurándome por algún tiempo, encontré un depósito abandonado de un chino que usaba el joi-to-swagger junto con el marco Koa. Como no había prejuicios contra Koa en nuestro equipo y no había razones para seguir ciegamente la tendencia Express, decidimos intentar despegar en esta pila.

Bifurqué este repositorio, solucioné errores, completé algunas cosas y ahora se lanzó mi primera contribución a OpenSource Koa-Joi-Swagger-TS. Pasamos con éxito ese proyecto y después ya había varios otros. Se ha vuelto muy conveniente escribir y mantener servicios REST, y los usuarios de estos servicios no necesitan nada más que un enlace a la documentación en línea de Swagger. Después de ellos, quedó claro dónde se puede desarrollar este paquete y ha experimentado varias mejoras más.

Ahora veamos cómo usar Koa-Joi-Swagger-TS puede escribir un servidor REST autodocumentado. Publiqué el código terminado aquí .

Como este proyecto es una demostración, simplifiqué y fusioné varios archivos en uno. En general, es bueno si el índice inicializa la aplicación y llama al archivo app.ts, que a su vez leerá recursos, llamadas para conectarse a la base de datos, etc. El servidor debe comenzar con el último comando (justo lo que se describirá a continuación).

Entonces, para empezar, crea index.ts con este contenido:

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} ...`); })(); 



Cuando inicie este servicio, se generará un servidor REST, que hasta ahora no sabe cómo. Ahora un poco sobre la arquitectura del proyecto. Desde que cambié a Node.JS desde Java, intenté construir un servicio con las mismas capas aquí.

  • Controladores
  • Servicios
  • Repositorios

Comencemos a conectar Koa-Joi-Swagger-TS . Instalarlo naturalmente.

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

Cree la carpeta "controladores" y la carpeta "esquemas" en ella. En la carpeta de controladores, cree nuestro primer controlador base.controller.ts :

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" } } }; } 


Como puede ver en los decoradores (anotaciones en Java), esta clase estará asociada con la ruta "/ api / v1", todos los métodos en el interior serán relativos a esta ruta.

Este método tiene una descripción del formato de respuesta, que se describe en el archivo "./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(); } 


Las posibilidades de tal descripción del esquema en Joi son muy amplias y se describen con más detalle aquí: www.npmjs.com/package/joi-to-swagger

Y aquí está el antepasado de la clase descrita (en realidad esta es la clase base para todas las respuestas de nuestro servicio):

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"); } 


Ahora registre estos circuitos y controladores en el sistema Koa-Joi-Swagger-TS.
Junto a index.ts, cree otro archivo routing.ts :

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(); }; 


Aquí creamos una instancia de la clase KJSRouter, que esencialmente es un enrutador Koa, pero con middlewares y controladores añadidos.

Por lo tanto, en el archivo index.ts , simplemente cambiamos

 const router = new Router(); 

en

 const router = loadRoutes(); 

Bueno, elimine el controlador innecesario:

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} ...`); })(); 


Cuando inicia este servicio, tenemos 3 rutas disponibles:
1. / api / v1 - ruta documentada
Que en mi caso se muestra:

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" } } 


Y dos rutas de servicio:

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

Esta es la página con la interfaz de usuario de Swagger: esta es una representación visual muy conveniente del esquema Swagger, en la que, además de ser conveniente de ver, incluso puede generar solicitudes y obtener respuestas reales del servidor.



Esta interfaz de usuario requiere acceso al archivo swagger.json, por lo que se incluyó la ruta anterior.

Bueno, todo parece estar ahí y todo funciona, pero ...

Con el tiempo, encerramos en un círculo que en tal implementación, tenemos mucha duplicación de código. En el caso en que los controladores necesitan hacer lo mismo. Fue por esto que más tarde finalicé el paquete y agregué la capacidad de describir el "contenedor" para los controladores.

Considere un ejemplo de tal servicio.

Supongamos que tenemos un controlador de "Usuarios" con varios métodos.

Consigue todos los usuarios
  @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); }; 


Actualizar usuario
  @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); }; 


Insertar usuario
  @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); }; 


Como puede ver, los tres métodos de controlador contienen código duplicado. Es para tales casos que ahora usamos esta oportunidad.

Primero, cree una función de contenedor, por ejemplo, directamente en el archivo routing.ts .

 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); }; 

Luego conéctelo a nuestro controlador.

Reemplazar

 router.loadController(UserController); 

en

 router.loadController(UserController, controllerDecorator); 

Bueno, simplifiquemos nuestros métodos de controlador

Controlador de usuario
  @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; } }; 


En este controladorDecorator, puede agregar cualquier lógica de comprobaciones o registros detallados de entradas / salidas.

Publiqué el código terminado aquí .

Ahora tenemos casi CRUDO listo. Eliminar se puede escribir por analogía. De hecho, ahora para escribir un nuevo controlador, debemos:

  1. Crear archivo controlador
  2. Agréguelo a routing.ts
  3. Describir los métodos.
  4. En cada método, use circuitos de entrada / salida
  5. Describa estos esquemas.
  6. Conecte estos esquemas en routing.ts

Si el paquete entrante no coincide con el esquema, el usuario de nuestro servicio REST recibirá un error 400 con una descripción de lo que está exactamente mal. Si el paquete saliente no es válido, se generará un error 500.

Bueno y quieto como una bagatela agradable. En Swagger UI, puede usar la funcionalidad " Pruébelo " en cualquier método. Se generará una solicitud a través de curl a su servicio en ejecución y, por supuesto, puede ver el resultado de inmediato. Y solo por esto es muy conveniente describir el parámetro " ejemplo " en el circuito. Porque la solicitud se generará de inmediato con un paquete listo basado en los ejemplos descritos.



Conclusiones


Muy conveniente y útil al final, la cosa resultó. Al principio, no querían validar los paquetes salientes, pero luego, con la ayuda de esta validación, detectaron varios errores importantes de su parte. Por supuesto, no puede usar todas las funciones de Joi (ya que estamos limitados por joi-to-swagger), pero sí las que son suficientes.

Ahora la documentación siempre está en línea y siempre corresponde estrictamente al código, y esto es lo principal.
¿Qué otras ideas hay?

¿Es posible agregar soporte express?
Acabo de leerlo .

Sería genial describir entidades una vez en un solo lugar. Porque ahora es necesario editar tanto los circuitos como las interfaces.

Quizás tengas algunas ideas interesantes. Mejor aún Solicitudes de extracción :)
Bienvenido a contribuyentes.

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


All Articles