Работа с REST API при помощи swagger-typescript-api

Прежде чем начать писать данную статью, я озадачился интересным вопросом. А кто как вообще работает с API в 2024 году? Для меня наличие Swagger-контракта или OpenAPI-контракта уже несколько лет как must have. И откровенно говоря, мне сложно представить, что люди не используют этот фреймворк для работы c REST API. Однако, если среди читателей таковые есть, и вам до сих пор скидывают «дтоошки», то вперед осваивать и продвигать OpenApi.

Для понимания работы swagger-typescript-api я сначала кратко опишу основные моменты спецификации OpenAPI. Читатели, которые уже знакомы с этим, могут сразу перейти к части про swagger-typescript-api.

Спецификация OpenAPI допускает описание удаленного API, доступного через HTTP или HTTP-подобные протоколы. OpenAPI можно представить, как ряд строительных блоков в определенной структуре. Для удобства сразу обозначу структуру и те самые строительные блоки:

Начнем со структуры. Каждый OpenAPI-контракт представляет собой JSON-объект, но описать его можно как и в JSON-формате, так и в YAML-формате. Я сразу приведу примеры разных реализаций, чтобы можно было наглядно оценить различия:

{
  "anObject": {
    "aNumber": 42,
    "aString": "This is a string",
    "aBoolean": true,
    "nothing": null,
    "arrayOfNumbers": [
      1,
      2,
      3
    ]
  }
}
# Anything after a hash sign is a comment
anObject:
  aNumber: 42
  aString: This is a string
  aBoolean: true
  nothing: null
  arrayOfNumbers:
    - 1
    - 2
    - 3

JSON не поддерживает комментарии и требует: запятые, разделяющие поля, фигурные скобки вокруг объектов, двойные кавычки вокруг строк и квадратные скобки вокруг массивов.

С другой стороны, YAML требует дефисов перед элементами массива и сильно зависит от отступов, что может быть неудобно для больших файлов (отступы в JSON совершенно необязательны).

YAML обычно предпочтительнее из-за немного уменьшенного размера файла, но эти два формата полностью взаимозаменяемы.

Рассмотрим, что включает OpenAPI-контракт (корневой объект OpenAPI). Только два поля объекта OpenAPI являются обязательными: openapi и info, но также нужно указать хотя бы одно из следующих полей:

  • paths

  • components

  • webhooks

Поле openapi (string) указывает версию спецификации, например 3.1.0. В поле info (Info Object) можно указать общую информацию об API, например, описание, автора и контактную информацию, но единственными обязательными полями являются title (string) и version (string).

А поле paths (Paths Object) описывает все API Endpoints, включая их параметры и все возможные ответы сервера. Вот так это будет выглядеть в YAML-формате:

openapi: 3.1.0
info:
  title: A minimal OpenAPI Description
  version: 0.0.1
paths: {}  # No endpoints defined

Теперь о API Endpoints (также называемых Operations или Routes). Они называются Paths в спецификации OpenAPI. Каждое поле в объекте Paths является объектом элемента пути (Path Item Object), описывающим один API endpoint.

Все пути должны начинаться с косой черты /, поскольку они непосредственно добавляются к URL-адресу сервера. Рассмотрите пример из Tic Tac Toe sample API:

openapi: 3.1.0
info:
  title: Tic Tac Toe
  description: |
    This API allows writing down marks on a Tic Tac Toe board
    and requesting the state of the board or of individual squares.
  version: 1.0.0
paths:
  # Whole board operations
  /board:
    get:
      summary: Get the whole board
      description: Retrieves the current state of the board and the winner.
      responses:
        "200":
          description: "OK"
          content:
            ...

Каждый объект элемента пути (Path Item Object) описывает HTTP операцию, которая может быть выполнена по определенному пути (EndPoint). В примере выше описана операция для метода get. Разрешенные операции соответствуют именам HTTP-методов, например get, put и т.д.

Каждый объект операции (Operation Object) предоставляет описание, а также параметры, полезную нагрузку и возможные ответы сервера.

Объект ответов сервера (Responses Object) — это контейнер для ожидаемых ответов. Имя каждого поля в этом объекте представляет собой код ответа HTTP (должен быть представлен хотя бы один, как правило »200»), а его значением является объект ответа (Response Object), содержащий сведения об ответе.

Объект ответа (Response Object) содержит поле description с человекопонятным описанием, а также самым важным полем content, которое описывает возможные полезные данные ответа.

Рассмотрим поле content подробнее. Оно используется как в объекте ответа (Response Object), так и в объектах тела запроса (Request Body Objects) и объединяет в себе стандарт RFC6838 Media Types и OpenAPI Media Type Objects. Вот пример поля content для метода get запроса /board:

openapi: 3.1.0
info:
  title: Tic Tac Toe
  description: |
    This API allows writing down marks on a Tic Tac Toe board
    and requesting the state of the board or of individual squares.
  version: 1.0.0
