Files
chill-bundles/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts
Julien Fastré 34af53130b Refactor and enhance ValidationException handling across types and components
- 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.
2025-09-23 21:26:12 +02:00

522 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
* ------------------------------------------------------
* 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 = 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;
};