Request class

The OrionplaceApi client finds a method by its url_name from Application Load, sends the data through the storefront gateway and returns the page_data of a successful answer or an error with the form fields.

In the browser the client comes from the useOrionplaceApi() hook, during the server render from getOrionplaceServerApi(). It is the same class as in the Nuxt starter.

Endpoint discovery

The client reads the current session's methods from the store: public ones from page_data.unauth.paths and, for a signed-in customer, page_data.auth.paths. A method is looked up by its url_name; the request goes to its target with the first method in methods.

  1. 1POST /api/apploadReceive the endpoint metadata for the current session.
  2. 2url_nameFind the endpoint by its stable name.
  3. 3target + methods[0]Use the discovered URL and first declared HTTP method.

When the name is missing from the current Application Load, nothing is sent: the client throws OrionplaceApiError with kind: 'unavailable'. A documentation catalog entry does not make a method available, and the backend checks permissions on every request.

Form types

Getting the client

useOrionplaceApi() creates one client per store and reads the methods, language, currency and sign-in state from it at the moment of each request, so a new Application Load does not require a new client. In server components and routes use await getOrionplaceServerApi(): it is shared within one request and passes on the cookies Application Load received.

The browser client works only in client components ('use client').

Example: sending a form

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 data

PropertyPurpose
request<T>(url_name, data, signal?, params?)Sends the request and returns the page_data of a successful answer. T describes page_data only and is not checked at runtime. Any unsuccessful answer throws OrionplaceApiError.
requestResponse(url_name, data, signal?, params?)Returns the whole answer (status, body, headers, cookies) without the success check. Use it for files and images such as receipts and attachments.
dataAn object is sent as JSON, FormData as multipart for file uploads. For GET the object's fields become query parameters (nested objects as JSON); FormData is never sent with GET.
signalAn AbortSignal cancels the request, for example when the page is left. A cancelled request throws AbortError; do not show it to the customer.
paramsFills a target such as /api/orders/{id}. Only positive integers are accepted; otherwise nothing is sent.

Every request gets the selected language as lang. An order, a preorder and a plan purchase or change get the selected currency (currency) unless the call sets one.

Transport and session

The browser sends the request to its own domain, /api/..., with the session cookies; the storefront gateway passes it on to OrionMarketplace. The answer is awaited for 35 seconds at most.

For requests other than GET the client sets the X-CSRFToken header from the csrftoken cookie; without that cookie it first calls POST /api/csrf_renew. After a CSRF rejection (419, or 403 with page_data.csrf: false) the token is renewed for the next action; the request itself is not repeated and reports its error.

A 401 answer while the storefront still shows a signed-in customer means the session has ended: the client calls the store's endSession() once, and the storefront switches to the guest session.

In the Android app requests go to the storefront's public address (APP_ORIGIN), and the app keeps the cookies itself.

Response and errors

Success means HTTP 2xx, form_errors: null and a non-empty success_message. An HTTP 200 answer can also carry form errors.

PropertyPurpose
OrionplaceApiError

The error request() and requestResponse() throw.

kindunavailable: nothing was sent: the method is missing from the current Application Load, a path parameter is wrong, or the client cannot send requests here. failed: an answer came, but it is not a success.
fieldsError texts per form field; __all__ is the form's general error.
statusThe answer's HTTP status, when there was one.
codesError codes, such as sign_in when the action needs a signed-in customer. Check codes, not texts: the texts are translated.
dataThe page_data of the unsuccessful answer.

apiErrorMessage(reason) returns OrionMarketplace's texts or an empty string, in which case show your own translated text. Technical errors (Failed to fetch, the error kind) are never shown to the customer.

Success and errors

Implementation source

The code from the starter project the customer receives.

lib/orionplace/api.ts

The OrionplaceApi class, OrionplaceApiError and the success check.

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

The browser client and its fetch transport.

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

The server-render client with the request 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
  );
}