paths:
  # Whole board operations
  /board:
    get:
      summary: Get the whole board
      description: Retrieves the current state of the board and the winner.
      responses:
        "200":
          description: "OK"
          content:
            application/json:
              schema:
                type: object
                properties:
                    winner:
                      type: string
                      enum: [".", "X", "O"]
                      description: |
                        Winner of the game. `.` means nobody has won yet.
                    board:
                      type: array
                      maxItems: 3
                      minItems: 3
                      items:
                          type: array
                          maxItems: 3
                          minItems: 3
                          items:
                            type: string
                            enum: [".", "X", "O"]
                            description: |
                              Possible values for a board square.
                              `.` means empty square.
  ...

Media Type Object описывает тип и структуру контента, а также может содержать примеры (подробнее в документации). Для типа application/json структура описана в поле schema.

Объект схемы (Schema Object) определяет тип данных, который может быть примитивом (integer, string, …), массивом или объектом в зависимости от поля type. Для каждого типа данных могут быть описаны свои ограничения, например длина и возможные значения для строк или максимальное и минимальное значение для целых чисел или списков.

Об описании ответов поговорили, теперь перейдем к запросам. OpenAPI предоставляет два механизма для указания входных данных — параметры и тело запроса (полезная нагрузка). Параметры обычно используются для идентификации ресурса (path parameters или query parameters), тогда как полезные данные предоставляют контент для этого ресурса (body parameters). Как всегда легче разобраться на примере:

paths:
  # Single square operations
  /board/{row}/{column}:
    parameters:
      - name: row
        in: path
        required: true
        schema:
          type: integer
          minimum: 1
          maximum: 3
      - name: column
        in: path
        required: true
        schema:
          type: integer
          minimum: 1
          maximum: 3
    get:
      summary: Get a single board square
      responses:
        ...
    put:
      summary: Set a single board square
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: string
              enum: [".", "X", "O"]
      responses:
        ...

В методе get видим список параметров. Каждый объект параметра (Parameter Object) описывает один параметр со следующими обязательными полями:

  • in (string) — это поле указывает местоположение параметра (path, query или header)

  • name (string) — это поле указывает название параметра и должно быть уникальным (регистрочувствительное)

Также могут быть указаны дополнительные поля description и required, а также тип параметра с помощью объекта схемы (Schema Object) в поле schema.

Когда данные передаются с помощью таких методов, как, например, POST или PUT, они помещаются в поле requestBody. Объект тела запроса (Request Body Object) содержит только одно поле — content, то самое, что используется и в объекте ответа (Response Object).

Помимо поле paths в корневом объекте OpenAPI (OpenAPI Object), есть очень полезное поле components. Это поле содержит определения объектов (Components Object), которые могут повторно использоваться в других частях описания. Другими словами, вы можете вынести повторяющиеся схемы в components, чтобы избежать дублирования и захламления. На примере это выглядит так:

components:
  schemas:
    coordinate:
      type: integer
      minimum: 1
      maximum: 3
  parameters:
    rowParam:
      name: row
      in: path
      required: true
      schema:
        $ref: "#/components/schemas/coordinate"
    columnParam:
      name: column
      in: path
      required: true
      schema:
        $ref: "#/components/schemas/coordinate"
paths:
  /board/{row}/{column}:
    parameters:
      - $ref: "#/components/parameters/rowParam"
      - $ref: "#/components/parameters/columnParam"

Здесь задано два Components Object — schemas и parameters. rowParam и columnParam ссылаются посредством Reference Objects на coordinate из schemas.

Также отдельно стоит отметить поле servers, которое может быть в корневом объекте OpenAPI (OpenAPI Object), объекте элемента пути (Path Item Object) и объекте операции (Operation Object).

Каждый элемент поля servers представляет собой объект сервера (Server Object), предоставляющий поле url с базовым URL-адресом для этого сервера, а также необязательное поле description. Вот как это выглядит на примере:

servers:
- url: https://europe.server.com/v1
  description: Server located in Germany.
- url: https://america.server.com/v1
  description: Server located in Atlanta, GA.
- url: https://asia.server.com/v1
  description: Server located in Shenzhen
paths:
  /users:
    get:
      servers:
      - url: https://europe.server2.com/v1

Теперь когда все в курсе, как устроен OpenAPI, можно перейти к работе с swagger-typescript-api.

Как вы могли догадаться swagger-typescript-api — это генератор, который за основу использует Swagger или OpenAPI контракт. Для генерации он использует поле `server`, чтобы указать адрес сервера в HTTP-клиенте, на основе которого он создает JS-класс, где он мапит поля paths в методы. Соответсвенно в этих методах будут использоваться ts-интерфейсы, которые будут сгенерированы на основе полей scheme, используя описанные там типы или ссылки на раздел components.

Эта библиотека довольно проста в использовании имеет хорошую документацию и примеры использования, а также много опций, которые позволяют использовать ее довольно гибко.

Установить библиотеку можно по команде npm i -D swagger-typescript-api.

Прежде всего расскажу пару советов, которые позволят использовать этот генератор эффективнее:

  1. Рекомендую все схемы ответов и запросов писать в компонентах, даже если они не используются повторно. Такой подход позволит сгенерировать отдельные ts-интерфейсы, которые возможно придется переиспользовать в проекте фронта. Пример из документации:

