Класс запросов
Клиент OrionplaceApi находит метод по url_name из загрузки приложения, отправляет данные через шлюз витрины и возвращает page_data успешного ответа или ошибку с полями формы.
В браузере клиент даёт хук useOrionplaceApi(), при серверном рендере — getOrionplaceServerApi(). Это тот же класс, что и в стартовом проекте Nuxt.
Поиск метода
Клиент читает методы текущей сессии из store: публичные из page_data.unauth.paths и, для вошедшего покупателя, page_data.auth.paths. Метод ищется по url_name, запрос уходит на его target первым методом из methods.
- 1
POST /api/apploadПолучить метаданные эндпоинтов для текущей сессии. - 2
url_nameНайти эндпоинт по его постоянному имени. - 3
target + methods[0]Использовать полученный URL и первый объявленный HTTP-метод.
Если имени нет в текущей загрузке приложения, запрос не отправляется: клиент бросает OrionplaceApiError с kind: 'unavailable'. Запись в каталоге документации не делает метод доступным, а права backend проверяет при каждом запросе.
Получение клиента
useOrionplaceApi() создаёт один клиент на store и читает из него методы, язык, валюту и вход покупателя в момент каждого запроса, поэтому после новой загрузки приложения клиент пересоздавать не нужно. В серверных компонентах и маршрутах используйте await getOrionplaceServerApi(): он один на запрос и передаёт cookies, полученные загрузкой приложения.
Браузерный клиент работает только в клиентских компонентах ('use client').
Пример: отправка формы
'use client';
import { useState } from 'react';
import { useOrionplaceApi } from '@/hooks/useOrionplaceApi';
import { apiErrorMessage, OrionplaceApiError } from '@/lib/orionplace/api';
export function ContactSend({ fields }: { fields: Record<string, string> }) {
const api = useOrionplaceApi();
const [errors, setErrors] = useState<Record<string, string>>({});
const [failed, setFailed] = useState('');
async function send() {
setErrors({});
setFailed('');
try {
// url_name from Application Load; the result is page_data.
await api.request('misc_contact', fields);
} catch (reason) {
if (reason instanceof OrionplaceApiError) {
setErrors(reason.fields);
}
setFailed(apiErrorMessage(reason) || 'The message was not sent.');
}
}
return (
<>
{errors.email && <p>{errors.email}</p>}
{failed && <p role="alert">{failed}</p>}
<button type="button" onClick={send}>
Send
</button>
</>
);
}Данные запроса
| Свойство | Назначение |
|---|---|
request<T>(url_name, data, signal?, params?) | Отправляет запрос и возвращает page_data успешного ответа. T описывает только page_data и не проверяет данные во время работы. Любой неуспешный ответ бросает OrionplaceApiError. |
requestResponse(url_name, data, signal?, params?) | Возвращает ответ целиком (status, body, headers, cookies) без проверки успеха. Нужен для файлов и картинок, например чеков и вложений. |
data | Объект уходит как JSON, FormData — как multipart для загрузки файлов. У GET поля объекта становятся параметрами адреса (вложенные объекты — JSON); FormData с GET не отправляется. |
signal | AbortSignal отменяет запрос, например при уходе со страницы. Отменённый запрос бросает AbortError, его не показывают покупателю. |
params | Подставляет числа в target вида /api/orders/{id}. Принимаются только положительные целые; иначе запрос не отправляется. |
Ко всем запросам добавляется lang выбранного языка. К заказу, предзаказу и покупке или смене тарифа клиент добавляет выбранную валюту (currency), если её не передали.
Отправка и сессия
Браузер отправляет запрос на свой домен, /api/..., с cookies сессии; шлюз витрины передаёт его в OrionMarketplace. Ответа ждут не дольше 35 секунд.
Для запросов, кроме GET, клиент ставит заголовок X-CSRFToken из cookie csrftoken; если cookie ещё нет, сначала вызывается POST /api/csrf_renew. После отказа CSRF (419, или 403 с page_data.csrf: false) токен обновляется для следующего действия, а сам запрос не повторяется и возвращает ошибку.
Ответ 401, пока покупатель на витрине считается вошедшим, значит, что сессия закончилась: клиент один раз вызывает endSession() store, и витрина переходит в гостевую сессию.
В приложении Android запросы идут на публичный адрес витрины (APP_ORIGIN), а cookies хранит само приложение.
Ответ и ошибки
Успех — это HTTP 2xx, form_errors: null и непустой success_message. Ответ HTTP 200 тоже может содержать ошибки формы.
| Свойство | Назначение |
|---|---|
OrionplaceApiErrorОшибка, которую бросают | |
kind | unavailable — запрос не отправлен: метода нет в текущей загрузке приложения, неверный параметр адреса или клиент здесь не может отправлять запросы. failed — ответ пришёл, но это не успех. |
fields | Тексты ошибок по полям формы; __all__ — общая ошибка формы. |
status | HTTP-статус ответа, если он был. |
codes | Коды ошибок, например sign_in, когда действие требует входа. Проверяйте коды, а не текст: тексты переведены. |
data | page_data неуспешного ответа. |
apiErrorMessage(reason) возвращает тексты OrionMarketplace или пустую строку — тогда покажите свой переведённый текст. Технические ошибки (Failed to fetch, вид ошибки) покупателю не показываются.
Исходный код
Код из стартового проекта, который получает покупатель.
lib/orionplace/api.ts
Класс OrionplaceApi, OrionplaceApiError и проверка успеха.
import type { IAppLoadPathObject, TMethod } from '@/interfaces/orionplace';
export interface OrionplaceApiResponse {
body: unknown;
status: number;
cookies: string[];
headers?: Record<string, string>;
}
export type OrionplaceApiData = object | FormData;
export interface OrionplaceApiRequest {
method: TMethod;
data: OrionplaceApiData;
headers: Record<string, string>;
signal?: AbortSignal;
}
export type OrionplaceTransport = (
path: string,
request: OrionplaceApiRequest,
) => Promise<OrionplaceApiResponse>;
/**
* The texts of the field errors, each once: OrionMarketplace often repeats a
* field's error for the whole form.
*/
export function fieldErrorText(fields: Record<string, unknown>) {
return [...new Set(Object.values(fields).filter(Boolean))].join(' ');
}
export class OrionplaceApiError extends Error {
readonly kind: 'unavailable' | 'failed';
readonly fields: Record<string, string>;
readonly status?: number;
readonly data?: unknown;
// Error codes of the answer, such as `sign_in` when OrionMarketplace asks the visitor
// to sign in first.
readonly codes: string[];
constructor(
kind: 'unavailable' | 'failed',
fields: Record<string, string> = {},
status?: number,
data?: unknown,
codes: string[] = [],
) {
super(fieldErrorText(fields) || kind);
this.kind = kind;
this.fields = fields;
this.status = status;
this.data = data;
this.codes = codes;
}
}
/**
* The text to show for a failed request: OrionMarketplace's own messages, or '' so the
* caller shows its translated fallback. A bare error kind ('unavailable',
* 'failed') or the browser's network message ('Failed to fetch') is never
* shown to the customer.
*/
export function apiErrorMessage(reason: unknown) {
if (reason instanceof OrionplaceApiError) {
return fieldErrorText(reason.fields);
}
if (
!(reason instanceof Error) ||
reason instanceof TypeError ||
reason.name === 'AbortError'
) {
return '';
}
return reason.message;
}
/**
* Requests whose amounts the backend counts in the customer's currency: an
* order, a preorder and a plan purchase are priced and charged in it. The
* client adds the selected currency to them (unless the caller set one).
*/
export const CURRENCY_REQUESTS = new Set([
'orders_create',
'preorders_create',
'subscriptions_subscribe',
'subscriptions_renew',
'subscriptions_change_renew',
'subscriptions_change_tariff',
'subscriptions_change_option',
]);
export class OrionplaceApi {
private renewal: Promise<void> | undefined;
private sessionEnding: Promise<void> | undefined;
private readonly transport: OrionplaceTransport;
private readonly endpoints: () => IAppLoadPathObject;
private readonly cookies: () => string;
private readonly locale: () => string;
private readonly canRequest: () => boolean;
private readonly isSignedIn: () => boolean;
private readonly currency: () => string | number | undefined;
private readonly onSessionEnded?: () => unknown;
constructor(options: {
transport: OrionplaceTransport;
endpoints: () => IAppLoadPathObject;
cookies: () => string;
locale?: () => string;
canRequest?: () => boolean;
isSignedIn?: () => boolean;
currency?: () => string | number | undefined;
onSessionEnded?: () => unknown;
}) {
this.transport = options.transport;
this.endpoints = options.endpoints;
this.cookies = options.cookies;
this.locale = options.locale ?? (() => '');
this.canRequest = options.canRequest ?? (() => true);
this.isSignedIn = options.isSignedIn ?? (() => false);
this.currency = options.currency ?? (() => undefined);
this.onSessionEnded = options.onSessionEnded;
}
get sessionEnd() {
return this.sessionEnding;
}
async request<T>(
name: string,
data: OrionplaceApiData,
signal?: AbortSignal,
params: Record<string, number> = {},
): Promise<T> {
return this.successData<T>(
await this.requestResponse(name, data, signal, params),
);
}
async requestResponse(
name: string,
data: OrionplaceApiData,
signal?: AbortSignal,
params: Record<string, number> = {},
): Promise<OrionplaceApiResponse> {
if (!this.canRequest()) {
throw new OrionplaceApiError('unavailable');
}
const discovered = Object.values(this.endpoints())
.flatMap((section) => Object.values(section))
.find((entry) => entry.url_name === name);
const endpoint =
discovered ??
(name === 'reload_customer_app'
? { target: 'api/reload_customer_app', methods: ['PATCH' as const] }
: undefined);
const method = endpoint?.methods[0];
let path = `/${endpoint?.target.replace(/^\/+/, '') ?? ''}`;
path = path.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (_, key: string) => {
const value = params[key];
if (!Number.isSafeInteger(value) || Number(value) <= 0) {
throw new OrionplaceApiError('unavailable');
}
return String(value);
});
if (
!endpoint ||
!method ||
!/^\/api\/[a-zA-Z0-9_/-]+(?:\.png)?$/.test(path)
) {
throw new OrionplaceApiError('unavailable');
}
if (method === 'GET' && data instanceof FormData) {
throw new OrionplaceApiError('unavailable');
}
const currency = Number(this.currency());
if (
CURRENCY_REQUESTS.has(name) &&
!(data instanceof FormData) &&
(data as Record<string, unknown>).currency === undefined &&
Number.isSafeInteger(currency) &&
currency > 0
) {
data = { ...data, currency };
}
const protectedRequest = method !== 'GET';
if (protectedRequest && !this.csrfToken()) {
await this.renewCsrf();
}
signal?.throwIfAborted();
const response = await this.send(path, method, data, signal);
const body = response.body as {
page_data?: { csrf?: boolean };
} | null;
const csrfRejected =
response.status === 419 ||
(response.status === 403 && body?.page_data?.csrf === false);
// Renew for the next explicit action and retain this request's rejection.
if (protectedRequest && csrfRejected) {
await this.renewCsrf();
signal?.throwIfAborted();
}
this.detectSessionEnd(response);
signal?.throwIfAborted();
return response;
}
private detectSessionEnd(response: OrionplaceApiResponse) {
if (
response.status !== 401 ||
this.sessionEnding ||
!this.onSessionEnded ||
!this.isSignedIn()
) {
return;
}
let ending: Promise<void>;
try {
ending = Promise.resolve(this.onSessionEnded()).then(() => undefined);
} catch (error) {
ending = Promise.reject(error);
}
this.sessionEnding = ending
.catch(() => undefined)
.finally(() => {
this.sessionEnding = undefined;
});
}
private csrfToken() {
return this.cookies().match(/(?:^|;\s*)csrftoken=([^;]*)/)?.[1] ?? '';
}
private localizedPath(path: string, requestedLocale = '') {
const locale = (requestedLocale || this.locale()).trim();
if (!locale) {
return path;
}
const separator = path.includes('?') ? '&' : '?';
return `${path}${separator}lang=${encodeURIComponent(locale)}`;
}
private async renewCsrf() {
if (!this.renewal) {
this.renewal = this.send('/api/csrf_renew', 'POST', {})
.then((response) => {
this.detectSessionEnd(response);
this.successData(response);
if (!this.csrfToken()) {
throw new OrionplaceApiError('failed');
}
})
.finally(() => {
this.renewal = undefined;
});
}
await this.renewal;
}
private send(
path: string,
method: TMethod,
data: OrionplaceApiData,
signal?: AbortSignal,
locale = '',
) {
const headers: Record<string, string> = {
Accept: 'application/json',
...(data instanceof FormData
? {}
: { 'Content-Type': 'application/json' }),
};
const token = this.csrfToken();
if (token) {
headers['X-CSRFToken'] = token;
}
return this.transport(this.localizedPath(path, locale), {
method,
data,
headers,
signal,
});
}
private successData<T>(response: OrionplaceApiResponse): T {
const body = response.body as {
form_errors?: unknown;
success_message?: unknown;
page_data?: T;
} | null;
if (
response.status < 200 ||
response.status >= 300 ||
body?.form_errors !== null ||
typeof body.success_message !== 'string' ||
!body.success_message
) {
const fields = Object.fromEntries(
Object.entries(body?.form_errors ?? {}).map(([name, entries]) => [
name,
Array.isArray(entries)
? entries.map((entry) => String(entry?.message ?? '')).join(' ')
: '',
]),
);
const codes = Object.values(body?.form_errors ?? {})
.flatMap((entries) => (Array.isArray(entries) ? entries : []))
.map((entry) => String(entry?.code ?? ''))
.filter(Boolean);
throw new OrionplaceApiError(
'failed',
fields,
response.status,
body?.page_data,
codes,
);
}
return body.page_data as T;
}
}
hooks/useOrionplaceApi.ts
Браузерный клиент и его отправка через fetch.
'use client';
import { useMemo } from 'react';
import {
useOrionplaceSelectionsStore,
useOrionplaceStore,
} from '@/providers/OrionplaceProvider';
import { requestBrowserAppLoad } from '@/lib/orionplace/appLoadClient';
import {
OrionplaceApi,
type OrionplaceApiRequest,
type OrionplaceApiResponse,
} from '@/lib/orionplace/api';
import {
APP_ORIGIN,
nativeAppCookies,
nativeAppRequest,
} from '@/lib/orionplace/nativeApp';
function queryValue(value: unknown) {
return value !== null && typeof value === 'object'
? JSON.stringify(value)
: String(value ?? '');
}
export async function transport(
path: string,
request: OrionplaceApiRequest,
): Promise<OrionplaceApiResponse> {
// The Android app sends its requests to the public storefront; see
// lib/orionplace/nativeApp.ts. Empty on the website.
if (APP_ORIGIN) {
return nativeAppRequest(APP_ORIGIN, path, request);
}
const url = new URL(path, window.location.origin);
if (request.method === 'GET' && !(request.data instanceof FormData)) {
for (const [key, value] of Object.entries(request.data)) {
url.searchParams.set(key, queryValue(value));
}
}
const response = await fetch(url, {
method: request.method,
headers: request.headers,
body:
request.method === 'GET'
? undefined
: request.data instanceof FormData
? request.data
: JSON.stringify(request.data),
credentials: 'same-origin',
cache: 'no-store',
signal: request.signal
? AbortSignal.any([request.signal, AbortSignal.timeout(35_000)])
: AbortSignal.timeout(35_000),
});
const contentType = response.headers.get('content-type') ?? '';
const body = contentType.includes('application/json')
? await response.json().catch(() => null)
: await response.blob();
return {
body,
status: response.status,
cookies: [],
headers: {
'content-type': contentType,
'content-disposition': response.headers.get('content-disposition') ?? '',
},
};
}
export function useOrionplaceApi() {
const store = useOrionplaceStore();
const selections = useOrionplaceSelectionsStore();
return useMemo(
() =>
new OrionplaceApi({
transport,
endpoints: () => store.getState().endpoints,
cookies: () => (APP_ORIGIN ? nativeAppCookies() : document.cookie),
locale: () => selections.getState().localeCode,
// The customer's currency for orders and plan purchases (CURRENCY_REQUESTS).
currency: () => selections.getState().currencyId,
isSignedIn: () =>
Boolean(
store.getState().result?.response.page_data.init.is_authenticated,
),
onSessionEnded: async () => {
const locale = selections.getState().localeCode;
await store
.getState()
.endSession((signal) => requestBrowserAppLoad(signal, locale));
},
}),
[selections, store],
);
}
lib/orionplace/serverApi.ts
Клиент для серверного рендера с cookies запроса.
import { cache } from 'react';
import { headers } from 'next/headers';
import { forwardMarketplace } from '@orionplace-runtime/marketplace.mjs';
import { getOrionplaceAppLoad } from '@/lib/orionplace/appLoadServer';
import {
apiErrorMessage,
OrionplaceApi,
type OrionplaceApiRequest,
type OrionplaceApiResponse,
} from '@/lib/orionplace/api';
import { featureLabels } from '@/lib/orionplace/featureLabels';
import { initialSnapshot } from '@/stores/orionplace';
import { getOrionplaceServerLocale } from '@/lib/orionplace/serverLocale';
function setCookiePair(value: string) {
return value.split(';', 1)[0]?.trim() ?? '';
}
function cookieName(value: string) {
return value.slice(0, value.indexOf('=')).trim();
}
function cookieJar(initial: string, additions: string[]) {
const values = new Map<string, string>();
for (const value of initial.split(';')) {
const pair = value.trim();
const name = cookieName(pair);
if (name) {
values.set(name, pair);
}
}
function apply(cookies: string[]) {
for (const value of cookies) {
const pair = setCookiePair(value);
const name = cookieName(pair);
if (!name) {
continue;
}
if (!pair.slice(name.length + 1)) {
values.delete(name);
} else {
values.set(name, pair);
}
}
}
apply(additions);
return {
apply,
header: () => [...values.values()].join('; '),
};
}
function queryValue(value: unknown) {
return value !== null && typeof value === 'object'
? JSON.stringify(value)
: String(value ?? '');
}
export const getOrionplaceServerApi = cache(async () => {
const incoming = await headers();
const appLoad = await getOrionplaceAppLoad();
const jar = cookieJar(incoming.get('cookie') ?? '', appLoad.cookies);
const host =
incoming.get('x-forwarded-host') || incoming.get('host') || 'localhost';
const protocol =
incoming.get('x-forwarded-proto')?.split(',')[0]?.trim() || 'https';
const origin = `${protocol}://${host}`;
async function transport(
path: string,
request: OrionplaceApiRequest,
): Promise<OrionplaceApiResponse> {
const url = new URL(path, origin);
if (request.method === 'GET' && !(request.data instanceof FormData)) {
for (const [key, value] of Object.entries(request.data)) {
url.searchParams.set(key, queryValue(value));
}
}
const forwarded = new Headers(request.headers);
for (const name of [
'x-front-domain',
'x-forwarded-host',
'x-forwarded-proto',
'host',
'accept-language',
'user-agent',
'x-init-ip',
'x-real-ip',
'x-forwarded-for',
'x-tz',
]) {
const value = incoming.get(name);
if (value) {
forwarded.set(name, value);
}
}
if (jar.header()) {
forwarded.set('cookie', jar.header());
}
forwarded.set('origin', origin);
const response = await forwardMarketplace(
new Request(url, {
method: request.method,
headers: forwarded,
body:
request.method === 'GET'
? undefined
: request.data instanceof FormData
? request.data
: JSON.stringify(request.data),
signal: request.signal,
}),
{ mimicFrontDomain: process.env.MIMIC_FRONT_DOMAIN ?? '' },
);
const cookies = response.headers.getSetCookie();
jar.apply(cookies);
const contentType = response.headers.get('content-type') ?? '';
const body = contentType.includes('application/json')
? await response.json().catch(() => null)
: await response.blob();
return {
body,
status: response.status,
cookies,
headers: {
'content-type': contentType,
'content-disposition':
response.headers.get('content-disposition') ?? '',
},
};
}
const result = appLoad.result;
const snapshot = initialSnapshot(result, appLoad.failure);
const locale = await getOrionplaceServerLocale();
return new OrionplaceApi({
transport,
endpoints: () => snapshot.endpoints,
cookies: jar.header,
locale: () => locale,
isSignedIn: () => Boolean(result?.response.page_data.init.is_authenticated),
});
});
/**
* The text a server-rendered page shows when its first request fails: OrionMarketplace's
* messages, or the translated failure in the visitor's language (never a
* bare error kind or an English fallback).
*/
export async function serverErrorText(reason: unknown) {
return (
apiErrorMessage(reason) ||
featureLabels(await getOrionplaceServerLocale()).failed
);
}