buckets/frontend/src/lib/fieldErrors.ts

108 lines
3.1 KiB
TypeScript
Raw Normal View History

import { ApiError } from './api'
/** Per-field validation messages: a 422 body's `{ errors: { field: [msg] } }`. */
export type FieldErrors = Record<string, string[]>
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string')
}
/**
* Extract the `{ errors: { field: [...] } }` map from a 422 ApiError body.
* Returns null for anything that isn't a well-formed 422 field-error response
* (wrong status, non-ApiError, non-JSON body, or a malformed errors shape) so
* the caller can fall back to a generic error message.
*/
export function parseFieldErrors(error: unknown): FieldErrors | null {
if (!(error instanceof ApiError) || error.status !== 422) {
return null
}
let parsed: unknown
try {
parsed = JSON.parse(error.body)
} catch {
return null
}
if (
typeof parsed !== 'object' ||
parsed === null ||
!('errors' in parsed) ||
typeof parsed.errors !== 'object' ||
parsed.errors === null
) {
return null
}
// Only accept the contract shape: every field maps to an array of strings.
const entries = Object.entries(parsed.errors as Record<string, unknown>)
if (!entries.every(([, messages]) => isStringArray(messages))) {
return null
}
return parsed.errors as FieldErrors
}
function isViolation(
value: unknown,
): value is { propertyPath: string; message: string } {
return (
typeof value === 'object' &&
value !== null &&
'propertyPath' in value &&
typeof value.propertyPath === 'string' &&
'message' in value &&
typeof value.message === 'string'
)
}
/**
* Extract the API Platform `{ violations: [{ propertyPath, message }] }` list
* from a 422 ApiError body, grouped into a per-field map keyed by propertyPath
* (multiple messages for the same path are collected in order).
*
* This is the sibling of {@link parseFieldErrors}: API Platform resources emit
* this violations shape, while the hand-rolled auth endpoints emit the
* `{ errors: { field: [...] } }` shape. Returns null for anything that isn't a
* well-formed 422 violations response including the auth shape so the
* caller can fall back to a generic error message.
*/
export function parseViolations(error: unknown): FieldErrors | null {
if (!(error instanceof ApiError) || error.status !== 422) {
return null
}
let parsed: unknown
try {
parsed = JSON.parse(error.body)
} catch {
return null
}
if (
typeof parsed !== 'object' ||
parsed === null ||
!('violations' in parsed) ||
!Array.isArray(parsed.violations)
) {
return null
}
// Only accept the contract shape: every entry has a string propertyPath and
// message. A malformed entry rejects the whole body rather than dropping it.
if (!parsed.violations.every(isViolation)) {
return null
}
const fieldErrors: FieldErrors = {}
for (const { propertyPath, message } of parsed.violations) {
if (!fieldErrors[propertyPath]) {
fieldErrors[propertyPath] = []
}
fieldErrors[propertyPath].push(message)
}
return fieldErrors
}