Сегодня я расскажу о том, как мы можем с помощью типов написать простое расширение для ExpressJS.

А если вы в своём приложении/приложениях используете только решения на TypeScript(JavaScript), то у вас отпадёт необходимость в Swagger.

Вообще, одно из главных преимуществ разработки серверного кода на NodeJS — это один язык программирования с Web-интерфейсом/React/Vue Native. Это даёт возможность написать общий код в одном месте только один раз и использовать его затем везде.

Именно это мы сейчас с вами и попытаемся сделать.

Представим простой монорепозиторий, который состоит из двух проектов:

  • server: Backend WebAPI, написанный на ExpressJS;

  • client: Frontend SPA-клиент, написанный на VanillaJS.

Приложение крайне простое - todo, которое должно уметь создавать, получать и удалять задания. Как обычно поступают в таких условиях? Пишут backend сервер, в лучшем случае подключают к нему swagger (что приводит к существенным изменениям в коде), или ведут Google Table со списком контрактов. В худшем - вам придётся каждый раз смотреть исходный код сервера, и подгонять под него свой код.

Объявляем общие типы

Для начала создадим другую папку в нашем монорепозитории и назовём её shared. Сделаем его npm проектом на typescript, выполнив в корне папке команды:

npm init
npm install typescript --save-dev

Для имени этого проекта я буду использовать имя монорепозитория и название папки:

// shared/package.json
{

    name: @express-ts-react/shared,
    ...
}

Несколько замечаний об этом проекте

Поскольку подразумевается, что код в этом проекте будет использоваться в разных службах и приложениях, то необходимо соблюдать следующие правила:

- Не устанавливать специфичные фреймворки и библиотеки;

- Желательно вообще ничего не устанавливать :)

- Старайтесь писать здесь максимально абстрактный код, или код, который будет работать везде.

- Каждое приложение или сервер, если предоставляет какие-то контракты для работы с ним, должно иметь собственную папку, и оно не должно ниоткуда, кроме common импортировать код.

Теперь создадим папку common в проекте @express-ts-react/shared и объявим её локальным модулем:

// shared/common/package.json

{

  "sideEffects": false,

  "main": "./index.js"

}

Там же создадим файл, в котором мы объявим самые важные генерики, которых уже будет достаточно, чтобы заменить нам swagger. EndpointMeta<T,U> — это тип, который описывает один endpoint. По сути, любой метод нашего RestApi задаётся таким набором. T и U здесь использованы не просто так, они понадобятся для автоматического и динамического формирования интерфейсов наших контроллеров. Пока вам нужно знать, что T —это параметры аргументов, которые принимает метод контроллера, а U — это формат ответа.

// shared/common/index.tsx

/**
 * REST description about endpoint
 * Can be extended with additional fields or methods. For instance, auth protected endpoint
 */

export type EndpointMeta<T = {}, U = {}> = {
  /**
   * helper for type in runtime definition
   */
  _: "endpointMeta";

  /**
   * Endpoint route
   */
  route: `/${string}` | `*`;

  /**
   * Url in express format without route prefix
   */
  url: `/${string}` | `*`;
  
  /**
   * Method in express format, can be extended by others
   */
  method?: "get" | "post" | "put" | "delete";
};

/**
 * Get argument types of endpoint method
 */

export type GetInnerArgsOfMeta<S> = S extends EndpointMeta<infer T, infer S>
  ? T
  : never;

/**
 * Get Response type of endpoint method
 */
export type GetInnerResponseOfMeta<S> = S extends EndpointMeta<infer T, infer S>
  ? S
  : never;

/**
 * Get endpoint type, where key is the endpoint name,
 * args - is the endpoint method arguments
 * and result is endpoint response
 */
export type EndpointsProvider<T extends typeof endpoints> = {
  [key in keyof T]: (
    args: GetInnerArgsOfMeta<T[key]>
  ) => GetInnerResponseOfMeta<T[key]>;
};

А теперь используя только эти четыре типа вы можете легко создавать интерфейсы/контракты для ваших контроллеров, и использовать этот код как на сервере, так и на клиенте. Снизу, например, представлены методы для нашего ToDo приложения:

