Files
shop/server/utils/cmsError.ts
libretech 5c773a99e6 feat(orders): pass the CMS's status and safe error fields to the browser
The order routes (get, put, add-product, remove-product, checkout, capture)
call forwardToCms, which throws a CMS error on as
createError({ statusCode, statusMessage, data: { message, errors?, missing? } }),
built by the pure shopErrorFromCms (server/utils/cmsError.ts).

Before, the FetchError was thrown on as it was: the browser got the CMS's
status, but Nitro treated it as unhandled, answered "Server Error" without
data and logged every CMS 4xx as [unhandled]. Now the browser also gets the
CMS's message, the errors of a rejected update and the fields a checkout
misses, and no other field. A status that is the shop's own fault (401, 403,
...) is answered 500, a CMS that does not answer 503; 5xx are logged without
the query and the order uuid.

npm test runs tests/unit with Node's type stripping and no dependencies, as
in libreshop/cms. nuxt.config keeps tests/ out of the app's type check.

Refs libretech/mp#71
2026-10-09 02:32:14 +02:00

82 lines
4.4 KiB
TypeScript

// How a failed CMS request answers the browser: with the CMS's status and only the fields of its error that are safe to show.
// The CMS (Strapi) answers an error as { data: null, error: { status, name, message, details } }, which $fetch throws as ofetch's
// FetchError with the response's status and parsed body. Thrown on as it is, Nitro treats it as unhandled: it hides the message and
// the data from the browser and logs the CMS URL. forwardToCms (cmsApi.ts) throws createError(shopErrorFromCms(error)) instead.
// Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/cmsError.test.ts.
/** What the browser receives as the data of the error. errors and missing are the CMS's details.errors and details.missing. */
export type ShopErrorData = { message: string; errors?: string[]; missing?: string[] };
/** The argument for h3's createError. */
export type ShopError = { statusCode: number; statusMessage: string; data: ShopErrorData };
// The statuses passed on: the CMS's answers about the order and its payment. Any other status, such as 401 or 403 for a wrong
// API token, is the shop's own fault and answered 500.
const STATUS_TEXTS = new Map<number, string>([
[400, "Bad Request"],
[404, "Not Found"],
[409, "Conflict"],
[500, "Internal Server Error"],
[502, "Bad Gateway"],
[503, "Service Unavailable"],
[504, "Gateway Timeout"]
]);
const MAX_MESSAGE_LENGTH = 200;
const MAX_STATUS_MESSAGE_LENGTH = 100;
const MAX_FIELD_LENGTH = 64;
const MAX_LIST_LENGTH = 20;
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const cut = (text: string, maxLength: number): string => (text.length > maxLength ? `${text.slice(0, maxLength)}…` : text);
// The strings of a list in the CMS's details, e.g. ["data.email: must be an email address of at most 254 characters"].
const stringList = (value: unknown, maxLength: number): string[] | undefined => {
if (!Array.isArray(value)) return undefined;
const list = value
.filter((item): item is string => typeof item === "string")
.slice(0, MAX_LIST_LENGTH)
.map((item) => cut(item, maxLength));
return list.length > 0 ? list : undefined;
};
// The status message is the HTTP reason phrase, which allows printable ASCII only.
const isReasonPhrase = (text: string): boolean => text.length <= MAX_STATUS_MESSAGE_LENGTH && /^[\x20-\x7e]+$/.test(text);
/**
* The error to throw to the browser for an error of $fetch to the CMS, or undefined if the error is not ofetch's FetchError
* (a bug, to rethrow as it is). The CMS's status is kept for 400, 404, 409, 500, 502, 503 and 504, any other is answered 500,
* and a CMS that does not answer 503. The data holds the CMS's message, details.errors and details.missing, and nothing else.
*/
export const shopErrorFromCms = (error: unknown): ShopError | undefined => {
if (!(error instanceof Error) || error.name !== "FetchError") return undefined;
const { status, data } = error as Error & { status?: unknown; data?: unknown };
if (typeof status !== "number") {
return { statusCode: 503, statusMessage: "Service Unavailable", data: { message: "The CMS did not answer" } };
}
const statusText = STATUS_TEXTS.get(status);
if (statusText === undefined) {
return { statusCode: 500, statusMessage: "Internal Server Error", data: { message: "Internal Server Error" } };
}
const cmsError: Record<string, unknown> = isObject(data) && isObject(data.error) ? data.error : {};
const details: Record<string, unknown> = isObject(cmsError.details) ? cmsError.details : {};
const message = typeof cmsError.message === "string" && cmsError.message !== "" ? cut(cmsError.message, MAX_MESSAGE_LENGTH) : statusText;
const errors = stringList(details.errors, MAX_MESSAGE_LENGTH);
const missing = stringList(details.missing, MAX_FIELD_LENGTH);
return {
statusCode: status,
statusMessage: isReasonPhrase(message) ? message : statusText,
data: { message, ...(errors ? { errors } : {}), ...(missing ? { missing } : {}) }
};
};
/** The log line for a failed CMS request: without its query, and with order uuids replaced, since a uuid opens its order. */
export const cmsErrorLogLine = (method: string, endpoint: string, error: ShopError): string =>
`[cms] ${method.toUpperCase()} ${endpoint.replace(/\?.*$/, "").replace(UUID, ":uuid")}: ${error.statusCode} ${error.data.message}`;