// 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([ [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 => 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 = isObject(data) && isObject(data.error) ? data.error : {}; const details: Record = 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}`;