Про преимущества и недостатки 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?
Прочитал только что.
Действительно было бы круто описывать сущности один раз в одном месте. Потому что сейчас необходимо править и схемы и интерфейсы.
Может у вас будут какие-то интересные идеи. А еще лучше Пулл реквесты :)
Добро пожаловать в контрибуторы.