107 lines
3.1 KiB
TypeScript
107 lines
3.1 KiB
TypeScript
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
|
|
}
|