mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-09-27 09:05:01 +00:00
- Simplify and extend type definitions in `types.ts` for dynamic and normalized keys. - Update `ValidationExceptionInterface` to include new methods for filtering violations. - Refactor `apiMethods.ts` to leverage updated exception types and key parsing. - Adjust `WritePersonViolationMap` for stricter type definitions. - Enhance `PersonEdit.vue` to use refined violation methods, improving validation error handling.
522 lines
16 KiB
TypeScript
522 lines
16 KiB
TypeScript
import {
|
||
DynamicKeys,
|
||
Scope,
|
||
ValidationExceptionInterface,
|
||
ValidationProblemFromMap,
|
||
ViolationFromMap
|
||
} 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 {
|
||
first: number;
|
||
items_per_page: number;
|
||
more: boolean;
|
||
next: string | null;
|
||
previous: string | null;
|
||
}
|
||
|
||
export interface PaginationResponse<T> {
|
||
pagination: Pagination;
|
||
results: T[];
|
||
count: number;
|
||
}
|
||
|
||
export type FetchParams = Record<string, string | number | null>;
|
||
|
||
export interface TransportExceptionInterface {
|
||
name: string;
|
||
}
|
||
|
||
export class ValidationException<
|
||
M extends Record<string, Record<string, string|number>> = Record<
|
||
string,
|
||
Record<string, string|number>
|
||
>,
|
||
>
|
||
extends Error
|
||
implements ValidationExceptionInterface<M>
|
||
{
|
||
public readonly name = "ValidationException" as const;
|
||
public readonly problems: ValidationProblemFromMap<M>;
|
||
public readonly violations: string[];
|
||
public readonly violationsList: ViolationFromMap<M>[];
|
||
public readonly titles: string[];
|
||
public readonly propertyPaths: DynamicKeys<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.problems = problem;
|
||
|
||
this.violationsList = problem.violations;
|
||
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 DynamicKeys<M> & string[];
|
||
|
||
this.byProperty = problem.violations.reduce(
|
||
(acc, v) => {
|
||
const key = v.propertyPath.replace('/\[\d+\]$/', "") 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);
|
||
}
|
||
}
|
||
|
||
violationsByNormalizedProperty(property: Extract<keyof M, string>): ViolationFromMap<M>[] {
|
||
return this.violationsList.filter((v) => v.propertyPath.replace(/\[\d+\]$/, "") === property);
|
||
}
|
||
|
||
violationsByNormalizedPropertyAndParams<
|
||
P extends Extract<keyof M, string>,
|
||
K extends Extract<keyof M[P], string>
|
||
>(
|
||
property: P,
|
||
param: K,
|
||
param_value: M[P][K]
|
||
): ViolationFromMap<M>[]
|
||
{
|
||
const list = this.violationsByNormalizedProperty(property);
|
||
|
||
return list.filter(
|
||
(v): boolean =>
|
||
!!v.parameters &&
|
||
// `with_parameter in v.parameters` check indexing
|
||
param in v.parameters &&
|
||
// the cast is safe, because we have overloading that bind the types
|
||
(v.parameters as M[P])[param] === param_value
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check that the exception is a ValidationExceptionInterface
|
||
* @param x
|
||
*/
|
||
export function isValidationException<M extends Record<string, Record<string, string|number>>>(
|
||
x: unknown,
|
||
): x is ValidationExceptionInterface<M> {
|
||
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 {
|
||
name: "AccessException";
|
||
violations: string[];
|
||
}
|
||
|
||
export interface NotFoundExceptionInterface
|
||
extends TransportExceptionInterface {
|
||
name: "NotFoundException";
|
||
}
|
||
|
||
export interface ServerExceptionInterface extends TransportExceptionInterface {
|
||
name: "ServerException";
|
||
message: string;
|
||
code: number;
|
||
body: string;
|
||
}
|
||
|
||
export interface ConflictHttpExceptionInterface
|
||
extends TransportExceptionInterface {
|
||
name: "ConflictHttpException";
|
||
violations: string[];
|
||
}
|
||
|
||
/**
|
||
* Generic api method that can be adapted to any fetch request.
|
||
*
|
||
* 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 = async <
|
||
Input,
|
||
Output,
|
||
M extends Record<string, Record<string, string|number>> = Record<
|
||
string,
|
||
Record<string, string|number>
|
||
>,
|
||
>(
|
||
method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE",
|
||
url: string,
|
||
body?: body | Input | null,
|
||
options?: FetchParams,
|
||
): Promise<Output> => {
|
||
let opts = {
|
||
method: method,
|
||
headers: {
|
||
"Content-Type": "application/json;charset=utf-8",
|
||
},
|
||
};
|
||
|
||
if (body !== null && typeof body !== "undefined") {
|
||
Object.assign(opts, { body: JSON.stringify(body) });
|
||
}
|
||
|
||
if (typeof options !== "undefined") {
|
||
opts = Object.assign(opts, options);
|
||
}
|
||
|
||
return fetch(url, opts).then(async (response) => {
|
||
if (response.status === 204) {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
if (response.ok) {
|
||
return response.json();
|
||
}
|
||
|
||
if (response.status === 422) {
|
||
// 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) {
|
||
throw AccessException(response);
|
||
}
|
||
|
||
if (response.status === 409) {
|
||
throw ConflictHttpException(response);
|
||
}
|
||
|
||
throw {
|
||
name: "Exception",
|
||
sta: response.status,
|
||
txt: response.statusText,
|
||
err: new Error(),
|
||
violations: response.body,
|
||
};
|
||
});
|
||
};
|
||
|
||
/**
|
||
* Fetch results with certain parameters
|
||
*/
|
||
function _fetchAction<T>(
|
||
page: number,
|
||
uri: string,
|
||
params?: FetchParams,
|
||
): Promise<PaginationResponse<T>> {
|
||
const item_per_page = 50;
|
||
|
||
const searchParams = new URLSearchParams();
|
||
searchParams.append("item_per_page", item_per_page.toString());
|
||
searchParams.append("page", page.toString());
|
||
|
||
if (params !== undefined) {
|
||
Object.keys(params).forEach((key) => {
|
||
const v = params[key];
|
||
if (typeof v === "string") {
|
||
searchParams.append(key, v);
|
||
} else if (typeof v === "number") {
|
||
searchParams.append(key, v.toString());
|
||
} else if (v === null) {
|
||
searchParams.append(key, "");
|
||
}
|
||
});
|
||
}
|
||
|
||
const url = uri + "?" + searchParams.toString();
|
||
|
||
return fetch(url, {
|
||
method: "GET",
|
||
headers: {
|
||
"Content-Type": "application/json;charset=utf-8",
|
||
},
|
||
})
|
||
.then((response) => {
|
||
if (response.ok) {
|
||
return response.json();
|
||
}
|
||
|
||
if (response.status === 404) {
|
||
throw NotFoundException(response);
|
||
}
|
||
|
||
if (response.status === 403) {
|
||
throw AccessException(response);
|
||
}
|
||
|
||
if (response.status >= 500) {
|
||
return response.text().then((body) => {
|
||
throw ServerException(response.status, body);
|
||
});
|
||
}
|
||
|
||
throw new Error("other network error");
|
||
})
|
||
.catch(
|
||
(
|
||
reason:
|
||
| NotFoundExceptionInterface
|
||
| ServerExceptionInterface
|
||
| ValidationExceptionInterface
|
||
| TransportExceptionInterface,
|
||
) => {
|
||
console.error(reason);
|
||
throw reason;
|
||
},
|
||
);
|
||
}
|
||
|
||
export const fetchResults = async <T>(
|
||
uri: string,
|
||
params?: FetchParams,
|
||
): Promise<T[]> => {
|
||
const promises: Promise<T[]>[] = [];
|
||
let page = 1;
|
||
const firstData: PaginationResponse<T> = (await _fetchAction(
|
||
page,
|
||
uri,
|
||
params,
|
||
)) as PaginationResponse<T>;
|
||
|
||
promises.push(Promise.resolve(firstData.results));
|
||
|
||
if (firstData.pagination.more) {
|
||
do {
|
||
page = ++page;
|
||
promises.push(
|
||
_fetchAction<T>(page, uri, params).then((r) =>
|
||
Promise.resolve(r.results),
|
||
),
|
||
);
|
||
} while (page * firstData.pagination.items_per_page < firstData.count);
|
||
}
|
||
|
||
return Promise.all(promises).then((values) => values.flat());
|
||
};
|
||
|
||
export const fetchScopes = (): Promise<Scope[]> => {
|
||
return fetchResults("/api/1.0/main/scope.json");
|
||
};
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
const AccessException = (response: Response): AccessExceptionInterface => {
|
||
const error = {} as AccessExceptionInterface;
|
||
error.name = "AccessException";
|
||
error.violations = ["You are not allowed to perform this action"];
|
||
|
||
return error;
|
||
};
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
const NotFoundException = (response: Response): NotFoundExceptionInterface => {
|
||
const error = {} as NotFoundExceptionInterface;
|
||
error.name = "NotFoundException";
|
||
|
||
return error;
|
||
};
|
||
|
||
const ServerException = (
|
||
code: number,
|
||
body: string,
|
||
): ServerExceptionInterface => {
|
||
const error = {} as ServerExceptionInterface;
|
||
error.name = "ServerException";
|
||
error.code = code;
|
||
error.body = body;
|
||
|
||
return error;
|
||
};
|
||
|
||
const ConflictHttpException = (
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
response: Response,
|
||
): ConflictHttpExceptionInterface => {
|
||
const error = {} as ConflictHttpExceptionInterface;
|
||
|
||
error.name = "ConflictHttpException";
|
||
error.violations = [
|
||
"Sorry, but someone else has already changed this entity. Please refresh the page and apply the changes again",
|
||
];
|
||
|
||
return error;
|
||
};
|