Files
tm-landing/lib/apiErrorHandler.ts
AmirReza Jamali 8518c2c140 refactor(inquiries): Update form validation and error handling
- Change required fields in inquiries form to enforce mandatory input
- Enhance API error handler to provide user-friendly messages without exposing raw server text
- Improve test cases for API error handling to ensure accurate message mapping and validation
- Refactor error messages for clarity and consistency across the application
2026-04-13 08:37:22 +03:30

375 lines
12 KiB
TypeScript

/**
* 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<string, string | string[] | unknown> | unknown;
error?: string | ApiNestedError | Record<string, unknown>;
detail?: string | string[] | Record<string, string[] | unknown> | FastApiDetailItem[];
}
interface ApiNestedError {
message?: string;
code?: string;
errors?: Record<string, unknown>;
details?: Record<string, unknown>;
}
interface FastApiDetailItem {
loc?: unknown[];
msg?: string;
type?: string;
}
/**
* Parsed error result
*/
export interface ParsedApiError {
generalMessage: string;
fieldErrors: Record<string, string>;
}
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<string, string> = {
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<string, string> = {
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<string, keyof typeof ERROR_MESSAGES> = {
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<string, unknown> {
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<string, string>,
source: Record<string, string>
): void {
for (const [k, v] of Object.entries(source)) {
const key = normalizeFieldName(k);
target[key] = v;
}
}
function parseErrorsRecord(record: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const [field, err] of Object.entries(record)) {
out[field] = normalizeFieldErrorValue(err);
}
return out;
}
function parseFastApiDetailArray(items: FastApiDetailItem[]): Record<string, string> {
const out: Record<string, string> = {};
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<string, string> {
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<string, unknown>);
}
return {};
}
/**
* Walks common API shapes and collects field errors + optional form-level key.
*/
function extractFieldErrorsFromBody(data: unknown): {
fieldErrors: Record<string, string>;
formLevel?: string;
} {
const fieldErrors: Record<string, string> = {};
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<string, unknown>;
if (isPlainObject(d.errors)) {
mergeFieldErrors(fieldErrors, parseErrorsRecord(d.errors as Record<string, unknown>));
}
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<string, unknown>));
}
if (isPlainObject(ne.details)) {
mergeFieldErrors(fieldErrors, parseErrorsRecord(ne.details as Record<string, unknown>));
}
}
if (typeof d.message === "string" && !formLevel) {
formLevel = inferMessageFromHint(d.message);
}
return { fieldErrors, formLevel };
}
function getAxiosResponse(error: unknown): AxiosError<ApiErrorResponse>["response"] | undefined {
if (axios.isAxiosError(error)) {
return error.response;
}
if (error instanceof Error && "isAxiosError" in error && (error as AxiosError).isAxiosError) {
return (error as AxiosError<ApiErrorResponse>).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<T extends FieldValues>(
error: unknown,
setError: UseFormSetError<T>,
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<T>, {
type: "server",
message,
});
hasFieldErrors = true;
}
});
showToast(parsedError.generalMessage);
}