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

Клиент OrionplaceApi находит метод по url_name из загрузки приложения, отправляет данные через шлюз витрины и возвращает page_data успешного ответа или ошибку с полями формы.

В браузере клиент даёт хук useOrionplaceApi(), при серверном рендере — getOrionplaceServerApi(). Это тот же класс, что и в стартовом проекте Nuxt.

Поиск метода

Клиент читает методы текущей сессии из store: публичные из page_data.unauth.paths и, для вошедшего покупателя, page_data.auth.paths. Метод ищется по url_name, запрос уходит на его target первым методом из methods.

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

Если имени нет в текущей загрузке приложения, запрос не отправляется: клиент бросает OrionplaceApiError с kind: 'unavailable'. Запись в каталоге документации не делает метод доступным, а права backend проверяет при каждом запросе.

Типы форм

Получение клиента

useOrionplaceApi() создаёт один клиент на store и читает из него методы, язык, валюту и вход покупателя в момент каждого запроса, поэтому после новой загрузки приложения клиент пересоздавать не нужно. В серверных компонентах и маршрутах используйте await getOrionplaceServerApi(): он один на запрос и передаёт cookies, полученные загрузкой приложения.

Браузерный клиент работает только в клиентских компонентах ('use client').

Пример: отправка формы

tsx
'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 не отправляется.
signalAbortSignal отменяет запрос, например при уходе со страницы. Отменённый запрос бросает 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

Ошибка, которую бросают request() и requestResponse().

kindunavailable — запрос не отправлен: метода нет в текущей загрузке приложения, неверный параметр адреса или клиент здесь не может отправлять запросы. failed — ответ пришёл, но это не успех.
fieldsТексты ошибок по полям формы; __all__ — общая ошибка формы.
statusHTTP-статус ответа, если он был.
codesКоды ошибок, например sign_in, когда действие требует входа. Проверяйте коды, а не текст: тексты переведены.
datapage_data неуспешного ответа.

apiErrorMessage(reason) возвращает тексты OrionMarketplace или пустую строку — тогда покажите свой переведённый текст. Технические ошибки (Failed to fetch, вид ошибки) покупателю не показываются.

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

Исходный код

Код из стартового проекта, который получает покупатель.

lib/orionplace/api.ts

Класс OrionplaceApi, OrionplaceApiError и проверка успеха.

typescript
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.

typescript
'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 запроса.

typescript
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
  );
}