@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 +49 -0
- package/README.md +49 -0
- package/dist/index.cjs +115 -1
- package/dist/index.d.cts +126 -1
- package/dist/index.d.ts +126 -1
- package/dist/index.js +112 -2
- package/package.json +2 -2
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))
|
|
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))
|
|
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
|
-
|
|
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.
|
|
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.
|
|
45
|
+
"@sia-ui/utils": "0.6.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"tsup": "^8.5.1",
|