Вызвать другой API из Node.js несложно.

Начиная с Node.js версии 18, веб-совместимая функция fetch доступна по умолчанию. Однако, поскольку он по-прежнему помечен как экспериментальный, существуют проверенные в боевых условиях библиотеки, такие как Axios, подходящие для производственной среды, предлагающие удобные API и полный набор функций:

import axios from 'axios';

const http = axios.create({
  baseURL: 'https://example.com/api'
});

export function getUser(userId: string) {
  // An equivalent to `GET /users?id=12345`
  return http.get('/user', {
    params: {
      id: 12345
    }
  });
}

Вот и все. Удачного кодирования!

Расширение области

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

Чтобы проиллюстрировать это, мы будем использовать реальный API из цифрового банка с общедоступной документацией: https://docs.solarisgroup.com/api-reference.

Отказ от ответственности: я не связан с Solaris SE. Я просто был знаком с этим API. Использование реалистичных примеров может быть полезным.

Получение данных о человеке

Начнем с простого вызова getPerson:

import axios from 'axios';

const http = axios.create({
  baseURL: process.env.API_BASE_URL
});

export function getPerson_v0(personId: string) {
  return http.get(`v1/persons/{id}`);
}

Как видите, он не сильно отличается от кода в вводной части. Однако, учитывая возвращаемое значение (см. объект ответа в документации), вы можете заметить некоторые проблемы.

Во-первых, Axios возвращает собственный объект ответа. Передача этого вверх по стеку не идеальна, поскольку нашим вызывающим сторонам (пользователям клиента API, который мы делаем) не нужно знать, какую библиотеку HTTP мы используем. Мы хотим сохранить гибкость, чтобы заменить его позже. Причем пользователей в первую очередь интересует сам объект Person, который содержится в свойстве data:

export function getPerson_v1(personId: string) {
  return http
    .get(`v1/persons/{id}`)
    .then((response) => response.data);
}

Теперь давайте рассмотрим само значение Person:

  1. Ключи находятся в snake_case, что может привести к предупреждениям от ESLint и жалобам от других разработчиков, поскольку нашим стандартом является camelCase.
  2. Даты представлены в виде строк.
export async function getPerson_v2(personId: string) {
  return http
    .get<Person>(`v1/persons/{id}`)
    .then((response) => response.data)
    .then(snakeToCamelCase)
    .then(restoreDates);
}

В приведенном выше коде snakeToCamelCase и restoreDates — это служебные функции, которые выполняют итерацию по ключам объекта и сопоставляют либо ключ, либо значение, в зависимости от того, что нужно исправить. Детали их реализации не являются предметом нашего обсуждения.

Я также добавил ‹Person› к параметрам типа. Естественно, нам нужно создать интерфейсы и перечисления для всех данных, которые мы отправляем или получаем. Они могут быть сгенерированы из спецификации OpenAPI или созданы вручную, в зависимости от документации.

Неожиданная ценность

Мы восстанавливаем значения даты, но нам не нужно восстанавливать перечисления, такие как employment_status, так как это просто строки. Но что, если в ответе появится новое неожиданное значение, которого нет в нашем EmploymentStatus? Или, что еще хуже, какое-то другое поле становится строкой вместо числа. Вероятно, мы испортим состояние приложения во время выполнения.

API — это живое существо; он меняется со временем. В нем также есть ошибки (у всего есть ошибки). Нам нужно защитить наш код от неверных данных ответа.

Проверка схемы

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

Для Node.js часто используются такие библиотеки, как Joi, Yup, Zod. Я буду использовать Runtypes.

import * as t from 'runtypes';

export const PersonSchema = t.Record({
  // Other fields are omitted for simplicity
  id: t.String,
  first_name: t.String,
  last_name: t.String,
  email: t.String,
  birth_date: t.InstanceOf(Date),
  employment_status: EmploymentStatusSchema,
});

export type Person = t.Static<typeof PersonSchema>;

export async function getPerson_v3(personId: string) {
  return http
    .get(`v1/persons/{id}`)
    .then((response) => response.data)
    .then(snakeToCamelCase)
    .then(restoreDates)
    .then((person) => PersonSchema.check(person));
}

Вызов .check(person) либо возвращает типизированный Person, либо завершается с ошибкой.

Аутентификация

Нам также необходимо обратиться к аутентификации. API защищен токеном носителя, который можно получить через конечную точку oAuth. Токен имеет срок действия, и нам нужно время от времени обновлять его. Я собираюсь инкапсулировать всю бизнес-логику, связанную с токенами, в компоненте TokenManager. Читается как «В основном я это пропущу»:

export class TokenManager {
  getToken(): Promise<string> {
    // ...
    return token;
  }

  close(): void {
    // ...
  }
}

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

Axios предоставляет перехватчики в качестве механизма, который мы будем использовать для внедрения токена:

const tokenManager = new TokenManager();

