Beaucoup d'articles ont déjà été écrits sur les avantages et les inconvénients de REST (et encore plus dans les commentaires qui leur sont adressés)). Et s'il arrivait que vous deviez développer un service dans lequel cette architecture devrait être appliquée, vous rencontrerez certainement sa documentation. En effet, lors de la création de chaque méthode, nous comprenons certainement que d'autres programmeurs se référeront à ces méthodes. Par conséquent, la documentation doit être complète et, surtout, pertinente.
Bienvenue au chat, où je vais décrire comment nous avons résolu ce problème dans notre équipe.
Un peu de contexte.
Notre équipe a été chargée d'émettre un produit backend sur
Node.js de complexité moyenne en peu de temps. Les programmeurs et mobilisateurs frontaux étaient censés interagir avec ce produit.
Après réflexion, nous avons décidé d'essayer d'utiliser
TypeScript comme
YaP .
TSLint et
Prettier bien ajustés nous ont aidés à obtenir le même style de code et un contrôle serré au stade du codage / assemblage (et
husky même au stade de la validation). Un typage fort a conduit tout le monde à décrire clairement les interfaces et les types de tous les objets. Il est devenu facile de lire et de comprendre ce que cette fonction prend exactement comme paramètre d'entrée, ce qu'elle renverra éventuellement et lesquelles des propriétés de l'objet sont obligatoires et lesquelles ne le sont pas. Le code a commencé à ressembler à Java à peu près). Et bien sûr,
TypeDoc a ajouté la lisibilité à chaque fonction.
Voici à quoi ressemblait le code:
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; } }
Nous avons pensé aux descendants, il ne sera pas difficile de maintenir notre code, il est temps de penser aux utilisateurs de notre serveur REST.
Comme tout a été fait assez rapidement, nous avons compris que l'écriture de code séparément et la documentation séparément pour lui seraient très difficiles. Surtout ajoutez des paramètres supplémentaires aux réponses ou aux demandes selon les exigences du front-end ou du mobilchiki et n'oubliez pas d'en avertir les autres. C'est là qu'une exigence claire est apparue: le
code avec la documentation doit toujours être synchronisé . Cela signifiait que le facteur humain devait être exclu et que la documentation devait influencer le code et que le code devait affecter la documentation.
Ici, je me suis plongé dans la recherche d'outils appropriés pour cela. Heureusement, le référentiel NPM n'est qu'un entrepôt de toutes sortes d'idées et de solutions.
Les exigences de l'outil étaient les suivantes:
- Synchronisation de la documentation avec le code;
- Prise en charge de TypeScript;
- Validation des paquets entrants / sortants;
- Package en direct et pris en charge.
J'ai dû écrire sur un service REST en utilisant de nombreux packages différents, dont les plus populaires sont: tsoa, swagger-node-express, express-openapi, swagger-codegen.

Mais dans certains cas, il n'y avait pas de prise en charge de TypeScript, dans certaines validations de packages, et certains ont pu générer du code basé sur la documentation, mais ils n'ont pas fourni de synchronisation supplémentaire.
C'est là que je suis tombé sur joi-to-swagger. Un excellent package qui peut transformer le décrit dans le schéma Joi en documentation swagger et même avec le support TypeScript. Tous les éléments sont exécutés à l'exception de la synchronisation. Après une précipitation pendant un certain temps, j'ai trouvé un référentiel abandonné d'un Chinois qui utilisait le
joi-to-swagger en conjonction avec le framework Koa. Puisqu'il n'y avait aucun préjugé contre Koa dans notre équipe, et qu'il n'y avait aucune raison de suivre aveuglément la tendance Express, nous avons décidé d'essayer de décoller sur cette pile.
J'ai bifurqué ce dépôt, corrigé des bugs, terminé certaines choses, et maintenant ma première contribution à OpenSource Koa-Joi-Swagger-TS est sortie. Nous avons réussi ce projet et après il y en avait déjà plusieurs autres. Il est devenu très pratique d'écrire et de maintenir des services REST, et les utilisateurs de ces services n'ont besoin que d'un lien vers la documentation en ligne de Swagger. Après eux, il est devenu clair où ce paquet peut être développé et il a subi plusieurs autres améliorations.
Voyons maintenant comment utiliser
Koa-Joi-Swagger-TS pour écrire un serveur REST auto-documenté.
J'ai posté le code fini ici .
Comme ce projet est une démo, j'ai simplifié et fusionné plusieurs fichiers en un seul. En général, il est bon que l'index initialise l'application et appelle le fichier app.ts, qui à son tour lira les ressources, les appels pour se connecter à la base de données, etc. Le serveur doit démarrer avec la dernière commande (exactement ce qui sera maintenant décrit ci-dessous).
Donc, pour commencer, créez
index.ts avec ce contenu:
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} ...`); })();
Lorsque vous démarrez ce service, un serveur REST sera levé, qui jusqu'à présent ne sait pas comment. Maintenant, un peu sur l'architecture du projet. Depuis que je suis passé à Node.JS à partir de Java, j'ai essayé de créer un service avec les mêmes couches ici.
- Contrôleurs
- Les services
- Dépôts
Commençons à connecter
Koa-Joi-Swagger-TS . Installez-le naturellement.
npm install koa-joi-swagger-ts --save
Créez-y le dossier
«contrôleurs» et le dossier
«schémas» . Dans le dossier contrôleurs, créez notre premier contrôleur
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" } } }; }
Comme vous pouvez le voir sur les décorateurs (annotations en Java), cette classe sera associée au chemin «/ api / v1», toutes les méthodes à l'intérieur seront relatives à ce chemin.
Cette méthode a une description du format de réponse, qui est décrit dans le fichier "./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(); }
Les possibilités d'une telle description du programme à Joi sont très étendues et décrites plus en détail ici:
www.npmjs.com/package/joi-to-swaggerEt voici l'ancêtre de la classe décrite (en fait c'est la classe de base pour toutes les réponses de notre service):
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"); }
Enregistrez maintenant ces circuits et contrôleurs dans le système Koa-Joi-Swagger-TS.
À côté de index.ts, créez un autre fichier
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(); };
Ici, nous créons une instance de la classe KJSRouter, qui est essentiellement un routeur Koa, mais avec des middlewares et des gestionnaires ajoutés.
Par conséquent, dans le fichier
index.ts ,
nous modifions simplement
const router = new Router();
sur
const router = loadRoutes();
Eh bien, supprimez le gestionnaire inutile:
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} ...`); })();
Lorsque vous démarrez ce service, 3 itinéraires s'offrent à nous:
1. / api / v1 - itinéraire documenté
Ce qui dans mon cas est montré:
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" } }
Et deux itinéraires de service:
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 / docsCette page avec l'interface utilisateur Swagger est une représentation visuelle très pratique du schéma Swagger, dans laquelle, en plus d'être pratique à voir, vous pouvez même générer des demandes et obtenir de vraies réponses du serveur.

