From 65aefcda656f5a2920f30d2ef4fab76e9fcf4945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Wed, 17 Sep 2025 16:54:16 +0200 Subject: [PATCH] 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. --- .../Resources/public/lib/api/apiMethods.ts | 319 +++++++++++++++--- 1 file changed, 277 insertions(+), 42 deletions(-) diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts index 887a376f3..36645d51b 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts @@ -2,7 +2,7 @@ import { Scope } from "../../types"; export type body = Record; export type fetchOption = Record; - +export type Primitive = string | number | boolean | null; export type Params = Record; export interface Pagination { @@ -25,20 +25,128 @@ export interface TransportExceptionInterface { name: string; } -export interface ValidationExceptionInterface - extends TransportExceptionInterface { +// Strict : uniquement les clés déclarées dans M[K] +export type ViolationFromMap< + M extends Record>, +> = { + [K in Extract]: { + propertyPath: K; + title: string; + parameters?: M[K]; // ← uniquement ces clés (pas d’extras) + type?: string; + }; +}[Extract]; + +export type ValidationProblemFromMap< + M extends Record>, +> = { + type: string; + title: string; + detail?: string; + violations: ViolationFromMap[]; +} & Record; + +export interface ValidationExceptionInterface< + M extends Record> = Record< + string, + Record + >, +> extends Error { name: "ValidationException"; - error: object; + /** Copie du payload serveur (utile pour logs/diagnostic) */ + problem: ValidationProblemFromMap; + /** Liste compacte "Titre: chemin" */ violations: string[]; + /** Uniquement les titres */ titles: string[]; - propertyPaths: string[]; + /** Uniquement les chemins de propriété */ + propertyPaths: Extract[]; + /** Indexation par propriété (utile pour afficher par champ) */ + byProperty: Record, string[]>; } -export interface ValidationErrorResponse extends TransportExceptionInterface { - violations: { - title: string; - propertyPath: string; - }[]; +export class ValidationException< + M extends Record> = Record< + string, + Record + >, + > + extends Error + implements ValidationExceptionInterface +{ + public readonly name = "ValidationException" as const; + public readonly problem: ValidationProblemFromMap; + public readonly violations: string[]; + public readonly titles: string[]; + public readonly propertyPaths: Extract[]; + public readonly byProperty: Record, string[]>; + + constructor(problem: ValidationProblemFromMap) { + 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[]; + + this.byProperty = problem.violations.reduce( + (acc, v) => { + const key = v.propertyPath as Extract; + (acc[key] ||= []).push(v.title); + return acc; + }, + {} as Record, 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>> { + 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 { @@ -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 - * and use of the @link{fetchResults} method. + * What this does + * - 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 + * ------------------------------------------------------ + * Symfony’s 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 you’ll 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 and throws a + * ValidationException. + * - 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( + * "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( + * "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 don’t care about parameter typing, you can omit M entirely and rely on the + * default loose typing (Record), but you’ll lose safety. + * + * Error taxonomy thrown by makeFetch + * - ValidationException 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 = ( +export const makeFetch = async < + Input, + Output, + M extends Record> = Record< + string, + Record + >, +>( method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE", url: string, body?: body | Input | null, @@ -90,7 +337,8 @@ export const makeFetch = ( if (typeof options !== "undefined") { opts = Object.assign(opts, options); } - return fetch(url, opts).then((response) => { + + return fetch(url, opts).then(async (response) => { if (response.status === 204) { return Promise.resolve(); } @@ -100,9 +348,20 @@ export const makeFetch = ( } if (response.status === 422) { - return response.json().then((response) => { - throw ValidationException(response); - }); + // Unprocessable Entity -> payload de validation Symfony + 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; + throw new ValidationException(problem); + } + + const err = new Error( + "Validation failed but payload is not a ValidationProblem", + ); + (err as any).raw = json; + throw err; } if (response.status === 403) { @@ -167,12 +426,6 @@ function _fetchAction( throw NotFoundException(response); } - if (response.status === 422) { - return response.json().then((response) => { - throw ValidationException(response); - }); - } - if (response.status === 403) { throw AccessException(response); } @@ -231,24 +484,6 @@ export const fetchScopes = (): Promise => { 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 const AccessException = (response: Response): AccessExceptionInterface => { const error = {} as AccessExceptionInterface;