Request class

The API class resolves endpoints by url_name from Application Load, sends request data and returns the HTTP response together with the API result.

Endpoint contracts are shared across technologies. The class documented here is the Nuxt transport also used by the API playground; it depends on the Nuxt application context, $fetch and cookies.

Endpoint discovery

After a successful POST /api/appload, public endpoints are in page_data.unauth.paths; the current authenticated session can also receive page_data.auth.paths. These objects group endpoint records by category and route. Look up the record by its url_name.

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

An endpoint record within the public or authenticated paths returned by Application Load.

url_nameStable endpoint identifier used by API.fromName(). It is not the request URL.
targetBackend-provided route. The named factory normalizes its leading slash.
methodsDeclared HTTP methods. The named factory takes methods[0]; it does not infer POST from an endpoint name.
formsAvailable form/filter metadata. Body nesting and accepted fields must still follow the endpoint contract; discovery metadata is not a complete request schema.

API.fromName() searches authenticated paths first, then public paths. If the name is absent, or its target or first method is missing, it returns undefined. A documentation catalog entry does not make an endpoint available. The backend still checks permissions when the request arrives.

Form types

Creating a request

Creating an API instance does not send a request. API.fromName<T>(url_name) selects both the URL and HTTP method from the current Application Load response. Use the named lookup for discovered endpoints.

PropertyPurpose
API.fromName<T>(url_name)Returns API<T> for a current endpoint or undefined when unavailable. Uses the discovered target in exact-URL mode.
new API<T>(url, method)Creates a request from an explicit URL and optional HTTP method. Without a method, fetch() uses POST. This constructor does not perform Application Load discovery.
useAPI<T>(url, method)Returns a new API<T> instance for an explicit URL and optional method, with the same behavior as the constructor.

Each instance captures the resolved URL and method when it is created. After Application Load changes, create a new instance to use the current discovery data. An existing instance does not update itself or refresh the session.

Request data and URL

Configuration methods return the same instance. fetch() sends the request body in the format documented for the endpoint.

PropertyPurpose
setBody(body)Stores an object or FormData for a later fetch. The object is passed as the request body without adding endpoint-specific nesting.
fetch(body, options)Sends the request. Its body argument takes precedence over the stored body; omitting it uses the value from setBody(). Transport options accept signal, timeout and retry.
setHeaders(headers)Replaces the configured custom header collection. The transport then sets its CSRF, origin, referer and time-zone headers; these are not overridden by the custom collection.
setPathParam(name, value)Replaces the first matching named path placeholder in the current URL with the supplied string or number. Values are inserted as supplied, without URL encoding.
setPathParams(params)Applies the supplied string values one by one through setPathParam().

URL and language settings

PropertyPurpose
getURL()Returns the initial URL, before setURL or path parameter changes.
getLastURL()Returns the last configured URL, or the initial URL when it has not been changed. It does not include language processing performed later by fetch.
setURL(url)Stores the URL used by the next fetch. The HTTP method stays unchanged.
changeURL(fn)Calls the supplied function with the initial URL and stores its result through setURL(). It does not pass the previously modified URL.
shouldUseExactURL(should)Enables or disables URL processing. With true, fetch uses the configured URL unchanged. Named requests enable this mode automatically.
shouldAddLangToQuery(lang)In non-exact mode, true appends ?lang= with the current locale; a string supplies an explicit language and false disables this suffix. Calling the method without an argument uses true.

Named requests use exact-URL mode by default. In this mode, shouldAddLangToQuery() has no effect. The playground prepares its displayed URL with the selected lang parameter before sending it through the same class.

Transport and session

Requests include session cookies. In the browser, the transport reads the CSRF cookie for the request header; during server rendering it also forwards request headers and applies backend Set-Cookie values to the outgoing response.

PropertyPurpose
credentialsUses include so requests carry session cookies where the browser permits them.
X-CSRFTokenReads the csrftoken cookie for each fetch and sends its value, or an empty string if absent.
X-TZSends the time zone resolved by the JavaScript runtime.
options.signalAn AbortSignal supplied by the caller. The class passes it to fetch; cancellation is handled through the failed-request result.
options.timeoutTimeout in milliseconds passed to $fetch. The class does not set its own timeout when this option is omitted.
options.retryRetry setting passed to $fetch. Use 0 to disable automatic retries; omitting it leaves retry behavior to the transport.
shouldShowLoadingIndicator(should)Controls the shared browser loading indicator; it is enabled by default. The flag is cleared in finally. The class does not open a sign-in dialog.

The playground calls API.fromName(url_name) again on Send, sets its own request timeout and disables automatic retries with retry: 0. It blocks duplicate submissions and aborts its request on unmount. It shows its own pending state instead of the global loading indicator.

Response and types

Check HTTP status and the API envelope separately. fetch() returns a result for successful responses and caught transport/HTTP failures. An HTTP 200 response can contain form_errors; the presence of page_data alone does not establish success.

