@sia-ui/api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,413 @@
1
+ type ApiMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
2
+ type QueryParamObject = Record<string, unknown>;
3
+ type QueryParams = Record<string, string | string[] | number | number[] | boolean | boolean[] | QueryParamObject | QueryParamObject[] | null | undefined>;
4
+ interface ApiRequestConfig {
5
+ headers?: Record<string, string> | undefined;
6
+ queryParams?: QueryParams | undefined;
7
+ body?: unknown;
8
+ signal?: AbortSignal | null | undefined;
9
+ timeoutMs?: number | undefined;
10
+ retry?: number | undefined;
11
+ onUploadProgress?: ((percent: number) => void) | undefined;
12
+ }
13
+ interface ApiRequestContext extends ApiRequestConfig {
14
+ url: string;
15
+ method: ApiMethod;
16
+ headers: Record<string, string>;
17
+ }
18
+ interface ApiTransportRequest {
19
+ url: string;
20
+ method: ApiMethod;
21
+ headers: Record<string, string>;
22
+ body?: unknown;
23
+ signal?: AbortSignal | null | undefined;
24
+ timeoutMs?: number | undefined;
25
+ onUploadProgress?: ((percent: number) => void) | undefined;
26
+ }
27
+ interface ApiTransportResponse<T = unknown> {
28
+ status: number;
29
+ statusText: string;
30
+ headers: Record<string, string>;
31
+ data: T;
32
+ }
33
+ interface ApiTransport {
34
+ request<T>(request: ApiTransportRequest): Promise<ApiTransportResponse<T>>;
35
+ }
36
+ /** Les points d'accroche d'un greffon — `name` n'en est pas un. */
37
+ type ApiPluginHook = "onRequest" | "onResponse" | "onError";
38
+ interface ApiPlugin {
39
+ /**
40
+ * Un nom, pour les traces et le débogage.
41
+ *
42
+ * Quand six greffons sont montés, savoir lequel a court-circuité une requête
43
+ * vaut mieux que de compter les positions dans un tableau.
44
+ */
45
+ name?: string | undefined;
46
+ onRequest?: (ctx: ApiRequestContext) => Promise<unknown> | unknown;
47
+ onResponse?: (response: ApiTransportResponse, ctx: ApiRequestContext) => Promise<unknown> | unknown;
48
+ onError?: (error: unknown, ctx: ApiRequestContext) => Promise<unknown> | unknown;
49
+ }
50
+ interface PageMeta {
51
+ page: number;
52
+ limit: number;
53
+ total?: number | undefined;
54
+ totalPages?: number | undefined;
55
+ }
56
+ interface CursorMeta {
57
+ nextCursor?: string | null | undefined;
58
+ prevCursor?: string | null | undefined;
59
+ }
60
+ interface PaginatedResponse<T> {
61
+ items: T[];
62
+ meta: PageMeta | CursorMeta;
63
+ }
64
+ interface ResponseHandlerConfig {
65
+ extractData?: (raw: unknown) => unknown;
66
+ extractPaginated?: (raw: unknown) => PaginatedResponse<unknown> | null;
67
+ extractCursor?: (raw: unknown) => PaginatedResponse<unknown> | null;
68
+ }
69
+ interface RefreshTokenConfig {
70
+ enabled: boolean;
71
+ refreshEndpoint: string;
72
+ getRefreshToken?: (() => Promise<string | null> | string | null | undefined) | undefined;
73
+ saveTokens: (accessToken: string, refreshToken?: string | undefined) => Promise<void> | void;
74
+ onRefreshFailure?: ((error: unknown) => void) | undefined;
75
+ }
76
+ interface ApiClientOptions {
77
+ baseURL: string;
78
+ transport?: ApiTransport | undefined;
79
+ getToken?: (() => string | null | Promise<string | null>) | undefined;
80
+ getLanguage?: (() => string | null | Promise<string | null>) | undefined;
81
+ defaultHeaders?: Record<string, string> | undefined;
82
+ defaultRetry?: number | undefined;
83
+ defaultTimeoutMs?: number | undefined;
84
+ plugins?: ApiPlugin[] | undefined;
85
+ responseHandler?: ResponseHandlerConfig | undefined;
86
+ }
87
+ type ApiRequestInput = string | {
88
+ path: string;
89
+ config?: ApiRequestConfig | undefined;
90
+ };
91
+ interface RtkBaseQueryResult<T = unknown> {
92
+ data?: T;
93
+ error?: {
94
+ status: number | "FETCH_ERROR";
95
+ data: unknown;
96
+ message: string;
97
+ };
98
+ }
99
+
100
+ declare class ApiClient {
101
+ private baseURL;
102
+ private transport;
103
+ private getToken;
104
+ private getLanguage;
105
+ private defaultHeaders;
106
+ private defaultRetry;
107
+ private defaultTimeoutMs;
108
+ private plugins;
109
+ private responseHandler;
110
+ constructor(options: ApiClientOptions);
111
+ addPlugin(plugin: ApiPlugin): void;
112
+ /**
113
+ * Monte un greffon et renvoie le client.
114
+ *
115
+ * Le chaînage rend l'assemblage lisible d'un coup d'œil — c'est là qu'on
116
+ * relit l'ordre des greffons, et l'ordre compte : le premier qui renvoie une
117
+ * valeur court-circuite les suivants.
118
+ */
119
+ use(plugin: ApiPlugin): this;
120
+ buildURL(path: string, params?: QueryParams): string;
121
+ private runPlugins;
122
+ private buildHeaders;
123
+ private shouldRetry;
124
+ private requestRaw;
125
+ request<T>(method: ApiMethod, path: string, config?: ApiRequestConfig): Promise<T>;
126
+ replay<T>(ctx: ApiRequestContext): Promise<T>;
127
+ get<T>(path: string, config?: ApiRequestConfig): Promise<T>;
128
+ post<T>(path: string, config?: ApiRequestConfig): Promise<T>;
129
+ put<T>(path: string, config?: ApiRequestConfig): Promise<T>;
130
+ patch<T>(path: string, config?: ApiRequestConfig): Promise<T>;
131
+ delete<T>(path: string, config?: ApiRequestConfig): Promise<T>;
132
+ /** Sans corps de réponse : sert à tester l'existence ou lire des en-têtes. */
133
+ head(path: string, config?: ApiRequestConfig): Promise<null>;
134
+ /**
135
+ * Une liste, qu'elle soit paginée ou non.
136
+ *
137
+ * Beaucoup d'points d'entrée renvoient un tableau nu quand il n'y a pas de
138
+ * pagination. Les appelants ne devraient pas avoir à deviner lequel : la
139
+ * méthode normalise les deux en `{ items, meta }`.
140
+ */
141
+ getList<T>(path: string, config?: ApiRequestConfig): Promise<PaginatedResponse<T>>;
142
+ getPaginated<T>(path: string, options?: {
143
+ page?: number;
144
+ limit?: number;
145
+ query?: QueryParams | undefined;
146
+ config?: ApiRequestConfig | undefined;
147
+ }): Promise<PaginatedResponse<T>>;
148
+ getCursorPage<T>(path: string, options?: {
149
+ cursor?: string | null;
150
+ limit?: number;
151
+ query?: QueryParams | undefined;
152
+ cursorParamName?: string | undefined;
153
+ config?: ApiRequestConfig | undefined;
154
+ }): Promise<PaginatedResponse<T>>;
155
+ query<T>(loader: () => Promise<T>): () => Promise<T>;
156
+ swrFetcher<T>(config?: ApiRequestConfig): (path: string) => Promise<T>;
157
+ rtkBaseQuery(): <T>(input: ApiRequestInput) => Promise<RtkBaseQueryResult<T>>;
158
+ }
159
+ declare function createApiClient(options: ApiClientOptions): ApiClient;
160
+
161
+ /**
162
+ * Une erreur de validation rattachée à un champ.
163
+ *
164
+ * C'est ce qui permet à un formulaire d'afficher « adresse invalide » sous
165
+ * l'adresse plutôt qu'un bandeau générique en haut de page.
166
+ */
167
+ interface FieldError {
168
+ field: string;
169
+ message: string;
170
+ code?: string | undefined;
171
+ }
172
+ declare class HttpError extends Error {
173
+ status: number;
174
+ body: unknown;
175
+ headers: Record<string, string>;
176
+ /** Les erreurs par champ, quand le serveur en renvoie. */
177
+ fields: FieldError[];
178
+ constructor(status: number, body?: unknown, message?: string, headers?: Record<string, string>);
179
+ static fromResponse(response: Response): Promise<HttpError>;
180
+ /**
181
+ * Retrouve les erreurs par champ dans un corps d'erreur.
182
+ *
183
+ * Trois formes couvertes, parce que trois serveurs sur quatre en utilisent
184
+ * une : la liste (`errors: [{ field, message }]`), la carte
185
+ * (`errors: { email: "…" }`) et la carte de listes, que produit Laravel.
186
+ */
187
+ static extractFields(body: unknown): FieldError[];
188
+ /** Le message rattaché à ce champ, s'il y en a un. */
189
+ fieldError(field: string): string | undefined;
190
+ static normalizeHeaders(headers: unknown): Record<string, string>;
191
+ static extractMessage(body: unknown): string | null;
192
+ isClientError(): boolean;
193
+ isServerError(): boolean;
194
+ }
195
+
196
+ interface AuthPluginOptions {
197
+ getToken: () => Promise<string | null> | string | null;
198
+ scheme?: string;
199
+ }
200
+ interface UnauthorizedPluginOptions {
201
+ shouldIgnore?: (ctx: ApiRequestContext) => boolean;
202
+ onUnauthorized: (error: HttpError, ctx: ApiRequestContext) => void;
203
+ }
204
+ declare function createAuthPlugin(options: AuthPluginOptions): ApiPlugin;
205
+ declare function createUnauthorizedPlugin(options: UnauthorizedPluginOptions): ApiPlugin;
206
+ declare function createRefreshTokenPlugin(config: RefreshTokenConfig, replayRequest: (ctx: ApiRequestContext) => Promise<unknown>): ApiPlugin;
207
+ interface LoggerPluginOptions {
208
+ /** Où écrire. Par défaut la console; un collecteur de métriques fait l'affaire. */
209
+ log?: (message: string, detail?: unknown) => void;
210
+ }
211
+ /**
212
+ * Trace chaque requête avec sa durée.
213
+ *
214
+ * La durée est mesurée sur le contexte lui-même : c'est le seul objet qui
215
+ * traverse `onRequest` et `onResponse` pour une même requête.
216
+ */
217
+ declare function createLoggerPlugin(options?: LoggerPluginOptions): ApiPlugin;
218
+ interface IdempotencyPluginOptions {
219
+ header?: string;
220
+ newKey?: () => string;
221
+ }
222
+ /**
223
+ * Pose une clé d'idempotence sur les écritures.
224
+ *
225
+ * Sans elle, un `POST` rejoué après une coupure réseau crée deux ressources.
226
+ * Avec elle, le serveur reconnaît la seconde tentative — à condition qu'il la
227
+ * gère, ce que ce greffon ne peut pas vérifier.
228
+ */
229
+ declare function createIdempotencyPlugin(options?: IdempotencyPluginOptions): ApiPlugin;
230
+ /**
231
+ * Coupe toute écriture.
232
+ *
233
+ * Utile pour une démonstration, un compte en lecture seule, ou un
234
+ * environnement gelé. L'erreur est levée avant que la requête ne parte : rien
235
+ * n'atteint le serveur.
236
+ */
237
+ declare function createReadOnlyPlugin(message?: string): ApiPlugin;
238
+ interface HeaderSignalPluginOptions {
239
+ /** L'en-tête qui déclenche l'action. */
240
+ header: string;
241
+ /** Ce qu'il faut faire quand le serveur le renvoie. */
242
+ onSignal: (value: string, ctx: ApiRequestContext) => void | Promise<void>;
243
+ /** Valeur attendue; par défaut, `"true"`. */
244
+ expect?: (value: string) => boolean;
245
+ }
246
+ /**
247
+ * Réagit à un en-tête de réponse.
248
+ *
249
+ * Généralisation de ce qu'un projet faisait pour un cas précis : le serveur
250
+ * renvoie `x-permissions-refreshed: true`, le client recharge l'utilisateur.
251
+ * Le motif — « le serveur signale, le client réagit » — vaut pour bien
252
+ * d'autres cas, donc le greffon ne fixe ni l'en-tête ni la réaction.
253
+ *
254
+ * Les appels concurrents sont dédoublonnés : dix requêtes qui portent le même
255
+ * signal ne déclenchent qu'une réaction.
256
+ */
257
+ declare function createHeaderSignalPlugin(options: HeaderSignalPluginOptions): ApiPlugin;
258
+
259
+ /**
260
+ * Où vit une session entre deux lancements.
261
+ *
262
+ * Une interface plutôt qu'une implémentation, parce que la bonne réponse
263
+ * dépend de l'hôte : le navigateur a `localStorage`, une application native a
264
+ * son trousseau, un test ne veut ni l'un ni l'autre. Ce module n'importe donc
265
+ * aucune API de plateforme — on lui passe le stockage.
266
+ */
267
+ interface TokenStorage {
268
+ getAccessToken(): string | null | Promise<string | null>;
269
+ getRefreshToken(): string | null | Promise<string | null>;
270
+ saveTokens(accessToken: string, refreshToken?: string): void | Promise<void>;
271
+ clear(): void | Promise<void>;
272
+ }
273
+ declare const ACCESS_TOKEN_KEY = "sia.accessToken";
274
+ declare const REFRESH_TOKEN_KEY = "sia.refreshToken";
275
+ /** Rien ne survit au rechargement — le bon défaut pour un test. */
276
+ declare function createMemoryTokenStorage(): TokenStorage;
277
+ interface BrowserTokenStorageOptions {
278
+ accessKey?: string;
279
+ refreshKey?: string;
280
+ }
281
+ /**
282
+ * Un stockage du navigateur — `localStorage` ou `sessionStorage`.
283
+ *
284
+ * Lisible par tout script de la page : c'est le compromis qu'accepte toute
285
+ * application web devant survivre à un rechargement. C'est aussi pourquoi le
286
+ * jeton d'accès doit être court, et le jeton de renouvellement à usage unique
287
+ * et révocable.
288
+ *
289
+ * Chaque accès est protégé : en navigation privée, ou avec les données de site
290
+ * bloquées, une simple lecture lève. Une session absente est un cas normal;
291
+ * une page qui tombe pour cette raison ne l'est pas.
292
+ */
293
+ declare function createBrowserTokenStorage(storage: Pick<Storage, "getItem" | "setItem" | "removeItem">, options?: BrowserTokenStorageOptions): TokenStorage;
294
+
295
+ /**
296
+ * Des filtres composés, transportés hors de l'URL.
297
+ *
298
+ * Une recherche à quatre critères sérialisée en paramètres produit une URL
299
+ * illisible, et dépasse vite la limite de longueur des serveurs. Un en-tête
300
+ * porte la même chose sans ces deux problèmes — au prix de ne pas apparaître
301
+ * dans un lien partageable, ce qui est acceptable pour un filtre de tableau.
302
+ */
303
+ type FilterOperator = "eq" | "ne" | "lt" | "lte" | "gt" | "gte" | "in" | "nin" | "like" | "between" | "isNull";
304
+ type FilterValue = string | number | boolean | null | Array<string | number | boolean>;
305
+ /** `{ statut: "payé" }` ou `{ montant: { gte: 1000 } }`. */
306
+ type FilterCondition = Record<string, FilterValue | Partial<Record<FilterOperator, FilterValue>>>;
307
+ interface FilterGroup {
308
+ $and?: ApiFilters[];
309
+ $or?: ApiFilters[];
310
+ }
311
+ type ApiFilters = FilterCondition | FilterGroup;
312
+ /**
313
+ * Ramène un filtre à sa forme composée.
314
+ *
315
+ * Un objet plat est le cas courant — « tous ces critères » — et il est plus
316
+ * agréable à écrire que `{ $and: [{ … }] }`. On accepte les deux et on
317
+ * normalise, pour que le serveur n'ait qu'une forme à lire.
318
+ */
319
+ declare function normalizeFilters(filters: ApiFilters | undefined): FilterGroup | undefined;
320
+ declare const FILTERS_HEADER = "X-Filters";
321
+ /**
322
+ * L'en-tête à joindre à la requête, ou rien s'il n'y a pas de filtre.
323
+ *
324
+ * ```ts
325
+ * const headers = filtersHeader({ statut: "payé", montant: { gte: 1000 } });
326
+ * await api.get("/factures", { headers });
327
+ * ```
328
+ */
329
+ declare function filtersHeader(filters: ApiFilters | undefined, header?: string): Record<string, string>;
330
+
331
+ /**
332
+ * La chaîne de requête d'un appel API.
333
+ *
334
+ * L'implémentation est celle de `@sia-ui/utils` — une seule pour tout le
335
+ * dépôt. Ce qui reste ici est le typage : `QueryParams` décrit ce qu'un
336
+ * appel accepte, et c'est une information du module API, pas des utils.
337
+ */
338
+ declare function buildQueryString(params?: QueryParams): string;
339
+
340
+ declare class ResponseHandler {
341
+ private config;
342
+ constructor(config?: ResponseHandlerConfig);
343
+ extractData<T>(raw: unknown): T;
344
+ extractPaginated<T>(raw: unknown): PaginatedResponse<T> | null;
345
+ extractCursor<T>(raw: unknown): PaginatedResponse<T> | null;
346
+ }
347
+
348
+ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = Partial<TEntity>, TListParams extends QueryParams = QueryParams> {
349
+ protected client: ApiClient;
350
+ protected resource: string;
351
+ constructor(client: ApiClient, resource: string);
352
+ protected normalizeQueryParams(params?: TListParams): TListParams | undefined;
353
+ list(params?: TListParams, config?: ApiRequestConfig): Promise<TEntity[]>;
354
+ getById(id: string, config?: ApiRequestConfig): Promise<TEntity>;
355
+ create(payload: TCreateDTO, config?: ApiRequestConfig): Promise<TEntity>;
356
+ update(id: string, payload: TUpdateDTO, config?: ApiRequestConfig): Promise<TEntity>;
357
+ patch(id: string, payload: Partial<TUpdateDTO>, config?: ApiRequestConfig): Promise<TEntity>;
358
+ remove(id: string, config?: ApiRequestConfig): Promise<void>;
359
+ /**
360
+ * Suppression en lot.
361
+ *
362
+ * Un `POST` plutôt que N `DELETE` : le serveur décide en une transaction,
363
+ * et l'interface n'a pas à gérer une suppression à moitié réussie.
364
+ */
365
+ removeMany(ids: string[], options?: {
366
+ hardDelete?: boolean;
367
+ }, config?: ApiRequestConfig): Promise<void>;
368
+ /**
369
+ * Une liste filtrée, la forme composée passant par un en-tête.
370
+ *
371
+ * Voir `filters.ts` : une recherche à plusieurs critères tient mal dans une
372
+ * URL.
373
+ */
374
+ search(filters: ApiFilters, params?: TListParams, config?: ApiRequestConfig): Promise<PaginatedResponse<TEntity>>;
375
+ paginated(options?: {
376
+ page?: number;
377
+ limit?: number;
378
+ query?: TListParams | undefined;
379
+ config?: ApiRequestConfig | undefined;
380
+ }): Promise<PaginatedResponse<TEntity>>;
381
+ cursorPage(options?: {
382
+ cursor?: string | null;
383
+ limit?: number;
384
+ query?: TListParams | undefined;
385
+ cursorParamName?: string | undefined;
386
+ config?: ApiRequestConfig | undefined;
387
+ }): Promise<PaginatedResponse<TEntity>>;
388
+ }
389
+ declare function createResourceService<TEntity>(client: ApiClient, resourcePath: string): BaseService<TEntity, Partial<TEntity>, Partial<TEntity>, QueryParams>;
390
+
391
+ interface AxiosLikeInstance {
392
+ request<T = unknown>(config: {
393
+ url: string;
394
+ method: string;
395
+ headers?: Record<string, string>;
396
+ data?: unknown;
397
+ signal?: AbortSignal | undefined;
398
+ timeout?: number | undefined;
399
+ onUploadProgress?: ((event: {
400
+ loaded: number;
401
+ total?: number | undefined;
402
+ }) => void) | undefined;
403
+ }): Promise<{
404
+ status: number;
405
+ statusText: string;
406
+ headers?: unknown;
407
+ data: T;
408
+ }>;
409
+ }
410
+ declare function createFetchTransport(fetcher?: typeof fetch): ApiTransport;
411
+ declare function createAxiosTransport(axios: AxiosLikeInstance): ApiTransport;
412
+
413
+ export { ACCESS_TOKEN_KEY, ApiClient, type ApiClientOptions, type ApiFilters, type ApiMethod, type ApiPlugin, type ApiPluginHook, type ApiRequestConfig, type ApiRequestContext, type ApiRequestInput, type ApiTransport, type ApiTransportRequest, type ApiTransportResponse, type AuthPluginOptions, BaseService, type BrowserTokenStorageOptions, type CursorMeta, FILTERS_HEADER, type FieldError, type FilterCondition, type FilterGroup, type FilterOperator, type FilterValue, type HeaderSignalPluginOptions, HttpError, type IdempotencyPluginOptions, type LoggerPluginOptions, type PageMeta, type PaginatedResponse, type QueryParamObject, type QueryParams, REFRESH_TOKEN_KEY, type RefreshTokenConfig, ResponseHandler, type ResponseHandlerConfig, type RtkBaseQueryResult, type TokenStorage, type UnauthorizedPluginOptions, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createReadOnlyPlugin, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, filtersHeader, normalizeFilters };