已经有很多关于REST的优缺点的文章(甚至在有关它们的评论中)。 而且,如果碰巧需要开发一种应在其中应用此体系结构的服务,那么您肯定会涉及其文档。 毕竟,创建每种方法后,我们当然理解其他程序员将引用这些方法。 因此,文档应该是全面的,最重要的是相关的。
欢迎来到这只猫,在这里我将描述我们如何在团队中解决这个问题。
有点背景。
我们的团队的任务是在短时间内在
Node.js上发布中等复杂度的后端产品。 前端程序员和动员者应该与该产品交互。
经过一番思考,我们决定尝试使用
TypeScript作为
YaP 。 精心调整的
TSLint和
Prettier帮助我们在编码/汇编阶段(甚至在提交阶段也很
沙哑 )实现相同的代码样式和严格检查。 强类型使每个人都清楚地描述所有对象的接口和类型。 易于阅读和理解此函数将什么作为输入参数,它将最终返回什么,以及对象的哪些属性是强制性的,哪些不是强制性的。 该代码开始非常类似于Java)。 当然,
TypeDoc为每个函数增加了可读性。
这是代码的外观:
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; } }
我们考虑了后代,维护我们的代码并不难,现在应该考虑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.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 /文档这是带有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。 删除可以类推。 实际上,现在要编写一个新的控制器,我们必须:
- 创建控制器文件
- 将其添加到routing.ts
- 描述方法
- 在每种方法中,使用输入/输出电路
- 描述这些方案
- 在routing.ts中连接这些方案
如果传入的数据包与方案不匹配,则我们的REST服务的用户将收到400错误,并描述了确切的错误。 如果输出数据包无效,则将生成500错误。
好吧,仍然是一个愉快的琐事。 在Swagger UI中,您可以在任何方法上使用“
试用 ”功能。 将通过curl向运行的服务生成一个请求,当然您可以立即看到结果。 为此,在电路中描述参数“
示例 ”非常方便。 因为将根据描述的示例立即使用现成的软件包生成请求。

结论
最终,结果非常方便且有用。 起初,他们不想验证传出数据包,但随后在此验证的帮助下,他们发现了几个重要的错误。 当然,您不能完全使用Joi的所有功能(因为我们受joi-swagger的限制),但这些功能已经足够。
现在,文档始终在线,并且始终严格对应于代码-这是主要内容。
还有什么其他想法?
是否可以添加快速支持?
我刚读过 。
一次描述实体真的很酷。 因为现在必须同时编辑电路和接口。
也许您会有一些有趣的想法。 更好的请求请求:)
欢迎贡献者。