@sia-ui/api 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/index.cjs +928 -0
- package/dist/index.d.cts +413 -0
- package/dist/index.d.ts +413 -0
- package/dist/index.js +904 -0
- package/package.json +59 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var async = require('@sia-ui/utils/async');
|
|
4
|
+
var query = require('@sia-ui/utils/query');
|
|
5
|
+
|
|
6
|
+
// src/http-error.ts
|
|
7
|
+
var HttpError = class _HttpError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
body;
|
|
10
|
+
headers;
|
|
11
|
+
/** Les erreurs par champ, quand le serveur en renvoie. */
|
|
12
|
+
fields;
|
|
13
|
+
constructor(status, body = null, message, headers = {}) {
|
|
14
|
+
super(message ?? _HttpError.extractMessage(body) ?? `HTTP ${status}`);
|
|
15
|
+
this.name = "HttpError";
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.body = body;
|
|
18
|
+
this.headers = headers;
|
|
19
|
+
this.fields = _HttpError.extractFields(body);
|
|
20
|
+
Object.setPrototypeOf(this, _HttpError.prototype);
|
|
21
|
+
}
|
|
22
|
+
static async fromResponse(response) {
|
|
23
|
+
const headers = _HttpError.normalizeHeaders(response.headers);
|
|
24
|
+
const contentType = headers["content-type"] ?? "";
|
|
25
|
+
const body = contentType.includes("application/json") ? await response.json().catch(() => null) : await response.text().catch(() => null);
|
|
26
|
+
return new _HttpError(response.status, body, void 0, headers);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Retrouve les erreurs par champ dans un corps d'erreur.
|
|
30
|
+
*
|
|
31
|
+
* Trois formes couvertes, parce que trois serveurs sur quatre en utilisent
|
|
32
|
+
* une : la liste (`errors: [{ field, message }]`), la carte
|
|
33
|
+
* (`errors: { email: "…" }`) et la carte de listes, que produit Laravel.
|
|
34
|
+
*/
|
|
35
|
+
static extractFields(body) {
|
|
36
|
+
if (!body || typeof body !== "object") return [];
|
|
37
|
+
const record = body;
|
|
38
|
+
const source = record.errors ?? record.fieldErrors ?? record.violations;
|
|
39
|
+
if (!source) return [];
|
|
40
|
+
if (Array.isArray(source)) {
|
|
41
|
+
return source.flatMap((entry) => {
|
|
42
|
+
if (!entry || typeof entry !== "object") return [];
|
|
43
|
+
const item = entry;
|
|
44
|
+
const field = item.field ?? item.property ?? item.path ?? item.name;
|
|
45
|
+
const message = item.message ?? item.error ?? item.detail;
|
|
46
|
+
if (typeof field !== "string" || typeof message !== "string") return [];
|
|
47
|
+
return [
|
|
48
|
+
{
|
|
49
|
+
field,
|
|
50
|
+
message,
|
|
51
|
+
...typeof item.code === "string" ? { code: item.code } : {}
|
|
52
|
+
}
|
|
53
|
+
];
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (typeof source === "object") {
|
|
57
|
+
return Object.entries(source).flatMap(
|
|
58
|
+
([field, value]) => {
|
|
59
|
+
const message = Array.isArray(value) ? value[0] : value;
|
|
60
|
+
return typeof message === "string" ? [{ field, message }] : [];
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
/** Le message rattaché à ce champ, s'il y en a un. */
|
|
67
|
+
fieldError(field) {
|
|
68
|
+
return this.fields.find((entry) => entry.field === field)?.message;
|
|
69
|
+
}
|
|
70
|
+
static normalizeHeaders(headers) {
|
|
71
|
+
if (!headers) return {};
|
|
72
|
+
if (typeof headers.forEach === "function") {
|
|
73
|
+
const normalized = {};
|
|
74
|
+
headers.forEach((value, key) => {
|
|
75
|
+
normalized[String(key).toLowerCase()] = String(value);
|
|
76
|
+
});
|
|
77
|
+
return normalized;
|
|
78
|
+
}
|
|
79
|
+
if (typeof headers === "object") {
|
|
80
|
+
return Object.fromEntries(
|
|
81
|
+
Object.entries(headers).map(
|
|
82
|
+
([key, value]) => [key.toLowerCase(), String(value ?? "")]
|
|
83
|
+
)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
static extractMessage(body) {
|
|
89
|
+
if (!body || typeof body !== "object") return null;
|
|
90
|
+
const record = body;
|
|
91
|
+
const directMessage = record.message;
|
|
92
|
+
if (Array.isArray(directMessage)) {
|
|
93
|
+
const values = directMessage.map((entry) => typeof entry === "string" ? entry.trim() : "").filter(Boolean);
|
|
94
|
+
return values.length > 0 ? values.join(" ") : null;
|
|
95
|
+
}
|
|
96
|
+
if (typeof directMessage === "string" && directMessage.trim().length > 0) {
|
|
97
|
+
return directMessage.trim();
|
|
98
|
+
}
|
|
99
|
+
if (Array.isArray(record.errors)) {
|
|
100
|
+
const nestedMessage = record.errors.map((entry) => {
|
|
101
|
+
if (!entry || typeof entry !== "object") return "";
|
|
102
|
+
const message = entry.message;
|
|
103
|
+
return typeof message === "string" ? message.trim() : "";
|
|
104
|
+
}).filter(Boolean)[0];
|
|
105
|
+
if (nestedMessage) return nestedMessage;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
isClientError() {
|
|
110
|
+
return this.status >= 400 && this.status < 500;
|
|
111
|
+
}
|
|
112
|
+
isServerError() {
|
|
113
|
+
return this.status >= 500;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
function buildQueryString(params) {
|
|
117
|
+
return query.toQueryString(params);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/response-handler.ts
|
|
121
|
+
function asRecord(value) {
|
|
122
|
+
return value && typeof value === "object" ? value : null;
|
|
123
|
+
}
|
|
124
|
+
var ResponseHandler = class {
|
|
125
|
+
config;
|
|
126
|
+
constructor(config = {}) {
|
|
127
|
+
this.config = config;
|
|
128
|
+
}
|
|
129
|
+
extractData(raw) {
|
|
130
|
+
if (this.config.extractData) return this.config.extractData(raw);
|
|
131
|
+
const record = asRecord(raw);
|
|
132
|
+
if (record && record.data !== void 0) return record.data;
|
|
133
|
+
return raw;
|
|
134
|
+
}
|
|
135
|
+
extractPaginated(raw) {
|
|
136
|
+
if (this.config.extractPaginated) {
|
|
137
|
+
return this.config.extractPaginated(raw);
|
|
138
|
+
}
|
|
139
|
+
const record = asRecord(raw);
|
|
140
|
+
if (!record) return null;
|
|
141
|
+
const data = record.data;
|
|
142
|
+
const meta = asRecord(record.meta);
|
|
143
|
+
const pagination = asRecord(meta?.pagination);
|
|
144
|
+
if (Array.isArray(data) && pagination) {
|
|
145
|
+
return {
|
|
146
|
+
items: data,
|
|
147
|
+
meta: {
|
|
148
|
+
page: Number(pagination.currentPage ?? pagination.page ?? 1),
|
|
149
|
+
limit: Number(pagination.pageSize ?? pagination.limit ?? data.length),
|
|
150
|
+
total: pagination.total === void 0 ? void 0 : Number(pagination.total),
|
|
151
|
+
totalPages: pagination.totalPages === void 0 ? void 0 : Number(pagination.totalPages)
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (Array.isArray(data) && meta) {
|
|
156
|
+
return { items: data, meta };
|
|
157
|
+
}
|
|
158
|
+
if (Array.isArray(record.items)) {
|
|
159
|
+
const limit = Number(record.limit ?? record.items.length);
|
|
160
|
+
const total = record.total === void 0 ? void 0 : Number(record.total);
|
|
161
|
+
return {
|
|
162
|
+
items: record.items,
|
|
163
|
+
meta: {
|
|
164
|
+
page: Number(record.page ?? 1),
|
|
165
|
+
limit,
|
|
166
|
+
total,
|
|
167
|
+
totalPages: record.totalPages === void 0 && total !== void 0 && limit > 0 ? Math.ceil(total / limit) : record.totalPages === void 0 ? void 0 : Number(record.totalPages)
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
extractCursor(raw) {
|
|
174
|
+
if (this.config.extractCursor) {
|
|
175
|
+
return this.config.extractCursor(raw);
|
|
176
|
+
}
|
|
177
|
+
const record = asRecord(raw);
|
|
178
|
+
if (!record) return null;
|
|
179
|
+
const items = record.data ?? record.items;
|
|
180
|
+
if (!Array.isArray(items)) return null;
|
|
181
|
+
const meta = asRecord(record.meta);
|
|
182
|
+
return {
|
|
183
|
+
items,
|
|
184
|
+
meta: {
|
|
185
|
+
nextCursor: String(
|
|
186
|
+
meta?.nextCursor ?? record.nextCursor ?? record.cursor ?? ""
|
|
187
|
+
) || null,
|
|
188
|
+
prevCursor: String(meta?.prevCursor ?? record.prevCursor ?? "") || null
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// src/transports.ts
|
|
195
|
+
function isBodyInit(value) {
|
|
196
|
+
return typeof value === "string" || value instanceof Blob || value instanceof FormData || value instanceof URLSearchParams || value instanceof ArrayBuffer;
|
|
197
|
+
}
|
|
198
|
+
function isFormData(value) {
|
|
199
|
+
return typeof FormData !== "undefined" && value instanceof FormData;
|
|
200
|
+
}
|
|
201
|
+
function removeContentType(headers) {
|
|
202
|
+
for (const key of Object.keys(headers)) {
|
|
203
|
+
if (key.toLowerCase() === "content-type") delete headers[key];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function serializeBody(body, headers) {
|
|
207
|
+
if (body === void 0 || body === null) return null;
|
|
208
|
+
if (isBodyInit(body)) {
|
|
209
|
+
if (isFormData(body)) removeContentType(headers);
|
|
210
|
+
return body;
|
|
211
|
+
}
|
|
212
|
+
const contentTypeKey = Object.keys(headers).find(
|
|
213
|
+
(key) => key.toLowerCase() === "content-type"
|
|
214
|
+
);
|
|
215
|
+
if (!contentTypeKey) headers["Content-Type"] = "application/json";
|
|
216
|
+
return JSON.stringify(body);
|
|
217
|
+
}
|
|
218
|
+
function withTimeout(signal, timeoutMs) {
|
|
219
|
+
if (!timeoutMs)
|
|
220
|
+
return { signal: signal ?? void 0, cleanup: () => void 0 };
|
|
221
|
+
const controller = new AbortController();
|
|
222
|
+
const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
|
|
223
|
+
const abort = () => controller.abort();
|
|
224
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
225
|
+
return {
|
|
226
|
+
signal: controller.signal,
|
|
227
|
+
cleanup: () => {
|
|
228
|
+
globalThis.clearTimeout(timeout);
|
|
229
|
+
signal?.removeEventListener("abort", abort);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function createFetchTransport(fetcher = fetch) {
|
|
234
|
+
return {
|
|
235
|
+
async request(request) {
|
|
236
|
+
const headers = { ...request.headers };
|
|
237
|
+
const { signal, cleanup } = withTimeout(
|
|
238
|
+
request.signal,
|
|
239
|
+
request.timeoutMs
|
|
240
|
+
);
|
|
241
|
+
try {
|
|
242
|
+
const init = {
|
|
243
|
+
method: request.method,
|
|
244
|
+
headers,
|
|
245
|
+
body: serializeBody(request.body, headers)
|
|
246
|
+
};
|
|
247
|
+
if (signal) init.signal = signal;
|
|
248
|
+
const response = await fetcher(request.url, init);
|
|
249
|
+
const normalizedHeaders = HttpError.normalizeHeaders(response.headers);
|
|
250
|
+
const contentType = normalizedHeaders["content-type"] ?? "";
|
|
251
|
+
const data = contentType.includes("application/json") ? await response.json().catch(() => null) : await response.text().catch(() => null);
|
|
252
|
+
return {
|
|
253
|
+
status: response.status,
|
|
254
|
+
statusText: response.statusText,
|
|
255
|
+
headers: normalizedHeaders,
|
|
256
|
+
data
|
|
257
|
+
};
|
|
258
|
+
} finally {
|
|
259
|
+
cleanup();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function createAxiosTransport(axios) {
|
|
265
|
+
return {
|
|
266
|
+
async request(request) {
|
|
267
|
+
const headers = { ...request.headers };
|
|
268
|
+
if (isFormData(request.body)) removeContentType(headers);
|
|
269
|
+
try {
|
|
270
|
+
const response = await axios.request({
|
|
271
|
+
url: request.url,
|
|
272
|
+
method: request.method,
|
|
273
|
+
headers,
|
|
274
|
+
data: request.body,
|
|
275
|
+
signal: request.signal ?? void 0,
|
|
276
|
+
timeout: request.timeoutMs,
|
|
277
|
+
onUploadProgress: request.onUploadProgress ? (event) => {
|
|
278
|
+
if (!event.total) return;
|
|
279
|
+
request.onUploadProgress?.(
|
|
280
|
+
Math.round(event.loaded * 100 / event.total)
|
|
281
|
+
);
|
|
282
|
+
} : void 0
|
|
283
|
+
});
|
|
284
|
+
return {
|
|
285
|
+
status: response.status,
|
|
286
|
+
statusText: response.statusText,
|
|
287
|
+
headers: HttpError.normalizeHeaders(response.headers),
|
|
288
|
+
data: response.data
|
|
289
|
+
};
|
|
290
|
+
} catch (error) {
|
|
291
|
+
const record = error;
|
|
292
|
+
if (record.response) {
|
|
293
|
+
return {
|
|
294
|
+
status: record.response.status,
|
|
295
|
+
statusText: record.response.statusText ?? "",
|
|
296
|
+
headers: HttpError.normalizeHeaders(record.response.headers),
|
|
297
|
+
data: record.response.data
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
throw new HttpError(0, null, record.message ?? String(error));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/client.ts
|
|
307
|
+
var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 429, 502, 503, 504]);
|
|
308
|
+
function isAbsoluteUrl(path) {
|
|
309
|
+
return /^https?:\/\//i.test(path);
|
|
310
|
+
}
|
|
311
|
+
function normalizeMethod(method) {
|
|
312
|
+
return method.toUpperCase();
|
|
313
|
+
}
|
|
314
|
+
function normalizeRequestInput(input) {
|
|
315
|
+
return typeof input === "string" ? { path: input } : input;
|
|
316
|
+
}
|
|
317
|
+
var ApiClient = class {
|
|
318
|
+
baseURL;
|
|
319
|
+
transport;
|
|
320
|
+
getToken;
|
|
321
|
+
getLanguage;
|
|
322
|
+
defaultHeaders;
|
|
323
|
+
defaultRetry;
|
|
324
|
+
defaultTimeoutMs;
|
|
325
|
+
plugins;
|
|
326
|
+
responseHandler;
|
|
327
|
+
constructor(options) {
|
|
328
|
+
this.baseURL = options.baseURL.replace(/\/+$/, "");
|
|
329
|
+
this.transport = options.transport ?? createFetchTransport();
|
|
330
|
+
this.getToken = options.getToken;
|
|
331
|
+
this.getLanguage = options.getLanguage;
|
|
332
|
+
this.defaultHeaders = options.defaultHeaders ?? {
|
|
333
|
+
"Content-Type": "application/json"
|
|
334
|
+
};
|
|
335
|
+
this.defaultRetry = options.defaultRetry ?? 2;
|
|
336
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 3e4;
|
|
337
|
+
this.plugins = options.plugins ?? [];
|
|
338
|
+
this.responseHandler = new ResponseHandler(options.responseHandler);
|
|
339
|
+
}
|
|
340
|
+
addPlugin(plugin) {
|
|
341
|
+
this.plugins.push(plugin);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Monte un greffon et renvoie le client.
|
|
345
|
+
*
|
|
346
|
+
* Le chaînage rend l'assemblage lisible d'un coup d'œil — c'est là qu'on
|
|
347
|
+
* relit l'ordre des greffons, et l'ordre compte : le premier qui renvoie une
|
|
348
|
+
* valeur court-circuite les suivants.
|
|
349
|
+
*/
|
|
350
|
+
use(plugin) {
|
|
351
|
+
this.plugins.push(plugin);
|
|
352
|
+
return this;
|
|
353
|
+
}
|
|
354
|
+
buildURL(path, params) {
|
|
355
|
+
const queryString = buildQueryString(params);
|
|
356
|
+
if (isAbsoluteUrl(path)) return `${path}${queryString}`;
|
|
357
|
+
const trimmed = path.startsWith("/") ? path : `/${path}`;
|
|
358
|
+
return `${this.baseURL}${trimmed}${queryString}`;
|
|
359
|
+
}
|
|
360
|
+
async runPlugins(hook, ...args) {
|
|
361
|
+
for (const plugin of this.plugins) {
|
|
362
|
+
const handler = plugin[hook];
|
|
363
|
+
if (!handler) continue;
|
|
364
|
+
const result = await handler(...args);
|
|
365
|
+
if (result !== void 0) return result;
|
|
366
|
+
}
|
|
367
|
+
return void 0;
|
|
368
|
+
}
|
|
369
|
+
async buildHeaders(custom) {
|
|
370
|
+
const token = this.getToken ? await this.getToken() : null;
|
|
371
|
+
const language = this.getLanguage ? await this.getLanguage() : null;
|
|
372
|
+
const headers = {
|
|
373
|
+
...this.defaultHeaders,
|
|
374
|
+
...custom ?? {}
|
|
375
|
+
};
|
|
376
|
+
if (token && !headers.Authorization)
|
|
377
|
+
headers.Authorization = `Bearer ${token}`;
|
|
378
|
+
if (language && !headers["Accept-Language"])
|
|
379
|
+
headers["Accept-Language"] = language;
|
|
380
|
+
return headers;
|
|
381
|
+
}
|
|
382
|
+
shouldRetry(error, attempt, maxRetries) {
|
|
383
|
+
if (attempt >= maxRetries) return false;
|
|
384
|
+
if (error instanceof DOMException && error.name === "AbortError")
|
|
385
|
+
return false;
|
|
386
|
+
if (error instanceof HttpError) {
|
|
387
|
+
return RETRYABLE_STATUSES.has(error.status);
|
|
388
|
+
}
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
async requestRaw(method, path, config = {}) {
|
|
392
|
+
const ctx = {
|
|
393
|
+
...config,
|
|
394
|
+
url: this.buildURL(path, config.queryParams),
|
|
395
|
+
method: normalizeMethod(method),
|
|
396
|
+
headers: await this.buildHeaders(config.headers)
|
|
397
|
+
};
|
|
398
|
+
const onRequestResult = await this.runPlugins("onRequest", ctx);
|
|
399
|
+
if (onRequestResult !== void 0) return onRequestResult;
|
|
400
|
+
const maxRetries = config.retry ?? this.defaultRetry;
|
|
401
|
+
let attempt = 0;
|
|
402
|
+
while (true) {
|
|
403
|
+
try {
|
|
404
|
+
const response = await this.transport.request({
|
|
405
|
+
url: ctx.url,
|
|
406
|
+
method: ctx.method,
|
|
407
|
+
headers: ctx.headers,
|
|
408
|
+
body: ctx.body,
|
|
409
|
+
signal: ctx.signal,
|
|
410
|
+
timeoutMs: ctx.timeoutMs ?? this.defaultTimeoutMs,
|
|
411
|
+
onUploadProgress: ctx.onUploadProgress
|
|
412
|
+
});
|
|
413
|
+
const pluginResult = await this.runPlugins(
|
|
414
|
+
"onResponse",
|
|
415
|
+
response,
|
|
416
|
+
ctx
|
|
417
|
+
);
|
|
418
|
+
if (pluginResult !== void 0) return pluginResult;
|
|
419
|
+
if (response.status >= 400) {
|
|
420
|
+
throw new HttpError(
|
|
421
|
+
response.status,
|
|
422
|
+
response.data,
|
|
423
|
+
void 0,
|
|
424
|
+
response.headers
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
if (response.status === 204) return null;
|
|
428
|
+
return response.data;
|
|
429
|
+
} catch (error) {
|
|
430
|
+
const pluginResult = await this.runPlugins("onError", error, ctx);
|
|
431
|
+
if (pluginResult !== void 0) return pluginResult;
|
|
432
|
+
if (!this.shouldRetry(error, attempt, maxRetries)) throw error;
|
|
433
|
+
attempt += 1;
|
|
434
|
+
const baseDelay = Math.min(1e3 * 2 ** attempt, 3e4);
|
|
435
|
+
await async.sleep(baseDelay + Math.random() * 0.1 * baseDelay);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async request(method, path, config) {
|
|
440
|
+
const raw = await this.requestRaw(method, path, config);
|
|
441
|
+
if (raw === null || typeof raw === "string") return raw;
|
|
442
|
+
return this.responseHandler.extractData(raw);
|
|
443
|
+
}
|
|
444
|
+
replay(ctx) {
|
|
445
|
+
return this.request(ctx.method, ctx.url, {
|
|
446
|
+
headers: ctx.headers,
|
|
447
|
+
body: ctx.body,
|
|
448
|
+
signal: ctx.signal,
|
|
449
|
+
timeoutMs: ctx.timeoutMs,
|
|
450
|
+
retry: 0,
|
|
451
|
+
onUploadProgress: ctx.onUploadProgress
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
get(path, config) {
|
|
455
|
+
return this.request("GET", path, config);
|
|
456
|
+
}
|
|
457
|
+
post(path, config) {
|
|
458
|
+
return this.request("POST", path, config);
|
|
459
|
+
}
|
|
460
|
+
put(path, config) {
|
|
461
|
+
return this.request("PUT", path, config);
|
|
462
|
+
}
|
|
463
|
+
patch(path, config) {
|
|
464
|
+
return this.request("PATCH", path, config);
|
|
465
|
+
}
|
|
466
|
+
delete(path, config) {
|
|
467
|
+
return this.request("DELETE", path, config);
|
|
468
|
+
}
|
|
469
|
+
/** Sans corps de réponse : sert à tester l'existence ou lire des en-têtes. */
|
|
470
|
+
head(path, config) {
|
|
471
|
+
return this.request("HEAD", path, config);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Une liste, qu'elle soit paginée ou non.
|
|
475
|
+
*
|
|
476
|
+
* Beaucoup d'points d'entrée renvoient un tableau nu quand il n'y a pas de
|
|
477
|
+
* pagination. Les appelants ne devraient pas avoir à deviner lequel : la
|
|
478
|
+
* méthode normalise les deux en `{ items, meta }`.
|
|
479
|
+
*/
|
|
480
|
+
async getList(path, config) {
|
|
481
|
+
const raw = await this.requestRaw("GET", path, config);
|
|
482
|
+
const parsed = this.responseHandler.extractPaginated(raw);
|
|
483
|
+
if (parsed) return parsed;
|
|
484
|
+
const fallback = raw;
|
|
485
|
+
const items = Array.isArray(raw) ? raw : fallback.data ?? fallback.items ?? [];
|
|
486
|
+
return { items, meta: { page: 1, limit: items.length, total: items.length } };
|
|
487
|
+
}
|
|
488
|
+
async getPaginated(path, options = {}) {
|
|
489
|
+
const page = options.page ?? 1;
|
|
490
|
+
const limit = options.limit ?? 20;
|
|
491
|
+
const raw = await this.requestRaw("GET", path, {
|
|
492
|
+
...options.config,
|
|
493
|
+
queryParams: { ...options.query ?? {}, page, limit }
|
|
494
|
+
});
|
|
495
|
+
const parsed = this.responseHandler.extractPaginated(raw);
|
|
496
|
+
if (parsed) return parsed;
|
|
497
|
+
const fallback = raw;
|
|
498
|
+
return {
|
|
499
|
+
items: fallback.data ?? fallback.items ?? [],
|
|
500
|
+
meta: {
|
|
501
|
+
page,
|
|
502
|
+
limit,
|
|
503
|
+
total: fallback.total,
|
|
504
|
+
totalPages: fallback.totalPages
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
async getCursorPage(path, options = {}) {
|
|
509
|
+
const cursorParamName = options.cursorParamName ?? "cursor";
|
|
510
|
+
const raw = await this.requestRaw("GET", path, {
|
|
511
|
+
...options.config,
|
|
512
|
+
queryParams: {
|
|
513
|
+
...options.query ?? {},
|
|
514
|
+
[cursorParamName]: options.cursor ?? null,
|
|
515
|
+
limit: options.limit ?? 20
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
const parsed = this.responseHandler.extractCursor(raw);
|
|
519
|
+
if (parsed) return parsed;
|
|
520
|
+
const fallback = raw;
|
|
521
|
+
return {
|
|
522
|
+
items: fallback.data ?? fallback.items ?? [],
|
|
523
|
+
meta: { nextCursor: fallback.nextCursor ?? null }
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
query(loader) {
|
|
527
|
+
return loader;
|
|
528
|
+
}
|
|
529
|
+
swrFetcher(config) {
|
|
530
|
+
return (path) => this.get(path, config);
|
|
531
|
+
}
|
|
532
|
+
rtkBaseQuery() {
|
|
533
|
+
return async (input) => {
|
|
534
|
+
const { path, config } = normalizeRequestInput(input);
|
|
535
|
+
try {
|
|
536
|
+
return { data: await this.get(path, config) };
|
|
537
|
+
} catch (error) {
|
|
538
|
+
if (error instanceof HttpError) {
|
|
539
|
+
return {
|
|
540
|
+
error: {
|
|
541
|
+
status: error.status,
|
|
542
|
+
data: error.body,
|
|
543
|
+
message: error.message
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
error: {
|
|
549
|
+
status: "FETCH_ERROR",
|
|
550
|
+
data: error,
|
|
551
|
+
message: error instanceof Error ? error.message : String(error)
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
function createApiClient(options) {
|
|
559
|
+
return new ApiClient(options);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// src/plugins.ts
|
|
563
|
+
function createAuthPlugin(options) {
|
|
564
|
+
return {
|
|
565
|
+
name: "auth",
|
|
566
|
+
onRequest: async (ctx) => {
|
|
567
|
+
const token = await options.getToken();
|
|
568
|
+
if (!token) return void 0;
|
|
569
|
+
const scheme = options.scheme ?? "Bearer";
|
|
570
|
+
ctx.headers.Authorization = scheme ? `${scheme} ${token}` : token;
|
|
571
|
+
return void 0;
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function createUnauthorizedPlugin(options) {
|
|
576
|
+
return {
|
|
577
|
+
name: "unauthorized",
|
|
578
|
+
onError: (error, ctx) => {
|
|
579
|
+
if (!(error instanceof HttpError) || error.status !== 401)
|
|
580
|
+
return void 0;
|
|
581
|
+
if (options.shouldIgnore?.(ctx)) return void 0;
|
|
582
|
+
options.onUnauthorized(error, ctx);
|
|
583
|
+
return void 0;
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
function unwrapTokenResponse(data) {
|
|
588
|
+
if (!data || typeof data !== "object") return {};
|
|
589
|
+
const record = data;
|
|
590
|
+
const nested = record.data;
|
|
591
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
|
|
592
|
+
return nested;
|
|
593
|
+
}
|
|
594
|
+
return record;
|
|
595
|
+
}
|
|
596
|
+
function extractAccessToken(data) {
|
|
597
|
+
const record = unwrapTokenResponse(data);
|
|
598
|
+
const token = record.accessToken ?? record.access_token;
|
|
599
|
+
return typeof token === "string" ? token : null;
|
|
600
|
+
}
|
|
601
|
+
function extractRefreshToken(data) {
|
|
602
|
+
const record = unwrapTokenResponse(data);
|
|
603
|
+
const token = record.refreshToken ?? record.refresh_token;
|
|
604
|
+
return typeof token === "string" ? token : void 0;
|
|
605
|
+
}
|
|
606
|
+
function createRefreshTokenPlugin(config, replayRequest) {
|
|
607
|
+
if (!config.enabled) return { name: "refresh-token" };
|
|
608
|
+
let isRefreshing = false;
|
|
609
|
+
let queue = [];
|
|
610
|
+
const processQueueSuccess = async () => {
|
|
611
|
+
const items = queue.slice();
|
|
612
|
+
queue = [];
|
|
613
|
+
for (const item of items) {
|
|
614
|
+
try {
|
|
615
|
+
item.resolve(await replayRequest(item.ctx));
|
|
616
|
+
} catch (error) {
|
|
617
|
+
item.reject(error);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
const processQueueFailure = (error) => {
|
|
622
|
+
const items = queue.slice();
|
|
623
|
+
queue = [];
|
|
624
|
+
for (const item of items) item.reject(error);
|
|
625
|
+
};
|
|
626
|
+
return {
|
|
627
|
+
name: "refresh-token",
|
|
628
|
+
onError: async (error, ctx) => {
|
|
629
|
+
if (!(error instanceof HttpError) || error.status !== 401)
|
|
630
|
+
return void 0;
|
|
631
|
+
if (ctx.url.includes(config.refreshEndpoint)) {
|
|
632
|
+
config.onRefreshFailure?.(error);
|
|
633
|
+
return void 0;
|
|
634
|
+
}
|
|
635
|
+
if (isRefreshing) {
|
|
636
|
+
return new Promise((resolve, reject) => {
|
|
637
|
+
queue.push({ resolve, reject, ctx });
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
isRefreshing = true;
|
|
641
|
+
try {
|
|
642
|
+
const refreshToken = await config.getRefreshToken?.();
|
|
643
|
+
if (!refreshToken) throw error;
|
|
644
|
+
const response = await fetch(config.refreshEndpoint, {
|
|
645
|
+
method: "POST",
|
|
646
|
+
headers: { "Content-Type": "application/json" },
|
|
647
|
+
body: JSON.stringify({ refreshToken })
|
|
648
|
+
});
|
|
649
|
+
if (!response.ok) throw await HttpError.fromResponse(response);
|
|
650
|
+
const data = await response.json().catch(() => null);
|
|
651
|
+
const accessToken = extractAccessToken(data);
|
|
652
|
+
if (!accessToken)
|
|
653
|
+
throw new Error("No access token in refresh response.");
|
|
654
|
+
await config.saveTokens(accessToken, extractRefreshToken(data));
|
|
655
|
+
isRefreshing = false;
|
|
656
|
+
await processQueueSuccess();
|
|
657
|
+
return replayRequest(ctx);
|
|
658
|
+
} catch (refreshError) {
|
|
659
|
+
isRefreshing = false;
|
|
660
|
+
processQueueFailure(refreshError);
|
|
661
|
+
config.onRefreshFailure?.(refreshError);
|
|
662
|
+
return void 0;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function createLoggerPlugin(options = {}) {
|
|
668
|
+
const log = options.log ?? ((message) => console.debug(message));
|
|
669
|
+
const started = /* @__PURE__ */ new WeakMap();
|
|
670
|
+
return {
|
|
671
|
+
name: "logger",
|
|
672
|
+
onRequest: (ctx) => {
|
|
673
|
+
started.set(ctx, Date.now());
|
|
674
|
+
log(`[api \u2192] ${ctx.method} ${ctx.url}`);
|
|
675
|
+
return void 0;
|
|
676
|
+
},
|
|
677
|
+
onResponse: (response, ctx) => {
|
|
678
|
+
const ms = Date.now() - (started.get(ctx) ?? Date.now());
|
|
679
|
+
log(`[api \u2190] ${ctx.method} ${ctx.url} ${response.status} (${ms}ms)`);
|
|
680
|
+
return void 0;
|
|
681
|
+
},
|
|
682
|
+
onError: (error, ctx) => {
|
|
683
|
+
const ms = Date.now() - (started.get(ctx) ?? Date.now());
|
|
684
|
+
log(`[api \u2715] ${ctx.method} ${ctx.url} (${ms}ms)`, error);
|
|
685
|
+
return void 0;
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
function createIdempotencyPlugin(options = {}) {
|
|
690
|
+
const header = options.header ?? "Idempotency-Key";
|
|
691
|
+
const newKey = options.newKey ?? (() => typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
692
|
+
const WRITES = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
|
|
693
|
+
return {
|
|
694
|
+
name: "idempotency",
|
|
695
|
+
onRequest: (ctx) => {
|
|
696
|
+
if (!WRITES.has(ctx.method)) return void 0;
|
|
697
|
+
const existing = Object.keys(ctx.headers).some(
|
|
698
|
+
(key) => key.toLowerCase() === header.toLowerCase()
|
|
699
|
+
);
|
|
700
|
+
if (!existing) ctx.headers[header] = newKey();
|
|
701
|
+
return void 0;
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function createReadOnlyPlugin(message = "Cette action est d\xE9sactiv\xE9e en lecture seule.") {
|
|
706
|
+
const SAFE = /* @__PURE__ */ new Set(["GET", "HEAD"]);
|
|
707
|
+
return {
|
|
708
|
+
name: "read-only",
|
|
709
|
+
onRequest: (ctx) => {
|
|
710
|
+
if (SAFE.has(ctx.method)) return void 0;
|
|
711
|
+
throw new HttpError(403, { message }, message);
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
function createHeaderSignalPlugin(options) {
|
|
716
|
+
const header = options.header.toLowerCase();
|
|
717
|
+
const expect = options.expect ?? ((value) => value.toLowerCase() === "true");
|
|
718
|
+
let pending = null;
|
|
719
|
+
const react = async (value, ctx) => {
|
|
720
|
+
if (pending) return pending;
|
|
721
|
+
pending = Promise.resolve(options.onSignal(value, ctx)).finally(() => {
|
|
722
|
+
pending = null;
|
|
723
|
+
});
|
|
724
|
+
return pending;
|
|
725
|
+
};
|
|
726
|
+
const read = (headers) => {
|
|
727
|
+
if (!headers || typeof headers !== "object") return void 0;
|
|
728
|
+
const record = headers;
|
|
729
|
+
const value = record[header] ?? record[options.header];
|
|
730
|
+
return typeof value === "string" ? value : void 0;
|
|
731
|
+
};
|
|
732
|
+
return {
|
|
733
|
+
name: `header-signal:${options.header}`,
|
|
734
|
+
onResponse: async (response, ctx) => {
|
|
735
|
+
const value = read(response.headers);
|
|
736
|
+
if (value && expect(value)) await react(value, ctx);
|
|
737
|
+
return void 0;
|
|
738
|
+
},
|
|
739
|
+
onError: async (error, ctx) => {
|
|
740
|
+
if (!(error instanceof HttpError)) return void 0;
|
|
741
|
+
const value = read(error.headers);
|
|
742
|
+
if (value && expect(value)) await react(value, ctx);
|
|
743
|
+
return void 0;
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// src/storage.ts
|
|
749
|
+
var ACCESS_TOKEN_KEY = "sia.accessToken";
|
|
750
|
+
var REFRESH_TOKEN_KEY = "sia.refreshToken";
|
|
751
|
+
function createMemoryTokenStorage() {
|
|
752
|
+
let access = null;
|
|
753
|
+
let refresh = null;
|
|
754
|
+
return {
|
|
755
|
+
getAccessToken: () => access,
|
|
756
|
+
getRefreshToken: () => refresh,
|
|
757
|
+
saveTokens: (accessToken, refreshToken) => {
|
|
758
|
+
access = accessToken;
|
|
759
|
+
refresh = refreshToken ?? null;
|
|
760
|
+
},
|
|
761
|
+
clear: () => {
|
|
762
|
+
access = null;
|
|
763
|
+
refresh = null;
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function createBrowserTokenStorage(storage, options = {}) {
|
|
768
|
+
const accessKey = options.accessKey ?? ACCESS_TOKEN_KEY;
|
|
769
|
+
const refreshKey = options.refreshKey ?? REFRESH_TOKEN_KEY;
|
|
770
|
+
const read = (key) => {
|
|
771
|
+
try {
|
|
772
|
+
return storage.getItem(key);
|
|
773
|
+
} catch {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
const write = (key, value) => {
|
|
778
|
+
try {
|
|
779
|
+
if (value === null) storage.removeItem(key);
|
|
780
|
+
else storage.setItem(key, value);
|
|
781
|
+
} catch {
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
return {
|
|
785
|
+
getAccessToken: () => read(accessKey),
|
|
786
|
+
getRefreshToken: () => read(refreshKey),
|
|
787
|
+
saveTokens: (accessToken, refreshToken) => {
|
|
788
|
+
write(accessKey, accessToken);
|
|
789
|
+
write(refreshKey, refreshToken ?? null);
|
|
790
|
+
},
|
|
791
|
+
clear: () => {
|
|
792
|
+
write(accessKey, null);
|
|
793
|
+
write(refreshKey, null);
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// src/filters.ts
|
|
799
|
+
function isGroup(filters) {
|
|
800
|
+
return "$and" in filters || "$or" in filters;
|
|
801
|
+
}
|
|
802
|
+
function normalizeFilters(filters) {
|
|
803
|
+
if (!filters || typeof filters !== "object") return void 0;
|
|
804
|
+
if (isGroup(filters)) return filters;
|
|
805
|
+
if (Object.keys(filters).length === 0) return void 0;
|
|
806
|
+
return { $and: [filters] };
|
|
807
|
+
}
|
|
808
|
+
var FILTERS_HEADER = "X-Filters";
|
|
809
|
+
function filtersHeader(filters, header = FILTERS_HEADER) {
|
|
810
|
+
const normalized = normalizeFilters(filters);
|
|
811
|
+
if (!normalized) return {};
|
|
812
|
+
return { [header]: JSON.stringify(normalized) };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// src/service.ts
|
|
816
|
+
var BaseService = class {
|
|
817
|
+
client;
|
|
818
|
+
resource;
|
|
819
|
+
constructor(client, resource) {
|
|
820
|
+
this.client = client;
|
|
821
|
+
this.resource = resource;
|
|
822
|
+
}
|
|
823
|
+
normalizeQueryParams(params) {
|
|
824
|
+
if (!params || typeof params !== "object") return params;
|
|
825
|
+
const normalized = { ...params };
|
|
826
|
+
for (const key of ["relations", "select"]) {
|
|
827
|
+
const value = normalized[key];
|
|
828
|
+
if (Array.isArray(value)) {
|
|
829
|
+
normalized[key] = value.map((item) => String(item).trim()).filter(Boolean).join(",");
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return normalized;
|
|
833
|
+
}
|
|
834
|
+
list(params, config) {
|
|
835
|
+
return this.client.get(this.resource, {
|
|
836
|
+
...config,
|
|
837
|
+
queryParams: this.normalizeQueryParams(params)
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
getById(id, config) {
|
|
841
|
+
return this.client.get(`${this.resource}/${id}`, config);
|
|
842
|
+
}
|
|
843
|
+
create(payload, config) {
|
|
844
|
+
return this.client.post(this.resource, {
|
|
845
|
+
...config,
|
|
846
|
+
body: payload
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
update(id, payload, config) {
|
|
850
|
+
return this.client.put(`${this.resource}/${id}`, {
|
|
851
|
+
...config,
|
|
852
|
+
body: payload
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
patch(id, payload, config) {
|
|
856
|
+
return this.client.patch(`${this.resource}/${id}`, {
|
|
857
|
+
...config,
|
|
858
|
+
body: payload
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
remove(id, config) {
|
|
862
|
+
return this.client.delete(`${this.resource}/${id}`, config);
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Suppression en lot.
|
|
866
|
+
*
|
|
867
|
+
* Un `POST` plutôt que N `DELETE` : le serveur décide en une transaction,
|
|
868
|
+
* et l'interface n'a pas à gérer une suppression à moitié réussie.
|
|
869
|
+
*/
|
|
870
|
+
removeMany(ids, options = {}, config) {
|
|
871
|
+
return this.client.post(`${this.resource}/manydelete`, {
|
|
872
|
+
...config,
|
|
873
|
+
body: { ids, hardDelete: options.hardDelete ?? false }
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Une liste filtrée, la forme composée passant par un en-tête.
|
|
878
|
+
*
|
|
879
|
+
* Voir `filters.ts` : une recherche à plusieurs critères tient mal dans une
|
|
880
|
+
* URL.
|
|
881
|
+
*/
|
|
882
|
+
search(filters, params, config) {
|
|
883
|
+
return this.client.getList(this.resource, {
|
|
884
|
+
...config,
|
|
885
|
+
headers: { ...config?.headers, ...filtersHeader(filters) },
|
|
886
|
+
queryParams: this.normalizeQueryParams(params)
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
paginated(options = {}) {
|
|
890
|
+
return this.client.getPaginated(this.resource, {
|
|
891
|
+
...options,
|
|
892
|
+
query: this.normalizeQueryParams(options.query)
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
cursorPage(options = {}) {
|
|
896
|
+
return this.client.getCursorPage(this.resource, {
|
|
897
|
+
...options,
|
|
898
|
+
query: this.normalizeQueryParams(options.query)
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
function createResourceService(client, resourcePath) {
|
|
903
|
+
return new BaseService(client, resourcePath);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
exports.ACCESS_TOKEN_KEY = ACCESS_TOKEN_KEY;
|
|
907
|
+
exports.ApiClient = ApiClient;
|
|
908
|
+
exports.BaseService = BaseService;
|
|
909
|
+
exports.FILTERS_HEADER = FILTERS_HEADER;
|
|
910
|
+
exports.HttpError = HttpError;
|
|
911
|
+
exports.REFRESH_TOKEN_KEY = REFRESH_TOKEN_KEY;
|
|
912
|
+
exports.ResponseHandler = ResponseHandler;
|
|
913
|
+
exports.buildQueryString = buildQueryString;
|
|
914
|
+
exports.createApiClient = createApiClient;
|
|
915
|
+
exports.createAuthPlugin = createAuthPlugin;
|
|
916
|
+
exports.createAxiosTransport = createAxiosTransport;
|
|
917
|
+
exports.createBrowserTokenStorage = createBrowserTokenStorage;
|
|
918
|
+
exports.createFetchTransport = createFetchTransport;
|
|
919
|
+
exports.createHeaderSignalPlugin = createHeaderSignalPlugin;
|
|
920
|
+
exports.createIdempotencyPlugin = createIdempotencyPlugin;
|
|
921
|
+
exports.createLoggerPlugin = createLoggerPlugin;
|
|
922
|
+
exports.createMemoryTokenStorage = createMemoryTokenStorage;
|
|
923
|
+
exports.createReadOnlyPlugin = createReadOnlyPlugin;
|
|
924
|
+
exports.createRefreshTokenPlugin = createRefreshTokenPlugin;
|
|
925
|
+
exports.createResourceService = createResourceService;
|
|
926
|
+
exports.createUnauthorizedPlugin = createUnauthorizedPlugin;
|
|
927
|
+
exports.filtersHeader = filtersHeader;
|
|
928
|
+
exports.normalizeFilters = normalizeFilters;
|