Selbstdokumentierender REST-Server (Node.JS, TypeScript, Koa, Joi, Swagger)


Es wurden bereits viele Artikel über die Vor- und Nachteile von REST geschrieben (und noch mehr in den Kommentaren dazu). Und wenn es so passiert ist, dass Sie einen Dienst entwickeln müssen, in dem diese Architektur angewendet werden soll, werden Sie sicherlich auf seine Dokumentation stoßen. Schließlich verstehen wir beim Erstellen jeder Methode, dass andere Programmierer auf diese Methoden verweisen. Daher sollte die Dokumentation umfassend und vor allem relevant sein.

Willkommen bei der Katze, wo ich beschreiben werde, wie wir dieses Problem in unserem Team gelöst haben.

Ein bisschen Kontext.

Unser Team wurde beauftragt, in kurzer Zeit ein Backend-Produkt mittlerer Komplexität auf Node.js herauszugeben. Frontend-Programmierer und Mobilisierer sollten mit diesem Produkt interagieren.

Nach einigem Überlegen haben wir uns entschlossen, TypeScript als YaP zu verwenden . Gut abgestimmte TSLint und Prettier haben uns geholfen, den gleichen Codestil und die genaue Überprüfung in der Codierungs- / Montagephase (und sogar in der Commit-Phase) zu erreichen. Starke Typisierung führte dazu, dass jeder die Schnittstellen und Typen aller Objekte klar beschrieb. Es ist leicht zu lesen und zu verstehen, was genau diese Funktion als Eingabeparameter verwendet, was sie schließlich zurückgibt und welche der Eigenschaften des Objekts obligatorisch sind und welche nicht. Der Code ähnelte ziemlich stark Java. Und natürlich hat TypeDoc jeder Funktion mehr Lesbarkeit verliehen.

So sah der Code aus:

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

Wir haben über die Nachkommen nachgedacht, es wird nicht schwierig sein, unseren Code zu pflegen, es ist Zeit, über die Benutzer unseres REST-Servers nachzudenken.

Da alles ziemlich schnell erledigt war, haben wir verstanden, dass es sehr schwierig sein würde, Code separat und Dokumentation separat zu schreiben. Fügen Sie Antworten oder Anfragen entsprechend den Anforderungen von Front-End oder Mobilchiki zusätzliche Parameter hinzu und vergessen Sie nicht, andere davor zu warnen. Hier zeigte sich eine klare Anforderung: Der Code mit der Dokumentation sollte immer synchronisiert werden . Dies bedeutete, dass der Faktor Mensch ausgeschlossen werden sollte und die Dokumentation den Code beeinflussen sollte und der Code die Dokumentation beeinflussen sollte.

Hier habe ich mich mit der Suche nach geeigneten Werkzeugen dafür befasst. Glücklicherweise ist das NPM-Repository nur ein Lagerhaus für alle Arten von Ideen und Lösungen.

Die Anforderungen an das Werkzeug waren wie folgt:

  • Dokumentationssynchronisation mit Code;
  • TypeScript-Unterstützung;
  • Validierung eingehender / ausgehender Pakete;
  • Live und unterstütztes Paket.

Ich musste über einen REST-Service mit vielen verschiedenen Paketen schreiben, von denen die beliebtesten sind: tsoa, ​​swagger-node-express, express-openapi, swagger-codegen.



In einigen Fällen gab es jedoch keine TypeScript-Unterstützung, in einigen Fällen eine Paketvalidierung, und in einigen Fällen konnte Code basierend auf der Dokumentation generiert werden, es wurde jedoch keine weitere Synchronisierung bereitgestellt.

Hier bin ich auf Joi-to-Swagger gestoßen. Ein großartiges Paket, das das in Joi beschriebene Schema in eine Prahlerdokumentation und sogar mit TypeScript-Unterstützung verwandeln kann. Alle Elemente außer der Synchronisation werden ausgeführt. Ich eilte einige Zeit und fand ein verlassenes Depot eines Chinesen, der das Joi-to-Swagger in Verbindung mit dem Koa-Framework verwendete. Da es in unserem Team keine Vorurteile gegen Koa gab und es keine Gründe gab, blind dem Express-Trend zu folgen, haben wir uns entschlossen, auf diesem Stack zu starten.

