@sia-ui/api 0.3.2 → 0.5.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,56 @@
1
1
  # @sia-ui/api
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Erreurs serveur dans les formulaires, client HTTP plus sûr, registre vérifié.
8
+
9
+ ## Défauts
10
+ - **Registre `drawer`** — `context.ts` et la dépendance `icons` manquaient :
11
+ `sia-ui add app-shell` produisait un projet qui ne compilait pas. Un test
12
+ vérifie désormais que chaque import relatif de chaque entrée est apporté par
13
+ son installation.
14
+ - **`sia-ui.css`** pose `box-sizing: border-box` (spécificité nulle). Sans
15
+ reset côté projet, `AppShell` débordait de 32 px.
16
+ - **`@sia-ui/api`** ne rejoue plus d'office que `GET` et `HEAD`
17
+ (`retryMethods`). Une écriture n'est rejouée que si l'appel pose `retry`.
18
+ - Plus aucun avertissement `react-hooks/exhaustive-deps` dans les composants.
19
+
20
+ ## Formulaires et erreurs serveur
21
+ - `Form` intercepte un envoi rejeté : erreurs de champ sous les champs, le
22
+ reste dans une alerte. `onSubmit` peut aussi rendre `{ champ: message }`
23
+ (`FormSubmitResult`), dans `useLocalForm`, `Form`, `CrudPage` et
24
+ l'adaptateur react-hook-form. `CrudPage` garde alors sa boîte ouverte.
25
+ - Les erreurs posées par `setErrors` survivent à la validation locale jusqu'à
26
+ la modification de leur champ.
27
+ - `HttpError.toFormErrors()`, option `fieldErrors` du client et préréglage
28
+ `nestFieldErrors` (class-validator). `readSubmitError` et `hasFormErrors`
29
+ dans `@sia-ui/headless`.
30
+ - La pagination à plat `{ data, total, page, limit }` est reconnue, et `get`
31
+ ne déballe plus `data` quand l'objet porte une pagination.
32
+
33
+ ## Tableaux
34
+ - `searchDelay` sur `DataTable` et `CrudPage`.
35
+ - `CrudPage` place les `extraRowActions` avant « Supprimer ».
36
+
37
+ ### Patch Changes
38
+
39
+ - Updated dependencies
40
+ - @sia-ui/utils@0.5.0
41
+
42
+ ## 0.4.0
43
+
44
+ ### Minor Changes
45
+
46
+ - Aligne tous les packages publics SIA UI sur la version 0.4.0 afin de fournir
47
+ un ensemble cohérent à installer, documenter et maintenir.
48
+
49
+ ### Patch Changes
50
+
51
+ - Updated dependencies
52
+ - @sia-ui/utils@0.4.0
53
+
3
54
  ## 0.3.2
4
55
 
5
56
  ### Patch Changes
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
- <p align="center"><img src="https://beatjo.github.io/sia-ui-site/brand/sia-ui-logo.png" alt="SIA UI" width="320"></p>
2
-
1
+ <p align="center"><img src="https://beatjo.github.io/sia-ui-site/brand/sia-ui-logo.png" alt="SIA UI" width="320"></p>
2
+
3
3
  # @sia-ui/api
4
4
 
5
5
  Un client HTTP typé, ses services CRUD et ses greffons. Aucun rapport avec
@@ -40,22 +40,90 @@ le projet utilise déjà axios. Le paquet ne dépend d'aucun des deux.
40
40
  | Filtres | `$and` / `$or` portés par un en-tête plutôt que par l'URL |
41
41
 
