自己文書化RESTサーバー(Node.JS、TypeScript、Koa、Joi、Swagger)







RESTの長所と短所については、かなりの数の記事がすでに書かれています(さらに、それらへのコメントで)。 また、このアーキテクチャを適用するサービスを開発する必要が生じた場合、ドキュメントに間違いなく出会うでしょう。 実際、各メソッドを作成するとき、他のプログラマーがこれらのメソッドを参照することを確かに理解しています。 したがって、ドキュメントは包括的であり、最も重要なのは関連性がある必要があります。



猫へようこそ、ここで私たちのチームでこの問題を解決した方法を説明します。



少しのコンテキスト。



私たちのチームは、短期間中程度の複雑さのNode.jsでバックエンド製品を発行することを任されました。 フロントエンドのプログラマーとモビライザーは、この製品と対話することになっています。



少し考えて、 TypeScriptYaPとして使用することにしました 。 よく調整された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リポジトリは、あらゆる種類のアイデアとソリューションの単なる倉庫です。



ツールの要件は次のとおりです。





多くの異なるパッケージを使用してRESTサービスで記述しなければなりませんでした。最も人気のあるパッケージは、tsoa、swagger-node-express、express-openapi、swagger-codegenです。







ただし、TypeScriptをサポートしていないもの、パケット検証を使用しているもの、ドキュメントに基づいてコードを生成できるものもありますが、それ以上の同期は提供されていません。



ここで私はジョイとスワーガーに出会いました。 記述されたJoiスキームをSwaggerドキュメントに変換し、さらにTypeScriptをサポートできる優れたパッケージ。 同期を除くすべてのアイテムが実行されます。 しばらく急いで、Koaフレームワークと一緒にjoi-to-swaggerを使用していた中国人の放棄されたリポジトリを見つけました。 私たちのチームにはKoaに対する偏見はなく、Expressトレンドに盲目的に追従する理由もなかったため、このスタックで離陸することを決定しました。



私はこのリポジトリをフォークし、バグを修正し、いくつかのことを完了しました。そして今、OpenSource Koa-Joi-Swagger-TSへの私の最初の貢献がリリースされました。 私たちはそのプロジェクトに合格し、その後、すでにいくつかのプロジェクトがありました。 RESTサービスの記述と保守が非常に便利になり、これらのサービスのユーザーはSwaggerオンラインドキュメントへのリンクのみを必要とします。 その後、このパッケージを開発できる場所が明らかになり、さらにいくつかの改善が行われました。



Koa-Joi-Swagger-TSを使用して、自己文書化RESTサーバーを作成する方法を見てみましょう。 完成したコードをここに投稿しました



このプロジェクトはデモなので、いくつかのファイルを単純化して1つにマージしました。 一般に、インデックスがアプリケーションを初期化し、app.tsファイルを呼び出すと、リソースが読み取られ、データベースに接続するための呼び出しなどが行われます。 サーバーは最後のコマンドで開始する必要があります(以下で説明する内容のみ)。



したがって、 まず最初に 、このコンテンツでindex.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を作成します。



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クラスのインスタンスを作成します。これは、本質的にコアルーターですが、ミドルウェアとハ​​ンドラーが追加されています。



したがって、 index.tsファイルでは、単に変更します



 const router = new Router();
      
      









 const router = loadRoutes();
      
      





さて、不要なハンドラーを削除します。



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







このサービスを開始すると、3つのルートが利用可能になります。

1. / api / v1-文書化されたルート

私の場合、これは示されています:



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







そして2つのサービスルート:



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



これはSwagger UIを備えたページです。これはSwaggerスキームを非常に便利に視覚的に表現したもので、見やすいだけでなく、サーバーからリクエストを生成して実際の回答を取得することもできます。







このUIにはswagger.jsonファイルへのアクセスが必要であるため、以前のルートが含まれていました。



まあ、すべてがそこにあるようで、すべてが機能しますが、!..



時間が経つにつれて、そのような実装では、かなり多くのコードの重複があることを丸で囲みました。 コントローラーが同じことをする必要がある場合。 このため、後でパッケージを完成させ、コントローラーの「ラッパー」を記述する機能を追加しました。



そのようなサービスの例を考えてみましょう。



いくつかのメソッドを持つ「Users」コントローラーがあるとします。



すべてのユーザーを取得する
  @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); };
      
      







ご覧のとおり、3つのコントローラーメソッドには重複したコードが含まれています。 そのような場合のために、私たちは今この機会を利用しています。



最初に、例えば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-to-swaggerによって制限されているため)が、それでも十分です。



現在、ドキュメントは常にオンラインであり、常にコードに厳密に対応しています。これが主なものです。

他にどんなアイデアがありますか?..



エクスプレスサポートを追加することはできますか?

読んだばかりです。



エンティティを1か所で一度記述するのは本当にクールです。 回路とインターフェースの両方を編集する必要があるためです。



たぶん、あなたはいくつかの興味深いアイデアを持っているでしょう。 より良いまだプルリクエスト:)

貢献者へようこそ。



All Articles