Класс запросов

Класс API находит эндпоинт по url_name из ответа загрузки приложения, отправляет данные запроса и возвращает HTTP-ответ вместе с результатом API.

Контракт эндпоинтов общий для всех технологий. Здесь описан Nuxt-транспорт, который использует и песочница API; класс зависит от контекста приложения Nuxt, $fetch и cookies.

Поиск эндпоинта

После успешного POST /api/appload публичные эндпоинты находятся в page_data.unauth.paths; для текущей авторизованной сессии также может прийти page_data.auth.paths. Записи сгруппированы по категориям и маршрутам. Нужная запись определяется по url_name.

  1. 1POST /api/apploadПолучить метаданные эндпоинтов для текущей сессии.
  2. 2url_nameНайти эндпоинт по его постоянному имени.
  3. 3target + methods[0]Использовать полученный URL и первый объявленный HTTP-метод.
СвойствоНазначение
IAppLoadPath

Запись эндпоинта внутри публичных или авторизованных paths из ответа загрузки приложения.

url_nameПостоянное имя эндпоинта для API.fromName(). Не является адресом запроса.
targetМаршрут, объявленный бэкендом. При создании по имени класс нормализует начальный слеш.
methodsОбъявленные HTTP-методы. При создании по имени используется methods[0]; класс не определяет POST по названию эндпоинта.
formsДоступные метаданные форм и фильтров. Вложенность тела и допустимые поля всё равно определяются контрактом эндпоинта: метаданные не заменяют полную схему запроса.

API.fromName() сначала ищет в авторизованных paths, затем в публичных. Если имя не найдено, адрес или первый метод отсутствуют, возвращается undefined. Наличие страницы в справочнике не делает метод доступным. При выполнении запроса бэкенд по-прежнему проверяет права.

Типы форм

Создание запроса

Создание экземпляра API не отправляет запрос. API.fromName<T>(url_name) выбирает URL и HTTP-метод из текущего ответа загрузки приложения. Для объявленных эндпоинтов используется поиск по имени.

СвойствоНазначение
API.fromName<T>(url_name)Возвращает API<T> для текущего эндпоинта или undefined, если он недоступен. Найденный target используется в режиме точного URL.
new API<T>(url, method)Создаёт запрос с явно переданным URL и необязательным HTTP-методом. Если метод не указан, fetch использует POST. Этот конструктор не ищет эндпоинт в ответе загрузки приложения.
useAPI<T>(url, method)Возвращает новый экземпляр API<T> с явным URL и необязательным методом, как и конструктор.

Экземпляр сохраняет найденные URL и метод при создании. После изменения данных загрузки приложения новый экземпляр использует актуальные paths. Уже созданный экземпляр не обновляет адрес и не перезагружает сессию автоматически.

Данные запроса и URL

Методы настройки возвращают тот же экземпляр. fetch() отправляет тело запроса в формате, описанном для эндпоинта.

СвойствоНазначение
setBody(body)Сохраняет объект или FormData для последующего fetch. Объект передаётся в тело запроса без дополнительных оболочек эндпоинта.
fetch(body, options)Отправляет запрос. Переданное тело имеет приоритет над сохранённым; без аргумента используется значение из setBody(). Настройки транспорта принимают signal, timeout и retry.
setHeaders(headers)Заменяет набор дополнительных заголовков. Затем транспорт задаёт CSRF, origin, referer и часовой пояс; этот набор не переопределяет их.
setPathParam(name, value)Заменяет первое совпадение именованного параметра пути в текущем URL переданной строкой или числом. Значение вставляется как есть, без URL-кодирования.
setPathParams(params)Последовательно применяет переданные строковые значения через setPathParam().

Адрес и язык запроса

СвойствоНазначение
getURL()Возвращает исходный URL до setURL и подстановки параметров пути.
getLastURL()Возвращает последний настроенный URL или исходный, если он не менялся. Обработку языка, выполняемую позже в fetch, не включает.
setURL(url)Сохраняет URL для следующего fetch. HTTP-метод остаётся прежним.
changeURL(fn)Вызывает функцию с исходным URL и сохраняет её результат через setURL(). Ранее изменённый URL в функцию не передаётся.
shouldUseExactURL(should)Включает или отключает обработку URL. При true fetch использует настроенный адрес без изменений. Создание по имени автоматически включает этот режим.
shouldAddLangToQuery(lang)В режиме обработки URL значение true добавляет ?lang= с текущей локалью; строка задаёт язык явно, false отключает этот суффикс. Вызов без аргумента использует true.