Cette interface utilisateur nécessite l'accès au fichier swagger.json, c'est pourquoi la route précédente a été incluse.
Eh bien, tout semble être là et tout fonctionne, mais! ..
Au fil du temps, nous avons encerclé que dans une telle implémentation, nous avons beaucoup de duplication de code. Dans le cas où les contrôleurs doivent faire la même chose. C'est à cause de cela que j'ai finalisé le package et ajouté la possibilité de décrire le «wrapper» pour les contrôleurs.
Prenons un exemple d'un tel service.
Supposons que nous ayons un contrôleur «Utilisateurs» avec plusieurs méthodes.
Obtenez tous les utilisateurs @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); };
Mettre à jour l'utilisateur @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); };
Insérer un utilisateur @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); };
Comme vous pouvez le voir, les trois méthodes de contrôleur contiennent du code en double. C'est pour de tels cas que nous utilisons maintenant cette opportunité.
Créez d'abord une fonction wrapper, par exemple, directement dans le fichier
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); };
Connectez-le ensuite à notre contrôleur.
Remplacer
router.loadController(UserController);
sur
router.loadController(UserController, controllerDecorator);
Eh bien, simplifions nos méthodes de contrôleur
Contrôleur utilisateur @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; } };
Dans ce
contrôleurDécorateur, vous pouvez ajouter toute logique de vérifications ou journaux détaillés des entrées / sorties.
J'ai posté le code fini ici .
Maintenant, nous avons presque CRUD prêt. La suppression peut être écrite par analogie. En fait, maintenant pour écrire un nouveau contrôleur, nous devons:
- Créer un fichier contrôleur
- Ajoutez-le à routing.ts
- Décrire les méthodes
- Dans chaque méthode, utilisez des circuits d'entrée / sortie
- Décrire ces schémas
- Connectez ces schémas dans routing.ts
Si le paquet entrant ne correspond pas au schéma, l'utilisateur de notre service REST recevra une erreur 400 avec une description de ce qui est exactement faux. Si le paquet sortant n'est pas valide, une erreur 500 sera générée.
Eh bien et toujours comme une bagatelle agréable. Dans Swagger UI, vous pouvez utiliser la fonctionnalité «
Try it out » sur n'importe quelle méthode. Une demande sera générée via curl à votre service en cours d'exécution, et bien sûr, vous pouvez voir le résultat tout de suite. Et juste pour cela, il est très pratique de décrire le paramètre «
exemple » dans le circuit. Parce que la demande sera générée immédiatement avec un package prêt à l'emploi basé sur les exemples décrits.

Conclusions
Très pratique et utile à la fin, la chose s'est avérée. Au début, ils ne voulaient pas valider les paquets sortants, mais avec l'aide de cette validation, ils ont détecté plusieurs bugs importants de leur côté. Bien sûr, vous ne pouvez pas utiliser pleinement toutes les fonctionnalités de Joi (car nous sommes limités par joi-to-swagger), mais celles qui suffisent.
Maintenant, la documentation est toujours en ligne et correspond toujours strictement au code - et c'est l'essentiel.
Quelles autres idées y a-t-il? ..
Est-il possible d'ajouter un support express?
Je viens de le lire .
Ce serait vraiment cool de décrire des entités une fois au même endroit. Parce que maintenant, il est nécessaire de modifier les circuits et les interfaces.
Vous aurez peut-être quelques idées intéressantes. Mieux encore, tirer des demandes :)
Bienvenue aux contributeurs.