Ich habe dieses Repository gegabelt, Fehler behoben, einige Dinge erledigt, und jetzt wurde mein erster Beitrag zu OpenSource Koa-Joi-Swagger-TS veröffentlicht. Wir haben dieses Projekt erfolgreich bestanden und danach gab es bereits mehrere andere. Das Schreiben und Verwalten von REST-Diensten ist sehr praktisch geworden, und Benutzer dieser Dienste benötigen lediglich einen Link zur Online-Dokumentation von Swagger. Danach wurde klar, wo dieses Paket entwickelt werden kann, und es wurden mehrere weitere Verbesserungen vorgenommen.

Nun wollen wir sehen, wie Sie mit Koa-Joi-Swagger-TS einen selbstdokumentierenden REST-Server schreiben können. Ich habe den fertigen Code hier gepostet .

Da es sich bei diesem Projekt um eine Demo handelt, habe ich mehrere Dateien vereinfacht und zu einer zusammengeführt. Im Allgemeinen ist es gut, wenn der Index die Anwendung initialisiert und die Datei app.ts aufruft, die wiederum Ressourcen, Aufrufe zur Verbindung mit der Datenbank usw. liest. Der Server sollte mit dem neuesten Befehl beginnen (genau das, was jetzt unten beschrieben wird).

Erstellen Sie zunächst index.ts mit folgendem Inhalt:

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



Wenn Sie diesen Dienst starten, wird ein REST-Server ausgelöst, der bisher nicht weiß, wie. Nun ein wenig zur Architektur des Projekts. Da ich von Java zu Node.JS gewechselt bin, habe ich versucht, hier einen Dienst mit denselben Ebenen zu erstellen.

  • Controller
  • Dienstleistungen
  • Repositories

Beginnen wir mit der Verbindung von Koa-Joi-Swagger-TS . Natürlich installieren.

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

Erstellen Sie den Ordner "Controller" und den Ordner "Schemas" . Erstellen Sie im Controller-Ordner unseren ersten Controller 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" } } }; } 


Wie Sie den Dekorateuren (Anmerkungen in Java) entnehmen können, wird diese Klasse dem Pfad "/ api / v1" zugeordnet. Alle darin enthaltenen Methoden sind relativ zu diesem Pfad.

Diese Methode enthält eine Beschreibung des Antwortformats, das in der Datei "./schemas/apiInfo.response.schema" beschrieben ist:

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


Die Möglichkeiten einer solchen Beschreibung des Schemas in Joi sind sehr umfangreich und werden hier ausführlicher beschrieben: www.npmjs.com/package/joi-to-swagger

Und hier ist der Vorfahr der beschriebenen Klasse (tatsächlich ist dies die Basisklasse für alle Antworten unseres Dienstes):

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


Registrieren Sie nun diese Schaltkreise und Steuerungen im Koa-Joi-Swagger-TS-System.
Erstellen Sie neben index.ts eine weitere routing.ts- Datei:

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


Hier erstellen wir eine Instanz der KJSRouter-Klasse, bei der es sich im Wesentlichen um einen Koa-Router handelt, wobei jedoch Middleware und Handler hinzugefügt wurden.

Daher ändern wir in der Datei index.ts einfach

 const router = new Router(); 

auf

 const router = loadRoutes(); 

Löschen Sie den unnötigen Handler:

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


Wenn Sie diesen Dienst starten, stehen uns 3 Routen zur Verfügung:
1. / api / v1 - dokumentierte Route
Was in meinem Fall gezeigt wird:

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


Und zwei Servicerouten:

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

Diese Seite mit der Swagger-Benutzeroberfläche ist eine sehr praktische visuelle Darstellung des Swagger-Schemas, in der Sie nicht nur bequem sehen, sondern auch Anfragen generieren und echte Antworten vom Server erhalten können.



