Refactor validation handling in apiMethods: Introduce strongly-typed ValidationException and ViolationFromMap. Replace generic validation logic with stricter, type-safe mappings. Update makeFetch to handle Symfony validation problems with enhanced error taxonomy.

This commit is contained in:
2025-09-17 16:54:16 +02:00
parent 4a73aaae94
commit 5ff374d2fa

View File

@@ -2,7 +2,7 @@ import { Scope } from "../../types";
export type body = Record<string, boolean | string | number | null>; export type body = Record<string, boolean | string | number | null>;
export type fetchOption = Record<string, boolean | string | number | null>; export type fetchOption = Record<string, boolean | string | number | null>;
export type Primitive = string | number | boolean | null;
export type Params = Record<string, number | string>; export type Params = Record<string, number | string>;
export interface Pagination { export interface Pagination {
@@ -25,20 +25,128 @@ export interface TransportExceptionInterface {
name: string; name: string;
} }
export interface ValidationExceptionInterface // Strict : uniquement les clés déclarées dans M[K]
extends TransportExceptionInterface { export type ViolationFromMap<
M extends Record<string, Record<string, unknown>>,
> = {
[K in Extract<keyof M, string>]: {
propertyPath: K;
title: string;
parameters?: M[K]; // ← uniquement ces clés (pas dextras)
type?: string;
};
}[Extract<keyof M, string>];
export type ValidationProblemFromMap<
M extends Record<string, Record<string, unknown>>,
> = {
type: string;
title: string;
detail?: string;
violations: ViolationFromMap<M>[];
} & Record<string, unknown>;
export interface ValidationExceptionInterface<
M extends Record<string, Record<string, unknown>> = Record<
string,
Record<string, unknown>
>,
> extends Error {
name: "ValidationException"; name: "ValidationException";
error: object; /** Copie du payload serveur (utile pour logs/diagnostic) */
problem: ValidationProblemFromMap<M>;
/** Liste compacte "Titre: chemin" */
violations: string[]; violations: string[];
/** Uniquement les titres */
titles: string[]; titles: string[];
propertyPaths: string[]; /** Uniquement les chemins de propriété */
propertyPaths: Extract<keyof M, string>[];
/** Indexation par propriété (utile pour afficher par champ) */
byProperty: Record<Extract<keyof M, string>, string[]>;
} }
export interface ValidationErrorResponse extends TransportExceptionInterface { export class ValidationException<
violations: { M extends Record<string, Record<string, unknown>> = Record<
title: string; string,
propertyPath: string; Record<string, unknown>
}[]; >,
>
extends Error
implements ValidationExceptionInterface<M>
{
public readonly name = "ValidationException" as const;
public readonly problem: ValidationProblemFromMap<M>;
public readonly violations: string[];
public readonly titles: string[];
public readonly propertyPaths: Extract<keyof M, string>[];
public readonly byProperty: Record<Extract<keyof M, string>, string[]>;
constructor(problem: ValidationProblemFromMap<M>) {
const message = [problem.title, problem.detail].filter(Boolean).join(" — ");
super(message);
Object.setPrototypeOf(this, new.target.prototype);
this.problem = problem;
this.violations = problem.violations.map(
(v) => `${v.title}: ${v.propertyPath}`,
);
this.titles = problem.violations.map((v) => v.title);
this.propertyPaths = problem.violations.map(
(v) => v.propertyPath,
) as Extract<keyof M, string>[];
this.byProperty = problem.violations.reduce(
(acc, v) => {
const key = v.propertyPath as Extract<keyof M, string>;
(acc[key] ||= []).push(v.title);
return acc;
},
{} as Record<Extract<keyof M, string>, string[]>,
);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ValidationException);
}
}
}
/**
* Check that the exception is a ValidationExceptionInterface
* @param x
*/
export function isValidationException(
x: unknown,
): x is ValidationExceptionInterface<Record<string, Record<string, unknown>>> {
return (
x instanceof ValidationException ||
(typeof x === "object" &&
x !== null &&
(x as any).name === "ValidationException")
);
}
export function isValidationProblem(x: unknown): x is {
type: string;
title: string;
violations: { propertyPath: string; title: string }[];
} {
if (!x || typeof x !== "object") return false;
const o = x as any;
return (
typeof o.type === "string" &&
typeof o.title === "string" &&
Array.isArray(o.violations) &&
o.violations.every(
(v: any) =>
v &&
typeof v === "object" &&
typeof v.propertyPath === "string" &&
typeof v.title === "string",
)
);
} }
export interface AccessExceptionInterface extends TransportExceptionInterface { export interface AccessExceptionInterface extends TransportExceptionInterface {
@@ -65,12 +173,151 @@ export interface ConflictHttpExceptionInterface
} }
/** /**
* Generic api method that can be adapted to any fetch request * Generic api method that can be adapted to any fetch request.
* *
* This method is suitable make a single fetch. When performing a GET to fetch a list of elements, always consider pagination * What this does
* and use of the @link{fetchResults} method. * - Performs a single HTTP request using fetch and returns the parsed JSON as Output.
* - Interprets common API errors and throws typed exceptions you can catch in your UI.
* - When the server returns a Symfony validation problem (HTTP 422), the error is
* rethrown as a typed ValidationException that is aware of your Violation Map (see below).
*
* Important: For GET endpoints that return lists, prefer using fetchResults, which
* handles pagination and aggregation for you.
*
* Violation Map (M): make your 422 errors strongly typed
* ------------------------------------------------------
* Symfonys validation problem+json payload looks like this (simplified):
*
* {
* "type": "https://symfony.com/errors/validation",
* "title": "Validation Failed",
* "violations": [
* {
* "propertyPath": "mobilenumber",
* "title": "This value is not a valid phone number.",
* "parameters": {
* "{{ value }}": "+33 1 02 03 04 05",
* "{{ types }}": "mobile number"
* },
* "type": "urn:uuid:..."
* }
* ]
* }
*
* The makeFetch generic type parameter M lets you describe, field by field, which
* parameters may appear for each propertyPath. Doing so gives you full type-safety when
* consuming ValidationException in your UI code.
*
* How to build M (Violation Map)
* - M is a map where each key is a server-side propertyPath (string), and the value is a
* record describing the allowed keys in the parameters object for that property.
* - Keys in parameters are the exact strings you receive from Symfony, including the
* curly-braced placeholders such as "{{ value }}", "{{ types }}", etc.
*
* Example from Person creation (WritePersonViolationMap)
* -----------------------------------------------------
* In ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.ts youll find:
*
* export type WritePersonViolationMap = {
* gender: {
* "{{ value }}": string | null;
* };
* mobilenumber: {
* "{{ types }}": string; // ex: "mobile number"
* "{{ value }}": string; // ex: "+33 1 02 03 04 05"
* };
* };
*
* This means:
* - If the server reports a violation for propertyPath "gender", the parameters object
* is expected to contain a key "{{ value }}" with a string or null value.
* - If the server reports a violation for propertyPath "mobilenumber", the parameters
* may include "{{ value }}" and "{{ types }}" as strings.
*
* How makeFetch uses M
* - When the response has status 422 and the payload matches a Symfony validation
* problem, makeFetch casts it to ValidationProblemFromMap<M> and throws a
* ValidationException<M>.
* - The ValidationException exposes helpful, pre-computed fields:
* - exception.problem: the full typed payload
* - exception.violations: ["Title: propertyPath", ...]
* - exception.titles: ["Title 1", "Title 2", ...]
* - exception.propertyPaths: ["gender", "mobilenumber", ...] (typed from M)
* - exception.byProperty: { gender: [titles...], mobilenumber: [titles...] }
*
* Typical usage patterns
* ----------------------
* 1) GET without Validation Map (no 422 expected):
*
* const centers = await makeFetch<null, { showCenters: boolean; centers: Center[] }>(
* "GET",
* "/api/1.0/person/creation/authorized-centers",
* null
* );
*
* 2) POST with body and Violation Map:
*
* type WritePersonViolationMap = {
* gender: { "{{ value }}": string | null };
* mobilenumber: { "{{ types }}": string; "{{ value }}": string };
* };
*
* try {
* const created = await makeFetch<PersonWrite, Person, WritePersonViolationMap>(
* "POST",
* "/api/1.0/person/person.json",
* personPayload
* );
* // Success path
* } catch (e) {
* if (isValidationException(e)) {
* // Fully typed:
* e.propertyPaths.includes("mobilenumber");
* const firstTitleForMobile = e.byProperty.mobilenumber?.[0];
* // You can also inspect parameter values:
* const v = e.problem.violations.find(v => v.propertyPath === "mobilenumber");
* const rawValue = v?.parameters?.["{{ value }}"]; // typed as string
* } else {
* // Other error handling (AccessException, ConflictHttpException, etc.)
* }
* }
*
* Tips to design your Violation Map
* - Use exact propertyPath strings as exposed by the API (they usually match your
* DTO field names or entity property paths used by the validator).
* - Inside each property, list only the placeholders that you actually read in the UI
* (you can always add more later). This keeps your types strict but pragmatic.
* - If a field may not include parameters at all, you can set it to an empty object {}.
* - If you dont care about parameter typing, you can omit M entirely and rely on the
* default loose typing (Record<string, Primitive>), but youll lose safety.
*
* Error taxonomy thrown by makeFetch
* - ValidationException<M> when status = 422 and payload is a validation problem.
* - AccessException when status = 403.
* - ConflictHttpException when status = 409.
* - A generic error object for other non-ok statuses.
*
* @typeParam Input - Shape of the request body you send (if any)
* @typeParam Output - Shape of the successful JSON response you expect
* @typeParam M - Violation Map describing the per-field parameters you expect
* in Symfony validation violations. See examples above.
*
* @param method The HTTP method to use (POST, GET, PUT, PATCH, DELETE)
* @param url The absolute or relative URL to call
* @param body The request payload. If null/undefined, no body is sent
* @param options Extra fetch options/headers merged into the request
*
* @returns The parsed JSON response typed as Output. For 204 No Content, resolves
* with undefined (void).
*/ */
export const makeFetch = <Input, Output>( export const makeFetch = async <
Input,
Output,
M extends Record<string, Record<string, unknown>> = Record<
string,
Record<string, Primitive>
>,
>(
method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE", method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE",
url: string, url: string,
body?: body | Input | null, body?: body | Input | null,
@@ -90,7 +337,8 @@ export const makeFetch = <Input, Output>(
if (typeof options !== "undefined") { if (typeof options !== "undefined") {
opts = Object.assign(opts, options); opts = Object.assign(opts, options);
} }
return fetch(url, opts).then((response) => {
return fetch(url, opts).then(async (response) => {
if (response.status === 204) { if (response.status === 204) {
return Promise.resolve(); return Promise.resolve();
} }
@@ -100,9 +348,20 @@ export const makeFetch = <Input, Output>(
} }
if (response.status === 422) { if (response.status === 422) {
return response.json().then((response) => { // Unprocessable Entity -> payload de validation Symfony
throw ValidationException(response); const json = await response.json().catch(() => undefined);
});
if (isValidationProblem(json)) {
// On ré-interprète le payload selon M (ParamMap) pour typer les violations
const problem = json as unknown as ValidationProblemFromMap<M>;
throw new ValidationException<M>(problem);
}
const err = new Error(
"Validation failed but payload is not a ValidationProblem",
);
(err as any).raw = json;
throw err;
} }
if (response.status === 403) { if (response.status === 403) {
@@ -167,12 +426,6 @@ function _fetchAction<T>(
throw NotFoundException(response); throw NotFoundException(response);
} }
if (response.status === 422) {
return response.json().then((response) => {
throw ValidationException(response);
});
}
if (response.status === 403) { if (response.status === 403) {
throw AccessException(response); throw AccessException(response);
} }
@@ -231,24 +484,6 @@ export const fetchScopes = (): Promise<Scope[]> => {
return fetchResults("/api/1.0/main/scope.json"); return fetchResults("/api/1.0/main/scope.json");
}; };
/**
* Error objects to be thrown
*/
const ValidationException = (
response: ValidationErrorResponse,
): ValidationExceptionInterface => {
const error = {} as ValidationExceptionInterface;
error.name = "ValidationException";
error.violations = response.violations.map(
(violation) => `${violation.title}: ${violation.propertyPath}`,
);
error.titles = response.violations.map((violation) => violation.title);
error.propertyPaths = response.violations.map(
(violation) => violation.propertyPath,
);
return error;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const AccessException = (response: Response): AccessExceptionInterface => { const AccessException = (response: Response): AccessExceptionInterface => {
const error = {} as AccessExceptionInterface; const error = {} as AccessExceptionInterface;