async function injectNewToken<
  C extends InternalAxiosRequestConfig | AxiosRequestConfig
>(config: C): Promise<C> {
  const token = await tokenManager.getToken();
  if (typeof config.headers?.setAuthorization == "function") {
    config.headers.setAuthorization(`Bearer ${token}`);
    return config;
  }
  return {
    ...config,
    headers: {
      ...config.headers,
      Authorization: `Bearer ${token}`,
    },
  };
}

httpClient.interceptors.request.use(async (config) => {
  return injectNewToken(config);
});

httpClient.interceptors.response.use(undefined, async (error) => {
  if (isAxiosError(error) && error.response?.status === 401 && error.config) {
    return injectNewToken(error.config).then((c) => httpClient.request(c));
  }
  throw error;
});

Код упрощен, но мы делаем следующее:

1. Перед фактической отправкой запроса мы просим TokenManger предоставить нам действительный токен, а затем добавляем его в качестве заголовка Authorization в конфигурацию запроса.
2 . В случае ответа об ошибке с кодом статуса 401, мы знаем из документации, что срок действия токена истек к моменту его поступления в API, и нам нужно получить новый и повторить запрос.

Ошибки

API может отвечать не только ошибками 401. Что еще более важно, есть целая страница, описывающая формат ответа об ошибке. Как минимум, нам нужно проанализировать ответ и выдать что-то вроде new Error(responseError.code). Но нам понадобится идентификатор ошибки, когда мы напишем в их поддержку (а нам понадобится много). Описание ошибки также полезно для отладки проблем.

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

Представляем пользовательские ошибки и сопоставление ошибок. Здесь я буду еще больше упрощать. В противном случае это будет тысяча строк кода. Например, я предположу, что в ответе может быть только 1 ошибка. И мы будем отображать только коды ошибок, игнорируя коды состояния и возможные комбинации.

Ошибки начинаются с создания типов и схем ответов сервера на ошибки:

export enum SolarErrorCode {
  METHOD_NOT_ALLOWED = "method_not_allowed",
  MODEL_NOT_FOUND = "model_not_found",
  UNAUTHORIZED_ACTION = "unauthorized_action",
}

export const SolarErrorDataSchema = t.Record({
  // Other fields are omitted for simplicity
  id: t.String,
  status: t.Number,
  code: t.String,
  detail: t.String,
});

export const SolarErrorResponseSchema = t.Record({
  // Other fields are omitted for simplicity
  errors: t.Array(SolarErrorDataSchema),
});

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

export enum ClientErrorCode {
  CONFIG_ISSUE = "config_issue",
  MODEL_NOT_FOUND = "model_not_found",
  NO_CONNECTION = "no_connection",
  UNAUTHORIZED = "unauthorized",
  UNEXPECTED_ERROR = "unexpected_error",
}

export class ClientError extends Error {
  static is(value: unknown): value is ClientError {
    return value instanceof ClientError;
  }

  static mapCode<R>(codeMap: { [key: string]: (error: ClientError) => R }) {
    return function clientErrorMapper(error: unknown) {
      if (ClientError.is(error)) {
        const remapper = codeMap[error.code] ?? codeMap["_"];
        if (typeof remapper === 'function') {
          return remapper(error);
        }
      }
      throw error;
    };
  }

  id?: string;
  code: string;

  constructor(
    errorData: SolarErrorData | Pick<SolarErrorData, "detail" | "code">
  ) {
    super(errorData.detail);
    if ("id" in errorData) {
      this.id = errorData.id;
    }
    this.code = errorData.code;
  }
}

Вот как сопоставить шаблон с ClientError:

const person = await getPerson("id").catch(
  ClientError.mapCode({
    [ClientErrorCode.CONFIG_ISSUE]: (e) => {
      throw new Error(`Another error: ${e.code}`);
    },
    [ClientErrorCode.UNEXPECTED_ERROR]: () => 123 as const,
  })
);

Здесь пользователи смогут переназначать ошибки клиента на свои собственные внутренние ошибки. Кроме того, если вы посмотрите на предполагаемый тип person, это будет Person | не определено | 123.

Но откуда берется эта ClientError? Мы анализируем ответы об ошибках и сопоставляем их с соответствующими значениями (например, undefined вместо 404) или экземплярами ClientError в зависимости от того, что требуется. Цель состоит в том, чтобы скрыть транспортные или специфичные для API вещи.

Вот помощник. Большинство условий (блоков if) исходят из того, как вы должны обрабатывать ошибки Axios:

