@marianmeres/http-utils 2.5.1 → 2.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/AGENTS.md +52 -7
- package/API.md +194 -10
- package/CLAUDE.md +3 -0
- package/README.md +24 -3
- package/dist/api.d.ts +52 -41
- package/dist/api.js +208 -80
- package/dist/mod.d.ts +1 -1
- package/package.json +9 -1
package/AGENTS.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
```yaml
|
|
6
6
|
name: "@marianmeres/http-utils"
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.5.1"
|
|
8
8
|
license: MIT
|
|
9
9
|
runtime: deno, node
|
|
10
10
|
type: library
|
|
@@ -46,19 +46,40 @@ function createHttpApi(
|
|
|
46
46
|
| `put` | `put(path, options?: DataOptions): Promise<unknown>` | PUT request |
|
|
47
47
|
| `patch` | `patch(path, options?: DataOptions): Promise<unknown>` | PATCH request |
|
|
48
48
|
| `del` | `del(path, options?: DataOptions): Promise<unknown>` | DELETE request |
|
|
49
|
-
| `url` | `url(path: string): string` | Build full URL |
|
|
49
|
+
| `url` | `url(path: string): string` | Build full URL (base trailing slash + path leading slash normalized) |
|
|
50
50
|
| `base` | `get/set base: string \| null` | Base URL property |
|
|
51
|
+
| `onRequest` | `onRequest(i: RequestInterceptor \| null): this` | Register request interceptor |
|
|
52
|
+
| `onResponse` | `onResponse(i: ResponseInterceptor \| null): this` | Register response interceptor |
|
|
51
53
|
|
|
52
54
|
### Exported Types
|
|
53
55
|
|
|
54
56
|
```typescript
|
|
55
|
-
type RequestData =
|
|
57
|
+
type RequestData =
|
|
58
|
+
| Record<string, unknown>
|
|
59
|
+
| unknown[]
|
|
60
|
+
| FormData
|
|
61
|
+
| Blob
|
|
62
|
+
| ArrayBuffer
|
|
63
|
+
| ArrayBufferView
|
|
64
|
+
| URLSearchParams
|
|
65
|
+
| ReadableStream
|
|
66
|
+
| string
|
|
67
|
+
| number
|
|
68
|
+
| boolean
|
|
69
|
+
| null;
|
|
70
|
+
|
|
71
|
+
type QueryValue =
|
|
72
|
+
| string | number | boolean
|
|
73
|
+
| (string | number | boolean)[]
|
|
74
|
+
| null | undefined;
|
|
56
75
|
|
|
57
76
|
interface FetchParams {
|
|
58
77
|
data?: RequestData;
|
|
59
78
|
token?: string | null;
|
|
60
|
-
headers?:
|
|
79
|
+
headers?: HeadersInit | null;
|
|
61
80
|
signal?: AbortSignal;
|
|
81
|
+
timeout?: number | null; // ms
|
|
82
|
+
query?: Record<string, QueryValue> | null;
|
|
62
83
|
credentials?: 'omit' | 'same-origin' | 'include' | null;
|
|
63
84
|
raw?: boolean | null;
|
|
64
85
|
assert?: boolean | null;
|
|
@@ -79,6 +100,16 @@ interface DataOptions {
|
|
|
79
100
|
|
|
80
101
|
type ErrorMessageExtractor = (body: unknown, response: Response) => string;
|
|
81
102
|
type ResponseHeaders = Record<string, string | number>;
|
|
103
|
+
|
|
104
|
+
type RequestInterceptor = (
|
|
105
|
+
init: RequestInit,
|
|
106
|
+
ctx: { method: string; url: string }
|
|
107
|
+
) => RequestInit | void | Promise<RequestInit | void>;
|
|
108
|
+
|
|
109
|
+
type ResponseInterceptor = (
|
|
110
|
+
response: Response,
|
|
111
|
+
ctx: { method: string; url: string }
|
|
112
|
+
) => Response | void | Promise<Response | void>;
|
|
82
113
|
```
|
|
83
114
|
|
|
84
115
|
### Error Classes (HTTP_ERROR namespace)
|
|
@@ -138,12 +169,26 @@ class HTTP_STATUS {
|
|
|
138
169
|
|
|
139
170
|
## Key Behaviors
|
|
140
171
|
|
|
141
|
-
1. **Auto JSON parsing**: Response bodies are
|
|
172
|
+
1. **Auto JSON parsing**: Response bodies are parsed as JSON if possible; empty bodies (204/205) return `null`
|
|
142
173
|
2. **Bearer token**: `token` param auto-adds `Authorization: Bearer {token}` header
|
|
143
174
|
3. **Error throwing**: By default, non-OK responses throw HttpError (disable with `assert: false`)
|
|
144
175
|
4. **Response headers**: Pass `respHeaders: {}` to capture response headers (mutated in place)
|
|
145
|
-
5. **Raw response**: Use `raw: true` to get raw Response object
|
|
146
|
-
6. **Error priority**: per-request extractor → per-instance → global → built-in fallback
|
|
176
|
+
5. **Raw response**: Use `raw: true` to get raw Response object; the caller must consume the body
|
|
177
|
+
6. **Error priority**: per-request extractor → per-instance → global → built-in fallback; a throwing extractor falls through to the next priority rather than crashing
|
|
178
|
+
7. **URL normalization**: `#url(path)` strips trailing `/` from base and ensures leading `/` on path, so `base + path` never produces `//` or missing `/`
|
|
179
|
+
8. **Timeout**: `params.timeout` (ms) aborts via `AbortSignal.timeout`; composed with `params.signal` via `AbortSignal.any`
|
|
180
|
+
9. **Query**: `params.query` object appended as URL search params; `null`/`undefined` skipped, arrays → repeated keys
|
|
181
|
+
|
|
182
|
+
## Request Body Serialization
|
|
183
|
+
|
|
184
|
+
| Runtime type | Sent as | Content-Type set |
|
|
185
|
+
|---|---|---|
|
|
186
|
+
| `null` / `undefined` | No body | — |
|
|
187
|
+
| `string` | raw | caller-controlled |
|
|
188
|
+
| `number` / `boolean` / plain object / array | `JSON.stringify` | `application/json` only if not already set |
|
|
189
|
+
| `FormData`, `URLSearchParams`, `Blob`, `ArrayBuffer`, typed arrays, `ReadableStream` | passed to fetch unchanged | fetch handles (e.g. multipart boundary, form-urlencoded) |
|
|
190
|
+
|
|
191
|
+
User-provided `Content-Type` header is always preserved.
|
|
147
192
|
|
|
148
193
|
## Development Commands
|
|
149
194
|
|
package/API.md
CHANGED
|
@@ -7,6 +7,10 @@ Complete API reference for `@marianmeres/http-utils`.
|
|
|
7
7
|
- [createHttpApi](#createhttpapi)
|
|
8
8
|
- [opts](#opts)
|
|
9
9
|
- [HttpApi Class](#httpapi-class)
|
|
10
|
+
- [Request Bodies](#request-bodies)
|
|
11
|
+
- [Query Parameters](#query-parameters)
|
|
12
|
+
- [Timeouts and Cancellation](#timeouts-and-cancellation)
|
|
13
|
+
- [Interceptors](#interceptors)
|
|
10
14
|
- [Types](#types)
|
|
11
15
|
- [HTTP Errors](#http-errors)
|
|
12
16
|
- [HTTP Status Codes](#http-status-codes)
|
|
@@ -208,7 +212,7 @@ Performs a DELETE request. Same signature as `post<T>()`.
|
|
|
208
212
|
|
|
209
213
|
#### `url(path)`
|
|
210
214
|
|
|
211
|
-
Builds the full URL from a path.
|
|
215
|
+
Builds the full URL from a path. Base trailing slashes and missing path leading slashes are normalized so there is exactly one `/` between base and path.
|
|
212
216
|
|
|
213
217
|
```ts
|
|
214
218
|
url(path: string): string
|
|
@@ -217,10 +221,22 @@ url(path: string): string
|
|
|
217
221
|
**Example:**
|
|
218
222
|
```ts
|
|
219
223
|
const api = createHttpApi("https://api.example.com");
|
|
220
|
-
api.url("/users");
|
|
221
|
-
api.url("
|
|
224
|
+
api.url("/users"); // "https://api.example.com/users"
|
|
225
|
+
api.url("users"); // "https://api.example.com/users" (leading slash added)
|
|
226
|
+
api.url("https://other.com/path"); // "https://other.com/path" (absolute URLs returned as-is)
|
|
227
|
+
|
|
228
|
+
const api2 = createHttpApi("https://api.example.com/v1/");
|
|
229
|
+
api2.url("/users"); // "https://api.example.com/v1/users" (no double slash)
|
|
222
230
|
```
|
|
223
231
|
|
|
232
|
+
#### `onRequest(interceptor)`
|
|
233
|
+
|
|
234
|
+
Registers a request interceptor. See [Interceptors](#interceptors).
|
|
235
|
+
|
|
236
|
+
#### `onResponse(interceptor)`
|
|
237
|
+
|
|
238
|
+
Registers a response interceptor. See [Interceptors](#interceptors).
|
|
239
|
+
|
|
224
240
|
### Properties
|
|
225
241
|
|
|
226
242
|
#### `base`
|
|
@@ -234,14 +250,160 @@ set base(v: string | null | undefined)
|
|
|
234
250
|
|
|
235
251
|
---
|
|
236
252
|
|
|
253
|
+
## Request Bodies
|
|
254
|
+
|
|
255
|
+
The `data` parameter is serialized based on its runtime type:
|
|
256
|
+
|
|
257
|
+
| Runtime type | Behavior | Content-Type |
|
|
258
|
+
|---|---|---|
|
|
259
|
+
| `null` / `undefined` | No body sent | — |
|
|
260
|
+
| `string` | Sent as-is | Caller's (fetch may default to `text/plain;charset=UTF-8`) |
|
|
261
|
+
| `number` / `boolean` | `JSON.stringify`'d (e.g. `0` → `"0"`) | `application/json` if not set |
|
|
262
|
+
| Plain object / array | `JSON.stringify`'d | `application/json` if not set |
|
|
263
|
+
| `FormData` | Passed to fetch unchanged | `multipart/form-data; boundary=…` (fetch auto-sets) |
|
|
264
|
+
| `URLSearchParams` | Passed to fetch unchanged | `application/x-www-form-urlencoded;charset=UTF-8` (fetch auto-sets) |
|
|
265
|
+
| `Blob` | Passed to fetch unchanged | From the Blob's `.type` |
|
|
266
|
+
| `ArrayBuffer` / typed arrays | Passed to fetch unchanged | Caller's (none by default) |
|
|
267
|
+
| `ReadableStream` | Passed to fetch unchanged | Caller's |
|
|
268
|
+
|
|
269
|
+
If you explicitly set `Content-Type` in `headers`, it is always respected — even for object data. Objects are still JSON-stringified; set your own body (e.g. via a string) if you need a non-JSON serialization.
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
// Plain object → JSON
|
|
273
|
+
await api.post("/users", { name: "John" });
|
|
274
|
+
|
|
275
|
+
// Respect user Content-Type
|
|
276
|
+
await api.post("/graph", { query: "{ me { id } }" }, {
|
|
277
|
+
headers: { "content-type": "application/graphql+json" },
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// FormData (file upload)
|
|
281
|
+
const fd = new FormData();
|
|
282
|
+
fd.append("file", fileBlob);
|
|
283
|
+
await api.post("/upload", fd);
|
|
284
|
+
|
|
285
|
+
// URL-encoded form
|
|
286
|
+
await api.post("/login", new URLSearchParams({ user: "a", pass: "b" }));
|
|
287
|
+
|
|
288
|
+
// Raw string body
|
|
289
|
+
await api.post("/logs", "raw log line", {
|
|
290
|
+
headers: { "content-type": "text/plain" },
|
|
291
|
+
});
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## Query Parameters
|
|
297
|
+
|
|
298
|
+
Use `params.query` to append query-string parameters to the URL.
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
await api.get("/search", {
|
|
302
|
+
query: {
|
|
303
|
+
q: "hello world",
|
|
304
|
+
page: 1,
|
|
305
|
+
active: true,
|
|
306
|
+
tag: ["a", "b"], // → ?tag=a&tag=b
|
|
307
|
+
ignored: null, // null and undefined are skipped
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
// GET /search?q=hello+world&page=1&active=true&tag=a&tag=b
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
If the path already contains `?`, query params are appended with `&`.
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
## Timeouts and Cancellation
|
|
318
|
+
|
|
319
|
+
`params.timeout` (in milliseconds) aborts the request via `AbortSignal.timeout`. It composes with a user-provided `signal` — whichever fires first aborts the request.
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
// Simple timeout
|
|
323
|
+
await api.get("/slow", { timeout: 5000 });
|
|
324
|
+
|
|
325
|
+
// Timeout + cancellable signal
|
|
326
|
+
const ctrl = new AbortController();
|
|
327
|
+
await api.get("/slow", {
|
|
328
|
+
timeout: 10_000,
|
|
329
|
+
signal: ctrl.signal,
|
|
330
|
+
});
|
|
331
|
+
// ctrl.abort() or timeout — whichever first — cancels the request.
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Uses `AbortSignal.any` when available (Node 20+, modern Deno). Falls back to manual composition otherwise.
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Interceptors
|
|
339
|
+
|
|
340
|
+
Register per-instance hooks that run around each request.
|
|
341
|
+
|
|
342
|
+
### `onRequest(interceptor)`
|
|
343
|
+
|
|
344
|
+
Called after defaults are merged, with the final `RequestInit` and resolved URL. Return a new `RequestInit` to replace the original, or `void` / `undefined` to keep it.
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
const api = createHttpApi("https://api.example.com").onRequest((init, ctx) => {
|
|
348
|
+
console.log(`[http] → ${ctx.method} ${ctx.url}`);
|
|
349
|
+
const h = new Headers(init.headers);
|
|
350
|
+
h.set("x-trace-id", crypto.randomUUID());
|
|
351
|
+
return { ...init, headers: h };
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### `onResponse(interceptor)`
|
|
356
|
+
|
|
357
|
+
Called before the body is consumed. **Must not read the body.** Return a replacement `Response` (e.g. after a retry) or `void` to keep the original. If you return a replacement, the original's body is cancelled for you.
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
api.onResponse(async (resp, ctx) => {
|
|
361
|
+
console.log(`[http] ← ${ctx.method} ${ctx.url} ${resp.status}`);
|
|
362
|
+
if (resp.status === 401) {
|
|
363
|
+
await refreshToken();
|
|
364
|
+
// return a new fetch() if you want to retry
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Only one interceptor of each kind is supported per instance. Passing `null` clears it.
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
237
373
|
## Types
|
|
238
374
|
|
|
239
375
|
### RequestData
|
|
240
376
|
|
|
241
|
-
Request body data type.
|
|
377
|
+
Request body data type. See [Request Bodies](#request-bodies) for serialization rules.
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
type RequestData =
|
|
381
|
+
| Record<string, unknown>
|
|
382
|
+
| unknown[]
|
|
383
|
+
| FormData
|
|
384
|
+
| Blob
|
|
385
|
+
| ArrayBuffer
|
|
386
|
+
| ArrayBufferView
|
|
387
|
+
| URLSearchParams
|
|
388
|
+
| ReadableStream
|
|
389
|
+
| string
|
|
390
|
+
| number
|
|
391
|
+
| boolean
|
|
392
|
+
| null;
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### QueryValue
|
|
396
|
+
|
|
397
|
+
A value for `FetchParams.query`. `null` / `undefined` entries are skipped; arrays emit repeated keys.
|
|
242
398
|
|
|
243
399
|
```ts
|
|
244
|
-
type
|
|
400
|
+
type QueryValue =
|
|
401
|
+
| string
|
|
402
|
+
| number
|
|
403
|
+
| boolean
|
|
404
|
+
| (string | number | boolean)[]
|
|
405
|
+
| null
|
|
406
|
+
| undefined;
|
|
245
407
|
```
|
|
246
408
|
|
|
247
409
|
### FetchParams
|
|
@@ -250,17 +412,21 @@ Parameters for fetch requests.
|
|
|
250
412
|
|
|
251
413
|
```ts
|
|
252
414
|
interface FetchParams {
|
|
253
|
-
/** Request body
|
|
415
|
+
/** Request body. See "Request Bodies" for serialization rules. */
|
|
254
416
|
data?: RequestData;
|
|
255
417
|
/** Bearer token (auto-adds `Authorization: Bearer {token}` header). */
|
|
256
418
|
token?: string | null;
|
|
257
419
|
/** Custom request headers. */
|
|
258
|
-
headers?:
|
|
259
|
-
/** AbortSignal for request cancellation. */
|
|
420
|
+
headers?: HeadersInit | null;
|
|
421
|
+
/** AbortSignal for request cancellation. Combined with `timeout` if both are set. */
|
|
260
422
|
signal?: AbortSignal;
|
|
423
|
+
/** Abort the request after this many milliseconds. Combined with `signal`. */
|
|
424
|
+
timeout?: number | null;
|
|
425
|
+
/** Query parameters appended to the URL. */
|
|
426
|
+
query?: Record<string, QueryValue> | null;
|
|
261
427
|
/** Credentials mode for the request. */
|
|
262
428
|
credentials?: 'omit' | 'same-origin' | 'include' | null;
|
|
263
|
-
/** If true, returns the raw Response object instead of parsed body. */
|
|
429
|
+
/** If true, returns the raw Response object instead of parsed body. Caller must consume the body. */
|
|
264
430
|
raw?: boolean | null;
|
|
265
431
|
/** If false, does not throw on HTTP errors (default: true). */
|
|
266
432
|
assert?: boolean | null;
|
|
@@ -313,12 +479,30 @@ Special keys added after request:
|
|
|
313
479
|
|
|
314
480
|
### ErrorMessageExtractor
|
|
315
481
|
|
|
316
|
-
Function to extract error messages from failed HTTP responses.
|
|
482
|
+
Function to extract error messages from failed HTTP responses. If the extractor throws, the call falls back to the next-priority extractor (per-instance → global → built-in) instead of crashing.
|
|
317
483
|
|
|
318
484
|
```ts
|
|
319
485
|
type ErrorMessageExtractor = (body: unknown, response: Response) => string;
|
|
320
486
|
```
|
|
321
487
|
|
|
488
|
+
### RequestInterceptor
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
type RequestInterceptor = (
|
|
492
|
+
init: RequestInit,
|
|
493
|
+
context: { method: string; url: string }
|
|
494
|
+
) => RequestInit | void | Promise<RequestInit | void>;
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### ResponseInterceptor
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
type ResponseInterceptor = (
|
|
501
|
+
response: Response,
|
|
502
|
+
context: { method: string; url: string }
|
|
503
|
+
) => Response | void | Promise<Response | void>;
|
|
504
|
+
```
|
|
505
|
+
|
|
322
506
|
---
|
|
323
507
|
|
|
324
508
|
## HTTP Errors
|
package/CLAUDE.md
ADDED
package/README.md
CHANGED
|
@@ -137,14 +137,35 @@ try {
|
|
|
137
137
|
|
|
138
138
|
### Key Features
|
|
139
139
|
|
|
140
|
-
- **Auto JSON**: Response bodies are automatically parsed as JSON
|
|
140
|
+
- **Auto JSON**: Response bodies are automatically parsed as JSON; empty bodies (204/205) return `null`
|
|
141
|
+
- **Smart body handling**: Plain objects → JSON; `FormData` / `URLSearchParams` / `Blob` / typed arrays / `ReadableStream` pass through; strings sent as-is
|
|
142
|
+
- **Query params**: Pass `query: { page: 1, tag: ["a", "b"] }` for URL search params
|
|
143
|
+
- **Timeouts**: Pass `timeout: 5000` for automatic request cancellation
|
|
141
144
|
- **Bearer tokens**: Use `token` param to auto-add `Authorization: Bearer` header
|
|
142
145
|
- **Response headers**: Pass `respHeaders: {}` to capture response headers
|
|
143
|
-
- **Raw response**: Use `raw: true` to get the raw Response object
|
|
146
|
+
- **Raw response**: Use `raw: true` to get the raw Response object (caller must consume the body)
|
|
144
147
|
- **Non-throwing**: Use `assert: false` to prevent throwing on errors
|
|
145
|
-
- **AbortController**: Pass `signal` for request cancellation
|
|
148
|
+
- **AbortController**: Pass `signal` for request cancellation (composes with `timeout`)
|
|
149
|
+
- **Interceptors**: `api.onRequest(...)` / `api.onResponse(...)` for tracing, auth refresh, etc.
|
|
146
150
|
- **Typed responses**: Use generics for type-safe responses: `api.get<User>("/users/1")`
|
|
147
151
|
|
|
152
|
+
### Query, Timeout, Interceptors
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
// Query params
|
|
156
|
+
await api.get("/search", { query: { q: "hi", tag: ["a", "b"] } });
|
|
157
|
+
|
|
158
|
+
// Timeout (abort after 5s; composes with AbortSignal)
|
|
159
|
+
await api.get("/slow", { timeout: 5000 });
|
|
160
|
+
|
|
161
|
+
// Interceptors
|
|
162
|
+
api.onRequest((init, { method, url }) => {
|
|
163
|
+
console.log(method, url);
|
|
164
|
+
}).onResponse(async (resp) => {
|
|
165
|
+
if (resp.status === 401) await refreshToken();
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
148
169
|
## Full API Reference
|
|
149
170
|
|
|
150
171
|
For complete API documentation including all error classes, HTTP status codes, types, and utilities, see **[API.md](API.md)**.
|
package/dist/api.d.ts
CHANGED
|
@@ -6,28 +6,60 @@
|
|
|
6
6
|
*/
|
|
7
7
|
/**
|
|
8
8
|
* Request body data type.
|
|
9
|
-
*
|
|
9
|
+
*
|
|
10
|
+
* Plain objects and arrays are JSON-serialized (with `Content-Type: application/json`
|
|
11
|
+
* when not set). Strings are sent as-is (the caller controls `Content-Type`).
|
|
12
|
+
* Native `BodyInit` types (`FormData`, `Blob`, `ArrayBuffer`, typed arrays,
|
|
13
|
+
* `URLSearchParams`, `ReadableStream`) are passed through unchanged so that
|
|
14
|
+
* `fetch` can handle content-type negotiation (e.g. multipart boundary for
|
|
15
|
+
* FormData, `application/x-www-form-urlencoded` for URLSearchParams).
|
|
10
16
|
*/
|
|
11
|
-
export type RequestData =
|
|
17
|
+
export type RequestData = Record<string, unknown> | unknown[] | FormData | Blob | ArrayBuffer | ArrayBufferView | URLSearchParams | ReadableStream | string | number | boolean | null;
|
|
18
|
+
/** A primitive that can be serialized into a query-string value. */
|
|
19
|
+
type QueryPrimitive = string | number | boolean;
|
|
20
|
+
/** A value for {@link FetchParams.query}. `null`/`undefined` entries are skipped. */
|
|
21
|
+
export type QueryValue = QueryPrimitive | QueryPrimitive[] | null | undefined;
|
|
12
22
|
interface BaseParams {
|
|
13
23
|
method: "GET" | "POST" | "PATCH" | "DELETE" | "PUT";
|
|
14
24
|
path: string;
|
|
15
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Request interceptor. Called after defaults are merged, with the final
|
|
28
|
+
* `RequestInit` and the resolved URL. May return an updated `RequestInit`
|
|
29
|
+
* (or a promise of one). Returning `undefined` keeps the original `init`.
|
|
30
|
+
*/
|
|
31
|
+
export type RequestInterceptor = (init: RequestInit, context: {
|
|
32
|
+
method: string;
|
|
33
|
+
url: string;
|
|
34
|
+
}) => RequestInit | void | Promise<RequestInit | void>;
|
|
35
|
+
/**
|
|
36
|
+
* Response interceptor. Called before the response body is consumed.
|
|
37
|
+
* May return a replacement `Response` (e.g. retry result); returning
|
|
38
|
+
* `undefined` keeps the original. Must not consume the response body.
|
|
39
|
+
*/
|
|
40
|
+
export type ResponseInterceptor = (response: Response, context: {
|
|
41
|
+
method: string;
|
|
42
|
+
url: string;
|
|
43
|
+
}) => Response | void | Promise<Response | void>;
|
|
16
44
|
/**
|
|
17
45
|
* Parameters for fetch requests.
|
|
18
46
|
*/
|
|
19
47
|
export interface FetchParams {
|
|
20
|
-
/** Request body
|
|
48
|
+
/** Request body. Plain objects/arrays are JSON-serialized; strings and native BodyInit types are passed through. */
|
|
21
49
|
data?: RequestData;
|
|
22
50
|
/** Bearer token (auto-adds `Authorization: Bearer {token}` header). */
|
|
23
51
|
token?: string | null;
|
|
24
52
|
/** Custom request headers. */
|
|
25
|
-
headers?: HeadersInit |
|
|
26
|
-
/** AbortSignal for request cancellation. */
|
|
53
|
+
headers?: HeadersInit | null;
|
|
54
|
+
/** AbortSignal for request cancellation. Combined with `timeout` if both are set. */
|
|
27
55
|
signal?: AbortSignal;
|
|
56
|
+
/** Abort the request after this many milliseconds. Combined with `signal`. */
|
|
57
|
+
timeout?: number | null;
|
|
58
|
+
/** Query parameters appended to the URL. Null/undefined values skipped; arrays become repeated keys. */
|
|
59
|
+
query?: Record<string, QueryValue> | null;
|
|
28
60
|
/** Credentials mode for the request. */
|
|
29
61
|
credentials?: "omit" | "same-origin" | "include" | null;
|
|
30
|
-
/** If true, returns the raw Response object instead of parsed body. */
|
|
62
|
+
/** If true, returns the raw Response object instead of parsed body. Caller must consume the body. */
|
|
31
63
|
raw?: boolean | null;
|
|
32
64
|
/** If false, does not throw on HTTP errors (default: true). */
|
|
33
65
|
assert?: boolean | null;
|
|
@@ -51,7 +83,7 @@ export type ResponseHeaders = Record<string, string | number>;
|
|
|
51
83
|
* Options for HTTP GET requests using the new cleaner API.
|
|
52
84
|
*/
|
|
53
85
|
export interface GetOptions {
|
|
54
|
-
/** Fetch parameters (headers, token, signal, credentials, raw, assert). */
|
|
86
|
+
/** Fetch parameters (headers, token, signal, credentials, raw, assert, timeout, query). */
|
|
55
87
|
params?: FetchParams;
|
|
56
88
|
/** Object to receive response headers (will be mutated). */
|
|
57
89
|
respHeaders?: ResponseHeaders | null;
|
|
@@ -64,7 +96,7 @@ export interface GetOptions {
|
|
|
64
96
|
export interface DataOptions {
|
|
65
97
|
/** Request body data. */
|
|
66
98
|
data?: RequestData;
|
|
67
|
-
/** Fetch parameters (headers, token, signal, credentials, raw, assert). */
|
|
99
|
+
/** Fetch parameters (headers, token, signal, credentials, raw, assert, timeout, query). */
|
|
68
100
|
params?: FetchParams;
|
|
69
101
|
/** Object to receive response headers (will be mutated). */
|
|
70
102
|
respHeaders?: ResponseHeaders | null;
|
|
@@ -91,6 +123,16 @@ export declare function opts<T extends GetOptions | DataOptions>(options: T): T;
|
|
|
91
123
|
export declare class HttpApi {
|
|
92
124
|
#private;
|
|
93
125
|
constructor(base?: string | null, defaults?: Partial<BaseFetchParams> | (() => Promise<Partial<BaseFetchParams>>), factoryErrorMessageExtractor?: ErrorMessageExtractor | null | undefined);
|
|
126
|
+
/**
|
|
127
|
+
* Register a request interceptor. Called after defaults are merged.
|
|
128
|
+
* Returning a new `RequestInit` replaces the original.
|
|
129
|
+
*/
|
|
130
|
+
onRequest(interceptor: RequestInterceptor | null): this;
|
|
131
|
+
/**
|
|
132
|
+
* Register a response interceptor. Called before the body is consumed.
|
|
133
|
+
* Must not consume the body. Returning a new `Response` replaces the original.
|
|
134
|
+
*/
|
|
135
|
+
onResponse(interceptor: ResponseInterceptor | null): this;
|
|
94
136
|
/**
|
|
95
137
|
* Performs a GET request (new options API - recommended).
|
|
96
138
|
*
|
|
@@ -101,54 +143,23 @@ export declare class HttpApi {
|
|
|
101
143
|
*
|
|
102
144
|
* @example
|
|
103
145
|
* ```ts
|
|
104
|
-
* const data = await api.get('/users', {
|
|
146
|
+
* const data = await api.get('/users', opts({
|
|
105
147
|
* params: { headers: { 'X-Custom': 'value' } },
|
|
106
148
|
* respHeaders: {}
|
|
107
|
-
* });
|
|
149
|
+
* }));
|
|
108
150
|
* ```
|
|
109
151
|
*/
|
|
110
152
|
get<T = unknown>(path: string, options: GetOptions): Promise<T>;
|
|
111
153
|
/**
|
|
112
154
|
* Performs a GET request (legacy API).
|
|
113
|
-
*
|
|
114
|
-
* @param path - The request path (will be appended to base URL if set).
|
|
115
|
-
* @param params - Optional fetch parameters.
|
|
116
|
-
* @param respHeaders - Optional object to be mutated with response headers.
|
|
117
|
-
* @param errorMessageExtractor - Optional custom error message extractor.
|
|
118
|
-
* @param _dumpParams - Internal parameter for testing.
|
|
119
|
-
* @returns The response body (auto-parsed as JSON if possible), or Response if `raw: true`.
|
|
120
|
-
* @throws {HttpError} When the response is not OK and `assert` is true (default).
|
|
121
155
|
*/
|
|
122
156
|
get<T = unknown>(path: string, params?: FetchParams, respHeaders?: ResponseHeaders | null, errorMessageExtractor?: ErrorMessageExtractor | null, _dumpParams?: boolean): Promise<T>;
|
|
123
157
|
/**
|
|
124
158
|
* Performs a POST request (new options API - recommended).
|
|
125
|
-
*
|
|
126
|
-
* @param path - The request path (will be appended to base URL if set).
|
|
127
|
-
* @param options - Request options object including data and params.
|
|
128
|
-
* @returns The response body (auto-parsed as JSON if possible), or Response if `raw: true`.
|
|
129
|
-
* @throws {HttpError} When the response is not OK and `assert` is true (default).
|
|
130
|
-
*
|
|
131
|
-
* @example
|
|
132
|
-
* ```ts
|
|
133
|
-
* await api.post('/users', {
|
|
134
|
-
* data: { name: 'John' },
|
|
135
|
-
* params: { headers: { 'X-Custom': 'value' } },
|
|
136
|
-
* respHeaders: {}
|
|
137
|
-
* });
|
|
138
|
-
* ```
|
|
139
159
|
*/
|
|
140
160
|
post<T = unknown>(path: string, options: DataOptions): Promise<T>;
|
|
141
161
|
/**
|
|
142
162
|
* Performs a POST request (legacy API).
|
|
143
|
-
*
|
|
144
|
-
* @param path - The request path (will be appended to base URL if set).
|
|
145
|
-
* @param data - Request body data.
|
|
146
|
-
* @param params - Optional fetch parameters.
|
|
147
|
-
* @param respHeaders - Optional object to be mutated with response headers.
|
|
148
|
-
* @param errorMessageExtractor - Optional custom error message extractor.
|
|
149
|
-
* @param _dumpParams - Internal parameter for testing.
|
|
150
|
-
* @returns The response body (auto-parsed as JSON if possible), or Response if `raw: true`.
|
|
151
|
-
* @throws {HttpError} When the response is not OK and `assert` is true (default).
|
|
152
163
|
*/
|
|
153
164
|
post<T = unknown>(path: string, data?: RequestData, params?: FetchParams, respHeaders?: ResponseHeaders | null, errorMessageExtractor?: ErrorMessageExtractor | null, _dumpParams?: boolean): Promise<T>;
|
|
154
165
|
/** Performs a PUT request (new options API). @see post */
|
package/dist/api.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { createHttpError } from "./error.js";
|
|
8
8
|
/**
|
|
9
9
|
* Deep merges two objects. Later properties overwrite earlier properties.
|
|
10
|
+
* Arrays are overwritten, not concatenated (conventional behavior).
|
|
10
11
|
*/
|
|
11
12
|
function deepMerge(target, source) {
|
|
12
13
|
const output = { ...target };
|
|
@@ -32,6 +33,78 @@ function deepMerge(target, source) {
|
|
|
32
33
|
function isObject(item) {
|
|
33
34
|
return item !== null && typeof item === "object" && !Array.isArray(item);
|
|
34
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Returns true for body types that native `fetch` knows how to serialize
|
|
38
|
+
* (including setting Content-Type where appropriate). These are passed through
|
|
39
|
+
* unchanged rather than JSON-stringified.
|
|
40
|
+
*/
|
|
41
|
+
function isNativeBodyInit(v) {
|
|
42
|
+
if (v instanceof FormData)
|
|
43
|
+
return true;
|
|
44
|
+
if (typeof Blob !== "undefined" && v instanceof Blob)
|
|
45
|
+
return true;
|
|
46
|
+
if (v instanceof ArrayBuffer)
|
|
47
|
+
return true;
|
|
48
|
+
if (ArrayBuffer.isView(v))
|
|
49
|
+
return true;
|
|
50
|
+
if (v instanceof URLSearchParams)
|
|
51
|
+
return true;
|
|
52
|
+
if (typeof ReadableStream !== "undefined" && v instanceof ReadableStream) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Appends query parameters to a URL path. Null/undefined values are skipped.
|
|
59
|
+
* Array values are emitted as repeated keys (e.g. `?tag=a&tag=b`).
|
|
60
|
+
*/
|
|
61
|
+
function appendQuery(path, query) {
|
|
62
|
+
const sp = new URLSearchParams();
|
|
63
|
+
for (const [k, v] of Object.entries(query)) {
|
|
64
|
+
if (v === null || v === undefined)
|
|
65
|
+
continue;
|
|
66
|
+
if (Array.isArray(v)) {
|
|
67
|
+
for (const item of v) {
|
|
68
|
+
if (item !== null && item !== undefined)
|
|
69
|
+
sp.append(k, String(item));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
sp.append(k, String(v));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const qs = sp.toString();
|
|
77
|
+
if (!qs)
|
|
78
|
+
return path;
|
|
79
|
+
return path + (path.includes("?") ? "&" : "?") + qs;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Combines a user-provided AbortSignal with an optional timeout-based signal.
|
|
83
|
+
* Returns `undefined` if neither is present.
|
|
84
|
+
*/
|
|
85
|
+
function composeSignal(userSignal, timeoutMs) {
|
|
86
|
+
if (!timeoutMs || timeoutMs <= 0)
|
|
87
|
+
return userSignal;
|
|
88
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
89
|
+
if (!userSignal)
|
|
90
|
+
return timeoutSignal;
|
|
91
|
+
// AbortSignal.any is available in Node 20+ and modern Deno.
|
|
92
|
+
if (typeof AbortSignal.any === "function") {
|
|
93
|
+
return AbortSignal.any([userSignal, timeoutSignal]);
|
|
94
|
+
}
|
|
95
|
+
// Fallback: manual composition.
|
|
96
|
+
const ctrl = new AbortController();
|
|
97
|
+
const abort = (reason) => ctrl.abort(reason);
|
|
98
|
+
if (userSignal.aborted)
|
|
99
|
+
abort(userSignal.reason);
|
|
100
|
+
else
|
|
101
|
+
userSignal.addEventListener("abort", () => abort(userSignal.reason));
|
|
102
|
+
if (timeoutSignal.aborted)
|
|
103
|
+
abort(timeoutSignal.reason);
|
|
104
|
+
else
|
|
105
|
+
timeoutSignal.addEventListener("abort", () => abort(timeoutSignal.reason));
|
|
106
|
+
return ctrl.signal;
|
|
107
|
+
}
|
|
35
108
|
/** Symbol marker for explicit options API detection. */
|
|
36
109
|
const OPTIONS_MARKER = Symbol("options");
|
|
37
110
|
/**
|
|
@@ -55,7 +128,6 @@ export function opts(options) {
|
|
|
55
128
|
*/
|
|
56
129
|
function parseGetOptions(paramsOrOptions, legacyRespHeaders, legacyErrorExtractor) {
|
|
57
130
|
if (paramsOrOptions && OPTIONS_MARKER in paramsOrOptions) {
|
|
58
|
-
// New options API (explicit via opts() wrapper)
|
|
59
131
|
const o = paramsOrOptions;
|
|
60
132
|
return {
|
|
61
133
|
params: o.params,
|
|
@@ -63,7 +135,6 @@ function parseGetOptions(paramsOrOptions, legacyRespHeaders, legacyErrorExtracto
|
|
|
63
135
|
errorExtractor: o.errorExtractor ?? null,
|
|
64
136
|
};
|
|
65
137
|
}
|
|
66
|
-
// Legacy positional API
|
|
67
138
|
return {
|
|
68
139
|
params: paramsOrOptions,
|
|
69
140
|
respHeaders: legacyRespHeaders ?? null,
|
|
@@ -77,7 +148,6 @@ function parseDataOptions(dataOrOptions, legacyParams, legacyRespHeaders, legacy
|
|
|
77
148
|
if (dataOrOptions &&
|
|
78
149
|
typeof dataOrOptions === "object" &&
|
|
79
150
|
OPTIONS_MARKER in dataOrOptions) {
|
|
80
|
-
// New options API (explicit via opts() wrapper)
|
|
81
151
|
const o = dataOrOptions;
|
|
82
152
|
return {
|
|
83
153
|
data: o.data ?? null,
|
|
@@ -86,7 +156,6 @@ function parseDataOptions(dataOrOptions, legacyParams, legacyRespHeaders, legacy
|
|
|
86
156
|
errorExtractor: o.errorExtractor ?? null,
|
|
87
157
|
};
|
|
88
158
|
}
|
|
89
|
-
// Legacy positional API
|
|
90
159
|
return {
|
|
91
160
|
data: dataOrOptions ?? null,
|
|
92
161
|
params: legacyParams,
|
|
@@ -94,45 +163,79 @@ function parseDataOptions(dataOrOptions, legacyParams, legacyRespHeaders, legacy
|
|
|
94
163
|
errorExtractor: legacyErrorExtractor ?? null,
|
|
95
164
|
};
|
|
96
165
|
}
|
|
97
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Builds the final RequestInit and serialized URL from FetchParams.
|
|
168
|
+
* Does not call fetch.
|
|
169
|
+
*/
|
|
170
|
+
function buildRequest(params) {
|
|
171
|
+
const { method, path, data = null, token = null, headers = null, signal, timeout, query, credentials, } = params;
|
|
98
172
|
const normalizedHeaders = {};
|
|
99
173
|
if (headers) {
|
|
100
174
|
new Headers(headers).forEach((value, key) => {
|
|
101
175
|
normalizedHeaders[key] = value;
|
|
102
176
|
});
|
|
103
177
|
}
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
credentials
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
178
|
+
const init = { method };
|
|
179
|
+
if (credentials)
|
|
180
|
+
init.credentials = credentials;
|
|
181
|
+
const composedSignal = composeSignal(signal, timeout ?? undefined);
|
|
182
|
+
if (composedSignal)
|
|
183
|
+
init.signal = composedSignal;
|
|
184
|
+
// Body handling — send in order of most specific to least.
|
|
185
|
+
if (data !== null && data !== undefined) {
|
|
186
|
+
if (isNativeBodyInit(data)) {
|
|
187
|
+
// fetch knows how to serialize these and sets Content-Type as needed.
|
|
188
|
+
init.body = data;
|
|
189
|
+
}
|
|
190
|
+
else if (typeof data === "string") {
|
|
191
|
+
// Raw strings are sent as-is; caller controls Content-Type.
|
|
192
|
+
init.body = data;
|
|
115
193
|
}
|
|
116
|
-
// Cover 99% of use cases (may not fit all scenarios)
|
|
117
194
|
else {
|
|
118
|
-
//
|
|
119
|
-
if (
|
|
195
|
+
// Plain objects, arrays, numbers, booleans → JSON.
|
|
196
|
+
if (!normalizedHeaders["content-type"]) {
|
|
120
197
|
normalizedHeaders["content-type"] = "application/json";
|
|
121
198
|
}
|
|
122
|
-
|
|
199
|
+
init.body = JSON.stringify(data);
|
|
123
200
|
}
|
|
124
201
|
}
|
|
125
202
|
// Opinionated convention: auto-add Bearer token
|
|
126
203
|
if (token) {
|
|
127
204
|
normalizedHeaders["authorization"] = `Bearer ${token}`;
|
|
128
205
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
206
|
+
init.headers = normalizedHeaders;
|
|
207
|
+
let url = path;
|
|
208
|
+
if (query)
|
|
209
|
+
url = appendQuery(url, query);
|
|
210
|
+
return { url, init };
|
|
211
|
+
}
|
|
212
|
+
const _fetch = async (params, respHeaders = null, errorMessageExtractor = null, requestInterceptor = null, responseInterceptor = null, _dumpParams = false) => {
|
|
133
213
|
if (_dumpParams)
|
|
134
214
|
return params;
|
|
135
|
-
|
|
215
|
+
let { url, init } = buildRequest(params);
|
|
216
|
+
if (requestInterceptor) {
|
|
217
|
+
const patched = await requestInterceptor(init, {
|
|
218
|
+
method: params.method,
|
|
219
|
+
url,
|
|
220
|
+
});
|
|
221
|
+
if (patched)
|
|
222
|
+
init = patched;
|
|
223
|
+
}
|
|
224
|
+
let r = await fetch(url, init);
|
|
225
|
+
if (responseInterceptor) {
|
|
226
|
+
const patched = await responseInterceptor(r, {
|
|
227
|
+
method: params.method,
|
|
228
|
+
url,
|
|
229
|
+
});
|
|
230
|
+
if (patched && patched !== r) {
|
|
231
|
+
// Cancel the original body so the underlying stream doesn't leak.
|
|
232
|
+
try {
|
|
233
|
+
await r.body?.cancel();
|
|
234
|
+
}
|
|
235
|
+
catch (_e) { /* ignore */ }
|
|
236
|
+
r = patched;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
136
239
|
if (params.raw)
|
|
137
240
|
return r;
|
|
138
241
|
// Convert Headers to plain object
|
|
@@ -143,34 +246,50 @@ const _fetch = async (params, respHeaders = null, errorMessageExtractor = null,
|
|
|
143
246
|
// Add status/text under special keys
|
|
144
247
|
{ __http_status_code__: r.status, __http_status_text__: r.statusText });
|
|
145
248
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
body
|
|
249
|
+
const text = await r.text();
|
|
250
|
+
let body = text;
|
|
251
|
+
if (text === "") {
|
|
252
|
+
// Treat empty body (204/205 and friends) as null rather than "".
|
|
253
|
+
body = null;
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
// prettier-ignore
|
|
257
|
+
try {
|
|
258
|
+
body = JSON.parse(text);
|
|
259
|
+
}
|
|
260
|
+
catch (_e) { /* ignore parse errors */ }
|
|
150
261
|
}
|
|
151
|
-
catch (_e) { /* ignore parse errors */ }
|
|
152
262
|
params.assert ??= true; // default is true
|
|
153
263
|
if (!r.ok && params.assert) {
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
264
|
+
// Now we need to extract an error message from an unknown response shape.
|
|
265
|
+
// We try, in order: the per-call extractor, the factory/global, and a
|
|
266
|
+
// built-in guess. If a user-provided extractor throws, we must not let
|
|
267
|
+
// it replace the real HTTP error — fall back to statusText.
|
|
268
|
+
const tryExtract = (fn) => {
|
|
269
|
+
if (!fn)
|
|
270
|
+
return null;
|
|
271
|
+
try {
|
|
272
|
+
return fn(body, r);
|
|
273
|
+
}
|
|
274
|
+
catch (_e) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const builtIn = (_body, _response) => {
|
|
279
|
+
const b = _body;
|
|
280
|
+
let msg = String(b?.error?.message ||
|
|
281
|
+
b?.message ||
|
|
282
|
+
b?.error ||
|
|
283
|
+
_response?.statusText ||
|
|
284
|
+
"Unknown error");
|
|
285
|
+
if (msg.length > 255)
|
|
286
|
+
msg = `[Shortened]: ${msg.slice(0, 255)}`;
|
|
287
|
+
return msg;
|
|
288
|
+
};
|
|
289
|
+
const msg = tryExtract(errorMessageExtractor) ??
|
|
290
|
+
tryExtract(createHttpApi.defaultErrorMessageExtractor) ??
|
|
291
|
+
builtIn(body, r);
|
|
292
|
+
throw createHttpError(r.status, msg, body, {
|
|
174
293
|
method: params.method,
|
|
175
294
|
path: params.path,
|
|
176
295
|
response: {
|
|
@@ -189,6 +308,8 @@ export class HttpApi {
|
|
|
189
308
|
#base;
|
|
190
309
|
#defaults;
|
|
191
310
|
#factoryErrorMessageExtractor;
|
|
311
|
+
#requestInterceptor;
|
|
312
|
+
#responseInterceptor;
|
|
192
313
|
constructor(base, defaults, factoryErrorMessageExtractor) {
|
|
193
314
|
this.#base = base;
|
|
194
315
|
this.#defaults = defaults;
|
|
@@ -211,54 +332,58 @@ export class HttpApi {
|
|
|
211
332
|
return { ...(this.#defaults || {}) };
|
|
212
333
|
}
|
|
213
334
|
#buildPath(path, base) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
335
|
+
const p = `${path ?? ""}`;
|
|
336
|
+
const b = `${base ?? ""}`;
|
|
337
|
+
if (/^https?:/i.test(p))
|
|
338
|
+
return p;
|
|
339
|
+
if (!b)
|
|
340
|
+
return p;
|
|
341
|
+
const baseNoTrail = b.replace(/\/+$/, "");
|
|
342
|
+
const pathLead = p.startsWith("/") ? p : `/${p}`;
|
|
343
|
+
return baseNoTrail + pathLead;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Register a request interceptor. Called after defaults are merged.
|
|
347
|
+
* Returning a new `RequestInit` replaces the original.
|
|
348
|
+
*/
|
|
349
|
+
onRequest(interceptor) {
|
|
350
|
+
this.#requestInterceptor = interceptor;
|
|
351
|
+
return this;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Register a response interceptor. Called before the body is consumed.
|
|
355
|
+
* Must not consume the body. Returning a new `Response` replaces the original.
|
|
356
|
+
*/
|
|
357
|
+
onResponse(interceptor) {
|
|
358
|
+
this.#responseInterceptor = interceptor;
|
|
359
|
+
return this;
|
|
217
360
|
}
|
|
218
361
|
async get(path, paramsOrOptions, respHeaders, errorMessageExtractor, _dumpParams = false) {
|
|
219
362
|
const { params, respHeaders: headers, errorExtractor, } = parseGetOptions(paramsOrOptions, respHeaders, errorMessageExtractor);
|
|
220
363
|
path = this.#buildPath(path, this.#base);
|
|
221
|
-
return _fetch(this.#merge(await this.#getDefs(), { ...params, method: "GET", path }), headers, errorExtractor ?? this.#factoryErrorMessageExtractor, _dumpParams);
|
|
364
|
+
return _fetch(this.#merge(await this.#getDefs(), { ...params, method: "GET", path }), headers, errorExtractor ?? this.#factoryErrorMessageExtractor, this.#requestInterceptor, this.#responseInterceptor, _dumpParams);
|
|
222
365
|
}
|
|
223
366
|
async post(path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams = false) {
|
|
224
|
-
|
|
225
|
-
path = this.#buildPath(path, this.#base);
|
|
226
|
-
return _fetch(this.#merge(await this.#getDefs(), {
|
|
227
|
-
...(fetchParams || {}),
|
|
228
|
-
data,
|
|
229
|
-
method: "POST",
|
|
230
|
-
path,
|
|
231
|
-
}), headers, errorExtractor ?? this.#factoryErrorMessageExtractor, _dumpParams);
|
|
367
|
+
return await this.#body("POST", path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams);
|
|
232
368
|
}
|
|
233
369
|
async put(path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams = false) {
|
|
234
|
-
|
|
235
|
-
path = this.#buildPath(path, this.#base);
|
|
236
|
-
return _fetch(this.#merge(await this.#getDefs(), {
|
|
237
|
-
...(fetchParams || {}),
|
|
238
|
-
data,
|
|
239
|
-
method: "PUT",
|
|
240
|
-
path,
|
|
241
|
-
}), headers, errorExtractor ?? this.#factoryErrorMessageExtractor, _dumpParams);
|
|
370
|
+
return await this.#body("PUT", path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams);
|
|
242
371
|
}
|
|
243
372
|
async patch(path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams = false) {
|
|
244
|
-
|
|
245
|
-
path = this.#buildPath(path, this.#base);
|
|
246
|
-
return _fetch(this.#merge(await this.#getDefs(), {
|
|
247
|
-
...(fetchParams || {}),
|
|
248
|
-
data,
|
|
249
|
-
method: "PATCH",
|
|
250
|
-
path,
|
|
251
|
-
}), headers, errorExtractor ?? this.#factoryErrorMessageExtractor, _dumpParams);
|
|
373
|
+
return await this.#body("PATCH", path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams);
|
|
252
374
|
}
|
|
253
375
|
async del(path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams = false) {
|
|
376
|
+
return await this.#body("DELETE", path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams);
|
|
377
|
+
}
|
|
378
|
+
async #body(method, path, dataOrOptions, params, respHeaders, errorMessageExtractor, _dumpParams) {
|
|
254
379
|
const { data, params: fetchParams, respHeaders: headers, errorExtractor, } = parseDataOptions(dataOrOptions, params, respHeaders, errorMessageExtractor);
|
|
255
380
|
path = this.#buildPath(path, this.#base);
|
|
256
381
|
return _fetch(this.#merge(await this.#getDefs(), {
|
|
257
382
|
...(fetchParams || {}),
|
|
258
383
|
data,
|
|
259
|
-
method
|
|
384
|
+
method,
|
|
260
385
|
path,
|
|
261
|
-
}), headers, errorExtractor ?? this.#factoryErrorMessageExtractor, _dumpParams);
|
|
386
|
+
}), headers, errorExtractor ?? this.#factoryErrorMessageExtractor, this.#requestInterceptor, this.#responseInterceptor, _dumpParams);
|
|
262
387
|
}
|
|
263
388
|
/**
|
|
264
389
|
* Helper method to build the full URL from a path.
|
|
@@ -306,6 +431,9 @@ export function createHttpApi(base, defaults, factoryErrorMessageExtractor) {
|
|
|
306
431
|
* Applied to all requests unless overridden at instance or request level.
|
|
307
432
|
* Priority: per-request → per-instance → global → built-in fallback.
|
|
308
433
|
*
|
|
434
|
+
* A throwing extractor will not crash the call — the next priority level is
|
|
435
|
+
* used as a fallback.
|
|
436
|
+
*
|
|
309
437
|
* @example
|
|
310
438
|
* ```ts
|
|
311
439
|
* createHttpApi.defaultErrorMessageExtractor = (body, response) => {
|
package/dist/mod.d.ts
CHANGED
|
@@ -21,6 +21,6 @@
|
|
|
21
21
|
* }
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
|
-
export { HttpApi, createHttpApi, opts, type DataOptions, type GetOptions, type FetchParams, type ErrorMessageExtractor, type ResponseHeaders, type RequestData, } from "./api.js";
|
|
24
|
+
export { HttpApi, createHttpApi, opts, type DataOptions, type GetOptions, type FetchParams, type ErrorMessageExtractor, type ResponseHeaders, type RequestData, type QueryValue, type RequestInterceptor, type ResponseInterceptor, } from "./api.js";
|
|
25
25
|
export { HTTP_ERROR, createHttpError, getErrorMessage } from "./error.js";
|
|
26
26
|
export { HTTP_STATUS } from "./status.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marianmeres/http-utils",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/mod.js",
|
|
6
6
|
"types": "dist/mod.d.ts",
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
"import": "./dist/mod.js"
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"README.md",
|
|
17
|
+
"API.md",
|
|
18
|
+
"AGENTS.md",
|
|
19
|
+
"CLAUDE.md"
|
|
20
|
+
],
|
|
13
21
|
"author": "Marian Meres",
|
|
14
22
|
"license": "MIT",
|
|
15
23
|
"dependencies": {},
|