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

Клиент 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. 1POST /api/apploadПолучить метаданные эндпоинтов для текущей сессии.
  2. 2url_nameНайти эндпоинт по его постоянному имени.
  3. 3target + 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.

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

svelte
<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 не отправляется.
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 неуспешного ответа.

fieldErrorText(reason.fields) собирает тексты ошибок без повторов. Если он пуст, покажите свой переведённый текст; технические ошибки (Failed to fetch, вид ошибки) покупателю не показываются.

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

Исходный код

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

src/lib/utils/orionplace/api.ts

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

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

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

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