Пример мапинга components в ts-интерфейсы

Пример мапинга components в ts-интерфейсы

  1. Выделите для ваших контрактов и генерируемых файлов отдельную директорию в проекте а-ля services и пропишите команды в package.json по генерации для каждого сервиса отдельно. Экономит время, так как не приходится постоянно копировать команду и заполнять значения для опций. У нас монорепо, поэтому мы вообще вынесли сервисы в отдельную библиотеку, которую шарим на все хостовые приложения. Вот как это выглядит:

Пример списка команд для генерации API по сервисам

Пример списка команд для генерации API по сервисам

Пример команд:

npx swagger-typescript-api -p ./swagger.json -o ./src -n myApi.ts
swagger-typescript-api -p ./swagger.json -o ./src -n myApi.ts
sta -p ./swagger.json -o ./src -n myApi.ts

Теперь опишу ряд опций, которыми я пользуюсь или пользовался, и которые, на мой взгляд, будут полезными.

Первым отмечу флаг --axios, который дает возможность переключить HTTP клиент с нативного fetch на axios. Здесь, конечно, присутствует вопрос вкусовщины. Ранее я любил axios, так как до появления fetch он был крайне удобен. Сейчас же я от него отказался из-за проблемы получения кастомных заголовков в ответе с сервера.

Есть такая проблема, которая решается дополнительной настройкой на бэке. Но в моем случае было быстрее переключиться на fetch, нежели дергать ребят, тем более, что fetch также удобен.

Также я использую флаги --path и --output, чтобы указать где брать контракт и куда положить сгенерированный контент.

Флаги на любителя — --responses и --type-prefix. Я добавляю —-responses, чтобы видеть возможные варианты ответа по конкретным HTTP-кодам, как представлено на скрине ниже:

Мапинг кодов ответа в JS Doc

Мапинг кодов ответа в JS Doc

Это полезно, когда приходится обрабатывать специально созданные бэком бизнес-ошибки.

Флаг --type-prefix же добавляет нужный вам префикс перед типами. Есть недостаток, что этот префикс ставится не только перед ts-интерфейсами, но и ts-типами и ts-enum«ами. Это можно решить, но об этом чуть позже.

Еще один полезный флаг --api-class-name, который поможет, если у вас много различных backend-сервисов. Тут, я думаю, все понятно и так.

Как же использовать сгенерированный контент? На выходе у вас будет JS-класс с методами вашего API, а также все интерфейсы, которые в этих методах используются. Например, вот такой вот миникласс для загрузки данных:

export class DataLoadingModuleApi extends HttpClient {
  facade = {
    /**
     * @description Загрузка файла в хранилище
     *
     * @name LoadFileFacadeLoadFilePost
     * @summary Load File
     * @request POST:/facade/load_file
     * @response `200` `FileUploadResponse` Successful Response
     * @response `422` `HTTPValidationError` Validation Error
     */
    loadFileFacadeLoadFilePost: (
      data: BodyLoadFileFacadeLoadFilePost,
      query?: {
        /**
         * Force
         * @default false
         */
        force?: boolean;
      },
      params: RequestParams = {},
    ) =>
      this.request({
        path: `/facade/load_file`,
        method: 'POST',
        query: query,
        body: data,
        type: ContentType.FormData,
        format: 'json',
        ...params,
      }),
  };
}

Метод request, который берется из родительского класса HttpClient, будет возвращать Promise с HttpResponse. Для Axios будет AxiosResponse соответсвенно. Тут уже нет ограничений для вашей фантазии — можете создавать экземпляр класса и дергать метод прямо в useEffect, можете использовать сторонние библиотеки для работы с API, например @tanstack/react-query, можете написать свой хук.

Вот пример хука с useMutation из @tanstack/react-query и использованием генерированного класса DataLoadingModuleApi:

export const useLoadFilePostMutation = () => {
  const [token] = useLocalStorage('accessToken', { value: '' });

  return useMutation<
    HttpResponse,
    HttpResponse,
    BodyLoadFileFacadeLoadFilePost
  >({
    mutationFn: (data: BodyLoadFileFacadeLoadFilePost) =>
      new DataLoadingModuleApi().facade.loadFileFacadeLoadFilePost(
        data,
        { force: true },
        { headers: { authorization: `Bearer ${token.value}` } },
      ),
  });
};

И на последок хотелось бы отметить флаг —-templates. Этот флаг дает возможность предоставить генератору свои кастомные eta-шаблоны. Это и решает ту проблему с префиксами, а также дает возможность затачивать этот инструмент под свои проекты.

На работе мы используем RemoteData и fp-ts, поэтому у нас создан кастомный хук для работы с API. Также для удобства есть функция-хелпер для работы с этим хуком, чтобы иметь возможность абстрактно извлекать методы внутри него.

И эту функцию-хелпер мы пока дополянем ручками при изменении контракта. Давно уже есть желание перейти на свои шаблоны, чтобы решить этот вопрос.

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

Всем спасибо за внимание!

© Habrahabr.ru