Mendokumentasikan server REST (Node.JS, TypeScript, Koa, Joi, Swagger)


Cukup banyak artikel yang telah ditulis tentang kelebihan dan kekurangan REST (dan bahkan lebih banyak di komentar untuk mereka)). Dan jika kebetulan Anda harus mengembangkan layanan di mana arsitektur ini harus diterapkan, maka Anda pasti akan menemukan dokumentasinya. Bagaimanapun, menciptakan setiap metode, kita tentu mengerti bahwa programmer lain akan merujuk pada metode ini. Oleh karena itu, dokumentasi harus komprehensif, dan yang paling penting - relevan.

Selamat datang di kucing, di mana saya akan menjelaskan bagaimana kami memecahkan masalah ini di tim kami.

Sedikit konteks.

Tim kami ditugaskan untuk mengeluarkan produk backend pada Node.js kompleksitas sedang dalam waktu singkat. Pemrogram dan penggerak Frontend seharusnya berinteraksi dengan produk ini.

Setelah beberapa pemikiran, kami memutuskan untuk mencoba menggunakan TypeScript sebagai YaP . TSLint dan Prettier yang disetel dengan baik membantu kami mencapai gaya kode yang sama dan pemeriksaan ketat pada tahap pengkodean / perakitan (dan husky bahkan pada tahap komit). Pengetikan yang kuat membuat semua orang dengan jelas menggambarkan antarmuka dan jenis semua objek. Telah menjadi mudah untuk membaca dan memahami apa sebenarnya fungsi ini sebagai parameter input, apa yang akhirnya akan kembali, dan properti mana dari objek yang wajib dan mana yang tidak. Kode mulai menyerupai Java cukup banyak). Dan tentu saja, TypeDoc menambahkan keterbacaan ke setiap fungsi.

Ini adalah bagaimana kode mulai terlihat:

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

Kami berpikir tentang keturunan, tidak akan sulit untuk mempertahankan kode kami, sekarang saatnya untuk memikirkan pengguna server REST kami.

Karena semuanya dilakukan dengan sangat cepat, kami memahami bahwa menulis kode secara terpisah dan dokumentasi secara terpisah akan sangat sulit. Terutama menambahkan parameter tambahan untuk jawaban atau permintaan sesuai dengan persyaratan front-end atau mobilchiki dan jangan lupa untuk memperingatkan orang lain tentang hal itu. Di sinilah muncul persyaratan yang jelas: kode dengan dokumentasi harus selalu disinkronkan . Ini berarti bahwa faktor manusia harus dikecualikan dan dokumentasi harus memengaruhi kode, dan kode harus memengaruhi dokumentasi.

Di sini saya menggali mencari alat yang cocok untuk ini. Untungnya, repositori NPM hanyalah gudang dari semua jenis ide dan solusi.

Persyaratan untuk alat ini adalah sebagai berikut:

  • Sinkronisasi dokumentasi dengan kode;
  • Dukungan TypeScript;
  • Validasi paket masuk / keluar;
  • Paket langsung dan didukung.

Saya harus menulis di layanan REST menggunakan banyak paket berbeda, yang paling populer adalah: tsoa, ​​swagger-node-express, express-openapi, swagger-codegen.



Tetapi di beberapa tidak ada dukungan TypeScript, di beberapa validasi paket, dan beberapa mampu menghasilkan kode berdasarkan dokumentasi, tetapi mereka tidak menyediakan sinkronisasi lebih lanjut.

Di sinilah saya menemukan joi-ke-angkuh. Paket hebat yang dapat mengubah skema yang dijelaskan dalam Joi menjadi dokumentasi swagger dan bahkan dengan dukungan TypeScript. Semua item dieksekusi kecuali untuk sinkronisasi. Setelah tergesa-gesa selama beberapa waktu, saya menemukan repositori Cina yang ditinggalkan yang menggunakan joi-to-swagger dalam hubungannya dengan kerangka kerja Koa. Karena tidak ada prasangka terhadap Koa di tim kami, dan tidak ada alasan untuk secara membabi buta mengikuti tren Express, kami memutuskan untuk mencoba lepas landas di tumpukan ini.

Saya memotong repositori ini, memperbaiki bug, menyelesaikan beberapa hal, dan sekarang kontribusi pertama saya ke OpenSource Koa-Joi-Swagger-TS dirilis. Kami berhasil melewati proyek itu dan setelah itu sudah ada beberapa yang lain. Menjadi sangat mudah untuk menulis dan memelihara layanan REST, dan pengguna layanan ini tidak memerlukan apa pun selain tautan ke dokumentasi online Swagger. Setelah mereka, menjadi jelas di mana paket ini dapat dikembangkan dan telah mengalami beberapa perbaikan lagi.

Sekarang mari kita lihat bagaimana menggunakan Koa-Joi-Swagger-TS Anda dapat menulis server REST yang mendokumentasikan diri. Saya memposting kode selesai di sini .

Karena proyek ini adalah demo, saya menyederhanakan dan menggabungkan beberapa file menjadi satu. Secara umum, ada baiknya jika indeks menginisialisasi aplikasi dan memanggil file app.ts, yang pada gilirannya akan membaca sumber daya, panggilan untuk terhubung ke database, dll. Server harus memulai dengan perintah terbaru (seperti yang sekarang akan dijelaskan di bawah).

Jadi, sebagai permulaan, buat index.ts dengan konten ini:

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



Ketika Anda memulai layanan ini, server REST akan dinaikkan, yang sejauh ini tidak tahu caranya. Sekarang sedikit tentang arsitektur proyek. Karena saya beralih ke Node.JS dari Jawa, saya mencoba membangun layanan dengan lapisan yang sama di sini.

  • Pengontrol
  • Layanan
  • Repositori

