Класс запросов
Клиент OrionplaceApi находит метод по url_name из загрузки приложения, отправляет данные через шлюз витрины и возвращает page_data успешного ответа или ошибку с полями формы.
В браузере клиент даёт useOrionplaceApi(), при серверной загрузке страницы — serverApi(event, endpoints, locale). Это тот же класс, что и в стартовом проекте 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 и читает из него методы, язык, валюту и вход покупателя в момент каждого запроса. Вызывайте его при инициализации компонента. В +page.server.ts используйте serverApi(event, endpoints, locale) (так делает loadStorefrontPage()): он передаёт cookies запроса и возвращает новые cookies браузеру.
Во время серверного рендера браузерный клиент не отправляет запросы и бросает kind: 'unavailable'; серверные данные загружайте в +page.server.ts.
Пример: отправка формы
<script lang="ts">
import { useOrionplaceApi } from '@/lib/composables/useOrionplaceApi.svelte';
import {
fieldErrorText,
OrionplaceApiError,
} from '@/lib/utils/orionplace/api';
let { fields }: { fields: Record<string, string> } = $props();
const api = useOrionplaceApi();
let errors = $state<Record<string, string>>({});
let failed = $state('');
async function send() {
errors = {};
failed = '';
try {
// url_name from Application Load; the result is page_data.
await api.request('misc_contact', { ...fields });
} catch (reason) {
if (reason instanceof OrionplaceApiError) {
errors = reason.fields;
failed = fieldErrorText(reason.fields);
}
failed ||= 'The message was not sent.';
}
}
</script>
{#if errors.email}<p>{errors.email}</p>{/if}
{#if failed}<p role="alert">{failed}</p>{/if}
<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 неуспешного ответа. |
fieldErrorText(reason.fields) собирает тексты ошибок без повторов. Если он пуст, покажите свой переведённый текст; технические ошибки (Failed to fetch, вид ошибки) покупателю не показываются.
Исходный код
Код из стартового проекта, который получает покупатель.
src/lib/utils/orionplace/api.ts
Класс OrionplaceApi, OrionplaceApiError и проверка успеха.
import type { IAppLoadPathObject } from '@/lib/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonPath';
import type { TMethod } from '@/lib/interfaces/orionplace/apiBase/TMethod';
export interface OrionplaceApiResponse {
body: unknown;
status: number;
cookies: string[];
headers?: Record<string, string>;
}
export type OrionplaceApiData = Record<string, unknown> | 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;
}
}
/**
* 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 transport: OrionplaceTransport;
private endpoints: () => IAppLoadPathObject;
private cookies: () => string;
private locale: () => string;
private canRequest: () => boolean;
private isSignedIn: () => boolean;
private currency: () => string | number | undefined;
private onSessionEnded: (() => unknown) | undefined;
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;
}
/** The guest refresh started by the latest HTTP 401, while it runs. */
get sessionEnd() {
return this.sessionEnding;
}
applicationLoad(signal: AbortSignal, locale = '') {
return this.send(
'/api/appload',
'POST',
{
detalize_mode: 'light',
ignore_cache: false,
},
signal,
locale,
);
}
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.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;
// A CSRF rejection: Django views answer 419, API endpoints answer 403
// with page_data.csrf set to false. The request is not repeated.
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;
}
/*
* The backend answers HTTP 401 when the request has no signed-in session:
* an authentication-required endpoint without a customer ({ auth: false }),
* or a session the backend rejected and whose cookies it deleted.
* A 401 while the application still shows a customer means the session
* ended. One handler call serves concurrent failures; a guest 401, 403 and
* CSRF rejection are ordinary request errors.
*/
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);
}
// The failed request reports its own error; the refresh has its own state.
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;
}
}
src/lib/composables/useOrionplaceApi.svelte.ts
Браузерный клиент и его отправка через fetch.
import { browser } from '$app/environment';
import { getContext, setContext } from 'svelte';
import { getOrionplaceContext } from '@/lib/orionplace/store.svelte';
import { OrionplaceApi } from '@/lib/utils/orionplace/api';
import {
APP_ORIGIN,
nativeAppCookies,
nativeAppRequest,
} from '@/lib/utils/orionplace/nativeApp';
import type {
OrionplaceApiRequest,
OrionplaceApiResponse,
} from '@/lib/utils/orionplace/api';
const clients = new WeakMap<object, OrionplaceApi>();
const apiKey = Symbol('orionplace-api');
export function provideOrionplaceApi(api: OrionplaceApi) {
setContext(apiKey, api);
}
export async function browserTransport(
path: string,
request: OrionplaceApiRequest,
): Promise<OrionplaceApiResponse> {
// The Android app sends its requests to the public storefront; see
// utils/orionplace/nativeApp.ts. Empty on the website.
if (APP_ORIGIN) {
return nativeAppRequest(APP_ORIGIN, path, request);
}
const query = new URLSearchParams();
if (request.method === 'GET' && !(request.data instanceof FormData)) {
for (const [key, value] of Object.entries(request.data)) {
query.set(
key,
value && typeof value === 'object'
? JSON.stringify(value)
: String(value ?? ''),
);
}
}
const suffix = query.toString();
const url = path + (suffix ? (path.includes('?') ? '&' : '?') + suffix : '');
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(35000)])
: AbortSignal.timeout(35000),
});
const contentType = response.headers.get('content-type') ?? '';
let body: unknown;
if (contentType.includes('application/json')) {
body = await response.json().catch(() => null);
} else if (
contentType.startsWith('image/') ||
contentType.includes('application/pdf') ||
response.headers.has('content-disposition')
) {
body = await response.blob();
} else {
body = await response.text();
}
return {
body,
status: response.status,
cookies: [],
headers: {
'content-type': contentType,
'content-disposition': response.headers.get('content-disposition') ?? '',
},
};
}
export function useOrionplaceApi() {
const provided = getContext<OrionplaceApi>(apiKey);
if (provided) {
return provided;
}
const store = getOrionplaceContext();
const existing = clients.get(store);
if (existing) {
return existing;
}
const api = new OrionplaceApi({
transport: browserTransport,
endpoints: () => store.endpoints,
cookies: () =>
browser ? (APP_ORIGIN ? nativeAppCookies() : document.cookie) : '',
locale: () => store.localeCode,
canRequest: () => browser,
isSignedIn: () => store.isAuthenticated,
// The customer's currency for orders and plan purchases (CURRENCY_REQUESTS).
currency: () => store.currencyId,
onSessionEnded: () =>
store.endSession((signal) =>
import('@/lib/orionplace/appLoadClient').then((module) =>
module.requestBrowserAppLoad(signal, store.localeCode),
),
),
});
clients.set(store, api);
return api;
}
src/lib/orionplace/serverApi.ts
Клиент для серверного рендера с cookies запроса.
import { env } from '$env/dynamic/private';
import { forwardMarketplace } from '@orionplace-runtime/marketplace.mjs';
import { relayCookies } from '@/lib/orionplace/appLoadServer';
import { OrionplaceApi } from '@/lib/utils/orionplace/api';
import type { OrionplaceApiRequest } from '@/lib/utils/orionplace/api';
import type { IAppLoadPathObject } from '@/lib/interfaces/orionplace';
import type { RequestEvent } from '@sveltejs/kit';
export function serverApi(
event: RequestEvent,
endpoints: IAppLoadPathObject,
locale: string,
) {
return new OrionplaceApi({
endpoints: () => endpoints,
locale: () => locale,
cookies: () =>
event.cookies
.getAll()
.map(({ name, value }) => `${name}=${value}`)
.join('; '),
transport: async (path: string, request: OrionplaceApiRequest) => {
const url = new URL(path, event.url.origin);
const headers = new Headers(event.request.headers);
headers.delete('content-length');
headers.set(
'cookie',
event.cookies
.getAll()
.map(({ name, value }) => `${name}=${value}`)
.join('; '),
);
headers.set('origin', event.url.origin);
for (const [name, value] of Object.entries(request.headers)) {
headers.set(name, value);
}
const form = request.data instanceof FormData;
if (request.method === 'GET') {
for (const [name, value] of Object.entries(request.data)) {
if (value !== undefined && value !== null) {
url.searchParams.set(
name,
typeof value === 'object' ? JSON.stringify(value) : String(value),
);
}
}
} else if (!form) {
headers.set('content-type', 'application/json');
} else {
headers.delete('content-type');
}
const response = await forwardMarketplace(
new Request(url, {
method: request.method,
headers,
body:
request.method === 'GET'
? undefined
: form
? (request.data as FormData)
: JSON.stringify(request.data),
signal: request.signal ?? AbortSignal.timeout(35_000),
}),
{ mimicFrontDomain: env.MIMIC_FRONT_DOMAIN ?? '' },
);
const cookies = response.headers.getSetCookie();
relayCookies(event.cookies, cookies);
const contentType = response.headers.get('content-type') ?? '';
return {
status: response.status,
cookies,
headers: Object.fromEntries(response.headers),
body: contentType.includes('json')
? await response.json()
: contentType.startsWith('image/') || contentType.includes('pdf')
? await response.blob()
: await response.text(),
};
},
});
}