PropertyPurpose
rawRaw HTTP response, including status, headers and the decoded body in _data. It may be absent after a network failure.
page_dataThe envelope’s page_data, typed as T. It can be absent or null depending on the endpoint and outcome.
form_errorsBackend field errors, retained when available. General errors use __all__. Inspect this separately from HTTP status.
success_messageThe envelope’s success_message; null or absent values become undefined. A message alone does not establish transport success.
errorConvenience error text: the first general or field error, or transport failure text. It does not replace the complete form_errors or raw response.

T in API<T> describes page_data, not the complete response envelope. It does not validate the request body or response at runtime. Endpoint pages provide separate request and response interfaces. Non-JSON bodies and HTTP details remain available through raw when a response was received.

An HTTP 401 response is returned as the request result; this class does not change the sign-in state of this website. The supplied Nuxt storefront’s shared API client removes the signed-in data and loads the guest session instead, as described in Success and errors.

Success and errors

Implementation source

These code blocks read the maintained implementation files. Copying preserves the actual source; its imported dependencies and Nuxt context are required.

app/functions/api/useAPI.ts

Shared request class, named factory, request configuration and transport response handling.

typescript
import { readCookieNow } from '@/functions/api/cookieNow';
import type { NuxtApp } from 'nuxt/app';
import SetCookieParser from 'set-cookie-parser';
import type { IFetch } from '@/interfaces/apiBase/IFetch.ts';
import type { TMethod } from '@/interfaces/apiBase/TMethod.ts';
import { getConditionalURL } from '@/functions/api/url.ts';
import { useCommonStore } from '@/stores/commonStore.ts';
import { findLiveApiEndpoint } from '@/functions/api/findLiveApiEndpoint';
import { useAppLoadStore } from '@/stores/apploadStore';

// Documentation pages read the demo storefront: the same name as DEMO_DATA_HEADER in applicationLoadBoundary.ts.
const DEMO_DATA_HEADER = 'X-Orionplace-Demo-Data';

// Browser requests that show the top loading bar and have not finished yet.
let indicatorRequests = 0;

export class API<T = {}> {
  private method?: TMethod;
  private initialURL: string;
  private lastURL?: string;
  private body: Record<string, any> | FormData | null             = null;
  private headers: Record<string, string | undefined> | undefined = undefined;

  private addLangToQuery: boolean | string                        = true;
  private useExactURL                                             = false;
  private showLoadingIndicator                                    = true;

  constructor(url: string, method?: TMethod | string) {
    this.method     = method as TMethod;
    this.initialURL = url;
  }

  public static fromName<T = {}>(name: string) {
    const endpoint = findLiveApiEndpoint(useAppLoadStore().state, name)?.path;
    if (!endpoint?.target || !endpoint.methods[0]) {
      return undefined;
    }

    const url      = `/${endpoint.target.replace(/^\/+/, '')}`;
    return new API<T>(url, endpoint.methods[0]).shouldUseExactURL();
  }

  public setBody(body: Record<string, any> | FormData = {}) {
    this.body = body;
    return this;
  }

  public setHeaders(
    addHeaders: Record<string, string | undefined> | undefined = undefined,
  ) {
    this.headers = addHeaders;
    return this;
  }

  public getURL() {
    return this.initialURL;
  }

  public getLastURL() {
    return this.lastURL ?? this.initialURL;
  }

  public setURL(url: string) {
    this.lastURL = url;
    return this;
  }

  public changeURL(fn: (currentURL: string) => string) {
    return this.setURL(fn(this.initialURL));
  }

  public setPathParam(param: string, value: string | number) {
    const url = this.getLastURL();

    return this.setURL(url.replace('{' + param + '}', String(value)));
  }

  public setPathParams(params: Record<string, string>) {
    Object.entries(params).forEach(([key, value]) =>
      this.setPathParam(key, value),
    );
    return this;
  }

  public shouldAddLangToQuery(lang: boolean | string = true) {
    this.addLangToQuery = lang;
    return this;
  }

  public shouldUseExactURL(should: boolean = true) {
    this.useExactURL = should;
    return this;
  }

  public shouldShowLoadingIndicator(should: boolean = true) {
    this.showLoadingIndicator = should;
    return this;
  }

