自记录REST服务器(Node.JS,TypeScript,Koa,Joi,Swagger)


已经有很多关于REST的优缺点的文章(甚至在有关它们的评论中)。 而且,如果碰巧需要开发一种应在其中应用此体系结构的服务,那么您肯定会涉及其文档。 毕竟,创建每种方法后,我们当然理解其他程序员将引用这些方法。 因此,文档应该是全面的,最重要的是相关的。

欢迎来到这只猫,在这里我将描述我们如何在团队中解决这个问题。

有点背景。

我们的团队的任务是在短时间内在Node.js上发布中等复杂度的后端产品。 前端程序员和动员者应该与该产品交互。

经过一番思考,我们决定尝试使用TypeScript作为YaP 。 精心调整的TSLintPrettier帮助我们在编码/汇编阶段(甚至在提交阶段也很沙哑 )实现相同的代码样式和严格检查。 强类型使每个人都清楚地描述所有对象的接口和类型。 易于阅读和理解此函数将什么作为输入参数,它将最终返回什么,以及对象的哪些属性是强制性的,哪些不是强制性的。 该代码开始非常类似于Java)。 当然, TypeDoc为每个函数增加了可读性。

这是代码的外观:

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

我们考虑了后代,维护我们的代码并不难,现在应该考虑REST服务器的用户了。

由于一切工作都非常迅速,因此我们了解到分别编写代码和单独编写文档非常困难。 尤其要根据前端或mobilchiki的要求将其他参数添加到答案或请求中,不要忘了警告其他人。 这是出现明确要求的地方: 带有文档代码应始终同步 。 这意味着应该排除人为因素,文档应影响代码,而代码应影响文档。

在这里,我深入研究了为此目的寻找合适的工具。 幸运的是,NPM存储库只是各种想法和解决方案的仓库。

该工具的要求如下:

  • 文档与代码同步;
  • TypeScript支持;
  • 验证传入/传出数据包;
  • 实时和受支持的软件包。

我不得不使用许多不同的软件包编写REST服务,其中最受欢迎的软件包是:tsoa,swagger-node-express,express-openapi,swagger-codegen。



但是在某些情况下,没有TypeScript支持,在某些程序包验证中,有些能够基于文档生成代码,但是它们没有提供进一步的同步。

这是我碰巧碰到的地方。 一个很棒的软件包,它可以将Joi方案中描述的内容变成详尽的文档,甚至带有TypeScript支持。 除同步外,所有项目均被执行。 经过一段时间的忙碌之后,我发现了一个废弃的中国人资料库,该人使用joi-to-swagger和Koa框架。 由于我们的团队中没有对Koa的偏见,也没有理由盲目追随Express的趋势,因此我们决定尝试从这个堆栈中脱颖而出。

我分叉了这个存储库,修复了错误,完成了一些工作,现在发布了我对OpenSource Koa-Joi-Swagger-TS的第一篇贡献。 我们成功通过了该项目,在此之后,已经有其他几个项目了。 编写和维护REST服务变得非常方便,这些服务的用户只需要Swagger在线文档的链接即可。 在他们之后,很清楚可以在哪里开发此软件包,并且还进行了其他一些改进。

现在,让我们看看如何使用Koa-Joi-Swagger-TS编写一个自我文档化的REST服务器。 我把完成的代码贴在这里

由于此项目是一个演示,因此我简化并合并了几个文件。 通常,如果索引初始化应用程序并调用app.ts文件,则该文件将是好的,这将依次读取资源,调用连接到数据库等。 服务器应以最新命令开头(下面将对此进行描述)。