Diese Benutzeroberfläche erfordert Zugriff auf die Datei swagger.json, weshalb die vorherige Route enthalten war.

Nun, alles scheint da zu sein und alles funktioniert, aber! ..

Im Laufe der Zeit haben wir festgestellt, dass in einer solchen Implementierung viele Codes dupliziert werden. In dem Fall, in dem die Steuerungen dasselbe tun müssen. Aus diesem Grund habe ich das Paket später fertiggestellt und die Möglichkeit hinzugefügt, den „Wrapper“ für die Controller zu beschreiben.

Betrachten Sie ein Beispiel für einen solchen Dienst.

Angenommen, wir haben einen "Benutzer" -Controller mit mehreren Methoden.

Holen Sie sich alle Benutzer
  @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); }; 


Benutzer aktualisieren
  @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); }; 


Benutzer einfügen
  @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); }; 


Wie Sie sehen können, enthalten die drei Controller-Methoden doppelten Code. In solchen Fällen nutzen wir jetzt diese Gelegenheit.

Erstellen Sie zunächst eine Wrapper-Funktion, z. B. direkt in der Datei 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); }; 

Schließen Sie es dann an unseren Controller an.

Ersetzen

 router.loadController(UserController); 

auf

 router.loadController(UserController, controllerDecorator); 

Lassen Sie uns unsere Controller-Methoden vereinfachen

Benutzersteuerung
  @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; } }; 


In diesem controllerDecorator können Sie eine beliebige Logik von Überprüfungen oder detaillierte Protokolle von Ein- / Ausgängen hinzufügen.

Ich habe den fertigen Code hier gepostet .

Jetzt haben wir fast CRUD fertig. Löschen kann analog geschrieben werden. Um einen neuen Controller zu schreiben, müssen wir:

  1. Controller-Datei erstellen
  2. Fügen Sie es zu routing.ts hinzu
  3. Beschreiben Sie Methoden
  4. Verwenden Sie bei jeder Methode Eingangs- / Ausgangsschaltungen
  5. Beschreiben Sie diese Schemata
  6. Verbinden Sie diese Schemata in routing.ts

Wenn das eingehende Paket nicht mit dem Schema übereinstimmt, erhält der Benutzer unseres REST-Dienstes einen 400-Fehler mit einer Beschreibung dessen, was genau falsch ist. Wenn das ausgehende Paket ungültig ist, wird ein 500-Fehler generiert.

Gut und still als angenehme Kleinigkeit. In der Swagger-Benutzeroberfläche können Sie die Funktion " Ausprobieren " für jede Methode verwenden. Eine Anfrage wird per Curl an Ihren laufenden Dienst generiert, und natürlich können Sie das Ergebnis sofort sehen. Und gerade dafür ist es sehr praktisch, den Parameter „ Beispiel “ in der Schaltung zu beschreiben. Weil die Anfrage sofort mit einem vorgefertigten Paket basierend auf den beschriebenen Beispielen generiert wird.



Schlussfolgerungen


Am Ende sehr praktisch und nützlich, stellte sich heraus. Zuerst wollten sie ausgehende Pakete nicht validieren, aber dann haben sie mit Hilfe dieser Validierung mehrere signifikante Fehler auf ihrer Seite entdeckt. Natürlich können Sie nicht alle Funktionen von Joi vollständig nutzen (da wir durch Joi-to-Swagger eingeschränkt sind), aber diejenigen, die völlig ausreichen.

Jetzt ist die Dokumentation immer online und entspricht immer genau dem Code - und das ist die Hauptsache.
Welche anderen Ideen gibt es?

Ist es möglich, Express-Support hinzuzufügen?
Ich habe es gerade gelesen .

Es wäre wirklich cool, Entitäten einmal an einem Ort zu beschreiben. Denn jetzt müssen sowohl Schaltkreise als auch Schnittstellen bearbeitet werden.

Vielleicht haben Sie einige interessante Ideen. Besser noch Pull Requests :)
Willkommen bei den Mitwirkenden.

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


All Articles