Every Core dna site answers three kinds of API request under /cdna-api: REST for everything, GraphQL for reads that span several entity types, and web events for endpoints you define yourself as jobs. Which fits which task, one worked request for each, and what is true of all three. Jobs were called hooks until the module was renamed Orchestration; the API's routes still use the old name.
L1–L3
Unit 06 · Client code and imports
Writing the client layer
Client code and importsPage 22 of 25Updated
An architecture for the scripts on a server-rendered or hybrid site, not snippets: one module owns every request, each kind of failure gets its own answer, an optimistic cart defers to the server, drafts are saved without secrets, and data the page already has is never fetched twice.
Who this is for. The JavaScript developer on a server-rendered or hybrid site, whose scripts call the API from the visitor's browser. Afterwards you can lay out a site's scripts so that the base URL, the token and the error handling live in one file, and give each kind of failure the answer it needs. Code that moves records in bulk from a server has pages of its own in this unit: The external-id pattern and Bulk reads and failure handling.
One API client module
Every request goes through one module. It owns the base URL, the headers, the CSRF token and the error handling; components import it and never call fetch themselves. The rule sounds fussy until the first time the token handling changes, or the API starts answering 401 because a session expired, and the fix is in one file instead of in every component that makes a call.
JavaScriptsrc/js/api.js
// The only file that calls fetch() on /cdna-api
const BASE = '/cdna-api/';
let csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? null;
export function setCsrfToken(token) { csrf = token; } // a fragment can hand one over later
export class ApiError extends Error {
constructor(kind, status, message, fields = {}, data = {}) {
super(message);
this.kind = kind; // 'network' | 'validation' | 'refused' | 'auth' | 'server'
this.status = status;
this.fields = fields; // { fieldName: ['message', ...] } for validation errors
this.data = data; // the response body, for a caller that needs more
}
}
export async function api(path, { method = 'GET', body, properties } = {}) {
const url = new URL(BASE + path, location.origin);
if (properties) url.searchParams.set('properties', properties);
const headers = { Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (method !== 'GET' && csrf) headers['X-Csrf-Token'] = csrf;
let response;
try {
response = await fetch(url, {
method, headers, credentials: 'same-origin',
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (e) {
throw new ApiError('network', 0, 'The connection failed. Check you are online and try again.');
}
const data = await response.json().catch(() => ({}));
// A 422, or a form submission's 207 (stored, but some answers failed the form's rules)
if ((response.status === 422 || response.status === 207) && data.validationErrors) {
const fields = Object.fromEntries(Object.entries(data.validationErrors)
.map(([name, errors]) => [name, errors.map(e => e.message)]));
throw new ApiError('validation', response.status, 'Please correct the highlighted fields.', fields, data);
}
if (response.ok) return data;
if (response.status === 401 || response.status === 403) {
throw new ApiError('auth', response.status, 'Your session has ended. Please sign in again.', {}, data);
}
if (response.status === 400 || response.status === 404 || response.status === 409) {
throw new ApiError('refused', response.status, data.message ?? 'That could not be done.', {}, data);
}
throw new ApiError('server', response.status, 'Something went wrong on our side. Please try again in a moment.', {}, data);
}
A component using it, an add-to-cart button. The cart's routes are open to visitors and answer with the whole cart, so the button hands the answer to whatever draws the cart:
The request shape of each call is in the API reference; the pattern for sites that mix a cached page with live fragments is on Hybrid builds, which uses a module of the same shape. A form built with the platform's form layout needs none of this: the platform's js-forms.js sends it and handles its answers.
Token and header in one place
The CSRF token is read once, from the page the server rendered, and kept in the module; nothing else knows it exists. The class of bug this removes is the one where each component finds the token its own way: one reads a meta tag, one a hidden input, one a global, and the day the token moves (into a fragment, because the page became cacheable) half the writes on the site start answering 403. With the token in the module, setCsrfToken() is the one line a fragment calls. Where the token comes from, and which routes check it, is on The CSRF token, and the 403 on a POST.
The same applies on a server. A headless front end's credential, an OAuth access token or, the older way, an API key, is read from the environment in one module, and no request is ever built anywhere else, so refreshing a token, rotating a key and moving from a key to OAuth are all one-file changes.
Error handling
Four failures look alike in a catch block and need different responses in front of the user:
Failure
How it shows
What the user should see
Network
fetch rejects; there is no status
“Check your connection”, and keep what they typed, because retrying will work
Validation
422 with validationErrors keyed by field; or, from a form submission, a 207, a success code meaning the answers were stored but some failed the form's rules
The message next to each field it belongs to, and nothing at the top of the form
Refused
400, 404 or 409 with a message: a quantity the cart will not take, a record that has gone, a submission that can no longer change
The platform's message, which for these routes is written for people, and no retry
Authorisation
401 (no session), 403 (refused, including a stale CSRF token)
“Sign in again”, and a way to do it that returns them here. Retrying will not help
Treat a 207 as a failure even though response.ok is true: a script that checks only for 2xx thanks a visitor for a submission nobody will act on (Submitting a form through the API). Map a field error back onto the form by name: each key in validationErrors is the property you sent, and for a form submission it is the element's name, which is the input's name:
JavaScriptsrc/js/ui.js
export function showFieldErrors(form, fields) {
for (const input of form.elements) input.setCustomValidity('');
for (const [name, messages] of Object.entries(fields)) {
const input = form.elements.namedItem(name);
if (input) input.setCustomValidity(messages.join(' '));
}
form.reportValidity();
}
Never show the message of a 422 as it arrives: it joins every failure into one string, with the platform's property paths in it. A key that names no input on the form (an element the form does not show, or a path such as patchRevision.areaContent(colours)) belongs in one message at the top, not nowhere (Responses and errors).
Optimistic UI and server truth
When a shopper changes a quantity, updating the number on screen straight away feels right. But the server decides the price, whether the stock exists, and which discounts now apply, so the screen has to be corrected by what the server answers. The pattern: change the display at once, send the request, then replace your guess with the cart the response carries, and put the old state back if the request failed.
JavaScriptsrc/js/cart.js
import { api } from './api.js';
let cart = window.__INITIAL_CART__; // seeded by the template, see below
export function renderCart(c) {
cart = c;
document.querySelector('[data-cart-count]').textContent =
c.items.reduce((sum, item) => sum + item.quantity, 0);
document.querySelector('[data-cart-total]').textContent = c.total;
}
export async function setQuantity(itemId, quantity) {
const before = structuredClone(cart);
const guess = structuredClone(cart);
guess.items.find(i => i.id === itemId).quantity = quantity; // 1. guess the count
document.querySelector('[data-cart-count]').textContent =
guess.items.reduce((sum, item) => sum + item.quantity, 0);
try {
const { data } = await api('cart/item/' + itemId, { method: 'PUT', body: { quantity } });
renderCart(data.cart); // 2. the server's answer wins
} catch (error) {
renderCart(before); // 3. undo the guess
alert(error.message);
}
}
Two details matter. The total is never computed in the browser, even as a guess, because discounts and tax make any local sum wrong in a way the shopper notices; only the count is guessed. And the reconcile uses the whole cart from the response rather than patching one line, because changing one item can change others: a free gift appears, a bundle discount moves. The same reasoning picks the request: PUT /cdna-api/cart/item/{id} with the quantity you want is safe to send twice, while adding an item adds its quantity again on every retry (Retries).
Debounced auto-save and restore
A long form that loses its contents on a reload loses the person filling it in. Save the fields as they type, at most every 800 milliseconds, in sessionStorage, which lasts for the tab and is gone when it closes; restore on load; clear on a successful submit.
JavaScriptsrc/js/form-draft.js
const NEVER_SAVE = new Set(['password', 'confirmPassword', '_csrf', 'cardNumber', 'cvc']);
export function keepDraft(form, key = 'draft:' + location.pathname) {
let saved = {};
try { saved = JSON.parse(sessionStorage.getItem(key) || '{}'); } catch (e) { /* storage blocked */ }
for (const [name, value] of Object.entries(saved)) {
const input = form.elements.namedItem(name);
if (input && input.type !== 'file') input.value = value;
}
let timer;
form.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => {
const data = {};
for (const [name, value] of new FormData(form)) {
if (!NEVER_SAVE.has(name) && typeof value === 'string') data[name] = value;
}
try { sessionStorage.setItem(key, JSON.stringify(data)); } catch (e) { /* storage full or blocked */ }
}, 800);
});
form.addEventListener('submit', () => { try { sessionStorage.removeItem(key); } catch (e) {} });
}
What is deliberately not saved: passwords, the CSRF token, anything a card or payment field holds, and files. Browser storage is readable by every script on the page and survives on shared computers; a saved password there is a password left on the desk. Use sessionStorage rather than localStorage so a draft does not outlive the tab, and expect it to be unavailable in some private windows, which is why every call is wrapped.
Seeding rather than re-fetching
If the template already rendered the data, give it to the script with the page instead of requesting it again. A page that renders a cart and then fetches it on load makes the browser wait for the HTML, then the script, then a second round trip, before the numbers are right; the seeded version is right on first paint. Templates read the visitor's cart through _cart, whose getCart() returns the cart itself:
A type="application/json" script is data, not code, so it needs no CSP nonce. Seed only what is the same for every visitor on a cached page; a cart is per visitor, so on a cached page it belongs in a fragment. The seeding how-to covers both cases.
Imports, bulk reads and failures
The other half of a client layer runs on a server and moves records in and out of the site. It has two pages of its own:
The external-id pattern: storing the other system's identifier on each record, so that running an import twice changes nothing, and what to do when the source has duplicates of its own. Every page in the portal that imports data links there.
Bulk reads and failure handling: page sizes, pacing and incremental runs that leave a production site unaffected, and partial batches, retries and what to log.
Both assume the credential and client module described here, run from a server with an OAuth access token.