Класс запросов
Класс API находит эндпоинт по url_name из ответа загрузки приложения, отправляет данные запроса и возвращает HTTP-ответ вместе с результатом API.
Контракт эндпоинтов общий для всех технологий. Здесь описан Nuxt-транспорт, который использует и песочница API; класс зависит от контекста приложения Nuxt, $fetch и cookies.
Поиск эндпоинта
После успешного POST /api/appload публичные эндпоинты находятся в page_data.unauth.paths; для текущей авторизованной сессии также может прийти page_data.auth.paths. Записи сгруппированы по категориям и маршрутам. Нужная запись определяется по url_name.
- 1
POST /api/apploadПолучить метаданные эндпоинтов для текущей сессии. - 2
url_nameНайти эндпоинт по его постоянному имени. - 3
target + 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.signal | AbortSignal от вызывающего кода. Класс передаёт его в 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 не подтверждает успех.
| Свойство | Назначение |
|---|---|
raw | HTTP-ответ со 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
Общий класс запросов: создание по имени, настройка запроса и обработка результата транспорта.
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 из ответа загрузки приложения.
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 };
}
}
}
}