42
42
  Voir [docs/fondations/module-api.md](https://beatjo.github.io/sia-ui-site/fondations/module-api.html).
43
-
44
- ## Contribuer
45
-
46
- Le code source principal de SIA UI est privé pour le moment. Les retours publics passent par le dépôt `BeatJo/sia-ui-site` : bugs, demandes de composants, corrections de documentation et questions d'usage.
47
-
48
- - Signaler un bug : https://github.com/BeatJo/sia-ui-site/issues
49
- - Lire la documentation : https://beatjo.github.io/sia-ui-site/
50
- - Voir la démonstration : https://beatjo.github.io/sia-ui-site/preview/
51
-
52
- Les contributions directes au code des packages se font sur invitation dans le dépôt source privé.
43
+
44
+ ## Contribuer
45
+
46
+ Le code source principal de SIA UI est privé pour le moment. Les retours publics passent par le dépôt `BeatJo/sia-ui-site` : bugs, demandes de composants, corrections de documentation et questions d'usage.
47
+
48
+ - Signaler un bug : https://github.com/BeatJo/sia-ui-site/issues
49
+ - Lire la documentation : https://beatjo.github.io/sia-ui-site/
50
+ - Voir la démonstration : https://beatjo.github.io/sia-ui-site/preview/
51
+
52
+ Les contributions directes au code des packages se font sur invitation dans le dépôt source privé.
53
53
  ## Journal des changements
54
54
 
55
55
  Le contenu ci-dessous reprend intégralement `CHANGELOG.md` pour rester visible sur npm.
56
56
 
57
+ <!-- sia:changelog:start -->
57
58
  # @sia-ui/api
58
59
 
60
+ ## 0.5.0
61
+
62
+ ### Minor Changes
63
+
64
+ - Erreurs serveur dans les formulaires, client HTTP plus sûr, registre vérifié.
65
+
66
+ ## Défauts
67
+ - **Registre `drawer`** — `context.ts` et la dépendance `icons` manquaient :
68
+ `sia-ui add app-shell` produisait un projet qui ne compilait pas. Un test
69
+ vérifie désormais que chaque import relatif de chaque entrée est apporté par
70
+ son installation.
71
+ - **`sia-ui.css`** pose `box-sizing: border-box` (spécificité nulle). Sans
72
+ reset côté projet, `AppShell` débordait de 32 px.
73
+ - **`@sia-ui/api`** ne rejoue plus d'office que `GET` et `HEAD`
74
+ (`retryMethods`). Une écriture n'est rejouée que si l'appel pose `retry`.
75
+ - Plus aucun avertissement `react-hooks/exhaustive-deps` dans les composants.
76
+
77
+ ## Formulaires et erreurs serveur
78
+ - `Form` intercepte un envoi rejeté : erreurs de champ sous les champs, le
79
+ reste dans une alerte. `onSubmit` peut aussi rendre `{ champ: message }`
80
+ (`FormSubmitResult`), dans `useLocalForm`, `Form`, `CrudPage` et
81
+ l'adaptateur react-hook-form. `CrudPage` garde alors sa boîte ouverte.
82
+ - Les erreurs posées par `setErrors` survivent à la validation locale jusqu'à
83
+ la modification de leur champ.
84
+ - `HttpError.toFormErrors()`, option `fieldErrors` du client et préréglage
85
+ `nestFieldErrors` (class-validator). `readSubmitError` et `hasFormErrors`
86
+ dans `@sia-ui/headless`.
87
+ - La pagination à plat `{ data, total, page, limit }` est reconnue, et `get`
88
+ ne déballe plus `data` quand l'objet porte une pagination.
89
+
90
+ ## Tableaux
91
+ - `searchDelay` sur `DataTable` et `CrudPage`.
92
+ - `CrudPage` place les `extraRowActions` avant « Supprimer ».
93
+
94
+ ### Patch Changes
95
+
96
+ - Updated dependencies
97
+ - @sia-ui/utils@0.5.0
98
+
99
+ ## 0.4.0
100
+
101
+ ### Minor Changes
102
+
103
+ - Aligne tous les packages publics SIA UI sur la version 0.4.0 afin de fournir
104
+ un ensemble cohérent à installer, documenter et maintenir.
105
+
106
+ ### Patch Changes
107
+
108
+ - Updated dependencies
109
+ - @sia-ui/utils@0.4.0
110
+
111
+ ## 0.3.2
112
+
113
+ ### Patch Changes
114
+
115
+ - Ajoute l’identité visuelle officielle de SIA UI et améliore la documentation publique, les changelogs et les ressources de contribution.
116
+ - Updated dependencies
117
+ - @sia-ui/utils@0.3.2
118
+
119
+ ## 0.3.1
120
+
121
+ ### Patch Changes
122
+
123
+ - Expose public contribution flow on npm
124
+ - Updated dependencies
125
+ - @sia-ui/utils@0.3.1
126
+
59
127
  ## 0.3.0
60
128
 
61
129
  ### Minor Changes
@@ -210,6 +278,7 @@ Le contenu ci-dessous reprend intégralement `CHANGELOG.md` pour rester visible
210
278
 
211
279
  - Updated dependencies [e4e4a72]
212
280
  - @sia-ui/utils@0.2.0
281
+ <!-- sia:changelog:end -->
213
282
 
214
283
  ## Licence
215
284
 
package/dist/index.cjs CHANGED
@@ -4,21 +4,46 @@ var async = require('@sia-ui/utils/async');
4
4
  var query = require('@sia-ui/utils/query');
5
5
 
6
6
  // src/http-error.ts
7
+ var NEST_FIELD = /^([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\s/;
8
+ var nestFieldErrors = (body) => {
9
+ const standard = HttpError.extractFields(body);
10
+ if (standard.length > 0 || !body || typeof body !== "object") return standard;
11
+ const messages = body.message;
12
+ if (!Array.isArray(messages)) return [];
13
+ return messages.flatMap((message) => {
14
+ if (typeof message !== "string") return [];
15
+ const field = NEST_FIELD.exec(message)?.[1];
16
+ return field ? [{ field, message }] : [];
17
+ });
18
+ };
7
19
  var HttpError = class _HttpError extends Error {
8
20
  status;
9
21
  body;
10
22
  headers;
11
23
  /** Les erreurs par champ, quand le serveur en renvoie. */
12
24
  fields;
13
- constructor(status, body = null, message, headers = {}) {
25
+ constructor(status, body = null, message, headers = {}, extractFields = _HttpError.extractFields) {
14
26
  super(message ?? _HttpError.extractMessage(body) ?? `HTTP ${status}`);
15
27
  this.name = "HttpError";
16
28
  this.status = status;
17
29
  this.body = body;
18
30
  this.headers = headers;
19
- this.fields = _HttpError.extractFields(body);
31
+ this.fields = extractFields(body);
20
32
  Object.setPrototypeOf(this, _HttpError.prototype);
21
33
  }
34
+ /**
35
+ * Les erreurs par champ, sous la forme qu'attend un formulaire.
36
+ *
37
+ * `FormAdapter.setErrors` prend `{ champ: message }` : c'est cette carte.
38
+ * Le premier message d'un champ l'emporte — un champ n'en affiche qu'un.
39
+ */
40
+ toFormErrors() {
41
+ const errors = {};
42
+ for (const { field, message } of this.fields) {
43
+ if (!(field in errors)) errors[field] = message;
44
+ }
45
+ return errors;
46
+ }
22
47
  static async fromResponse(response) {
23
48
  const headers = _HttpError.normalizeHeaders(response.headers);
24
49
  const contentType = headers["content-type"] ?? "";
@@ -121,6 +146,10 @@ function buildQueryString(params) {
121
146
  function asRecord(value) {
122
147
  return value && typeof value === "object" ? value : null;
123
148
  }
149
+ var PAGE_KEYS = ["total", "page", "limit", "totalPages"];
150
+ function hasPageKeys(record) {
151
+ return PAGE_KEYS.some((key) => record[key] !== void 0);
152
+ }
124
153
  var ResponseHandler = class {
125
154
  config;
126
155
  constructor(config = {}) {
@@ -129,7 +158,9 @@ var ResponseHandler = class {
129
158
  extractData(raw) {
130
159
  if (this.config.extractData) return this.config.extractData(raw);
131
160
  const record = asRecord(raw);
132
- if (record && record.data !== void 0) return record.data;
161
+ if (record && record.data !== void 0 && !hasPageKeys(record)) {
162
+ return record.data;
163
+ }
133
164
  return raw;
134
165
  }
135
166
  extractPaginated(raw) {
@@ -155,11 +186,12 @@ var ResponseHandler = class {
155
186
  if (Array.isArray(data) && meta) {
156
187
  return { items: data, meta };
157
188
  }
158
- if (Array.isArray(record.items)) {
159
- const limit = Number(record.limit ?? record.items.length);
189
+ const items = Array.isArray(record.items) ? record.items : Array.isArray(data) && hasPageKeys(record) ? data : null;
190
+ if (items) {
191
+ const limit = Number(record.limit ?? items.length);
160
192
  const total = record.total === void 0 ? void 0 : Number(record.total);
161
193
  return {
162
- items: record.items,
194
+ items,
163
195
  meta: {
164
196
  page: Number(record.page ?? 1),
165
197
  limit,
@@ -305,6 +337,7 @@ function createAxiosTransport(axios) {
305
337
 
306
338
  // src/client.ts
307
339
  var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 429, 502, 503, 504]);
340
+ var DEFAULT_RETRY_METHODS = ["GET", "HEAD"];
308
341
  function isAbsoluteUrl(path) {
309
342
  return /^https?:\/\//i.test(path);
310
343
  }
@@ -321,6 +354,8 @@ var ApiClient = class {
321
354
  getLanguage;
322
355
  defaultHeaders;
323
356
  defaultRetry;
357
+ retryMethods;
358
+ fieldErrors;
324
359
  defaultTimeoutMs;
325
360
  plugins;
326
361
  responseHandler;
@@ -333,6 +368,10 @@ var ApiClient = class {
333
368
  "Content-Type": "application/json"
334
369
  };
335
370
  this.defaultRetry = options.defaultRetry ?? 2;
371
+ this.retryMethods = new Set(
372
+ (options.retryMethods ?? DEFAULT_RETRY_METHODS).map(normalizeMethod)
373
+ );
374
+ this.fieldErrors = options.fieldErrors;
336
375
  this.defaultTimeoutMs = options.defaultTimeoutMs ?? 3e4;
337
376
  this.plugins = options.plugins ?? [];
338
377
  this.responseHandler = new ResponseHandler(options.responseHandler);
@@ -397,7 +436,7 @@ var ApiClient = class {
397
436
  };
398
437
  const onRequestResult = await this.runPlugins("onRequest", ctx);
399
438
  if (onRequestResult !== void 0) return onRequestResult;
400
- const maxRetries = config.retry ?? this.defaultRetry;
439
+ const maxRetries = config.retry ?? (this.retryMethods.has(ctx.method) ? this.defaultRetry : 0);
401
440
  let attempt = 0;
402
441
  while (true) {
403
442
  try {
@@ -421,7 +460,8 @@ var ApiClient = class {
421
460
  response.status,
422
461
  response.data,
423
462
  void 0,
424
- response.headers
463
+ response.headers,
464
+ this.fieldErrors
425
465
  );
426
466
  }
427
467
  if (response.status === 204) return null;
@@ -925,4 +965,5 @@ exports.createRefreshTokenPlugin = createRefreshTokenPlugin;
925
965
  exports.createResourceService = createResourceService;
926
966
  exports.createUnauthorizedPlugin = createUnauthorizedPlugin;
927
967
  exports.filtersHeader = filtersHeader;
968
+ exports.nestFieldErrors = nestFieldErrors;
928
969
  exports.normalizeFilters = normalizeFilters;
package/dist/index.d.cts CHANGED
@@ -1,3 +1,63 @@
1
+ /**
2
+ * Une erreur de validation rattachée à un champ.
3
+ *
4
+ * C'est ce qui permet à un formulaire d'afficher « adresse invalide » sous
5
+ * l'adresse plutôt qu'un bandeau générique en haut de page.
6
+ */
7
+ interface FieldError {
8
+ field: string;
9
+ message: string;
10
+ code?: string | undefined;
11
+ }
12
+ /**
13
+ * Lit les erreurs par champ dans un corps d'erreur.
14
+ *
15
+ * Chaque serveur a sa forme ; celle-ci se règle une fois sur le client
16
+ * (`fieldErrors` de `createApiClient`) plutôt qu'à chaque formulaire.
17
+ */
18
+ type FieldErrorExtractor = (body: unknown) => FieldError[];
19
+ /**
20
+ * Le préréglage NestJS.
21
+ *
22
+ * `ValidationPipe` renvoie `{ message: string[] }`, chaque phrase commençant
23
+ * par le chemin du champ : « email must be an email », « address.city should
24
+ * not be empty ». Le champ est ce premier mot ; le message reste la phrase
25
+ * entière, que le serveur a écrite pour être lue. Les formes génériques
26
+ * (`errors`, `fieldErrors`, `violations`) passent d'abord : un
27
+ * `exceptionFactory` personnalisé les produit souvent.
28
+ */
29
+ declare const nestFieldErrors: FieldErrorExtractor;
30
+ declare class HttpError extends Error {
31
+ status: number;
32
+ body: unknown;
33
+ headers: Record<string, string>;
34
+ /** Les erreurs par champ, quand le serveur en renvoie. */
35
+ fields: FieldError[];
36
+ constructor(status: number, body?: unknown, message?: string, headers?: Record<string, string>, extractFields?: FieldErrorExtractor);
37
+ /**
38
+ * Les erreurs par champ, sous la forme qu'attend un formulaire.
39
+ *
40
+ * `FormAdapter.setErrors` prend `{ champ: message }` : c'est cette carte.
41
+ * Le premier message d'un champ l'emporte — un champ n'en affiche qu'un.
42
+ */
43
+ toFormErrors(): Record<string, string>;
44
+ static fromResponse(response: Response): Promise<HttpError>;
45
+ /**
46
+ * Retrouve les erreurs par champ dans un corps d'erreur.
47
+ *
48
+ * Trois formes couvertes, parce que trois serveurs sur quatre en utilisent
49
+ * une : la liste (`errors: [{ field, message }]`), la carte
50
+ * (`errors: { email: "…" }`) et la carte de listes, que produit Laravel.
51
+ */
52
+ static extractFields(body: unknown): FieldError[];
53
+ /** Le message rattaché à ce champ, s'il y en a un. */
54
+ fieldError(field: string): string | undefined;
55
+ static normalizeHeaders(headers: unknown): Record<string, string>;
56
+ static extractMessage(body: unknown): string | null;
57
+ isClientError(): boolean;
58
+ isServerError(): boolean;
59
+ }
60
+
1
61
  type ApiMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
2
62
  type QueryParamObject = Record<string, unknown>;
3
63
  type QueryParams = Record<string, string | string[] | number | number[] | boolean | boolean[] | QueryParamObject | QueryParamObject[] | null | undefined>;
@@ -79,10 +139,24 @@ interface ApiClientOptions {
79
139
  getToken?: (() => string | null | Promise<string | null>) | undefined;
80
140
  getLanguage?: (() => string | null | Promise<string | null>) | undefined;
81
141
  defaultHeaders?: Record<string, string> | undefined;
142
+ /**
143
+ * Nouvelles tentatives sur erreur réseau ou statut transitoire (408, 429,
144
+ * 502, 503, 504), pour les seules `retryMethods`. `retry` sur un appel
145
+ * passe devant.
146
+ */
82
147
  defaultRetry?: number | undefined;
148
+ /** Les méthodes rejouées d'office. Par défaut `GET` et `HEAD`. */
149
+ retryMethods?: ApiMethod[] | undefined;
83
150
  defaultTimeoutMs?: number | undefined;
84
151
  plugins?: ApiPlugin[] | undefined;
85
152
  responseHandler?: ResponseHandlerConfig | undefined;
153
+ /**
154
+ * Où lire les erreurs par champ d'une réponse en échec.
155
+ *
156
+ * Par défaut `errors`, `fieldErrors` ou `violations`. `nestFieldErrors`
157
+ * lit en plus le `message: string[]` de class-validator.
158
+ */
159
+ fieldErrors?: ((body: unknown) => FieldError[]) | undefined;
86
160
  }
87
161
  type ApiRequestInput = string | {
88
162
  path: string;
@@ -104,6 +178,8 @@ declare class ApiClient {
104
178
  private getLanguage;
105
179
  private defaultHeaders;
106
180
  private defaultRetry;
181
+ private retryMethods;
182
+ private fieldErrors;
107
183
  private defaultTimeoutMs;
108
184
  private plugins;
109
185
  private responseHandler;
@@ -158,41 +234,6 @@ declare class ApiClient {
158
234
  }
159
235
  declare function createApiClient(options: ApiClientOptions): ApiClient;
160
236
 
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
237
  interface AuthPluginOptions {
197
238
  getToken: () => Promise<string | null> | string | null;
198
239
  scheme?: string;
@@ -410,4 +451,4 @@ interface AxiosLikeInstance {
410
451
  declare function createFetchTransport(fetcher?: typeof fetch): ApiTransport;
411
452
  declare function createAxiosTransport(axios: AxiosLikeInstance): ApiTransport;
412
453
 
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 };
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 };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,63 @@
1
+ /**
2
+ * Une erreur de validation rattachée à un champ.
3
+ *
4
+ * C'est ce qui permet à un formulaire d'afficher « adresse invalide » sous
5
+ * l'adresse plutôt qu'un bandeau générique en haut de page.
6
+ */
7
+ interface FieldError {
8
+ field: string;
9
+ message: string;
10
+ code?: string | undefined;
11
+ }
12
+ /**
13
+ * Lit les erreurs par champ dans un corps d'erreur.
14
+ *
15
+ * Chaque serveur a sa forme ; celle-ci se règle une fois sur le client
16
+ * (`fieldErrors` de `createApiClient`) plutôt qu'à chaque formulaire.
17
+ */
18
+ type FieldErrorExtractor = (body: unknown) => FieldError[];
19
+ /**
20
+ * Le préréglage NestJS.
21
+ *
22
+ * `ValidationPipe` renvoie `{ message: string[] }`, chaque phrase commençant
23
+ * par le chemin du champ : « email must be an email », « address.city should
24
+ * not be empty ». Le champ est ce premier mot ; le message reste la phrase
25
+ * entière, que le serveur a écrite pour être lue. Les formes génériques
26
+ * (`errors`, `fieldErrors`, `violations`) passent d'abord : un
27
+ * `exceptionFactory` personnalisé les produit souvent.
28
+ */
29
+ declare const nestFieldErrors: FieldErrorExtractor;
30
+ declare class HttpError extends Error {
31
+ status: number;
32
+ body: unknown;
33
+ headers: Record<string, string>;
34
+ /** Les erreurs par champ, quand le serveur en renvoie. */
35
+ fields: FieldError[];
36
+ constructor(status: number, body?: unknown, message?: string, headers?: Record<string, string>, extractFields?: FieldErrorExtractor);
37
+ /**
38
+ * Les erreurs par champ, sous la forme qu'attend un formulaire.
39
+ *
40
+ * `FormAdapter.setErrors` prend `{ champ: message }` : c'est cette carte.
41
+ * Le premier message d'un champ l'emporte — un champ n'en affiche qu'un.
42
+ */
43
+ toFormErrors(): Record<string, string>;
44
+ static fromResponse(response: Response): Promise<HttpError>;
45
+ /**
46
+ * Retrouve les erreurs par champ dans un corps d'erreur.
47
+ *
48
+ * Trois formes couvertes, parce que trois serveurs sur quatre en utilisent
49
+ * une : la liste (`errors: [{ field, message }]`), la carte
50
+ * (`errors: { email: "…" }`) et la carte de listes, que produit Laravel.
51
+ */
52
+ static extractFields(body: unknown): FieldError[];
53
+ /** Le message rattaché à ce champ, s'il y en a un. */
54
+ fieldError(field: string): string | undefined;
55
+ static normalizeHeaders(headers: unknown): Record<string, string>;
56
+ static extractMessage(body: unknown): string | null;
57
+ isClientError(): boolean;
58
+ isServerError(): boolean;
59
+ }
60
+
1
61
  type ApiMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
2
62
  type QueryParamObject = Record<string, unknown>;
3
63
  type QueryParams = Record<string, string | string[] | number | number[] | boolean | boolean[] | QueryParamObject | QueryParamObject[] | null | undefined>;
@@ -79,10 +139,24 @@ interface ApiClientOptions {
79
139
  getToken?: (() => string | null | Promise<string | null>) | undefined;
80
140
  getLanguage?: (() => string | null | Promise<string | null>) | undefined;
81
141
  defaultHeaders?: Record<string, string> | undefined;
142
+ /**
143
+ * Nouvelles tentatives sur erreur réseau ou statut transitoire (408, 429,
144
+ * 502, 503, 504), pour les seules `retryMethods`. `retry` sur un appel
145
+ * passe devant.
146
+ */
82
147
  defaultRetry?: number | undefined;
148
+ /** Les méthodes rejouées d'office. Par défaut `GET` et `HEAD`. */
149
+ retryMethods?: ApiMethod[] | undefined;
83
150
  defaultTimeoutMs?: number | undefined;
84
151
  plugins?: ApiPlugin[] | undefined;
85
152
  responseHandler?: ResponseHandlerConfig | undefined;
153
+ /**
154
+ * Où lire les erreurs par champ d'une réponse en échec.
155
+ *
156
+ * Par défaut `errors`, `fieldErrors` ou `violations`. `nestFieldErrors`
157
+ * lit en plus le `message: string[]` de class-validator.
158
+ */
159
+ fieldErrors?: ((body: unknown) => FieldError[]) | undefined;
86
160
  }
87
161
  type ApiRequestInput = string | {
88
162
  path: string;
@@ -104,6 +178,8 @@ declare class ApiClient {
104
178
  private getLanguage;
105
179
  private defaultHeaders;
106
180
  private defaultRetry;
181
+ private retryMethods;
182
+ private fieldErrors;
107
183
  private defaultTimeoutMs;
108
184
  private plugins;
109
185
  private responseHandler;
@@ -158,41 +234,6 @@ declare class ApiClient {
158
234
  }
159
235
  declare function createApiClient(options: ApiClientOptions): ApiClient;
160
236
 
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
237
  interface AuthPluginOptions {
197
238
  getToken: () => Promise<string | null> | string | null;
198
239
  scheme?: string;
@@ -410,4 +451,4 @@ interface AxiosLikeInstance {
410
451
  declare function createFetchTransport(fetcher?: typeof fetch): ApiTransport;
411
452
  declare function createAxiosTransport(axios: AxiosLikeInstance): ApiTransport;
412
453
 
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 };
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 };
package/dist/index.js CHANGED
@@ -2,21 +2,46 @@ import { sleep } from '@sia-ui/utils/async';
2
2
  import { toQueryString } from '@sia-ui/utils/query';
3
3
 
4
4
  // src/http-error.ts
5
+ var NEST_FIELD = /^([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\s/;
6
+ var nestFieldErrors = (body) => {
7
+ const standard = HttpError.extractFields(body);
8
+ if (standard.length > 0 || !body || typeof body !== "object") return standard;
9
+ const messages = body.message;
10
+ if (!Array.isArray(messages)) return [];
11
+ return messages.flatMap((message) => {
12
+ if (typeof message !== "string") return [];
13
+ const field = NEST_FIELD.exec(message)?.[1];
14
+ return field ? [{ field, message }] : [];
15
+ });
16
+ };
5
17
  var HttpError = class _HttpError extends Error {
6
18
  status;
7
19
  body;
8
20
  headers;
9
21
  /** Les erreurs par champ, quand le serveur en renvoie. */
10
22
  fields;
11
- constructor(status, body = null, message, headers = {}) {
23
+ constructor(status, body = null, message, headers = {}, extractFields = _HttpError.extractFields) {
12
24
  super(message ?? _HttpError.extractMessage(body) ?? `HTTP ${status}`);
13
25
  this.name = "HttpError";
14
26
  this.status = status;
15
27
  this.body = body;
16
28
  this.headers = headers;
17
- this.fields = _HttpError.extractFields(body);
29
+ this.fields = extractFields(body);
18
30
  Object.setPrototypeOf(this, _HttpError.prototype);
19
31
  }
32
+ /**
33
+ * Les erreurs par champ, sous la forme qu'attend un formulaire.
34
+ *
35
+ * `FormAdapter.setErrors` prend `{ champ: message }` : c'est cette carte.
36
+ * Le premier message d'un champ l'emporte — un champ n'en affiche qu'un.
37
+ */
38
+ toFormErrors() {
39
+ const errors = {};
40
+ for (const { field, message } of this.fields) {
41
+ if (!(field in errors)) errors[field] = message;
42
+ }
43
+ return errors;
44
+ }
20
45
  static async fromResponse(response) {
21
46
  const headers = _HttpError.normalizeHeaders(response.headers);
22
47
  const contentType = headers["content-type"] ?? "";
@@ -119,6 +144,10 @@ function buildQueryString(params) {
119
144
  function asRecord(value) {
120
145
  return value && typeof value === "object" ? value : null;
121
146
  }
147
+ var PAGE_KEYS = ["total", "page", "limit", "totalPages"];
148
+ function hasPageKeys(record) {
149
+ return PAGE_KEYS.some((key) => record[key] !== void 0);
150
+ }
122
151
  var ResponseHandler = class {
123
152
  config;
124
153
  constructor(config = {}) {
@@ -127,7 +156,9 @@ var ResponseHandler = class {
127
156
  extractData(raw) {
128
157
  if (this.config.extractData) return this.config.extractData(raw);
129
158
  const record = asRecord(raw);
130
- if (record && record.data !== void 0) return record.data;
159
+ if (record && record.data !== void 0 && !hasPageKeys(record)) {
160
+ return record.data;
161
+ }
131
162
  return raw;
132
163
  }
133
164
  extractPaginated(raw) {
@@ -153,11 +184,12 @@ var ResponseHandler = class {
153
184
  if (Array.isArray(data) && meta) {
154
185
  return { items: data, meta };
155
186
  }
156
- if (Array.isArray(record.items)) {
157
- const limit = Number(record.limit ?? record.items.length);
187
+ const items = Array.isArray(record.items) ? record.items : Array.isArray(data) && hasPageKeys(record) ? data : null;
188
+ if (items) {
189
+ const limit = Number(record.limit ?? items.length);
158
190
  const total = record.total === void 0 ? void 0 : Number(record.total);
159
191
  return {
160
- items: record.items,
192
+ items,
161
193
  meta: {
162
194
  page: Number(record.page ?? 1),
163
195
  limit,
@@ -303,6 +335,7 @@ function createAxiosTransport(axios) {
303
335
 
304
336
  // src/client.ts
305
337
  var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 429, 502, 503, 504]);
338
+ var DEFAULT_RETRY_METHODS = ["GET", "HEAD"];
306
339
  function isAbsoluteUrl(path) {
307
340
  return /^https?:\/\//i.test(path);
308
341
  }
@@ -319,6 +352,8 @@ var ApiClient = class {
319
352
  getLanguage;
320
353
  defaultHeaders;
321
354
  defaultRetry;
355
+ retryMethods;
356
+ fieldErrors;
322
357
  defaultTimeoutMs;
323
358
  plugins;
324
359
  responseHandler;
@@ -331,6 +366,10 @@ var ApiClient = class {
331
366
  "Content-Type": "application/json"
332
367
  };
333
368
  this.defaultRetry = options.defaultRetry ?? 2;
369
+ this.retryMethods = new Set(
370
+ (options.retryMethods ?? DEFAULT_RETRY_METHODS).map(normalizeMethod)
371
+ );
372
+ this.fieldErrors = options.fieldErrors;
334
373
  this.defaultTimeoutMs = options.defaultTimeoutMs ?? 3e4;
335
374
  this.plugins = options.plugins ?? [];
336
375
  this.responseHandler = new ResponseHandler(options.responseHandler);
@@ -395,7 +434,7 @@ var ApiClient = class {
395
434
  };
396
435
  const onRequestResult = await this.runPlugins("onRequest", ctx);
397
436
  if (onRequestResult !== void 0) return onRequestResult;
398
- const maxRetries = config.retry ?? this.defaultRetry;
437
+ const maxRetries = config.retry ?? (this.retryMethods.has(ctx.method) ? this.defaultRetry : 0);
399
438
  let attempt = 0;
400
439
  while (true) {
401
440
  try {
@@ -419,7 +458,8 @@ var ApiClient = class {
419
458
  response.status,
420
459
  response.data,
421
460
  void 0,
422
- response.headers
461
+ response.headers,
462
+ this.fieldErrors
423
463
  );
424
464
  }
425
465
  if (response.status === 204) return null;
@@ -901,4 +941,4 @@ function createResourceService(client, resourcePath) {
901
941
  return new BaseService(client, resourcePath);
902
942
  }
903
943
 
904
- 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, normalizeFilters };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sia-ui/api",
3
- "version": "0.3.2",
3
+ "version": "0.5.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.3.2"
45
+ "@sia-ui/utils": "0.5.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "tsup": "^8.5.1",