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 useOrionplaceApi(), during a page server load from serverApi(event, endpoints, locale). 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() takes the client from the context or creates one per store, and reads the methods, language, currency and sign-in state from it at the moment of each request. Call it during component initialization. In +page.server.ts use serverApi(event, endpoints, locale) (as loadStorefrontPage() does): it passes on the request cookies and returns new cookies to the browser.

During the server render the browser client sends nothing and throws kind: 'unavailable'; load server data in +page.server.ts.

Example: sending a form

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

fieldErrorText(reason.fields) joins the error texts without repeats. When it is empty, 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.

src/lib/utils/orionplace/api.ts

The OrionplaceApi class, OrionplaceApiError and the success check.

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

The browser client and its fetch transport.

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

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