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:
export interface IResponseData<T> { nonce: number; code: number; message?: string; data?: T; } export class TransferObjectUtils { 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-swaggerE 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.jsonswagger.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 / docsEsta é 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:
- Criar arquivo do controlador
- Adicione-o ao routing.ts
- Descrever métodos
- Em cada método, use circuitos de entrada / saída
- Descreva esses esquemas
- 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.