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
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
// 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}`;
|
||||
Reference in New Issue
Block a user