Servidor REST auto-documentado (Node.JS, TypeScript, Koa, Joi, Swagger)


Muitos artigos já foram escritos sobre as vantagens e desvantagens do REST (e ainda mais nos comentários a eles)). E se você tiver que desenvolver um serviço no qual essa arquitetura deva ser aplicada, certamente encontrará a documentação. Afinal, ao criar cada método, certamente entendemos que outros programadores se referirão a esses métodos. Portanto, a documentação deve ser abrangente e, o mais importante, relevante.

Bem-vindo ao gato, onde descreverei como resolvemos esse problema em nossa equipe.

Um pouco de contexto.

Nossa equipe teve a tarefa de emitir um produto de back-end no Node.js de média complexidade em pouco tempo. Os programadores e mobilizadores de front-end deveriam interagir com este produto.

Depois de pensar um pouco, decidimos tentar usar o TypeScript como um YaP . O TSLint e o Prettier bem ajustados nos ajudaram a obter o mesmo estilo de código e uma verificação rigorosa no estágio de codificação / montagem (e rouca, mesmo no estágio de confirmação). A digitação forte levou todos a descrever claramente as interfaces e os tipos de todos os objetos. Tornou-se fácil ler e entender o que exatamente essa função leva como parâmetro de entrada, o que retornará eventualmente e quais das propriedades do objeto são obrigatórias e quais não são. O código começou a se parecer muito com Java). E, é claro, o TypeDoc adicionou legibilidade a todas as funções.

Foi assim que o código começou a aparecer:

