mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-09-25 16:14:59 +00:00
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:
@@ -2,7 +2,7 @@ import { Scope } from "../../types";
|
||||
|
||||
export type body = 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 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<string, Record<string, unknown>>,
|
||||
> = {
|
||||
[K in Extract<keyof M, string>]: {
|
||||
propertyPath: K;
|
||||
title: string;
|
||||
parameters?: M[K]; // ← uniquement ces clés (pas d’extras)
|
||||
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";
|
||||
error: object;
|
||||
/** Copie du payload serveur (utile pour logs/diagnostic) */
|
||||
problem: ValidationProblemFromMap<M>;
|
||||
/** Liste compacte "Titre: chemin" */
|
||||
violations: string[];
|
||||
/** Uniquement les titres */
|
||||
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 {
|
||||
violations: {
|
||||
title: string;
|
||||
propertyPath: string;
|
||||
}[];
|
||||
export class ValidationException<
|
||||
M extends Record<string, Record<string, unknown>> = Record<
|
||||
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 {
|
||||
@@ -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<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 don’t care about parameter typing, you can omit M entirely and rely on the
|
||||
* default loose typing (Record<string, Primitive>), but you’ll 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",
|
||||
url: string,
|
||||
body?: body | Input | null,
|
||||
@@ -90,7 +337,8 @@ export const makeFetch = <Input, Output>(
|
||||
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 = <Input, Output>(
|
||||
}
|
||||
|
||||
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<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) {
|
||||
@@ -167,12 +426,6 @@ function _fetchAction<T>(
|
||||
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<Scope[]> => {
|
||||
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;
|
||||
|
Reference in New Issue
Block a user