@sia-ui/api 0.5.0 → 0.6.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # @sia-ui/api
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Rôles, requêtes serveur, ressources par mode, et deux blocs de console.
8
+ - **Rôles** — la couche d'accès lit les rôles de la session sous la forme
9
+ `role:<nom>` (`role("admin")`), avec les trois évaluateurs.
10
+ - **Tableaux servis par le serveur** — `useDataTableQuery` accepte un
11
+ `storage` (TanStack Router, React Router) et `serverParams` pour les noms
12
+ attendus (`limit`, `sort` + `order`, `ASC`/`DESC`). `toServerParams` dans
13
+ `@sia-ui/headless`.
14
+ - **TanStack Query** — `createQueryResource(service)` : clés emboîtées,
15
+ options de requête avec annulation, mutations qui invalident ce qu'elles
16
+ rendent périmé. Aucune dépendance à TanStack.
17
+ - **Ressources** — `inForm` et `required` par mode (`"create"`, `"edit"`),
18
+ `value` pour une colonne calculée.
19
+ - **En-têtes** — `PageHeader` `level`, `CrudPage` `headingLevel`. Le repli
20
+ suit la place disponible ; les actions ne s'écrasent plus.
21
+ - **Nouveaux** — `SecretFields` (secrets en écriture seule), `ActivityLog`
22
+ (journal filtré par le serveur), `StatusBadge` et `toneOf`, dix icônes de
23
+ console.
24
+ - Les icônes des boutons de `CrudPage` et `DataTable` passent par
25
+ `leftIcon` : elles ne se collent plus au libellé.
26
+
27
+ - `sia-ui update`, la feuille commune suivie, et des erreurs HTTP en français.
28
+ - **`sia-ui update [nom...]`** — met à jour les entrées en retard sans perdre
29
+ de retouche : un fichier intact est remplacé, un fichier modifié depuis son
30
+ installation est gardé et signalé (`sia-ui diff`, puis `--force` pour
31
+ l'écraser). Les dépendances de registre apparues sont installées, les
32
+ paquets npm annoncés. `--dry-run` montre sans rien écrire. Une entrée dont
33
+ un fichier a été gardé reste « en retard » dans le verrou.
34
+ - **`sia-ui.css` suivie** — son empreinte est notée dans `sia-ui.lock.json`.
35
+ `update` la met à jour comme un composant ; `add` aussi, si elle n'a pas
36
+ été retouchée. `init` ne fait plus qu'initialiser. Elle est lue au même
37
+ registre que les composants, HTTP compris. `doctor` signale une feuille en
38
+ retard, modifiée ou copiée avant le suivi.
39
+ - `list --outdated` et `doctor` conseillent `sia-ui update` au lieu de
40
+ `add --overwrite`, qui écrasait aussi les retouches.
41
+ - **`statusMessages`** (`@sia-ui/api`) — option du client, avec
42
+ `FRENCH_STATUS_MESSAGES`. Remplace les messages génériques du serveur
43
+ (« Conflict », « Forbidden resource », « Cannot GET /x »,
44
+ `ThrottlerException`) et l'échec réseau de `fetch`, jamais un message métier
45
+ ni une erreur de champs.
46
+
47
+ ### Patch Changes
48
+
49
+ - Updated dependencies
50
+ - @sia-ui/utils@0.6.0
51
+
3
52
  ## 0.5.0
4
53
 
5
54
  ### Minor Changes
package/README.md CHANGED
@@ -57,6 +57,55 @@ Le contenu ci-dessous reprend intégralement `CHANGELOG.md` pour rester visible
57
57
  <!-- sia:changelog:start -->
58
58
  # @sia-ui/api
59
59
 
60
+ ## 0.6.0
61
+
62
+ ### Minor Changes
63
+
64
+ - Rôles, requêtes serveur, ressources par mode, et deux blocs de console.
65
+ - **Rôles** — la couche d'accès lit les rôles de la session sous la forme
66
+ `role:<nom>` (`role("admin")`), avec les trois évaluateurs.
67
+ - **Tableaux servis par le serveur** — `useDataTableQuery` accepte un
68
+ `storage` (TanStack Router, React Router) et `serverParams` pour les noms
69
+ attendus (`limit`, `sort` + `order`, `ASC`/`DESC`). `toServerParams` dans
70
+ `@sia-ui/headless`.
71
+ - **TanStack Query** — `createQueryResource(service)` : clés emboîtées,
72
+ options de requête avec annulation, mutations qui invalident ce qu'elles
73
+ rendent périmé. Aucune dépendance à TanStack.
74
+ - **Ressources** — `inForm` et `required` par mode (`"create"`, `"edit"`),
75
+ `value` pour une colonne calculée.
76
+ - **En-têtes** — `PageHeader` `level`, `CrudPage` `headingLevel`. Le repli
77
+ suit la place disponible ; les actions ne s'écrasent plus.
78
+ - **Nouveaux** — `SecretFields` (secrets en écriture seule), `ActivityLog`
79
+ (journal filtré par le serveur), `StatusBadge` et `toneOf`, dix icônes de
80
+ console.
81
+ - Les icônes des boutons de `CrudPage` et `DataTable` passent par
82
+ `leftIcon` : elles ne se collent plus au libellé.
83
+
84
+ - `sia-ui update`, la feuille commune suivie, et des erreurs HTTP en français.
85
+ - **`sia-ui update [nom...]`** — met à jour les entrées en retard sans perdre
86
+ de retouche : un fichier intact est remplacé, un fichier modifié depuis son
87
+ installation est gardé et signalé (`sia-ui diff`, puis `--force` pour
88
+ l'écraser). Les dépendances de registre apparues sont installées, les
89
+ paquets npm annoncés. `--dry-run` montre sans rien écrire. Une entrée dont
90
+ un fichier a été gardé reste « en retard » dans le verrou.
91
+ - **`sia-ui.css` suivie** — son empreinte est notée dans `sia-ui.lock.json`.
92
+ `update` la met à jour comme un composant ; `add` aussi, si elle n'a pas
93
+ été retouchée. `init` ne fait plus qu'initialiser. Elle est lue au même
94
+ registre que les composants, HTTP compris. `doctor` signale une feuille en
95
+ retard, modifiée ou copiée avant le suivi.
96
+ - `list --outdated` et `doctor` conseillent `sia-ui update` au lieu de
97
+ `add --overwrite`, qui écrasait aussi les retouches.
98
+ - **`statusMessages`** (`@sia-ui/api`) — option du client, avec
99
+ `FRENCH_STATUS_MESSAGES`. Remplace les messages génériques du serveur
100
+ (« Conflict », « Forbidden resource », « Cannot GET /x »,
101
+ `ThrottlerException`) et l'échec réseau de `fetch`, jamais un message métier
102
+ ni une erreur de champs.
103
+
104
+ ### Patch Changes
105
+
106
+ - Updated dependencies
107
+ - @sia-ui/utils@0.6.0
108
+
60
109
  ## 0.5.0
61
110
 
62
111
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -138,6 +138,66 @@ var HttpError = class _HttpError extends Error {
138
138
  return this.status >= 500;
139
139
  }
140
140
  };
141
+
142
+ // src/status-messages.ts
143
+ var FRENCH_STATUS_MESSAGES = {
144
+ 0: "Le serveur est injoignable. V\xE9rifiez votre connexion.",
145
+ 400: "La demande est invalide.",
146
+ 401: "Votre session a expir\xE9. Reconnectez-vous.",
147
+ 403: "Vous n'avez pas les droits n\xE9cessaires pour cette action.",
148
+ 404: "L'\xE9l\xE9ment demand\xE9 est introuvable.",
149
+ 408: "Le serveur a mis trop de temps \xE0 r\xE9pondre.",
150
+ 409: "Cette op\xE9ration entre en conflit avec l'\xE9tat actuel. Rechargez puis r\xE9essayez.",
151
+ 413: "Le contenu envoy\xE9 est trop volumineux.",
152
+ 422: "Certaines valeurs sont invalides.",
153
+ 429: "Trop de tentatives. R\xE9essayez dans un instant.",
154
+ 500: "Une erreur est survenue sur le serveur.",
155
+ 502: "Le service est momentan\xE9ment indisponible.",
156
+ 503: "Le service est momentan\xE9ment indisponible.",
157
+ 504: "Le serveur a mis trop de temps \xE0 r\xE9pondre."
158
+ };
159
+ var ECHEC_RESEAU = /failed to fetch|networkerror|load failed/i;
160
+ var PHRASES = {
161
+ 400: "bad request",
162
+ 401: "unauthorized",
163
+ 403: "forbidden",
164
+ 404: "not found",
165
+ 405: "method not allowed",
166
+ 408: "request timeout",
167
+ 409: "conflict",
168
+ 413: "payload too large",
169
+ 415: "unsupported media type",
170
+ 422: "unprocessable entity",
171
+ 429: "too many requests",
172
+ 500: "internal server error",
173
+ 502: "bad gateway",
174
+ 503: "service unavailable",
175
+ 504: "gateway timeout"
176
+ };
177
+ function isGenericMessage(status, message, body) {
178
+ const texte = message?.trim().toLowerCase();
179
+ if (!texte || texte === `http ${status}`) return true;
180
+ const phrase = PHRASES[status];
181
+ if (phrase && (texte === phrase || texte === `${phrase} resource`)) return true;
182
+ const erreur = body && typeof body === "object" ? body.error : void 0;
183
+ if (typeof erreur === "string" && texte === erreur.trim().toLowerCase()) {
184
+ return true;
185
+ }
186
+ return /^cannot (get|post|put|patch|delete|head) /.test(texte) || /exception\b/.test(texte);
187
+ }
188
+ function localizeError(error, messages) {
189
+ if (error instanceof HttpError) {
190
+ const remplacant = messages[error.status];
191
+ if (remplacant && error.fields.length === 0 && isGenericMessage(error.status, error.message, error.body)) {
192
+ error.message = remplacant;
193
+ }
194
+ return error;
195
+ }
196
+ if (error instanceof TypeError && messages[0] && ECHEC_RESEAU.test(error.message)) {
197
+ error.message = messages[0];
198
+ }
199
+ return error;
200
+ }
141
201
  function buildQueryString(params) {
142
202
  return query.toQueryString(params);
143
203
  }
@@ -356,6 +416,7 @@ var ApiClient = class {
356
416
  defaultRetry;
357
417
  retryMethods;
358
418
  fieldErrors;
419
+ statusMessages;
359
420
  defaultTimeoutMs;
360
421
  plugins;
361
422
  responseHandler;
@@ -372,6 +433,7 @@ var ApiClient = class {
372
433
  (options.retryMethods ?? DEFAULT_RETRY_METHODS).map(normalizeMethod)
373
434
  );
374
435
  this.fieldErrors = options.fieldErrors;
436
+ this.statusMessages = options.statusMessages;
375
437
  this.defaultTimeoutMs = options.defaultTimeoutMs ?? 3e4;
376
438
  this.plugins = options.plugins ?? [];
377
439
  this.responseHandler = new ResponseHandler(options.responseHandler);
@@ -469,7 +531,9 @@ var ApiClient = class {
469
531
  } catch (error) {
470
532
  const pluginResult = await this.runPlugins("onError", error, ctx);
471
533
  if (pluginResult !== void 0) return pluginResult;
472
- if (!this.shouldRetry(error, attempt, maxRetries)) throw error;
534
+ if (!this.shouldRetry(error, attempt, maxRetries)) {
535
+ throw this.statusMessages ? localizeError(error, this.statusMessages) : error;
536
+ }
473
537
  attempt += 1;
474
538
  const baseDelay = Math.min(1e3 * 2 ** attempt, 3e4);
475
539
  await async.sleep(baseDelay + Math.random() * 0.1 * baseDelay);
@@ -860,6 +924,10 @@ var BaseService = class {
860
924
  this.client = client;
861
925
  this.resource = resource;
862
926
  }
927
+ /** Le chemin de la ressource — sert aussi de racine aux clés de cache. */
928
+ get path() {
929
+ return this.resource;
930
+ }
863
931
  normalizeQueryParams(params) {
864
932
  if (!params || typeof params !== "object") return params;
865
933
  const normalized = { ...params };
@@ -943,10 +1011,53 @@ function createResourceService(client, resourcePath) {
943
1011
  return new BaseService(client, resourcePath);
944
1012
  }
945
1013
 
1014
+ // src/query-resource.ts
1015
+ function createQueryResource(service, options = {}) {
1016
+ const racine = options.name ?? service.path;
1017
+ const keys = {
1018
+ all: [racine],
1019
+ lists: () => [racine, "list"],
1020
+ list: (params) => [racine, "list", params ?? {}],
1021
+ details: () => [racine, "detail"],
1022
+ detail: (id) => [racine, "detail", id]
1023
+ };
1024
+ const invalider = (client, ...cles) => Promise.all(cles.map((queryKey) => client.invalidateQueries({ queryKey })));
1025
+ return {
1026
+ keys,
1027
+ /** Une liste nue — `service.list`. */
1028
+ listQuery: (params) => ({
1029
+ queryKey: keys.list(params),
1030
+ queryFn: ({ signal } = {}) => service.list(params, { signal })
1031
+ }),
1032
+ /** Une page — `service.paginated`, rendue en `{ items, meta }`. */
1033
+ pageQuery: (page = {}) => ({
1034
+ queryKey: keys.list(page),
1035
+ queryFn: ({ signal } = {}) => service.paginated({ ...page, config: { signal } })
1036
+ }),
1037
+ detailQuery: (id) => ({
1038
+ queryKey: keys.detail(id),
1039
+ queryFn: ({ signal } = {}) => service.getById(id, { signal })
1040
+ }),
1041
+ /**
1042
+ * Crée sans `id`, modifie avec. Invalide les listes, et le détail
1043
+ * modifié : c'est ce que chaque écran écrivait à la main, ou oubliait.
1044
+ */
1045
+ saveMutation: (client) => ({
1046
+ mutationFn: ({ id, values }) => id === void 0 ? service.create(values) : service.update(id, values),
1047
+ onSuccess: (_, { id }) => id === void 0 ? invalider(client, keys.lists()) : invalider(client, keys.lists(), keys.detail(id))
1048
+ }),
1049
+ removeMutation: (client) => ({
1050
+ mutationFn: (id) => service.remove(id),
1051
+ onSuccess: (_, id) => invalider(client, keys.lists(), keys.detail(id))
1052
+ })
1053
+ };
1054
+ }
1055
+
946
1056
  exports.ACCESS_TOKEN_KEY = ACCESS_TOKEN_KEY;
947
1057
  exports.ApiClient = ApiClient;
948
1058
  exports.BaseService = BaseService;
949
1059
  exports.FILTERS_HEADER = FILTERS_HEADER;
1060
+ exports.FRENCH_STATUS_MESSAGES = FRENCH_STATUS_MESSAGES;
950
1061
  exports.HttpError = HttpError;
951
1062
  exports.REFRESH_TOKEN_KEY = REFRESH_TOKEN_KEY;
952
1063
  exports.ResponseHandler = ResponseHandler;
@@ -960,10 +1071,13 @@ exports.createHeaderSignalPlugin = createHeaderSignalPlugin;
960
1071
  exports.createIdempotencyPlugin = createIdempotencyPlugin;
961
1072
  exports.createLoggerPlugin = createLoggerPlugin;
962
1073
  exports.createMemoryTokenStorage = createMemoryTokenStorage;
1074
+ exports.createQueryResource = createQueryResource;
963
1075
  exports.createReadOnlyPlugin = createReadOnlyPlugin;
964
1076
  exports.createRefreshTokenPlugin = createRefreshTokenPlugin;
965
1077
  exports.createResourceService = createResourceService;
966
1078
  exports.createUnauthorizedPlugin = createUnauthorizedPlugin;
967
1079
  exports.filtersHeader = filtersHeader;
1080
+ exports.isGenericMessage = isGenericMessage;
1081
+ exports.localizeError = localizeError;
968
1082
  exports.nestFieldErrors = nestFieldErrors;
969
1083
  exports.normalizeFilters = normalizeFilters;
package/dist/index.d.cts CHANGED
@@ -157,6 +157,15 @@ interface ApiClientOptions {
157
157
  * lit en plus le `message: string[]` de class-validator.
158
158
  */
159
159
  fieldErrors?: ((body: unknown) => FieldError[]) | undefined;
160
+ /**
161
+ * Des messages par statut, à la place des messages génériques du serveur.
162
+ *
163
+ * `FRENCH_STATUS_MESSAGES` couvre les cas courants. Seul un message qui
164
+ * n'apprend rien est remplacé — « Conflict », « Forbidden resource »,
165
+ * « Cannot GET /x » — jamais un message métier. `0` vaut pour l'échec
166
+ * réseau.
167
+ */
168
+ statusMessages?: Readonly<Record<number, string>> | undefined;
160
169
  }
161
170
  type ApiRequestInput = string | {
162
171
  path: string;
@@ -180,6 +189,7 @@ declare class ApiClient {
180
189
  private defaultRetry;
181
190
  private retryMethods;
182
191
  private fieldErrors;
192
+ private statusMessages;
183
193
  private defaultTimeoutMs;
184
194
  private plugins;
185
195
  private responseHandler;
@@ -234,6 +244,31 @@ declare class ApiClient {
234
244
  }
235
245
  declare function createApiClient(options: ApiClientOptions): ApiClient;
236
246
 
247
+ /**
248
+ * Les messages par statut, en français.
249
+ *
250
+ * `0` couvre l'échec réseau : le serveur injoignable, la connexion coupée.
251
+ */
252
+ declare const FRENCH_STATUS_MESSAGES: Readonly<Record<number, string>>;
253
+ /**
254
+ * Vrai quand le message du serveur est générique.
255
+ *
256
+ * Absent, la phrase HTTP du statut, le champ `error` de NestJS
257
+ * (`"Conflict"`), sa variante `"Forbidden resource"`, le 404 de route
258
+ * (`"Cannot GET /x"`) ou le nom d'une exception
259
+ * (`"ThrottlerException: Too Many Requests"`). Un message métier —
260
+ * « Ce client existe déjà » — ne l'est pas : il reste tel quel.
261
+ */
262
+ declare function isGenericMessage(status: number, message: string | undefined, body?: unknown): boolean;
263
+ /**
264
+ * Remplace un message générique par celui de la table.
265
+ *
266
+ * L'erreur garde son type et son corps : seul `message` change, et seulement
267
+ * s'il n'apprenait rien. Une erreur de champs n'est jamais touchée — ses
268
+ * messages par champ disent déjà ce qui ne va pas.
269
+ */
270
+ declare function localizeError(error: unknown, messages: Readonly<Record<number, string>>): unknown;
271
+
237
272
  interface AuthPluginOptions {
238
273
  getToken: () => Promise<string | null> | string | null;
239
274
  scheme?: string;
@@ -390,6 +425,8 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
390
425
  protected client: ApiClient;
391
426
  protected resource: string;
392
427
  constructor(client: ApiClient, resource: string);
428
+ /** Le chemin de la ressource — sert aussi de racine aux clés de cache. */
429
+ get path(): string;
393
430
  protected normalizeQueryParams(params?: TListParams): TListParams | undefined;
394
431
  list(params?: TListParams, config?: ApiRequestConfig): Promise<TEntity[]>;
395
432
  getById(id: string, config?: ApiRequestConfig): Promise<TEntity>;
@@ -429,6 +466,94 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
429
466
  }
430
467
  declare function createResourceService<TEntity>(client: ApiClient, resourcePath: string): BaseService<TEntity, Partial<TEntity>, Partial<TEntity>, QueryParams>;
431
468
 
469
+ /**
470
+ * Ce que l'invalidation demande d'un client de cache.
471
+ *
472
+ * Le `QueryClient` de TanStack Query le satisfait tel quel ; aucune
473
+ * dépendance n'est donc tirée ici. Un autre cache n'a qu'à fournir cette
474
+ * seule méthode.
475
+ */
476
+ interface QueryInvalidator {
477
+ invalidateQueries: (filters: {
478
+ queryKey: readonly unknown[];
479
+ }) => unknown;
480
+ }
481
+ /** Le contexte qu'un `queryFn` reçoit — seul le signal d'annulation sert. */
482
+ interface QueryFnContext {
483
+ signal?: AbortSignal | undefined;
484
+ }
485
+ /** Ce que reçoit `saveMutation` : sans `id`, une création. */
486
+ interface SaveVariables<TCreate, TUpdate> {
487
+ id?: string | undefined;
488
+ values: TCreate | TUpdate;
489
+ }
490
+ interface QueryResourceOptions {
491
+ /**
492
+ * La racine des clés. À défaut, le chemin du service.
493
+ *
494
+ * Deux ressources sous la même racine s'invalideraient l'une l'autre ;
495
+ * c'est ce nom qui les sépare.
496
+ */
497
+ name?: string | undefined;
498
+ }
499
+ /**
500
+ * Les clés et les options de requête d'une ressource.
501
+ *
502
+ * `api.query` se contentait de rendre le chargeur : chaque écran réinventait
503
+ * ses clés de cache, et la moitié oubliait d'invalider la liste après une
504
+ * création. Ici, les clés s'emboîtent — `lists()` couvre toutes les listes,
505
+ * quels qu'en soient les filtres — et chaque mutation sait ce qu'elle rend
506
+ * périmé.
507
+ *
508
+ * ```tsx
509
+ * const factures = createQueryResource(factureService);
510
+ *
511
+ * const { data } = useQuery(factures.pageQuery({ page, limit: 20 }));
512
+ * const enregistrer = useMutation(factures.saveMutation(queryClient));
513
+ * enregistrer.mutate({ id, values }); // crée sans id, modifie avec
514
+ * ```
515
+ */
516
+ declare function createQueryResource<TEntity, TCreate = Partial<TEntity>, TUpdate = Partial<TEntity>, TParams extends QueryParams = QueryParams>(service: BaseService<TEntity, TCreate, TUpdate, TParams>, options?: QueryResourceOptions): {
517
+ keys: {
518
+ all: readonly [string];
519
+ lists: () => readonly [string, "list"];
520
+ list: (params?: unknown) => readonly [string, "list", {}];
521
+ details: () => readonly [string, "detail"];
522
+ detail: (id: string) => readonly [string, "detail", string];
523
+ };
524
+ /** Une liste nue — `service.list`. */
525
+ listQuery: (params?: TParams) => {
526
+ queryKey: readonly [string, "list", {}];
527
+ queryFn: ({ signal }?: QueryFnContext) => Promise<TEntity[]>;
528
+ };
529
+ /** Une page — `service.paginated`, rendue en `{ items, meta }`. */
530
+ pageQuery: (page?: {
531
+ page?: number;
532
+ limit?: number;
533
+ query?: TParams;
534
+ }) => {
535
+ queryKey: readonly [string, "list", {}];
536
+ queryFn: ({ signal }?: QueryFnContext) => Promise<PaginatedResponse<TEntity>>;
537
+ };
538
+ detailQuery: (id: string) => {
539
+ queryKey: readonly [string, "detail", string];
540
+ queryFn: ({ signal }?: QueryFnContext) => Promise<TEntity>;
541
+ };
542
+ /**
543
+ * Crée sans `id`, modifie avec. Invalide les listes, et le détail
544
+ * modifié : c'est ce que chaque écran écrivait à la main, ou oubliait.
545
+ */
546
+ saveMutation: (client: QueryInvalidator) => {
547
+ mutationFn: ({ id, values }: SaveVariables<TCreate, TUpdate>) => Promise<TEntity>;
548
+ onSuccess: (_: TEntity, { id }: SaveVariables<TCreate, TUpdate>) => Promise<unknown[]>;
549
+ };
550
+ removeMutation: (client: QueryInvalidator) => {
551
+ mutationFn: (id: string) => Promise<void>;
552
+ onSuccess: (_: void, id: string) => Promise<unknown[]>;
553
+ };
554
+ };
555
+ type QueryResource<TEntity> = ReturnType<typeof createQueryResource<TEntity>>;
556
+
432
557
  interface AxiosLikeInstance {
433
558
  request<T = unknown>(config: {
434
559
  url: string;
@@ -451,4 +576,4 @@ interface AxiosLikeInstance {
451
576
  declare function createFetchTransport(fetcher?: typeof fetch): ApiTransport;
452
577
  declare function createAxiosTransport(axios: AxiosLikeInstance): ApiTransport;
453
578
 
454
- 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 FieldErrorExtractor, 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, nestFieldErrors, normalizeFilters };
579
+ 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, FRENCH_STATUS_MESSAGES, type FieldError, type FieldErrorExtractor, type FilterCondition, type FilterGroup, type FilterOperator, type FilterValue, type HeaderSignalPluginOptions, HttpError, type IdempotencyPluginOptions, type LoggerPluginOptions, type PageMeta, type PaginatedResponse, type QueryFnContext, type QueryInvalidator, type QueryParamObject, type QueryParams, type QueryResource, type QueryResourceOptions, REFRESH_TOKEN_KEY, type RefreshTokenConfig, ResponseHandler, type ResponseHandlerConfig, type RtkBaseQueryResult, type SaveVariables, type TokenStorage, type UnauthorizedPluginOptions, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createQueryResource, createReadOnlyPlugin, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, filtersHeader, isGenericMessage, localizeError, nestFieldErrors, normalizeFilters };
package/dist/index.d.ts CHANGED
@@ -157,6 +157,15 @@ interface ApiClientOptions {
157
157
  * lit en plus le `message: string[]` de class-validator.
158
158
  */
159
159
  fieldErrors?: ((body: unknown) => FieldError[]) | undefined;
160
+ /**
161
+ * Des messages par statut, à la place des messages génériques du serveur.
162
+ *
163
+ * `FRENCH_STATUS_MESSAGES` couvre les cas courants. Seul un message qui
164
+ * n'apprend rien est remplacé — « Conflict », « Forbidden resource »,
165
+ * « Cannot GET /x » — jamais un message métier. `0` vaut pour l'échec
166
+ * réseau.
167
+ */
168
+ statusMessages?: Readonly<Record<number, string>> | undefined;
160
169
  }
161
170
  type ApiRequestInput = string | {
162
171
  path: string;
@@ -180,6 +189,7 @@ declare class ApiClient {
180
189
  private defaultRetry;
181
190
  private retryMethods;
182
191
  private fieldErrors;
192
+ private statusMessages;
183
193
  private defaultTimeoutMs;
184
194
  private plugins;
185
195
  private responseHandler;
@@ -234,6 +244,31 @@ declare class ApiClient {
234
244
  }
235
245
  declare function createApiClient(options: ApiClientOptions): ApiClient;
236
246
 
247
+ /**
248
+ * Les messages par statut, en français.
249
+ *
250
+ * `0` couvre l'échec réseau : le serveur injoignable, la connexion coupée.
251
+ */
252
+ declare const FRENCH_STATUS_MESSAGES: Readonly<Record<number, string>>;
253
+ /**
254
+ * Vrai quand le message du serveur est générique.
255
+ *
256
+ * Absent, la phrase HTTP du statut, le champ `error` de NestJS
257
+ * (`"Conflict"`), sa variante `"Forbidden resource"`, le 404 de route
258
+ * (`"Cannot GET /x"`) ou le nom d'une exception
259
+ * (`"ThrottlerException: Too Many Requests"`). Un message métier —
260
+ * « Ce client existe déjà » — ne l'est pas : il reste tel quel.
261
+ */
262
+ declare function isGenericMessage(status: number, message: string | undefined, body?: unknown): boolean;
263
+ /**
264
+ * Remplace un message générique par celui de la table.
265
+ *
266
+ * L'erreur garde son type et son corps : seul `message` change, et seulement
267
+ * s'il n'apprenait rien. Une erreur de champs n'est jamais touchée — ses
268
+ * messages par champ disent déjà ce qui ne va pas.
269
+ */
270
+ declare function localizeError(error: unknown, messages: Readonly<Record<number, string>>): unknown;
271
+
237
272
  interface AuthPluginOptions {
238
273
  getToken: () => Promise<string | null> | string | null;
239
274
  scheme?: string;
@@ -390,6 +425,8 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
390
425
  protected client: ApiClient;
391
426
  protected resource: string;
392
427
  constructor(client: ApiClient, resource: string);
428
+ /** Le chemin de la ressource — sert aussi de racine aux clés de cache. */
429
+ get path(): string;
393
430
  protected normalizeQueryParams(params?: TListParams): TListParams | undefined;
394
431
  list(params?: TListParams, config?: ApiRequestConfig): Promise<TEntity[]>;
395
432
  getById(id: string, config?: ApiRequestConfig): Promise<TEntity>;
@@ -429,6 +466,94 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
429
466
  }
430
467
  declare function createResourceService<TEntity>(client: ApiClient, resourcePath: string): BaseService<TEntity, Partial<TEntity>, Partial<TEntity>, QueryParams>;
431
468
 
469
+ /**
470
+ * Ce que l'invalidation demande d'un client de cache.
471
+ *
472
+ * Le `QueryClient` de TanStack Query le satisfait tel quel ; aucune
473
+ * dépendance n'est donc tirée ici. Un autre cache n'a qu'à fournir cette
474
+ * seule méthode.
475
+ */
476
+ interface QueryInvalidator {
477
+ invalidateQueries: (filters: {
478
+ queryKey: readonly unknown[];
479
+ }) => unknown;
480
+ }
481
+ /** Le contexte qu'un `queryFn` reçoit — seul le signal d'annulation sert. */
482
+ interface QueryFnContext {
483
+ signal?: AbortSignal | undefined;
484
+ }
485
+ /** Ce que reçoit `saveMutation` : sans `id`, une création. */
486
+ interface SaveVariables<TCreate, TUpdate> {
487
+ id?: string | undefined;
488
+ values: TCreate | TUpdate;
489
+ }
490
+ interface QueryResourceOptions {
491
+ /**
492
+ * La racine des clés. À défaut, le chemin du service.
493
+ *
494
+ * Deux ressources sous la même racine s'invalideraient l'une l'autre ;
495
+ * c'est ce nom qui les sépare.
496
+ */
497
+ name?: string | undefined;
498
+ }
499
+ /**
500
+ * Les clés et les options de requête d'une ressource.
501
+ *
502
+ * `api.query` se contentait de rendre le chargeur : chaque écran réinventait
503
+ * ses clés de cache, et la moitié oubliait d'invalider la liste après une
504
+ * création. Ici, les clés s'emboîtent — `lists()` couvre toutes les listes,
505
+ * quels qu'en soient les filtres — et chaque mutation sait ce qu'elle rend
506
+ * périmé.
507
+ *
508
+ * ```tsx
509
+ * const factures = createQueryResource(factureService);
510
+ *
511
+ * const { data } = useQuery(factures.pageQuery({ page, limit: 20 }));
512
+ * const enregistrer = useMutation(factures.saveMutation(queryClient));
513
+ * enregistrer.mutate({ id, values }); // crée sans id, modifie avec
514
+ * ```
515
+ */
516
+ declare function createQueryResource<TEntity, TCreate = Partial<TEntity>, TUpdate = Partial<TEntity>, TParams extends QueryParams = QueryParams>(service: BaseService<TEntity, TCreate, TUpdate, TParams>, options?: QueryResourceOptions): {
517
+ keys: {
518
+ all: readonly [string];
519
+ lists: () => readonly [string, "list"];
520
+ list: (params?: unknown) => readonly [string, "list", {}];
521
+ details: () => readonly [string, "detail"];
522
+ detail: (id: string) => readonly [string, "detail", string];
523
+ };
524
+ /** Une liste nue — `service.list`. */
525
+ listQuery: (params?: TParams) => {
526
+ queryKey: readonly [string, "list", {}];
527
+ queryFn: ({ signal }?: QueryFnContext) => Promise<TEntity[]>;
528
+ };
529
+ /** Une page — `service.paginated`, rendue en `{ items, meta }`. */
530
+ pageQuery: (page?: {
531
+ page?: number;
532
+ limit?: number;
533
+ query?: TParams;
534
+ }) => {
535
+ queryKey: readonly [string, "list", {}];
536
+ queryFn: ({ signal }?: QueryFnContext) => Promise<PaginatedResponse<TEntity>>;
537
+ };
538
+ detailQuery: (id: string) => {
539
+ queryKey: readonly [string, "detail", string];
540
+ queryFn: ({ signal }?: QueryFnContext) => Promise<TEntity>;
541
+ };
542
+ /**
543
+ * Crée sans `id`, modifie avec. Invalide les listes, et le détail
544
+ * modifié : c'est ce que chaque écran écrivait à la main, ou oubliait.
545
+ */
546
+ saveMutation: (client: QueryInvalidator) => {
547
+ mutationFn: ({ id, values }: SaveVariables<TCreate, TUpdate>) => Promise<TEntity>;
548
+ onSuccess: (_: TEntity, { id }: SaveVariables<TCreate, TUpdate>) => Promise<unknown[]>;
549
+ };
550
+ removeMutation: (client: QueryInvalidator) => {
551
+ mutationFn: (id: string) => Promise<void>;
552
+ onSuccess: (_: void, id: string) => Promise<unknown[]>;
553
+ };
554
+ };
555
+ type QueryResource<TEntity> = ReturnType<typeof createQueryResource<TEntity>>;
556
+
432
557
  interface AxiosLikeInstance {
433
558
  request<T = unknown>(config: {
434
559
  url: string;
@@ -451,4 +576,4 @@ interface AxiosLikeInstance {
451
576
  declare function createFetchTransport(fetcher?: typeof fetch): ApiTransport;
452
577
  declare function createAxiosTransport(axios: AxiosLikeInstance): ApiTransport;
453
578
 
454
- 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 FieldErrorExtractor, 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, nestFieldErrors, normalizeFilters };
579
+ 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, FRENCH_STATUS_MESSAGES, type FieldError, type FieldErrorExtractor, type FilterCondition, type FilterGroup, type FilterOperator, type FilterValue, type HeaderSignalPluginOptions, HttpError, type IdempotencyPluginOptions, type LoggerPluginOptions, type PageMeta, type PaginatedResponse, type QueryFnContext, type QueryInvalidator, type QueryParamObject, type QueryParams, type QueryResource, type QueryResourceOptions, REFRESH_TOKEN_KEY, type RefreshTokenConfig, ResponseHandler, type ResponseHandlerConfig, type RtkBaseQueryResult, type SaveVariables, type TokenStorage, type UnauthorizedPluginOptions, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createQueryResource, createReadOnlyPlugin, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, filtersHeader, isGenericMessage, localizeError, nestFieldErrors, normalizeFilters };
package/dist/index.js CHANGED
@@ -136,6 +136,66 @@ var HttpError = class _HttpError extends Error {
136
136
  return this.status >= 500;
137
137
  }
138
138
  };
139
+
140
+ // src/status-messages.ts
141
+ var FRENCH_STATUS_MESSAGES = {
142
+ 0: "Le serveur est injoignable. V\xE9rifiez votre connexion.",
143
+ 400: "La demande est invalide.",
144
+ 401: "Votre session a expir\xE9. Reconnectez-vous.",
145
+ 403: "Vous n'avez pas les droits n\xE9cessaires pour cette action.",
146
+ 404: "L'\xE9l\xE9ment demand\xE9 est introuvable.",
147
+ 408: "Le serveur a mis trop de temps \xE0 r\xE9pondre.",
148
+ 409: "Cette op\xE9ration entre en conflit avec l'\xE9tat actuel. Rechargez puis r\xE9essayez.",
149
+ 413: "Le contenu envoy\xE9 est trop volumineux.",
150
+ 422: "Certaines valeurs sont invalides.",
151
+ 429: "Trop de tentatives. R\xE9essayez dans un instant.",
152
+ 500: "Une erreur est survenue sur le serveur.",
153
+ 502: "Le service est momentan\xE9ment indisponible.",
154
+ 503: "Le service est momentan\xE9ment indisponible.",
155
+ 504: "Le serveur a mis trop de temps \xE0 r\xE9pondre."
156
+ };
157
+ var ECHEC_RESEAU = /failed to fetch|networkerror|load failed/i;
158
+ var PHRASES = {
159
+ 400: "bad request",
160
+ 401: "unauthorized",
161
+ 403: "forbidden",
162
+ 404: "not found",
163
+ 405: "method not allowed",
164
+ 408: "request timeout",
165
+ 409: "conflict",
166
+ 413: "payload too large",
167
+ 415: "unsupported media type",
168
+ 422: "unprocessable entity",
169
+ 429: "too many requests",
170
+ 500: "internal server error",
171
+ 502: "bad gateway",
172
+ 503: "service unavailable",
173
+ 504: "gateway timeout"
174
+ };
175
+ function isGenericMessage(status, message, body) {
176
+ const texte = message?.trim().toLowerCase();
177
+ if (!texte || texte === `http ${status}`) return true;
178
+ const phrase = PHRASES[status];
179
+ if (phrase && (texte === phrase || texte === `${phrase} resource`)) return true;
180
+ const erreur = body && typeof body === "object" ? body.error : void 0;
181
+ if (typeof erreur === "string" && texte === erreur.trim().toLowerCase()) {
182
+ return true;
183
+ }
184
+ return /^cannot (get|post|put|patch|delete|head) /.test(texte) || /exception\b/.test(texte);
185
+ }
186
+ function localizeError(error, messages) {
187
+ if (error instanceof HttpError) {
188
+ const remplacant = messages[error.status];
189
+ if (remplacant && error.fields.length === 0 && isGenericMessage(error.status, error.message, error.body)) {
190
+ error.message = remplacant;
191
+ }
192
+ return error;
193
+ }
194
+ if (error instanceof TypeError && messages[0] && ECHEC_RESEAU.test(error.message)) {
195
+ error.message = messages[0];
196
+ }
197
+ return error;
198
+ }
139
199
  function buildQueryString(params) {
140
200
  return toQueryString(params);
141
201
  }
@@ -354,6 +414,7 @@ var ApiClient = class {
354
414
  defaultRetry;
355
415
  retryMethods;
356
416
  fieldErrors;
417
+ statusMessages;
357
418
  defaultTimeoutMs;
358
419
  plugins;
359
420
  responseHandler;
@@ -370,6 +431,7 @@ var ApiClient = class {
370
431
  (options.retryMethods ?? DEFAULT_RETRY_METHODS).map(normalizeMethod)
371
432
  );
372
433
  this.fieldErrors = options.fieldErrors;
434
+ this.statusMessages = options.statusMessages;
373
435
  this.defaultTimeoutMs = options.defaultTimeoutMs ?? 3e4;
374
436
  this.plugins = options.plugins ?? [];
375
437
  this.responseHandler = new ResponseHandler(options.responseHandler);
@@ -467,7 +529,9 @@ var ApiClient = class {
467
529
  } catch (error) {
468
530
  const pluginResult = await this.runPlugins("onError", error, ctx);
469
531
  if (pluginResult !== void 0) return pluginResult;
470
- if (!this.shouldRetry(error, attempt, maxRetries)) throw error;
532
+ if (!this.shouldRetry(error, attempt, maxRetries)) {
533
+ throw this.statusMessages ? localizeError(error, this.statusMessages) : error;
534
+ }
471
535
  attempt += 1;
472
536
  const baseDelay = Math.min(1e3 * 2 ** attempt, 3e4);
473
537
  await sleep(baseDelay + Math.random() * 0.1 * baseDelay);
@@ -858,6 +922,10 @@ var BaseService = class {
858
922
  this.client = client;
859
923
  this.resource = resource;
860
924
  }
925
+ /** Le chemin de la ressource — sert aussi de racine aux clés de cache. */
926
+ get path() {
927
+ return this.resource;
928
+ }
861
929
  normalizeQueryParams(params) {
862
930
  if (!params || typeof params !== "object") return params;
863
931
  const normalized = { ...params };
@@ -941,4 +1009,46 @@ function createResourceService(client, resourcePath) {
941
1009
  return new BaseService(client, resourcePath);
942
1010
  }
943
1011
 
944
- export { ACCESS_TOKEN_KEY, ApiClient, BaseService, FILTERS_HEADER, HttpError, REFRESH_TOKEN_KEY, ResponseHandler, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createReadOnlyPlugin, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, filtersHeader, nestFieldErrors, normalizeFilters };
1012
+ // src/query-resource.ts
1013
+ function createQueryResource(service, options = {}) {
1014
+ const racine = options.name ?? service.path;
1015
+ const keys = {
1016
+ all: [racine],
1017
+ lists: () => [racine, "list"],
1018
+ list: (params) => [racine, "list", params ?? {}],
1019
+ details: () => [racine, "detail"],
1020
+ detail: (id) => [racine, "detail", id]
1021
+ };
1022
+ const invalider = (client, ...cles) => Promise.all(cles.map((queryKey) => client.invalidateQueries({ queryKey })));
1023
+ return {
1024
+ keys,
1025
+ /** Une liste nue — `service.list`. */
1026
+ listQuery: (params) => ({
1027
+ queryKey: keys.list(params),
1028
+ queryFn: ({ signal } = {}) => service.list(params, { signal })
1029
+ }),
1030
+ /** Une page — `service.paginated`, rendue en `{ items, meta }`. */
1031
+ pageQuery: (page = {}) => ({
1032
+ queryKey: keys.list(page),
1033
+ queryFn: ({ signal } = {}) => service.paginated({ ...page, config: { signal } })
1034
+ }),
1035
+ detailQuery: (id) => ({
1036
+ queryKey: keys.detail(id),
1037
+ queryFn: ({ signal } = {}) => service.getById(id, { signal })
1038
+ }),
1039
+ /**
1040
+ * Crée sans `id`, modifie avec. Invalide les listes, et le détail
1041
+ * modifié : c'est ce que chaque écran écrivait à la main, ou oubliait.
1042
+ */
1043
+ saveMutation: (client) => ({
1044
+ mutationFn: ({ id, values }) => id === void 0 ? service.create(values) : service.update(id, values),
1045
+ onSuccess: (_, { id }) => id === void 0 ? invalider(client, keys.lists()) : invalider(client, keys.lists(), keys.detail(id))
1046
+ }),
1047
+ removeMutation: (client) => ({
1048
+ mutationFn: (id) => service.remove(id),
1049
+ onSuccess: (_, id) => invalider(client, keys.lists(), keys.detail(id))
1050
+ })
1051
+ };
1052
+ }
1053
+
1054
+ export { ACCESS_TOKEN_KEY, ApiClient, BaseService, FILTERS_HEADER, FRENCH_STATUS_MESSAGES, HttpError, REFRESH_TOKEN_KEY, ResponseHandler, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createQueryResource, createReadOnlyPlugin, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, filtersHeader, isGenericMessage, localizeError, nestFieldErrors, normalizeFilters };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sia-ui/api",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Typed HTTP client with CRUD services, plugins and injectable transports.",
5
5
  "keywords": [
6
6
  "sia-ui",
@@ -42,7 +42,7 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@sia-ui/utils": "0.5.0"
45
+ "@sia-ui/utils": "0.6.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "tsup": "^8.5.1",