/** * 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 nos descendentes, não será difícil manter nosso código, é hora de pensar nos usuários do nosso servidor REST.

Como tudo foi feito rapidamente, entendemos que escrever código separadamente e documentação separadamente seria muito difícil. Adicione parâmetros adicionais adicionais a respostas ou solicitações de acordo com os requisitos do front-end ou do mobilchiki e não se esqueça de avisar outras pessoas sobre isso. É aqui que um requisito claro apareceu: o código com a documentação sempre deve ser sincronizado . Isso significava que o fator humano deveria ser excluído e a documentação deveria influenciar o código, e o código deveria afetar a documentação.

Aqui, mergulhei na busca de ferramentas adequadas para isso. Felizmente, o repositório NPM é apenas um depósito de todos os tipos de idéias e soluções.

Os requisitos para a ferramenta foram os seguintes:

  • Sincronização de documentação com código;
  • Suporte TypeScript;
  • Validação de pacotes de entrada / saída;
  • Pacote ao vivo e suportado.

Eu tive que escrever em um serviço REST usando muitos pacotes diferentes, os mais populares são: tsoa, ​​swagger-node-express, express-openapi, swagger-codegen.



Mas, em alguns, não havia suporte ao TypeScript, em algumas validações de pacotes, e alguns conseguiram gerar código com base na documentação, mas não forneceram sincronização adicional.

Foi aqui que me deparei com joi-to-swagger. Um ótimo pacote que pode transformar o descrito no esquema Joi em documentação arrogante e até com suporte ao TypeScript. Todos os itens são executados, exceto a sincronização. Após algum tempo, encontrei um repositório abandonado de um chinês que usava o joi-to-swagger em conjunto com a estrutura Koa. Como não havia preconceitos contra Koa em nossa equipe e não havia motivos para seguir cegamente a tendência do Express, decidimos tentar decolar nesta pilha.

Bifurquei este repositório, corrigi bugs, concluí algumas coisas e agora minha primeira contribuição ao OpenSource Koa-Joi-Swagger-TS foi lançada. Passamos com sucesso esse projeto e depois dele já havia vários outros. Tornou-se muito conveniente escrever e manter serviços REST, e os usuários desses serviços não precisam de nada além de um link para a documentação online do Swagger. Depois deles, ficou claro onde esse pacote pode ser desenvolvido e passou por várias outras melhorias.

Agora vamos ver como, usando o Koa-Joi-Swagger-TS, você pode escrever um servidor REST auto-documentado. Publiquei o código finalizado aqui .

Como este projeto é uma demonstração, simplifiquei e mesclei vários arquivos em um. Em geral, é bom que o índice inicialize o aplicativo e chame o arquivo app.ts, que, por sua vez, lerá recursos, chamadas para conectar-se ao banco de dados etc. O servidor deve começar com o comando mais recente (exatamente o que agora será descrito abaixo).

Portanto, para iniciantes, crie index.ts com este conteúdo:

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



Quando você inicia esse serviço, um servidor REST será gerado, o que até o momento não sabe como. Agora um pouco sobre a arquitetura do projeto. Desde que mudei para Node.JS a partir de Java, tentei criar um serviço com as mesmas camadas aqui.

  • Controladores
  • Serviços
  • Repositórios

Vamos começar a conectar Koa-Joi-Swagger-TS . Instale-o naturalmente.

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

Crie a pasta "controllers" e a pasta "schemas" nela. Na pasta controllers, crie nosso primeiro 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 você pode ver nos decoradores (anotações em Java), essa classe será associada ao caminho “/ api / v1”, todos os métodos contidos nele serão relativos a esse caminho.

Este método possui uma descrição do formato de resposta, descrito no arquivo "./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(); } 


As possibilidades dessa descrição do esquema em Joi são muito extensas e descritas em mais detalhes aqui: www.npmjs.com/package/joi-to-swagger

E aqui está o ancestral da classe descrita (na verdade, esta é a classe base de todas as respostas do nosso serviço):

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


Agora registre esses circuitos e controladores no sistema Koa-Joi-Swagger-TS.
Ao lado de index.ts, crie outro arquivo 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(); }; 


Aqui, criamos uma instância da classe KJSRouter, que é essencialmente um roteador Koa, mas com middlewares e manipuladores adicionados.

Portanto, no arquivo index.ts, simplesmente mudamos

 const router = new Router(); 

em

 const router = loadRoutes(); 

Bem, exclua o manipulador desnecessário:

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


Quando você inicia este serviço, três rotas estão disponíveis para nós:
1. / api / v1 - rota documentada
Que no meu caso é mostrado:

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


E duas rotas de serviço:

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 é a página com a interface do usuário do Swagger - esta é uma representação visual muito conveniente do esquema Swagger, na qual, além de ser conveniente de ver, você pode até gerar solicitações e obter respostas reais do servidor.



Essa interface do usuário requer acesso ao arquivo swagger.json, e é por isso que a rota anterior foi incluída.

Bem, tudo parece estar lá e tudo funciona, mas! ..

Com o tempo, circulamos que, em tal implementação, temos muita duplicação de código. No caso em que os controladores precisam fazer a mesma coisa. Foi por isso que finalizei o pacote mais tarde e adicionei a capacidade de descrever o "invólucro" para os controladores.

Considere um exemplo desse serviço.

Suponha que tenhamos um controlador "Usuários" com vários métodos.

Obter todos os usuários
  @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); }; 


Atualizar usuário
  @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); }; 


Inserir usuário
  @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 você pode ver, os três métodos do controlador contêm código duplicado. É nesses casos que agora estamos usando esta oportunidade.

Primeiro, crie uma função de wrapper, por exemplo, diretamente no arquivo 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); }; 

Em seguida, conecte-o ao nosso controlador.

Substitua

 router.loadController(UserController); 

em

 router.loadController(UserController, controllerDecorator); 

Bem, vamos simplificar nossos métodos de controle

Controlador de usuário
  @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; } }; 


Neste controllerDecorator, você pode adicionar qualquer lógica de verificações ou logs detalhados de entradas / saídas.

Publiquei o código finalizado aqui .

Agora temos quase CRUD pronto. Excluir pode ser escrito por analogia. De fato, agora para escrever um novo controlador, devemos:

  1. Criar arquivo do controlador
  2. Adicione-o ao routing.ts
  3. Descrever métodos
  4. Em cada método, use circuitos de entrada / saída
  5. Descreva esses esquemas
  6. Conecte esses esquemas em routing.ts

Se o pacote recebido não corresponder ao esquema, o usuário do nosso serviço REST receberá um erro 400 com uma descrição do que está exatamente errado. Se o pacote de saída for inválido, um erro 500 será gerado.

Bem e imóvel como uma ninharia agradável. Na interface do usuário do Swagger, você pode usar a funcionalidade " Experimente " em qualquer método. Uma solicitação será gerada via curl para o serviço em execução e, é claro, você poderá ver o resultado imediatamente. E apenas para isso é muito conveniente descrever o parâmetro " exemplo " no circuito. Porque a solicitação será gerada imediatamente com um pacote pronto com base nos exemplos descritos.



Conclusões


Muito conveniente e útil no final, a coisa acabou. No início, eles não queriam validar pacotes de saída, mas, com a ajuda dessa validação, detectaram vários erros significativos. Obviamente, você não pode usar totalmente todos os recursos do Joi (já que somos limitados pelo joi-to-swagger), mas aqueles que são suficientes.

Agora a documentação está sempre online e sempre corresponde estritamente ao código - e isso é a principal.
Que outras idéias existem?

É possível adicionar suporte expresso?
Acabei de ler .

Seria realmente legal descrever entidades uma vez em um só lugar. Porque agora é necessário editar circuitos e interfaces.

Talvez você tenha algumas idéias interessantes. Melhor ainda Pull Requests :)
Bem-vindo aos colaboradores.

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


All Articles