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 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 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";
|
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
|
||||||
|
* ------------------------------------------------------
|
||||||
|
* 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",
|
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;
|
||||||
|
Reference in New Issue
Block a user