import { DynamicKeys, Scope, ValidationExceptionInterface, ValidationProblemFromMap, ViolationFromMap } from "../../types"; export type body = Record; export type fetchOption = Record; export type Primitive = string | number | boolean | null; export type Params = Record; export interface Pagination { first: number; items_per_page: number; more: boolean; next: string | null; previous: string | null; } export interface PaginationResponse { pagination: Pagination; results: T[]; count: number; } export type FetchParams = Record; export interface TransportExceptionInterface { name: string; } export class ValidationException< M extends Record> = Record< string, Record >, > extends Error implements ValidationExceptionInterface { public readonly name = "ValidationException" as const; public readonly problems: ValidationProblemFromMap; public readonly violations: string[]; public readonly violationsList: ViolationFromMap[]; public readonly titles: string[]; public readonly propertyPaths: DynamicKeys & string[]; 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.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 & string[]; this.byProperty = problem.violations.reduce( (acc, v) => { const key = v.propertyPath.replace('/\[\d+\]$/', "") as Extract; (acc[key] ||= []).push(v.title); return acc; }, {} as Record, string[]>, ); if (Error.captureStackTrace) { Error.captureStackTrace(this, ValidationException); } } violationsByNormalizedProperty(property: Extract): ViolationFromMap[] { return this.violationsList.filter((v) => v.propertyPath.replace(/\[\d+\]$/, "") === property); } violationsByNormalizedPropertyAndParams< P extends Extract, K extends Extract >( property: P, param: K, param_value: M[P][K] ): ViolationFromMap[] { 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>>( 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 { 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 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 = async < Input, Output, M extends Record> = Record< string, Record >, >( method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE", url: string, body?: body | Input | null, options?: FetchParams, ): Promise => { 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; 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) { 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( page: number, uri: string, params?: FetchParams, ): Promise> { 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 ( uri: string, params?: FetchParams, ): Promise => { const promises: Promise[] = []; let page = 1; const firstData: PaginationResponse = (await _fetchAction( page, uri, params, )) as PaginationResponse; promises.push(Promise.resolve(firstData.results)); if (firstData.pagination.more) { do { page = ++page; promises.push( _fetchAction(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 => { 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; };