@sia-ui/api 0.4.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 +88 -0
- package/README.md +88 -0
- package/dist/index.cjs +164 -9
- package/dist/index.d.cts +198 -32
- package/dist/index.d.ts +198 -32
- package/dist/index.js +160 -10
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,93 @@
|
|
|
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
|
+
|
|
52
|
+
## 0.5.0
|
|
53
|
+
|
|
54
|
+
### Minor Changes
|
|
55
|
+
|
|
56
|
+
- Erreurs serveur dans les formulaires, client HTTP plus sûr, registre vérifié.
|
|
57
|
+
|
|
58
|
+
## Défauts
|
|
59
|
+
- **Registre `drawer`** — `context.ts` et la dépendance `icons` manquaient :
|
|
60
|
+
`sia-ui add app-shell` produisait un projet qui ne compilait pas. Un test
|
|
61
|
+
vérifie désormais que chaque import relatif de chaque entrée est apporté par
|
|
62
|
+
son installation.
|
|
63
|
+
- **`sia-ui.css`** pose `box-sizing: border-box` (spécificité nulle). Sans
|
|
64
|
+
reset côté projet, `AppShell` débordait de 32 px.
|
|
65
|
+
- **`@sia-ui/api`** ne rejoue plus d'office que `GET` et `HEAD`
|
|
66
|
+
(`retryMethods`). Une écriture n'est rejouée que si l'appel pose `retry`.
|
|
67
|
+
- Plus aucun avertissement `react-hooks/exhaustive-deps` dans les composants.
|
|
68
|
+
|
|
69
|
+
## Formulaires et erreurs serveur
|
|
70
|
+
- `Form` intercepte un envoi rejeté : erreurs de champ sous les champs, le
|
|
71
|
+
reste dans une alerte. `onSubmit` peut aussi rendre `{ champ: message }`
|
|
72
|
+
(`FormSubmitResult`), dans `useLocalForm`, `Form`, `CrudPage` et
|
|
73
|
+
l'adaptateur react-hook-form. `CrudPage` garde alors sa boîte ouverte.
|
|
74
|
+
- Les erreurs posées par `setErrors` survivent à la validation locale jusqu'à
|
|
75
|
+
la modification de leur champ.
|
|
76
|
+
- `HttpError.toFormErrors()`, option `fieldErrors` du client et préréglage
|
|
77
|
+
`nestFieldErrors` (class-validator). `readSubmitError` et `hasFormErrors`
|
|
78
|
+
dans `@sia-ui/headless`.
|
|
79
|
+
- La pagination à plat `{ data, total, page, limit }` est reconnue, et `get`
|
|
80
|
+
ne déballe plus `data` quand l'objet porte une pagination.
|
|
81
|
+
|
|
82
|
+
## Tableaux
|
|
83
|
+
- `searchDelay` sur `DataTable` et `CrudPage`.
|
|
84
|
+
- `CrudPage` place les `extraRowActions` avant « Supprimer ».
|
|
85
|
+
|
|
86
|
+
### Patch Changes
|
|
87
|
+
|
|
88
|
+
- Updated dependencies
|
|
89
|
+
- @sia-ui/utils@0.5.0
|
|
90
|
+
|
|
3
91
|
## 0.4.0
|
|
4
92
|
|
|
5
93
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -57,6 +57,94 @@ 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
|
+
|
|
109
|
+
## 0.5.0
|
|
110
|
+
|
|
111
|
+
### Minor Changes
|
|
112
|
+
|
|
113
|
+
- Erreurs serveur dans les formulaires, client HTTP plus sûr, registre vérifié.
|
|
114
|
+
|
|
115
|
+
## Défauts
|
|
116
|
+
- **Registre `drawer`** — `context.ts` et la dépendance `icons` manquaient :
|
|
117
|
+
`sia-ui add app-shell` produisait un projet qui ne compilait pas. Un test
|
|
118
|
+
vérifie désormais que chaque import relatif de chaque entrée est apporté par
|
|
119
|
+
son installation.
|
|
120
|
+
- **`sia-ui.css`** pose `box-sizing: border-box` (spécificité nulle). Sans
|
|
121
|
+
reset côté projet, `AppShell` débordait de 32 px.
|
|
122
|
+
- **`@sia-ui/api`** ne rejoue plus d'office que `GET` et `HEAD`
|
|
123
|
+
(`retryMethods`). Une écriture n'est rejouée que si l'appel pose `retry`.
|
|
124
|
+
- Plus aucun avertissement `react-hooks/exhaustive-deps` dans les composants.
|
|
125
|
+
|
|
126
|
+
## Formulaires et erreurs serveur
|
|
127
|
+
- `Form` intercepte un envoi rejeté : erreurs de champ sous les champs, le
|
|
128
|
+
reste dans une alerte. `onSubmit` peut aussi rendre `{ champ: message }`
|
|
129
|
+
(`FormSubmitResult`), dans `useLocalForm`, `Form`, `CrudPage` et
|
|
130
|
+
l'adaptateur react-hook-form. `CrudPage` garde alors sa boîte ouverte.
|
|
131
|
+
- Les erreurs posées par `setErrors` survivent à la validation locale jusqu'à
|
|
132
|
+
la modification de leur champ.
|
|
133
|
+
- `HttpError.toFormErrors()`, option `fieldErrors` du client et préréglage
|
|
134
|
+
`nestFieldErrors` (class-validator). `readSubmitError` et `hasFormErrors`
|
|
135
|
+
dans `@sia-ui/headless`.
|
|
136
|
+
- La pagination à plat `{ data, total, page, limit }` est reconnue, et `get`
|
|
137
|
+
ne déballe plus `data` quand l'objet porte une pagination.
|
|
138
|
+
|
|
139
|
+
## Tableaux
|
|
140
|
+
- `searchDelay` sur `DataTable` et `CrudPage`.
|
|
141
|
+
- `CrudPage` place les `extraRowActions` avant « Supprimer ».
|
|
142
|
+
|
|
143
|
+
### Patch Changes
|
|
144
|
+
|
|
145
|
+
- Updated dependencies
|
|
146
|
+
- @sia-ui/utils@0.5.0
|
|
147
|
+
|
|
60
148
|
## 0.4.0
|
|
61
149
|
|
|
62
150
|
### Minor Changes
|
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 =
|
|
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"] ?? "";
|
|
@@ -113,6 +138,66 @@ var HttpError = class _HttpError extends Error {
|
|
|
113
138
|
return this.status >= 500;
|
|
114
139
|
}
|
|
115
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
|
+
}
|
|
116
201
|
function buildQueryString(params) {
|
|
117
202
|
return query.toQueryString(params);
|
|
118
203
|
}
|
|
@@ -121,6 +206,10 @@ function buildQueryString(params) {
|
|
|
121
206
|
function asRecord(value) {
|
|
122
207
|
return value && typeof value === "object" ? value : null;
|
|
123
208
|
}
|
|
209
|
+
var PAGE_KEYS = ["total", "page", "limit", "totalPages"];
|
|
210
|
+
function hasPageKeys(record) {
|
|
211
|
+
return PAGE_KEYS.some((key) => record[key] !== void 0);
|
|
212
|
+
}
|
|
124
213
|
var ResponseHandler = class {
|
|
125
214
|
config;
|
|
126
215
|
constructor(config = {}) {
|
|
@@ -129,7 +218,9 @@ var ResponseHandler = class {
|
|
|
129
218
|
extractData(raw) {
|
|
130
219
|
if (this.config.extractData) return this.config.extractData(raw);
|
|
131
220
|
const record = asRecord(raw);
|
|
132
|
-
if (record && record.data !== void 0
|
|
221
|
+
if (record && record.data !== void 0 && !hasPageKeys(record)) {
|
|
222
|
+
return record.data;
|
|
223
|
+
}
|
|
133
224
|
return raw;
|
|
134
225
|
}
|
|
135
226
|
extractPaginated(raw) {
|
|
@@ -155,11 +246,12 @@ var ResponseHandler = class {
|
|
|
155
246
|
if (Array.isArray(data) && meta) {
|
|
156
247
|
return { items: data, meta };
|
|
157
248
|
}
|
|
158
|
-
|
|
159
|
-
|
|
249
|
+
const items = Array.isArray(record.items) ? record.items : Array.isArray(data) && hasPageKeys(record) ? data : null;
|
|
250
|
+
if (items) {
|
|
251
|
+
const limit = Number(record.limit ?? items.length);
|
|
160
252
|
const total = record.total === void 0 ? void 0 : Number(record.total);
|
|
161
253
|
return {
|
|
162
|
-
items
|
|
254
|
+
items,
|
|
163
255
|
meta: {
|
|
164
256
|
page: Number(record.page ?? 1),
|
|
165
257
|
limit,
|
|
@@ -305,6 +397,7 @@ function createAxiosTransport(axios) {
|
|
|
305
397
|
|
|
306
398
|
// src/client.ts
|
|
307
399
|
var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 429, 502, 503, 504]);
|
|
400
|
+
var DEFAULT_RETRY_METHODS = ["GET", "HEAD"];
|
|
308
401
|
function isAbsoluteUrl(path) {
|
|
309
402
|
return /^https?:\/\//i.test(path);
|
|
310
403
|
}
|
|
@@ -321,6 +414,9 @@ var ApiClient = class {
|
|
|
321
414
|
getLanguage;
|
|
322
415
|
defaultHeaders;
|
|
323
416
|
defaultRetry;
|
|
417
|
+
retryMethods;
|
|
418
|
+
fieldErrors;
|
|
419
|
+
statusMessages;
|
|
324
420
|
defaultTimeoutMs;
|
|
325
421
|
plugins;
|
|
326
422
|
responseHandler;
|
|
@@ -333,6 +429,11 @@ var ApiClient = class {
|
|
|
333
429
|
"Content-Type": "application/json"
|
|
334
430
|
};
|
|
335
431
|
this.defaultRetry = options.defaultRetry ?? 2;
|
|
432
|
+
this.retryMethods = new Set(
|
|
433
|
+
(options.retryMethods ?? DEFAULT_RETRY_METHODS).map(normalizeMethod)
|
|
434
|
+
);
|
|
435
|
+
this.fieldErrors = options.fieldErrors;
|
|
436
|
+
this.statusMessages = options.statusMessages;
|
|
336
437
|
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 3e4;
|
|
337
438
|
this.plugins = options.plugins ?? [];
|
|
338
439
|
this.responseHandler = new ResponseHandler(options.responseHandler);
|
|
@@ -397,7 +498,7 @@ var ApiClient = class {
|
|
|
397
498
|
};
|
|
398
499
|
const onRequestResult = await this.runPlugins("onRequest", ctx);
|
|
399
500
|
if (onRequestResult !== void 0) return onRequestResult;
|
|
400
|
-
const maxRetries = config.retry ?? this.defaultRetry;
|
|
501
|
+
const maxRetries = config.retry ?? (this.retryMethods.has(ctx.method) ? this.defaultRetry : 0);
|
|
401
502
|
let attempt = 0;
|
|
402
503
|
while (true) {
|
|
403
504
|
try {
|
|
@@ -421,7 +522,8 @@ var ApiClient = class {
|
|
|
421
522
|
response.status,
|
|
422
523
|
response.data,
|
|
423
524
|
void 0,
|
|
424
|
-
response.headers
|
|
525
|
+
response.headers,
|
|
526
|
+
this.fieldErrors
|
|
425
527
|
);
|
|
426
528
|
}
|
|
427
529
|
if (response.status === 204) return null;
|
|
@@ -429,7 +531,9 @@ var ApiClient = class {
|
|
|
429
531
|
} catch (error) {
|
|
430
532
|
const pluginResult = await this.runPlugins("onError", error, ctx);
|
|
431
533
|
if (pluginResult !== void 0) return pluginResult;
|
|
432
|
-
if (!this.shouldRetry(error, attempt, maxRetries))
|
|
534
|
+
if (!this.shouldRetry(error, attempt, maxRetries)) {
|
|
535
|
+
throw this.statusMessages ? localizeError(error, this.statusMessages) : error;
|
|
536
|
+
}
|
|
433
537
|
attempt += 1;
|
|
434
538
|
const baseDelay = Math.min(1e3 * 2 ** attempt, 3e4);
|
|
435
539
|
await async.sleep(baseDelay + Math.random() * 0.1 * baseDelay);
|
|
@@ -820,6 +924,10 @@ var BaseService = class {
|
|
|
820
924
|
this.client = client;
|
|
821
925
|
this.resource = resource;
|
|
822
926
|
}
|
|
927
|
+
/** Le chemin de la ressource — sert aussi de racine aux clés de cache. */
|
|
928
|
+
get path() {
|
|
929
|
+
return this.resource;
|
|
930
|
+
}
|
|
823
931
|
normalizeQueryParams(params) {
|
|
824
932
|
if (!params || typeof params !== "object") return params;
|
|
825
933
|
const normalized = { ...params };
|
|
@@ -903,10 +1011,53 @@ function createResourceService(client, resourcePath) {
|
|
|
903
1011
|
return new BaseService(client, resourcePath);
|
|
904
1012
|
}
|
|
905
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
|
+
|
|
906
1056
|
exports.ACCESS_TOKEN_KEY = ACCESS_TOKEN_KEY;
|
|
907
1057
|
exports.ApiClient = ApiClient;
|
|
908
1058
|
exports.BaseService = BaseService;
|
|
909
1059
|
exports.FILTERS_HEADER = FILTERS_HEADER;
|
|
1060
|
+
exports.FRENCH_STATUS_MESSAGES = FRENCH_STATUS_MESSAGES;
|
|
910
1061
|
exports.HttpError = HttpError;
|
|
911
1062
|
exports.REFRESH_TOKEN_KEY = REFRESH_TOKEN_KEY;
|
|
912
1063
|
exports.ResponseHandler = ResponseHandler;
|
|
@@ -920,9 +1071,13 @@ exports.createHeaderSignalPlugin = createHeaderSignalPlugin;
|
|
|
920
1071
|
exports.createIdempotencyPlugin = createIdempotencyPlugin;
|
|
921
1072
|
exports.createLoggerPlugin = createLoggerPlugin;
|
|
922
1073
|
exports.createMemoryTokenStorage = createMemoryTokenStorage;
|
|
1074
|
+
exports.createQueryResource = createQueryResource;
|
|
923
1075
|
exports.createReadOnlyPlugin = createReadOnlyPlugin;
|
|
924
1076
|
exports.createRefreshTokenPlugin = createRefreshTokenPlugin;
|
|
925
1077
|
exports.createResourceService = createResourceService;
|
|
926
1078
|
exports.createUnauthorizedPlugin = createUnauthorizedPlugin;
|
|
927
1079
|
exports.filtersHeader = filtersHeader;
|
|
1080
|
+
exports.isGenericMessage = isGenericMessage;
|
|
1081
|
+
exports.localizeError = localizeError;
|
|
1082
|
+
exports.nestFieldErrors = nestFieldErrors;
|
|
928
1083
|
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,33 @@ 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;
|
|
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;
|
|
86
169
|
}
|
|
87
170
|
type ApiRequestInput = string | {
|
|
88
171
|
path: string;
|
|
@@ -104,6 +187,9 @@ declare class ApiClient {
|
|
|
104
187
|
private getLanguage;
|
|
105
188
|
private defaultHeaders;
|
|
106
189
|
private defaultRetry;
|
|
190
|
+
private retryMethods;
|
|
191
|
+
private fieldErrors;
|
|
192
|
+
private statusMessages;
|
|
107
193
|
private defaultTimeoutMs;
|
|
108
194
|
private plugins;
|
|
109
195
|
private responseHandler;
|
|
@@ -159,39 +245,29 @@ declare class ApiClient {
|
|
|
159
245
|
declare function createApiClient(options: ApiClientOptions): ApiClient;
|
|
160
246
|
|
|
161
247
|
/**
|
|
162
|
-
*
|
|
248
|
+
* Les messages par statut, en français.
|
|
163
249
|
*
|
|
164
|
-
*
|
|
165
|
-
* l'adresse plutôt qu'un bandeau générique en haut de page.
|
|
250
|
+
* `0` couvre l'échec réseau : le serveur injoignable, la connexion coupée.
|
|
166
251
|
*/
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
}
|
|
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;
|
|
195
271
|
|
|
196
272
|
interface AuthPluginOptions {
|
|
197
273
|
getToken: () => Promise<string | null> | string | null;
|
|
@@ -349,6 +425,8 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
|
|
|
349
425
|
protected client: ApiClient;
|
|
350
426
|
protected resource: string;
|
|
351
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;
|
|
352
430
|
protected normalizeQueryParams(params?: TListParams): TListParams | undefined;
|
|
353
431
|
list(params?: TListParams, config?: ApiRequestConfig): Promise<TEntity[]>;
|
|
354
432
|
getById(id: string, config?: ApiRequestConfig): Promise<TEntity>;
|
|
@@ -388,6 +466,94 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
|
|
|
388
466
|
}
|
|
389
467
|
declare function createResourceService<TEntity>(client: ApiClient, resourcePath: string): BaseService<TEntity, Partial<TEntity>, Partial<TEntity>, QueryParams>;
|
|
390
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
|
+
|
|
391
557
|
interface AxiosLikeInstance {
|
|
392
558
|
request<T = unknown>(config: {
|
|
393
559
|
url: string;
|
|
@@ -410,4 +576,4 @@ interface AxiosLikeInstance {
|
|
|
410
576
|
declare function createFetchTransport(fetcher?: typeof fetch): ApiTransport;
|
|
411
577
|
declare function createAxiosTransport(axios: AxiosLikeInstance): ApiTransport;
|
|
412
578
|
|
|
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 };
|
|
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
|
@@ -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,33 @@ 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;
|
|
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;
|
|
86
169
|
}
|
|
87
170
|
type ApiRequestInput = string | {
|
|
88
171
|
path: string;
|
|
@@ -104,6 +187,9 @@ declare class ApiClient {
|
|
|
104
187
|
private getLanguage;
|
|
105
188
|
private defaultHeaders;
|
|
106
189
|
private defaultRetry;
|
|
190
|
+
private retryMethods;
|
|
191
|
+
private fieldErrors;
|
|
192
|
+
private statusMessages;
|
|
107
193
|
private defaultTimeoutMs;
|
|
108
194
|
private plugins;
|
|
109
195
|
private responseHandler;
|
|
@@ -159,39 +245,29 @@ declare class ApiClient {
|
|
|
159
245
|
declare function createApiClient(options: ApiClientOptions): ApiClient;
|
|
160
246
|
|
|
161
247
|
/**
|
|
162
|
-
*
|
|
248
|
+
* Les messages par statut, en français.
|
|
163
249
|
*
|
|
164
|
-
*
|
|
165
|
-
* l'adresse plutôt qu'un bandeau générique en haut de page.
|
|
250
|
+
* `0` couvre l'échec réseau : le serveur injoignable, la connexion coupée.
|
|
166
251
|
*/
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
}
|
|
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;
|
|
195
271
|
|
|
196
272
|
interface AuthPluginOptions {
|
|
197
273
|
getToken: () => Promise<string | null> | string | null;
|
|
@@ -349,6 +425,8 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
|
|
|
349
425
|
protected client: ApiClient;
|
|
350
426
|
protected resource: string;
|
|
351
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;
|
|
352
430
|
protected normalizeQueryParams(params?: TListParams): TListParams | undefined;
|
|
353
431
|
list(params?: TListParams, config?: ApiRequestConfig): Promise<TEntity[]>;
|
|
354
432
|
getById(id: string, config?: ApiRequestConfig): Promise<TEntity>;
|
|
@@ -388,6 +466,94 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
|
|
|
388
466
|
}
|
|
389
467
|
declare function createResourceService<TEntity>(client: ApiClient, resourcePath: string): BaseService<TEntity, Partial<TEntity>, Partial<TEntity>, QueryParams>;
|
|
390
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
|
+
|
|
391
557
|
interface AxiosLikeInstance {
|
|
392
558
|
request<T = unknown>(config: {
|
|
393
559
|
url: string;
|
|
@@ -410,4 +576,4 @@ interface AxiosLikeInstance {
|
|
|
410
576
|
declare function createFetchTransport(fetcher?: typeof fetch): ApiTransport;
|
|
411
577
|
declare function createAxiosTransport(axios: AxiosLikeInstance): ApiTransport;
|
|
412
578
|
|
|
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 };
|
|
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
|
@@ -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 =
|
|
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"] ?? "";
|
|
@@ -111,6 +136,66 @@ var HttpError = class _HttpError extends Error {
|
|
|
111
136
|
return this.status >= 500;
|
|
112
137
|
}
|
|
113
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
|
+
}
|
|
114
199
|
function buildQueryString(params) {
|
|
115
200
|
return toQueryString(params);
|
|
116
201
|
}
|
|
@@ -119,6 +204,10 @@ function buildQueryString(params) {
|
|
|
119
204
|
function asRecord(value) {
|
|
120
205
|
return value && typeof value === "object" ? value : null;
|
|
121
206
|
}
|
|
207
|
+
var PAGE_KEYS = ["total", "page", "limit", "totalPages"];
|
|
208
|
+
function hasPageKeys(record) {
|
|
209
|
+
return PAGE_KEYS.some((key) => record[key] !== void 0);
|
|
210
|
+
}
|
|
122
211
|
var ResponseHandler = class {
|
|
123
212
|
config;
|
|
124
213
|
constructor(config = {}) {
|
|
@@ -127,7 +216,9 @@ var ResponseHandler = class {
|
|
|
127
216
|
extractData(raw) {
|
|
128
217
|
if (this.config.extractData) return this.config.extractData(raw);
|
|
129
218
|
const record = asRecord(raw);
|
|
130
|
-
if (record && record.data !== void 0
|
|
219
|
+
if (record && record.data !== void 0 && !hasPageKeys(record)) {
|
|
220
|
+
return record.data;
|
|
221
|
+
}
|
|
131
222
|
return raw;
|
|
132
223
|
}
|
|
133
224
|
extractPaginated(raw) {
|
|
@@ -153,11 +244,12 @@ var ResponseHandler = class {
|
|
|
153
244
|
if (Array.isArray(data) && meta) {
|
|
154
245
|
return { items: data, meta };
|
|
155
246
|
}
|
|
156
|
-
|
|
157
|
-
|
|
247
|
+
const items = Array.isArray(record.items) ? record.items : Array.isArray(data) && hasPageKeys(record) ? data : null;
|
|
248
|
+
if (items) {
|
|
249
|
+
const limit = Number(record.limit ?? items.length);
|
|
158
250
|
const total = record.total === void 0 ? void 0 : Number(record.total);
|
|
159
251
|
return {
|
|
160
|
-
items
|
|
252
|
+
items,
|
|
161
253
|
meta: {
|
|
162
254
|
page: Number(record.page ?? 1),
|
|
163
255
|
limit,
|
|
@@ -303,6 +395,7 @@ function createAxiosTransport(axios) {
|
|
|
303
395
|
|
|
304
396
|
// src/client.ts
|
|
305
397
|
var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 429, 502, 503, 504]);
|
|
398
|
+
var DEFAULT_RETRY_METHODS = ["GET", "HEAD"];
|
|
306
399
|
function isAbsoluteUrl(path) {
|
|
307
400
|
return /^https?:\/\//i.test(path);
|
|
308
401
|
}
|
|
@@ -319,6 +412,9 @@ var ApiClient = class {
|
|
|
319
412
|
getLanguage;
|
|
320
413
|
defaultHeaders;
|
|
321
414
|
defaultRetry;
|
|
415
|
+
retryMethods;
|
|
416
|
+
fieldErrors;
|
|
417
|
+
statusMessages;
|
|
322
418
|
defaultTimeoutMs;
|
|
323
419
|
plugins;
|
|
324
420
|
responseHandler;
|
|
@@ -331,6 +427,11 @@ var ApiClient = class {
|
|
|
331
427
|
"Content-Type": "application/json"
|
|
332
428
|
};
|
|
333
429
|
this.defaultRetry = options.defaultRetry ?? 2;
|
|
430
|
+
this.retryMethods = new Set(
|
|
431
|
+
(options.retryMethods ?? DEFAULT_RETRY_METHODS).map(normalizeMethod)
|
|
432
|
+
);
|
|
433
|
+
this.fieldErrors = options.fieldErrors;
|
|
434
|
+
this.statusMessages = options.statusMessages;
|
|
334
435
|
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 3e4;
|
|
335
436
|
this.plugins = options.plugins ?? [];
|
|
336
437
|
this.responseHandler = new ResponseHandler(options.responseHandler);
|
|
@@ -395,7 +496,7 @@ var ApiClient = class {
|
|
|
395
496
|
};
|
|
396
497
|
const onRequestResult = await this.runPlugins("onRequest", ctx);
|
|
397
498
|
if (onRequestResult !== void 0) return onRequestResult;
|
|
398
|
-
const maxRetries = config.retry ?? this.defaultRetry;
|
|
499
|
+
const maxRetries = config.retry ?? (this.retryMethods.has(ctx.method) ? this.defaultRetry : 0);
|
|
399
500
|
let attempt = 0;
|
|
400
501
|
while (true) {
|
|
401
502
|
try {
|
|
@@ -419,7 +520,8 @@ var ApiClient = class {
|
|
|
419
520
|
response.status,
|
|
420
521
|
response.data,
|
|
421
522
|
void 0,
|
|
422
|
-
response.headers
|
|
523
|
+
response.headers,
|
|
524
|
+
this.fieldErrors
|
|
423
525
|
);
|
|
424
526
|
}
|
|
425
527
|
if (response.status === 204) return null;
|
|
@@ -427,7 +529,9 @@ var ApiClient = class {
|
|
|
427
529
|
} catch (error) {
|
|
428
530
|
const pluginResult = await this.runPlugins("onError", error, ctx);
|
|
429
531
|
if (pluginResult !== void 0) return pluginResult;
|
|
430
|
-
if (!this.shouldRetry(error, attempt, maxRetries))
|
|
532
|
+
if (!this.shouldRetry(error, attempt, maxRetries)) {
|
|
533
|
+
throw this.statusMessages ? localizeError(error, this.statusMessages) : error;
|
|
534
|
+
}
|
|
431
535
|
attempt += 1;
|
|
432
536
|
const baseDelay = Math.min(1e3 * 2 ** attempt, 3e4);
|
|
433
537
|
await sleep(baseDelay + Math.random() * 0.1 * baseDelay);
|
|
@@ -818,6 +922,10 @@ var BaseService = class {
|
|
|
818
922
|
this.client = client;
|
|
819
923
|
this.resource = resource;
|
|
820
924
|
}
|
|
925
|
+
/** Le chemin de la ressource — sert aussi de racine aux clés de cache. */
|
|
926
|
+
get path() {
|
|
927
|
+
return this.resource;
|
|
928
|
+
}
|
|
821
929
|
normalizeQueryParams(params) {
|
|
822
930
|
if (!params || typeof params !== "object") return params;
|
|
823
931
|
const normalized = { ...params };
|
|
@@ -901,4 +1009,46 @@ function createResourceService(client, resourcePath) {
|
|
|
901
1009
|
return new BaseService(client, resourcePath);
|
|
902
1010
|
}
|
|
903
1011
|
|
|
904
|
-
|
|
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",
|