// shared/server/index.tsx

import { EndpointMeta, EndpointsProvider } from "../shared";

const getTasks: EndpointMeta<
  {
    query?: {
      status?: boolean;
      ids?: string[];
    };
  },
  Promise<Task[]>
> = {
  _: "endpointMeta",
  url: "/",
  method: "get",
};

const getTaskById: EndpointMeta<
  {
    params: {
      id: string;
    };
  },
  Promise<Task>
> = {
  _: "endpointMeta",
 url: "/:id",
  method: "get",
};

const addTask: EndpointMeta<{ body: Task }, Promise<Task>> = {
  _: "endpointMeta",
  url: "/:id",
  method: "post",
};

const deleteTaskById: EndpointMeta<
  {
    params: {
      id: string;
    };
  },
  Promise<Task>
> = {
  _: "endpointMeta",
  url: "/:id",
  method: "delete",
};

// Our final endpoints collection
const endpoints = {
  getTasks,
  getTaskById,
  addTask,
  deleteTaskById,
};

Четыре переменные, по сути, содержат всю необходимую информацию, которую нужно, чтобы:

  • Создать ExpressJs роутинг;

  • Без подглядывания в сторонние данные написать клиент для этого сервера.

Почему я уверен, что не придётся подглядывать? Давайте создадим простой класс, который можно использовать везде, независимо от среды исполнения и объявим его, как реализующим этот непонятный тип EndpointsProvider:

// shard/server/index.tsx

class TaskController implements EndpointsProvider<typeof endpoints> {}

Если мы оставим класс как есть, то, во-первых, на нас будет ругаться vscode, а во-вторых, при попытки собрать проект командой npx tsc мы увидим ошибку:

blog/index.ts:77:7 - error TS2420: Class 'TaskController' incorrectly implements interface 'EndpointsProvider<{ getTasks: EndpointMeta<{ query?: { status?: boolean | undefined; ids?: string[] | undefined; } | undefined; }, Task[]>; getTaskById: EndpointMeta<{ params: { id: string; }; }, Task>; addTask: EndpointMeta<...>; deleteTaskById: EndpointMeta<...>; }>'.

  Type 'TaskController' is missing the following properties from type 'EndpointsProvider<{ getTasks: EndpointMeta<{ query?: { status?: boolean | undefined; ids?: string[] | undefined; } | undefined; }, Task[]>; getTaskById: EndpointMeta<{ params: { id: string; }; }, Task>; addTask: EndpointMeta<...>; deleteTaskById: EndpointMeta<...>; }>': getTasks, getTaskById, addTask, deleteTaskById

77 class TaskController implements EndpointsProvider<typeof endpoints> {}

Found 1 error.

Эта ошибка говорит, что наш класс TaskController неправильно реализует EndpointsProvider: отсутствуют функции addTaskgetTaskByIdgetTasksdeleteTask. Давайте теперь попробуем реализовать эти методы.

// shared/server/index.tsx

...

class TaskController implements EndpointsProvider<typeof endpoints> {
  getTaskById: (args: {
    params: {
      id: string;
    };
  }) => Task = (args) => {
    return {
      id: "",
      description: "",
      done: true,
    };
  };

  getTasks: (args: {
    query?: {
          status?: boolean;
          ids?: string[];
        };
  }) => Task[] = (args) => {
    return [{
        id: "",
        description: "",
        done: true,
    }];
  };

  addTask: (args: { body: Task }) => Task = (args) => {
    return args.body;
  };

  deleteTaskById: (args: {
    params: {
      id: string;
    };
  }) => Task = (args) => {
    return {
      id: "",
      description: "",
      done: true,
    };
  };
}

Теперь команда npx tsx ничего не пишет нам, а значит, всё работает! Как видно, это класс для контроллера, и в нём уже содержится информация о конкретной реализации методов, поэтому по-хорошему этот класс нужно уже выносить в проект нашего сервера. Чтобы сделать клиент, нужно унаследовать точно такой же интерфейс, но уже вместо конкретной реализации, нужно заменять url на параметры и формировать fetch запрос на сервер.