Mari kita mulai menghubungkan Koa-Joi-Swagger-TS . Instal secara alami.

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

Buat folder "controllers" dan folder "schemas" di dalamnya. Di folder controllers, buat base.controller.ts controller pertama kami:

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


Seperti yang dapat Anda lihat dari dekorator (anotasi di Jawa), kelas ini akan dikaitkan dengan jalur "/ api / v1", semua metode di dalamnya akan relatif terhadap jalur ini.

Metode ini memiliki deskripsi format respons, yang dijelaskan dalam file "./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(); } 


Kemungkinan uraian skema seperti ini di Joi sangat luas dan dijelaskan secara lebih rinci di sini: www.npmjs.com/package/joi-to-swagger

Dan inilah leluhur dari kelas yang dijelaskan (sebenarnya ini adalah kelas dasar untuk semua jawaban dari layanan kami):

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


Sekarang daftarkan sirkuit dan pengontrol ini dalam sistem Koa-Joi-Swagger-TS.
Di sebelah index.ts, buat file routing.ts lain:

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


Di sini kita membuat instance dari kelas KJSRouter, yang pada dasarnya adalah router Koa, tetapi dengan middlewares dan handler ditambahkan.

Oleh karena itu, dalam file index.ts kita cukup mengubah

 const router = new Router(); 

pada

 const router = loadRoutes(); 

Ya, hapus handler yang tidak perlu:

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


Saat Anda memulai layanan ini, 3 rute tersedia untuk kami:
1. / api / v1 - rute yang didokumentasikan
Yang dalam kasus saya ditampilkan:

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


Dan dua rute layanan:

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

Ini adalah halaman dengan UI Swagger - ini adalah representasi visual yang sangat nyaman dari skema Swagger, di mana, selain nyaman untuk dilihat, Anda bahkan dapat menghasilkan permintaan dan mendapatkan jawaban nyata dari server.



UI ini memerlukan akses ke file swagger.json, itulah sebabnya rute sebelumnya dimasukkan.

Yah, semuanya tampaknya ada di sana dan semuanya berfungsi, tapi! ..

Seiring waktu, kami mengitari bahwa dalam implementasi seperti itu, kami memiliki banyak duplikasi kode. Dalam kasus ketika pengendali perlu melakukan hal yang sama. Karena hal inilah saya kemudian menyelesaikan paket dan menambahkan kemampuan untuk menggambarkan "pembungkus" untuk pengontrol.

Pertimbangkan contoh layanan semacam itu.

Misalkan kita memiliki pengontrol "Pengguna" dengan beberapa metode.

Dapatkan semua pengguna
  @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); }; 


Perbarui pengguna
  @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); }; 


Sisipkan pengguna
  @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); }; 


Seperti yang Anda lihat, tiga metode pengontrol berisi kode duplikat. Untuk kasus-kasus seperti itulah kita sekarang menggunakan kesempatan ini.

Pertama, buat fungsi wrapper, misalnya, langsung di file 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); }; 

Kemudian hubungkan ke pengontrol kami.

Ganti

 router.loadController(UserController); 

pada

 router.loadController(UserController, controllerDecorator); 

Baiklah, mari sederhanakan metode pengontrol kami

Pengontrol pengguna
  @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; } }; 


Dalam controllerDecorator ini , Anda dapat menambahkan logika pemeriksaan atau log terperinci dari input / output.

Saya memposting kode selesai di sini .

Sekarang kita sudah hampir siap CRUD. Hapus dapat ditulis dengan analogi. Bahkan, sekarang untuk menulis controller baru, kita harus:

  1. Buat file pengontrol
  2. Tambahkan ke routing.ts
  3. Jelaskan metode
  4. Dalam setiap metode, gunakan sirkuit input / output
  5. Jelaskan skema ini
  6. Hubungkan skema ini di routing.ts

Jika paket yang masuk tidak sesuai dengan skema, pengguna layanan REST kami akan menerima 400 kesalahan dengan deskripsi tentang apa yang sebenarnya salah. Jika paket keluar tidak valid, maka kesalahan 500 akan dihasilkan.

Baik dan masih sebagai hal yang menyenangkan. Di Swagger UI, Anda dapat menggunakan fungsionalitas " Cobalah " pada metode apa pun. Permintaan akan dihasilkan melalui curl ke layanan Anda yang sedang berjalan, dan tentu saja Anda dapat melihat hasilnya segera. Dan hanya untuk ini, sangat mudah untuk menggambarkan parameter " contoh " di sirkuit. Karena permintaan akan dihasilkan segera dengan paket yang sudah jadi berdasarkan contoh yang dijelaskan.



Kesimpulan


Sangat nyaman dan bermanfaat pada akhirnya, hal itu ternyata. Pada awalnya, mereka tidak ingin memvalidasi paket keluar, tetapi kemudian dengan bantuan validasi ini mereka menangkap beberapa bug signifikan di pihak mereka. Tentu saja, Anda tidak dapat sepenuhnya menggunakan semua fitur Joi (karena kami dibatasi oleh joi-to-swagger), tetapi yang cukup memadai.

Sekarang dokumentasi selalu online dan selalu benar-benar sesuai dengan kode - dan ini adalah hal utama.
Apa gagasan lain yang ada?

Apakah mungkin menambahkan dukungan ekspres?
Saya baru saja membacanya .

Akan sangat keren untuk menggambarkan entitas sekali di satu tempat. Karena sekarang perlu mengedit sirkuit dan antarmuka.

Mungkin Anda akan memiliki beberapa ide menarik. Permintaan yang Lebih Baik dan Menarik :)
Selamat datang di kontributor.

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


All Articles