/** * API Error Handler Utility * Parses server error responses and extracts field-level validation errors. * Server text is mapped to safe, user-facing copy — raw messages are not echoed. */ import axios, { AxiosError } from "axios"; import { FieldValues, Path, UseFormSetError } from "react-hook-form"; /** * Standard API error response structure (and common variants we normalize). */ export interface ApiErrorResponse { message?: string; errors?: Record | unknown; error?: string | ApiNestedError | Record; detail?: string | string[] | Record | FastApiDetailItem[]; } interface ApiNestedError { message?: string; code?: string; errors?: Record; details?: Record; } interface FastApiDetailItem { loc?: unknown[]; msg?: string; type?: string; } /** * Parsed error result */ export interface ParsedApiError { generalMessage: string; fieldErrors: Record; } const DEFAULT_GENERAL = "Something went wrong. Please try again."; /** Shown under a field when the server flagged it but we do not expose its raw text. */ const GENERIC_FIELD_MESSAGE = "This value could not be accepted. Check for typos and remove any HTML, scripts, or unusual symbols."; const FIELD_NAME_MAP: Record = { fullName: "full_name", full_name: "full_name", email: "email", phone: "phone", phoneNumber: "phone_number", phone_number: "phone_number", message: "message", companyName: "company_name", company_name: "company_name", workEmail: "work_email", work_email: "work_email", }; const ERROR_MESSAGES: Record = { invalid_input: "That does not look like valid input. Use plain text only — no HTML or scripts.", xss_detected: "Invalid content was detected. Remove any HTML tags, scripts, or code-like text.", validation_failed: "Please check this field and try again.", required: "This field is required.", invalid_email: "Please enter a valid email address.", invalid_phone: "Please enter a valid phone number using digits and common phone symbols only.", too_short: "This value is too short.", too_long: "This value is too long.", duplicate: "This value is already in use. Try a different one.", duplicate_inquiry: "You already have a pending inquiry with this email. Please wait for it to be processed.", }; /** * Maps a single server token or short code to a friendly message. */ function mapErrorCode(code: string): string | null { const key = code.trim().toLowerCase().replace(/[\s-]+/g, "_"); if (ERROR_MESSAGES[key]) return ERROR_MESSAGES[key]; const aliases: Record = { invalid_email_format: "invalid_email", email_invalid: "invalid_email", invalid_email: "invalid_email", invalid_phone_format: "invalid_phone", invalid_phone: "invalid_phone", phone_invalid: "invalid_phone", xss: "xss_detected", xss_detected: "xss_detected", malicious_content: "xss_detected", validation_error: "validation_failed", validation_failed: "validation_failed", duplicate_inquiry: "duplicate_inquiry", duplicate: "duplicate", }; const mappedKey = aliases[key]; return mappedKey ? ERROR_MESSAGES[mappedKey] : null; } /** * Infers a safe message from server hint text without echoing the full string. */ function inferMessageFromHint(hint: string): string { const h = hint.toLowerCase(); if (/(xss|script|html|tag|malicious|injection)/i.test(hint)) { return ERROR_MESSAGES.xss_detected; } if (/email/.test(h)) return ERROR_MESSAGES.invalid_email; if (/phone|tel|mobile/.test(h)) return ERROR_MESSAGES.invalid_phone; if (/company|organization|organisation/.test(h)) { return "Please use a valid company name (plain text, no HTML or scripts)."; } if (/name|full/.test(h)) { return "Please enter a valid name using letters and common characters only."; } if (/required|missing|empty/.test(h)) return ERROR_MESSAGES.required; if (/long|length|exceed/.test(h)) return ERROR_MESSAGES.too_long; if (/short|minimum/.test(h)) return ERROR_MESSAGES.too_short; if (/duplicate|already|exist/.test(h)) return ERROR_MESSAGES.duplicate_inquiry; return GENERIC_FIELD_MESSAGE; } function normalizeFieldName(fieldName: string): string { return FIELD_NAME_MAP[fieldName] || fieldName; } function isPlainObject(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } /** * Turns assorted server error values into a single user-safe string. */ function normalizeFieldErrorValue(value: unknown): string { if (value === null || value === undefined) return GENERIC_FIELD_MESSAGE; if (typeof value === "number" || typeof value === "boolean") { return mapErrorCode(String(value)) ?? GENERIC_FIELD_MESSAGE; } if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) return GENERIC_FIELD_MESSAGE; const byCode = mapErrorCode(trimmed); if (byCode) return byCode; if (/^[a-z0-9_.]+$/i.test(trimmed) && trimmed.length <= 80) { return mapErrorCode(trimmed.replace(/\./g, "_")) ?? GENERIC_FIELD_MESSAGE; } return inferMessageFromHint(trimmed); } if (Array.isArray(value)) { const parts = value .map((v) => normalizeFieldErrorValue(v)) .filter((s, i, arr) => s && arr.indexOf(s) === i); return parts.length ? parts.join(" ") : GENERIC_FIELD_MESSAGE; } if (isPlainObject(value)) { const msg = typeof value.message === "string" ? value.message : typeof value.msg === "string" ? value.msg : typeof value.detail === "string" ? value.detail : ""; if (msg) return inferMessageFromHint(msg); } return GENERIC_FIELD_MESSAGE; } function mergeFieldErrors( target: Record, source: Record ): void { for (const [k, v] of Object.entries(source)) { const key = normalizeFieldName(k); target[key] = v; } } function parseErrorsRecord(record: Record): Record { const out: Record = {}; for (const [field, err] of Object.entries(record)) { out[field] = normalizeFieldErrorValue(err); } return out; } function parseFastApiDetailArray(items: FastApiDetailItem[]): Record { const out: Record = {}; for (const item of items) { if (!item || typeof item !== "object") continue; const loc = Array.isArray(item.loc) ? item.loc : []; const rawKey = loc.filter((x): x is string => typeof x === "string").pop(); const field = rawKey ? normalizeFieldName(rawKey) : "_form"; const hint = item.msg ?? item.type ?? ""; out[field] = hint ? inferMessageFromHint(String(hint)) : GENERIC_FIELD_MESSAGE; } return out; } function parseDetail(detail: unknown): Record { if (!detail) return {}; if (typeof detail === "string") { return { _form: inferMessageFromHint(detail) }; } if (Array.isArray(detail)) { if (detail.length && typeof detail[0] === "object" && detail[0] !== null && "loc" in detail[0]) { return parseFastApiDetailArray(detail as FastApiDetailItem[]); } return { _form: normalizeFieldErrorValue(detail) }; } if (isPlainObject(detail)) { return parseErrorsRecord(detail as Record); } return {}; } /** * Walks common API shapes and collects field errors + optional form-level key. */ function extractFieldErrorsFromBody(data: unknown): { fieldErrors: Record; formLevel?: string; } { const fieldErrors: Record = {}; let formLevel: string | undefined; if (!data) return { fieldErrors }; if (typeof data === "string") { formLevel = inferMessageFromHint(data); return { fieldErrors, formLevel }; } if (!isPlainObject(data)) return { fieldErrors }; const d = data as Record; if (isPlainObject(d.errors)) { mergeFieldErrors(fieldErrors, parseErrorsRecord(d.errors as Record)); } if (Array.isArray(d.errors)) { mergeFieldErrors(fieldErrors, { _form: normalizeFieldErrorValue(d.errors) }); } const detailParsed = parseDetail(d.detail); mergeFieldErrors(fieldErrors, detailParsed); if (detailParsed._form && !fieldErrors._form) { fieldErrors._form = detailParsed._form; } const err = d.error; if (typeof err === "string") { formLevel = inferMessageFromHint(err); } else if (isPlainObject(err)) { const ne = err as ApiNestedError; if (typeof ne.message === "string") { formLevel = inferMessageFromHint(ne.message); } if (typeof ne.code === "string") { const byCode = mapErrorCode(ne.code); if (byCode) formLevel = byCode; } if (isPlainObject(ne.errors)) { mergeFieldErrors(fieldErrors, parseErrorsRecord(ne.errors as Record)); } if (isPlainObject(ne.details)) { mergeFieldErrors(fieldErrors, parseErrorsRecord(ne.details as Record)); } } if (typeof d.message === "string" && !formLevel) { formLevel = inferMessageFromHint(d.message); } return { fieldErrors, formLevel }; } function getAxiosResponse(error: unknown): AxiosError["response"] | undefined { if (axios.isAxiosError(error)) { return error.response; } if (error instanceof Error && "isAxiosError" in error && (error as AxiosError).isAxiosError) { return (error as AxiosError).response; } return undefined; } /** * Parses API error response and extracts field-level errors */ export function parseApiError(error: unknown): ParsedApiError { const result: ParsedApiError = { generalMessage: DEFAULT_GENERAL, fieldErrors: {}, }; const response = getAxiosResponse(error); if (!response) { if (error instanceof Error) { result.generalMessage = "Unable to connect to the server. Check your connection and try again."; } return result; } const { status, data } = response; const extracted = extractFieldErrorsFromBody(data); Object.assign(result.fieldErrors, extracted.fieldErrors); const validationSummary = "Some information could not be accepted. Please review the highlighted fields below."; if (status === 400 || status === 422) { result.generalMessage = validationSummary; if (extracted.formLevel && Object.keys(result.fieldErrors).length === 0) { result.generalMessage = extracted.formLevel; } } else if (status === 401) { result.generalMessage = "Authentication is required. Sign in and try again."; } else if (status === 403) { result.generalMessage = "You do not have permission to perform this action."; } else if (status === 404) { result.generalMessage = "The requested resource was not found."; } else if (status === 409) { result.generalMessage = ERROR_MESSAGES.duplicate_inquiry; } else if (status === 429) { result.generalMessage = "Too many attempts. Please wait a moment and try again."; } else if (status >= 500) { result.generalMessage = "The server had a problem. Please try again in a few minutes."; } else if (status >= 400 && status < 500) { result.generalMessage = Object.keys(result.fieldErrors).length > 0 ? validationSummary : extracted.formLevel ?? "We could not complete this request. Please check your input and try again."; } if (result.fieldErrors._form) { const formOnly = result.fieldErrors._form; delete result.fieldErrors._form; if (Object.keys(result.fieldErrors).length === 0) { result.generalMessage = formOnly; } } return result; } /** * Handles API error and sets form field errors using react-hook-form */ export function handleApiError( error: unknown, setError: UseFormSetError, showToast: (message: string) => void, formFields?: string[] ): void { const parsedError = parseApiError(error); let hasFieldErrors = false; Object.entries(parsedError.fieldErrors).forEach(([field, message]) => { if (!formFields || formFields.includes(field)) { setError(field as Path, { type: "server", message, }); hasFieldErrors = true; } }); showToast(parsedError.generalMessage); }