Open source
Node.JS
API
TypeScript
May 2

Самодокументируемый REST сервер (Node.JS, TypeScript, Koa, Joi, Swagger)

Tutorial

Про преимущества и недостатки 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 можно написать по аналогии. По сути теперь для написания нового контроллера мы должны:

  1. Создать файл контроллера
  2. Добавить его в routing.ts
  3. Описать методы
  4. В каждом методе использовать схемы входов/выходов
  5. Описать эти схемы
  6. Подключить эти схемы в routing.ts

Если входящий пакет не будет соответствовать схеме, пользователь нашего REST сервиса получит ошибку 400 с описанием что именно не так. Если же исходящий пакет будет невалидный, то будет сгенерирована ошибка 500.

Ну и еще как приятная мелочь. В Swagger UI можно использовать функциональность “Try it out” на любом методе. Будет сгенерирован запрос через curl на ваш запущенный сервис, ну и конечно же результат вы сможете тут же увидеть. И вот именно для этого очень удобно в схеме описывать параметр ”example”. Потому что запрос будет сгенерирован уже сразу с готовым пакетом основанном на описанных экзамплах.



Выводы


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

Теперь документация у нас всегда онлайн и всегда строго соответсвует коду — и это главное.
Какие еще есть идеи?..

Возможно добавить поддержку express?
Прочитал только что.

Действительно было бы круто описывать сущности один раз в одном месте. Потому что сейчас необходимо править и схемы и интерфейсы.

Может у вас будут какие-то интересные идеи. А еще лучше Пулл реквесты :)
Добро пожаловать в контрибуторы.
+9
5.7k 75
Comments 14
Top of the day