Запросы по имени изначально используют режим точного URL. В нём shouldAddLangToQuery() не влияет на адрес. Песочница заранее добавляет выбранный lang в отображаемый URL и отправляет запрос через тот же класс.

Транспорт и сессия

Запросы передают cookies сессии. В браузере транспорт читает CSRF-cookie для заголовка; при серверном рендеринге также передаёт заголовки входящего запроса и применяет полученные от бэкенда Set-Cookie к ответу приложения.

СвойствоНазначение
credentialsЗначение include передаёт cookies сессии в пределах правил браузера.
X-CSRFTokenПри каждом fetch читает cookie csrftoken и передаёт её значение; при отсутствии передаётся пустая строка.
X-TZПередаёт часовой пояс, определённый средой выполнения JavaScript.
options.signalAbortSignal от вызывающего кода. Класс передаёт его в fetch; отмена обрабатывается как неудавшийся запрос.
options.timeoutТайм-аут в миллисекундах, передаваемый в $fetch. Если настройка не указана, класс не задаёт собственный тайм-аут.
options.retryНастройка повторов для $fetch. Значение 0 отключает автоматические повторы; без него поведение определяет транспорт.
shouldShowLoadingIndicator(should)Управляет общим индикатором загрузки в браузере; по умолчанию включён. Флаг сбрасывается в finally. Класс не открывает окно входа.

При нажатии «Отправить» песочница заново вызывает API.fromName(url_name), задаёт собственный тайм-аут запроса и отключает автоматические повторы через retry: 0. Повторная отправка во время выполнения заблокирована; при размонтировании запрос отменяется. Вместо общего индикатора используется собственное состояние отправки.

Ответ и типы

HTTP-статус и оболочка API проверяются отдельно. fetch() возвращает результат и для полученного ответа, и при перехваченной ошибке транспорта или HTTP. Ответ HTTP 200 может содержать form_errors; одно наличие page_data не подтверждает успех.

СвойствоНазначение
rawHTTP-ответ со status, заголовками и разобранным телом в _data. При сетевой ошибке может отсутствовать.
page_dataПоле page_data из оболочки, типизированное как T. В зависимости от метода и результата может отсутствовать или быть null.
form_errorsОшибки полей, возвращённые бэкендом. Общие ошибки находятся в __all__. Проверяются отдельно от HTTP-статуса.
success_messageПоле success_message из оболочки; null или отсутствие превращаются в undefined. Одно сообщение не подтверждает успех HTTP-запроса.
errorКраткий текст: первая общая ошибка, ошибка поля или сообщение об ошибке транспорта. Не заменяет полный form_errors и HTTP-ответ.

Тип T в API<T> описывает page_data, а не всю оболочку ответа. Он не проверяет тело запроса или ответ во время выполнения. На страницах эндпоинтов доступны отдельные интерфейсы запроса и ответа. Если ответ получен, его HTTP-данные и тело не в формате JSON доступны через raw.

Ответ HTTP 401 возвращается как результат запроса; этот класс не меняет состояние входа на этом сайте. Общий API-клиент поставляемого магазина на Nuxt в этом случае удаляет данные вошедшего пользователя и загружает гостевой сеанс, как описано в разделе «Успех и ошибки».

Успех и ошибки

Исходный код

Блоки читают поддерживаемые файлы реализации. Копирование сохраняет исходный код; для работы нужны его импортируемые зависимости и контекст Nuxt.

app/functions/api/useAPI.ts

Общий класс запросов: создание по имени, настройка запроса и обработка результата транспорта.

typescript
import { readCookieNow } from '@/functions/api/cookieNow';
import type { NuxtApp } from 'nuxt/app';
import SetCookieParser from 'set-cookie-parser';
import type { IFetch } from '@/interfaces/apiBase/IFetch.ts';
import type { TMethod } from '@/interfaces/apiBase/TMethod.ts';
import { getConditionalURL } from '@/functions/api/url.ts';
import { useCommonStore } from '@/stores/commonStore.ts';
import { findLiveApiEndpoint } from '@/functions/api/findLiveApiEndpoint';
import { useAppLoadStore } from '@/stores/apploadStore';

