openhands / src /manifests /manifest-error-map.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
63522a5 verified
Raw History Blame Contribute Delete
4.74 kB
/**
* Translate service-reported errors back to the form field that produced them.
*
* The host validates and submits the *derived payload*, so errors come back
* addressed by payload path (`trigger.schedule`) rather than by form field
* (`schedule`). The reverse lookup is derived from the same builder that made
* the payload; the mapping is lossy by nature, since one payload value can be
* built from several fields.
*/
import type { SetupRequestBody } from "./types";
/** An error the service reported, addressed by payload path where it gave one. */
export interface ManifestServiceError {
path: string;
message: string;
}
export interface MappedManifestErrors {
/** Keyed by form field name. */
fieldErrors: Record<string, string>;
/** Errors with no field to attach to; shown against the form as a whole. */
formErrors: string[];
}
const EMPTY_MAPPED_ERRORS: MappedManifestErrors = {
fieldErrors: {},
formErrors: [],
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Turn a service `loc` array into a path into the payload actually sent.
*
* A validation framework may address a value through segments that are not keys
* of the request body — a discriminated union reports
* `["body","trigger","cron","schedule"]` for a body whose trigger has no `cron`
* key. Walking the payload and skipping segments that do not resolve reduces
* that to `trigger.schedule` without knowing anything about the payload's
* meaning.
*/
export function locToPayloadPath(
loc: readonly (string | number)[],
payload: unknown,
): string {
const segments = loc[0] === "body" ? loc.slice(1) : loc;
const parts: string[] = [];
let current: unknown = payload;
segments.forEach((segment) => {
if (typeof segment === "number") {
if (Array.isArray(current) && parts.length > 0) {
parts[parts.length - 1] += `[${segment}]`;
current = current[segment];
}
return;
}
if (isRecord(current) && segment in current) {
parts.push(segment);
current = current[segment];
}
});
return parts.join(".");
}
/**
* Read errors out of a service response body, whichever of the two shapes it
* uses: a preflight result keyed by field, or a validation failure keyed by
* `loc`.
*/
export function normalizeServiceErrors(
body: unknown,
payload: SetupRequestBody | null,
): ManifestServiceError[] {
if (!isRecord(body)) return [];
if (Array.isArray(body.errors)) {
return body.errors.flatMap((entry) => {
if (!isRecord(entry) || typeof entry.message !== "string") return [];
return [
{
path: typeof entry.field === "string" ? entry.field : "",
message: entry.message,
},
];
});
}
const { detail } = body;
if (typeof detail === "string") {
return [{ path: "", message: detail }];
}
if (Array.isArray(detail)) {
return detail.flatMap((entry) => {
if (!isRecord(entry) || typeof entry.msg !== "string") return [];
const loc = Array.isArray(entry.loc)
? (entry.loc as (string | number)[])
: [];
return [{ path: locToPayloadPath(loc, payload), message: entry.msg }];
});
}
return [];
}
/**
* The fields behind a payload path.
*
* The map is derived from a payload where every list holds a single entry, so
* a path addressing a later index names the same field the first one does.
* Reading every index as the first is what lets an error about the second
* repository of a multi-value field reach that field at all, rather than
* falling through to a form-level message that points at nothing.
*/
function fieldsAt(
path: string,
errorMap: Record<string, string[]>,
): string[] | undefined {
return errorMap[path] ?? errorMap[path.replace(/\[\d+\]/g, "[0]")];
}
/** Apply the derived payload-path map, falling back to a form-level error. */
export function mapServiceErrors(
errors: readonly ManifestServiceError[],
errorMap: Record<string, string[]>,
): MappedManifestErrors {
if (errors.length === 0) return EMPTY_MAPPED_ERRORS;
const fieldErrors: Record<string, string> = {};
const formErrors: string[] = [];
errors.forEach(({ path, message }) => {
const fields = path ? fieldsAt(path, errorMap) : undefined;
if (!fields?.length) {
formErrors.push(message);
return;
}
fields.forEach((field) => {
// Keep the first error reported against a field; later ones would
// silently replace a message the user has not read yet.
if (!(field in fieldErrors)) fieldErrors[field] = message;
});
});
return { fieldErrors, formErrors };
}