因此,对于初学者来说,创建具有以下内容的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} ...`); })(); 



当您启动此服务时,将启动一个REST服务器,到目前为止,尚不知道该如何做。 现在介绍一下项目的体系结构。 由于我从Java切换到Node.JS,因此尝试在此处构建具有相同层的服务。

  • 控制器
  • 服务项目
  • 储存库

让我们开始连接Koa-Joi-Swagger-TS 。 自然地安装它。

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

在其中创建“ controllers”文件夹和“ schemas”文件夹。 在controllers文件夹中,创建我们的第一个控制器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" } } }; } 


从装饰器(Java注释)中可以看到,该类将与路径“ / api / v1”相关联,内部的所有方法都将与此路径相关。

此方法对响应格式进行了描述,在文件“ ./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(); } 


在Joi中对方案进行这种描述的可能性非常广泛,并在此处进行了更详细的描述: www.npmjs.com/package/joi-to-swagger

这是所描述类的祖先(实际上,这是我们服务的所有答案的基类):

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


现在,在Koa-Joi-Swagger-TS系统中注册这些电路和控制器。
在index.ts旁边,创建另一个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(); }; 


在这里,我们创建了KJSRouter类的实例,该实例本质上是Koa-router,但是添加了中间件和处理程序。

因此,在index.ts文件中, 我们只需更改

 const router = new Router(); 



 const router = loadRoutes(); 

好吧,删除不必要的处理程序:

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


启动此服务时,我们可以使用3条路线:
1. / api / v1-记录的路由
在我的情况下显示:

http://本地主机: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" } } 


和两条服务路线:

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 /文档

这是带有Swagger UI的页面-这是Swagger方案的非常方便的可视表示形式,在其中,除了方便查看之外,您甚至可以生成请求并从服务器获取真实答案。



此用户界面需要访问swagger.json文件,这就是为什么包含先前路由的原因。

好吧,一切似乎都在那里,一切正常,但是!..

随着时间的流逝,我们圈出一个这样的实现,我们有很多代码重复。 在控制器需要执行相同操作的情况下。 正是由于这个原因,我后来完成了该程序包,并添加了描述控制器的“包装器”的功能。

考虑这种服务的一个例子。

假设我们有一个带有几种方法的“用户”控制器。

获取所有用户
  @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); }; 


更新用户
  @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); }; 


插入用户
  @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); }; 


如您所见,这三个控制器方法包含重复的代码。 在这种情况下,我们现在利用这个机会。

首先,例如,直接在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); }; 

然后将其连接到我们的控制器。

更换

 router.loadController(UserController); 



 router.loadController(UserController, controllerDecorator); 

好吧,让我们简化控制器方法

用户控制器
  @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; } }; 


在此controllerDecorator中,您可以添加任何检查逻辑或输入/输出的详细日志。

我把完成的代码贴在这里

现在我们几乎已经准备好CRUD。 删除可以类推。 实际上,现在要编写一个新的控制器,我们必须:

  1. 创建控制器文件
  2. 将其添加到routing.ts
  3. 描述方法
  4. 在每种方法中,使用输入/输出电路
  5. 描述这些方案
  6. 在routing.ts中连接这些方案

如果传入的数据包与方案不匹配,则我们的REST服务的用户将收到400错误,并描述了确切的错误。 如果输出数据包无效,则将生成500错误。

好吧,仍然是一个愉快的琐事。 在Swagger UI中,您可以在任何方法上使用“ 试用 ”功能。 将通过curl向运行的服务生成一个请求,当然您可以立即看到结果。 为此,在电路中描述参数“ 示例 ”非常方便。 因为将根据描述的示例立即使用现成的软件包生成请求。



结论


最终,结果非常方便且有用。 起初,他们不想验证传出数据包,但随后在此验证的帮助下,他们发现了几个重要的错误。 当然,您不能完全使用Joi的所有功能(因为我们受joi-swagger的限制),但这些功能已经足够。

现在,文档始终在线,并且始终严格对应于代码-这是主要内容。
还有什么其他想法?

是否可以添加快速支持?
我刚读过

一次描述实体真的很酷。 因为现在必须同时编辑电路和接口。

也许您会有一些有趣的想法。 更好的请求请求:)
欢迎贡献者。

Source: https://habr.com/ru/post/zh-CN449906/


All Articles