// Documentation pages read the demo storefront: the same name as DEMO_DATA_HEADER in applicationLoadBoundary.ts.
const DEMO_DATA_HEADER = 'X-Orionplace-Demo-Data';

// Browser requests that show the top loading bar and have not finished yet.
let indicatorRequests = 0;

export class API<T = {}> {
  private method?: TMethod;
  private initialURL: string;
  private lastURL?: string;
  private body: Record<string, any> | FormData | null             = null;
  private headers: Record<string, string | undefined> | undefined = undefined;

  private addLangToQuery: boolean | string                        = true;
  private useExactURL                                             = false;
  private showLoadingIndicator                                    = true;

  constructor(url: string, method?: TMethod | string) {
    this.method     = method as TMethod;
    this.initialURL = url;
  }

  public static fromName<T = {}>(name: string) {
    const endpoint = findLiveApiEndpoint(useAppLoadStore().state, name)?.path;
    if (!endpoint?.target || !endpoint.methods[0]) {
      return undefined;
    }

    const url      = `/${endpoint.target.replace(/^\/+/, '')}`;
    return new API<T>(url, endpoint.methods[0]).shouldUseExactURL();
  }

  public setBody(body: Record<string, any> | FormData = {}) {
    this.body = body;
    return this;
  }

  public setHeaders(
    addHeaders: Record<string, string | undefined> | undefined = undefined,
  ) {
    this.headers = addHeaders;
    return this;
  }

  public getURL() {
    return this.initialURL;
  }

  public getLastURL() {
    return this.lastURL ?? this.initialURL;
  }

  public setURL(url: string) {
    this.lastURL = url;
    return this;
  }

  public changeURL(fn: (currentURL: string) => string) {
    return this.setURL(fn(this.initialURL));
  }

  public setPathParam(param: string, value: string | number) {
    const url = this.getLastURL();

    return this.setURL(url.replace('{' + param + '}', String(value)));
  }

  public setPathParams(params: Record<string, string>) {
    Object.entries(params).forEach(([key, value]) =>
      this.setPathParam(key, value),
    );
    return this;
  }

  public shouldAddLangToQuery(lang: boolean | string = true) {
    this.addLangToQuery = lang;
    return this;
  }

  public shouldUseExactURL(should: boolean = true) {
    this.useExactURL = should;
    return this;
  }

  public shouldShowLoadingIndicator(should: boolean = true) {
    this.showLoadingIndicator = should;
    return this;
  }