export function mapAxiosError<R>(errorMap: {
  [key: string]: (() => R) | ClientErrorCode;
}) {
  return function axiosErrorMapper(error: unknown): R {
    if (!isAxiosError(error)) {
      throw error;
    }
    if (error.response) {
      const data = SolarErrorResponseSchema.check(error.response.data);
      const firstError = data.errors[0];
      const valueMapper = errorMap[firstError.code] ?? errorMap["_"];
      if (typeof valueMapper === "function") {
        return valueMapper();
      }
      throw new ClientError({
        id: firstError.id,
        code:
          typeof valueMapper === "string"
            ? valueMapper
            : ClientErrorCode.UNEXPECTED_ERROR,
        detail: firstError.detail,
        status: firstError.status,
      });
    } else if (error.request) {
      throw new ClientError({
        code: ClientErrorCode.NO_CONNECTION,
        detail: "Connection failed, please check your network",
      });
    } else {
      throw new ClientError({
        code: ClientErrorCode.CONFIG_ISSUE,
        detail: "Request configuration is invalid",
      });
    }
  };
}

Снова помощник, но уже примененный к getPerson:

export async function getPerson_v4(personId: string) {
  return httpClient
    .get(`v1/persons/${personId}`)
    .then((response) => response.data)
    .then(snakeToCamelCase)
    .then(restoreDates)
    .then((person) => PersonSchema.check(person))
    .catch(
      mapAxiosError({
        [SolarErrorCode.MODEL_NOT_FOUND]: () => undefined,
        [SolarErrorCode.UNAUTHORIZED_ACTION]: ClientErrorCode.UNAUTHORIZED,
        _: ClientErrorCode.UNEXPECTED_ERROR,
      })
    );
}

Теперь половина кода в getPerson будет повторяться в других функциях API, поэтому нам нужно извлечь этот код:

export interface TypedRequestConfig<T> extends AxiosRequestConfig<T> {
  schema?: Runtype<T>;
}

export async function makeRequest<T>(
  config: TypedRequestConfig<T>
): Promise<T> {
  const response = await httpClient.request(config);

  const result = snakeToCamelCase(restoreDates(response.data));

  if (config.schema) {
    return config.schema.check(result);
  }

  return result;
}

Что удаляет много дублирования:

export async function getPerson_v5(personId: string) {
  return makeRequest({
    method: "GET",
    url: `v1/persons/${personId}`,
    schema: PersonSchema,
  }).catch(
    mapAxiosError({
      [SolarErrorCode.MODEL_NOT_FOUND]: () => undefined,
      [SolarErrorCode.UNAUTHORIZED_ACTION]: ClientErrorCode.UNAUTHORIZED,
      _: ClientErrorCode.UNEXPECTED_ERROR,
    })
  );
}

Подведение итогов

Бьюсь об заклад, вы уже устали от написания кода. Но еще так много нужно сделать:

  1. Автоматический выключатель и повторные попытки. В случае сбоя сети удобно автоматически повторить попытку. Но вам нужно контролировать это поведение и предотвращать бесконечные циклы, особенно с ответами 401 и недействительными токенами.
  2. Логи для отладки. Бывают плохие вещи, и вам нужно будет отлаживать приложение, возможно, даже в реальной среде. Хорошо иметь какой-то способ включить журналы, показывающие запросы и ответы.
  3. Телеметрия. Возможно, вы захотите собрать время отклика и другие данные и отправить их в Prometheus или другую систему.
  4. Запрос переопределяет. Должен быть способ прикрепить дополнительные заголовки, установить определенный тайм-аут и т. д. для каждой функции API в отдельности.
  5. Распределенная трассировка. Это можно сделать с помощью переопределения запроса, но вы, вероятно, захотите настроить его один раз во время инициализации клиента.
  6. Избавьтесь от process.env. Используйте надежный компонент конфигурации, например envalid. Используйте Lodash или другую библиотеку вместо typeof.
  7. Кто знает, что еще.

Я хочу, чтобы вы поняли, что создать клиент производственного уровня сложно. Вы определенно можете вызывать API из Node.js, как показано в начале этого поста. Но через пару лет работы нескольких разработчиков или даже команд приложение и клиент станут огромными. Неуправляемая и случайная сложность будет молча тратить время каждого разработчика, когда они добавляют новую функцию, обновляют ее или исправляют ошибку. И это не будет видно на диаграммах Jira. Итак, в какой-то момент вам понадобится рефакторинг, и вы можете использовать этот пост для вдохновения.

Бонусный контент

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

Говоря о шагах, если бы мы создали функцию для чего-то вроде POST (где мы отправляем данные в API), у нас был бы еще один конвейер. Этот конвейер будет обрабатывать тело запроса: преобразовывать camelCase в snake_case и т. д. Это похоже на конвейер ответов, но в противоположном направлении.

Вы уже видели конвейеры данных. В Express обработка запросов представляет собой конвейер данных. Каждое промежуточное ПО так или иначе влияет на запрос или преобразует его, пока он не достигнет вашего обработчика (контроллера).

Многие рекомендации, которые я дал, применимы и к веб-клиентам, и к универсальным клиентам.

Полный исходный код доступен в CodeSandbox.