
Про преимущества и недостатки REST написано уже довольно много статей (и еще больше в комментариях к ним) ). И если уж так вышло, что вам предстоит разработать сервис, в котором должна быть применена именно эта архитектура, то вы обязательно столкнетесь с ее документированием. Ведь, создавая каждый метод, мы конечно же понимаем, что другие программисты будут к этим методам обращаться. Поэтому документация должна быть исчерпывающей, а главное — актуальной.
Добро пожаловать под кат, где я опишу, как мы решали эту задачу в нашей команде.
Немного контекста.
У нашей команды была поставлена задача в короткий срок выдать бэкэнд продукт на Node.js средней сложности. С данным продуктом должны были взаимодействовать фронтэнд программисты и мобильщики.
После некоторых размышлений мы решили попробовать использовать в качестве ЯП TypeScript. Грамотно настроенный TSLint и Prettier помогли нам добиться одинакового стиля кода и жесткой его проверки на этапе кодинга/сборки (а husky даже на этапе коммита). Строгая типизация принудила всех описать четко интерфейсы и типы всех объектов. Стало легко читать и понимать что именно принимает входящим параметром данная функция, что она в итоге вернет и какие из свойств объекта обязательные, а какие нет. Код довольно сильно стал напоминать 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 серве��а.
Так как делалось все довольно стремительно, мы понимали, что писать отдельно код и отдельно к нему документацию будет весьма сложно. Особенно добавлять доп параметры в ответы или запросы по требованиям фронэндщиков или мобильщиков и не забывать предупреждать об этом других. Вот тут и появилось четкое требование: код с документацией должен быть синхронизирован всегда. Это означало, что человеческий фактор должен быть исключен и документация должна влиять на код, а код на документацию.
Вот тут я углубился в поиск подходящих для этого инструментов. Благо, что NPM репозиторий — это просто кладезь всевозможных идей и решений.
Требования для инструмента были следующие:
- Синхронизация документации с кодом;
- Поддержка TypeScript;
- Валидация входящих/исходящих пакетов;
- Живой и поддерживаемый пакет.
Пришлось написать по REST сервису с использованием многих разных пакетов, самые популярные из которых: tsoa, swagger-node-express, express-openapi, swagger-codegen.

Но в некоторых не было поддержки TypeScript, в некоторых валидации пакетов, а некоторые умели генерить код на основании документации, но дальнейшей синхронизации уже не обеспечивали.
Вот тут я и наткнулся на joi-to-swagger. Отличный пакет, который умеет описанную в Joi схему превращать в swagger документацию да еще и с поддержкой TypeScript. Все пункты выполнены кроме синхронизации. Порыв еще какое-то время, я нашел заброшенный репозиторий одного китайца, который использовал joi-to-swagger в связке с Koa фреймворком. Так как предубеждений против Koa в нашей команде не было, а слепо следовать Express тренду причин тоже не было, решили попробовать взлететь на этом стеке.
Я форкнул этот репозиторий, пофиксил баги, доделал некоторые штуки и вот вышел в свет мой первый вклад в OpenSource Koa-Joi-Swagger-TS. Тот проект мы успешно сдали и после него уже было несколько других. REST сервисы стало писать и поддерживать очень удобно, а пользователям этих сервисов ничего не нужно кроме ссылки на онлайн документацию Swagger. После них стало видно куда можно развивать этот пакет и он претерпел еще несколько доработок.
Теперь давайте посмотрим как с использованием Koa-Joi-Swagger-TS можно написать самодокументируемый REST сервер. Готовый код я выложил тут.
Так как этот проект демонстрационный, я упростил и слил несколько файлов в один. Вообще хорошо, если в индексе будет инициализация приложения и вызов файла 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 сервер, который пока что ничего не умеет. Теперь немного про архитектуру проекта. Так как я перешел на Node.JS из Java, я постарался и тут построить сервис с такими же слоями.
- Контроллеры
- Сервисы
- Репозитории
Приступим к подключению 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, который по сути есть Koa-router, но уже с добавленными middlewares и обработчиками в них.
Поэтому в файле 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. /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 all 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); };
Update user
@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); };
Insert user
@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);
Ну и упростим наши методы контроллера
User controller
@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. Delete можно написать по аналогии. По сути теперь для написания нового контроллера мы должны:
- Создать файл контроллера
- Добавить его в routing.ts
- Описать методы
- В каждом методе использовать схемы входов/выходов
- Описать эти схемы
- Подключить эти схемы в routing.ts
Если входящий пакет не будет соответствовать схеме, пользователь нашего REST сервиса получит ошибку 400 с описанием что именно не так. Если же исходящий пакет будет невалидный, то будет сгенерирована ошибка 500.
Ну и еще как приятная мелочь. В Swagger UI можно использовать функциональность “Try it out” на любом методе. Будет сгенерирован запрос через curl на ваш запущенный сервис, ну и конечно же результат вы сможете тут же увидеть. И вот именно для этого очень удобно в схеме описывать параметр ”example”. Потому что запрос будет сгенерирован уже сразу с готовым пакетом основанном на описанных экзамплах.

Выводы
Очень удобная и полезная в итоге получилась штука. Вначале не хотели валидировать исходящие пакеты, но потом с помощью этой валидации поймали несколько существенных багов на своей стороне. Конечно в полной мере нельзя использовать все возможности Joi (так как мы ограничены joi-to-swagger), но и тех, что есть вполне хватает.
Теперь документация у нас всегда онлайн и всегда строго соответсвует коду — и это главное.
Какие еще есть идеи?..
Возможно добавить поддержку express?
Прочитал только что.
Действительно было бы круто описывать сущности один раз в одном месте. Потому что сейчас необходимо править и схемы и интерфейсы.
Может у вас будут какие-то интересные идеи. А еще лучше Пулл реквесты :)
Добро пожаловать в контрибуторы.