  public async fetch<T2 extends T>(
    _body?: Record<string, any> | FormData,
    options: { signal?: AbortSignal; timeout?: number; retry?: number } = {},
  ) {
    const nuxt        = useNuxtApp();
    const pinia       = nuxt.$pinia as import('pinia').Pinia;
    const commonStore = !nuxt.ssrContext ? useCommonStore(pinia) : null;

    const headers     = this.getDefaultHeaders(nuxt);
    const URL         = this.useExactURL
      ? (this.lastURL ?? this.initialURL)
      : getConditionalURL(this.lastURL ?? this.initialURL, this.addLangToQuery);
    try {
      if (commonStore && this.showLoadingIndicator) {
        indicatorRequests          += 1;
        commonStore.isGlobalLoading = true;
      }

      const result                     = await $fetch.raw<IFetch<T2>>(URL, {
        body: _body ?? this.body,
        method: this.method ?? 'POST',
        headers: headers,
        credentials: 'include',
        signal: options.signal,
        timeout: options.timeout,
        retry: options.retry,

        // Forward backend cookies through the SSR response.
        onResponse(ctx) {
          if (!import.meta.server) {
            return;
          }
          const raw     = ctx.response.headers.get('Set-Cookie');
          if (!raw) {
            return;
          }
          const cookies = SetCookieParser.parse(
            SetCookieParser.splitCookiesString(raw),
          );
          nuxt.runWithContext(() =>
            cookies.forEach((cookie) => {
              const dynamic = useCookie(cookie.name, cookie as any);
              dynamic.value = cookie.value;
            }),
          );
        },
        onResponseError(ctx) {
          // The server journal keeps failed backend calls; the browser already lists them in its Network tab.
          if (import.meta.server) console.error('[API] Response error', ctx.response?.status, ctx.request);
          if (ctx.response?.status === 453) {
            if (import.meta.client) {
              const message =
                ctx.response?._data?.form_errors?.__all__?.[0]?.message ??
                'Rate limit error';

              localStorage.setItem('453', message);
            }
          }
        },
      });

      let json: IFetch<T2> | undefined = undefined;
      try {
        json = result._data;
      } catch (e) {
        if (import.meta.server) console.error('[API/JSON]', e);
      }

      return {
        raw: result,
        page_data: json?.page_data,
        form_errors: json?.form_errors,
        success_message: json?.success_message ?? undefined,
        error: json?.form_errors
          ? (json.form_errors.__all__?.at(0)?.message ??
            Object.values(json.form_errors).at(0)?.at(0)?.message ??
            'Error')
          : undefined,
      };
    } catch (ex: any) {
      if (import.meta.server) console.error('[API] Request failed', URL, ex?.message ?? ex);
      // HTTP failures can still contain the backend response envelope.
      const json = ex?.data as IFetch<T2> | undefined;
      if (json?.page_data || json?.form_errors) {
        return {
          raw: ex?.response,
          page_data: json?.page_data,
          form_errors: json?.form_errors,
          success_message: json?.success_message ?? undefined,
          error: json?.form_errors
            ? (json.form_errors.__all__?.at(0)?.message ??
              Object.values(json.form_errors).at(0)?.at(0)?.message ??
              'Error')
            : undefined,
        };
      }
      return {
        raw: ex?.response,
        page_data: undefined,
        form_errors: undefined,
        success_message: undefined,
        error: (ex?.toString() || 'Error') as string,
      };
    } finally {
      // The bar stays while any request that shows it is still running, not until the first one ends.
      if (commonStore && this.showLoadingIndicator) {
        indicatorRequests           = Math.max(0, indicatorRequests - 1);
        commonStore.isGlobalLoading = indicatorRequests > 0;
      }
    }
  }

  protected getDefaultHeaders(nuxt: NuxtApp) {
    const headers = import.meta.server
      ? new Headers(useRequestEvent(nuxt)?.headers)
      : new Headers();

    if (this.headers) {
      Object.entries(this.headers).forEach(([headerName, headerValue]) => {
        headers.set(headerName, headerValue!);
      });
    }

    headers.set('X-CSRFToken', readCookieNow('csrftoken') ?? '');
    headers.set('Referer', useRequestURL().origin);
    headers.set('Origin', useRequestURL().origin);
    headers.set('X-TZ', Intl.DateTimeFormat().resolvedOptions().timeZone);
    // Documentation pages read the demo storefront (server/api/[...path].ts); a copied page request never decides it.
    headers.delete(DEMO_DATA_HEADER);
    if (useCommonStore(nuxt.$pinia as import('pinia').Pinia).apiScope === 'demo') {
      headers.set(DEMO_DATA_HEADER, '1');
    }

    // A server render's own call copies the visitor's request headers. A visitor who follows a link from
    // another site arrives with `Sec-Fetch-Site: cross-site`; the gateway's cross-site check would refuse the
    // render's Application Load and every page would answer 503. The call comes from this server.
    if (import.meta.server) {
      headers.set('sec-fetch-site', 'same-origin');
      headers.set('sec-fetch-mode', 'cors');
      headers.set('sec-fetch-dest', 'empty');
    }

    headers.delete('connection');
    return headers;
  }
}

export default function useAPI<T = {}>(url: string, method?: TMethod | string) {
  return new API<T>(url, method as TMethod);
}
app/functions/api/findLiveApiEndpoint.ts

Поиск url_name в текущих авторизованных и публичных paths из ответа загрузки приложения.

typescript
import type { IAppLoad } from '@/interfaces/appBase/IAppLoad';
import type { IAppLoadPath } from '@/interfaces/appBase/appLoadCommon/IAppLoadCommonPath';

export function findLiveApiEndpoint(
  appLoad: IAppLoad | undefined,
  urlName: string,
): { section: 'auth' | 'unauth'; path: IAppLoadPath } | undefined {
  for (const section of ['auth', 'unauth'] as const) {
    const groups = Object.values(appLoad?.[section]?.paths ?? {});
    for (const group of groups) {
      const path = Object.values(group).find(
        (endpoint) => endpoint.url_name === urlName,
      );
      if (path) {
        return { section, path };
      }
    }
  }
}