В следующей части я покажу, как можно автоматизировать на основе const endpoints формирование сервера и класса клиента. А также мы создадим базовый контроллер, запрос и ответ, сделаем их расширяемыми собственными свойствами и спасём себе жизнь от ошибок и сохраним часы и дни для синхронизации работы серверов и клиентов.

При использовании TypeScript код заметно увеличивается в размере, и кому-то это может казаться некрасивым. Но когда вы работаете в современной IDE или текстовом редакторе на стероидах (VSCode) - то вы оцените, насколько силён TypeScript. Его использование существенно снижает количество ошибок, а также доступность для понимания кода для вас и членов вашей команды.

Комментарии (2)


  1. Shatun
    04.08.2021 15:28
    +4

    Сваггер-это общий формат, понятный всем, а не только js/ts программистам.

    Мне интересно в вашем решении как пишутся например мобильные приложения? Или интеграции с другими АПИ, не на js? Сторонние команды?

    Как вы выставялете наружу документацию?


    1. marvin_42 Автор
      04.08.2021 16:48

      Согласен насчёт свагера, кроме того, это еще и интерактивный доступ к этой документации, где можно ручками потыкать и поотправлять запросы. Очень удобно, особенно в комбинации с ASP.Net.

      Но использование сваггера с js/ts сулит проблемами. По сравнению, например, с тем же ASP.Net, подключить Swagger к ExpressJs так себе задачка. Кроме того, существующие решения в основной своей массе требуют определённой структуры написания кода. Часть этих решений вообще опираются на JSDoc, что по моему мнению, очень плохо. Потому что: метод изменил, а комментарий исправить забыл - и ну просто никак компиляторы и рабочие утилиты тебе этого не подскажут, не намекнут. Это всё приводит к тому, что на платформе nodejs Swagger не так распространён. В описанном способе, можно писать код в каком угодно стиле/подходе - в данном случае все подходы равны. Есть, конечно, ровнее, но о нём будет рассказано в следующей публикации.

      Касательно первого вопроса: создаём проект контрактов/интерфейсов (в данной статье это shared) - и подключаем эту shared библиотеку к проектам, откуда планируется дергать соответствующее апи. Если проект на js/ts (React.Native) - то всё просто. Но если решение на другом языке, такой способ уже не прокатит. Но в самом начале статьи я как раз обозначил условие, при котором в swagger отпадает необходимость.

      В моей практике были ситуации, когда нужно было пилить/интегрировать службы не на js. Это были внутренние проекты, и с моей стороны было достаточно передать доступ к репозиторию shared с интерфейсами RestAPI, которые на самом деле выглядят почти как json, но где вместо значений указан тип ключа, и url ендпоинта. У людей с C#/Java/PHP, кто знаком с форматом json не возникало вопросов :)

      Для внешних интеграций тоже всё очень просто, можно два способа:

      1. Поднимается простой сервак (можно даже отделаться одним nginx), который отдаёт по названию службы соотсветсвующий файлик ts из папки shared монорепозитория. Обновился интерфейс, стянули с гита изменения, и всё снова актуально. Открываешь страницу http://site.site/shared/task.ts - и перед тобой полное описание RestApi. В моём примере не показано, но вообще я обычно пишу комменты к методам, чтобы прям совсем было всё очевидно.

      2. Реализуется в сервере endpoint, который будет возвращать свой файлик с интерфейсами. Это выглядит так:

      const getAPI = EndpointMeta<
        {
          params?: {
            controller?: `${string}.ts`;
        	};
        },
        Promise<string>
      > = {
        _: "endpointMeta",
        url: "/:controller",
        route: "/docs",
        method: "get",
      };

      Но вопрос о целесообразности применения такого подохода зависит от существующих проектов и команды, если вы пишите новый сервер на ExpressJS, который будете интегрировать с другими, написанных на других языках, то тогда навряд ли есть смысл во всём этом. Хотя повторю, существующее решение не связывает по рукам и ногам того, кто пишет сервак.