  public async fetch<T2 extends T>(
    _body?: Record<string, any> | FormData,
    options: { signal?: AbortSignal; timeout?: number; retry?: number } = {},
  ) {
    const nuxt        = useNuxtApp();
    const pinia       = nuxt.$pinia as import('pinia').Pinia;
    const commonStore = !nuxt.ssrContext ? useCommonStore(pinia) : null;

    const headers     = this.getDefaultHeaders(nuxt);
    const URL         = this.useExactURL
      ? (this.lastURL ?? this.initialURL)
      : getConditionalURL(this.lastURL ?? this.initialURL, this.addLangToQuery);
    try {
      if (commonStore && this.showLoadingIndicator) {
        indicatorRequests          += 1;
        commonStore.isGlobalLoading = true;
      }

      const result                     = await $fetch.raw<IFetch<T2>>(URL, {
        body: _body ?? this.body,
        method: this.method ?? 'POST',
        headers: headers,
        credentials: 'include',
        signal: options.signal,
        timeout: options.timeout,
        retry: options.retry,

        // Forward backend cookies through the SSR response.
        onResponse(ctx) {
          if (!import.meta.server) {
            return;
          }
          const raw     = ctx.response.headers.get('Set-Cookie');
          if (!raw) {
            return;
          }
          const cookies = SetCookieParser.parse(
            SetCookieParser.splitCookiesString(raw),
          );
          nuxt.runWithContext(() =>
            cookies.forEach((cookie) => {
              const dynamic = useCookie(cookie.name, cookie as any);
              dynamic.value = cookie.value;
            }),
          );
        },
        onResponseError(ctx) {
          // The server journal keeps failed backend calls; the browser already lists them in its Network tab.
          if (import.meta.server) console.error('[API] Response error', ctx.response?.status, ctx.request);
          if (ctx.response?.status === 453) {
            if (import.meta.client) {
              const message =
                ctx.response?._data?.form_errors?.__all__?.[0]?.message ??
                'Rate limit error';

              localStorage.setItem('453', message);
            }
          }
        },
      });

      let json: IFetch<T2> | undefined = undefined;
      try {
        json = result._data;
      } catch (e) {
        if (import.meta.server) console.error('[API/JSON]', e);
      }

      return {
        raw: result,
        page_data: json?.page_data,
        form_errors: json?.form_errors,
        success_message: json?.success_message ?? undefined,
        error: json?.form_errors
          ? (json.form_errors.__all__?.at(0)?.message ??
            Object.values(json.form_errors).at(0)?.at(0)?.message ??
            'Error')
          : undefined,
      };
    } catch (ex: any) {
      if (import.meta.server) console.error('[API] Request failed', URL, ex?.message ?? ex);
      // HTTP failures can still contain the backend response envelope.
      const json = ex?.data as IFetch<T2> | undefined;
      if (json?.page_data || json?.form_errors) {
        return {
          raw: ex?.response,
          page_data: json?.page_data,
          form_errors: json?.form_errors,
          success_message: json?.success_message ?? undefined,
          error: json?.form_errors
            ? (json.form_errors.__all__?.at(0)?.message ??
              Object.values(json.form_errors).at(0)?.at(0)?.message ??
              'Error')
            : undefined,
        };
      }
      return {
        raw: ex?.response,
        page_data: undefined,
        form_errors: undefined,
        success_message: undefined,
        error: (ex?.toString() || 'Error') as string,
      };
    } finally {
      // The bar stays while any request that shows it is still running, not until the first one ends.
      if (commonStore && this.showLoadingIndicator) {
        indicatorRequests           = Math.max(0, indicatorRequests - 1);
        commonStore.isGlobalLoading = indicatorRequests > 0;
      }
    }
  }

  protected getDefaultHeaders(nuxt: NuxtApp) {
    const headers = import.meta.server
      ? new Headers(useRequestEvent(nuxt)?.headers)
      : new Headers();

    if (this.headers) {
      Object.entries(this.headers).forEach(([headerName, headerValue]) => {
        headers.set(headerName, headerValue!);
      });
    }

    headers.set('X-CSRFToken', readCookieNow('csrftoken') ?? '');
    headers.set('Referer', useRequestURL().origin);
    headers.set('Origin', useRequestURL().origin);
    headers.set('X-TZ', Intl.DateTimeFormat().resolvedOptions().timeZone);
    // Documentation pages read the demo storefront (server/api/[...path].ts); a copied page request never decides it.
    headers.delete(DEMO_DATA_HEADER);
    if (useCommonStore(nuxt.$pinia as import('pinia').Pinia).apiScope === 'demo') {
      headers.set(DEMO_DATA_HEADER, '1');
    }

    // A server render's own call copies the visitor's request headers. A visitor who follows a link from
    // another site arrives with `Sec-Fetch-Site: cross-site`; the gateway's cross-site check would refuse the
    // render's Application Load and every page would answer 503. The call comes from this server.
    if (import.meta.server) {
      headers.set('sec-fetch-site', 'same-origin');
      headers.set('sec-fetch-mode', 'cors');
      headers.set('sec-fetch-dest', 'empty');
    }

    headers.delete('connection');
    return headers;
  }
}

export default function useAPI<T = {}>(url: string, method?: TMethod | string) {
  return new API<T>(url, method as TMethod);
}
app/functions/api/findLiveApiEndpoint.ts

Lookup of url_name in the current authenticated and public Application Load paths.

typescript
import type { IAppLoad } from '@/interfaces/appBase/IAppLoad';
import type { IAppLoadPath } from '@/interfaces/appBase/appLoadCommon/IAppLoadCommonPath';

export function findLiveApiEndpoint(
  appLoad: IAppLoad | undefined,
  urlName: string,
): { section: 'auth' | 'unauth'; path: IAppLoadPath } | undefined {
  for (const section of ['auth', 'unauth'] as const) {
    const groups = Object.values(appLoad?.[section]?.paths ?? {});
    for (const group of groups) {
      const path = Object.values(group).find(
        (endpoint) => endpoint.url_name === urlName,
      );
      if (path) {
        return { section, path };
      }
    }
  }
}