aspi 2.2.1 → 2.4.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/README.md +391 -580
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,35 +1,38 @@
|
|
|
1
|
-
#
|
|
1
|
+
# aspi
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
[](https://bundlephobia.com/package/aspi)
|
|
4
|
+
[](./LICENSE)
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
A tiny, type-safe HTTP client for TypeScript built on native `fetch`.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
- No extra weight – only a thin wrapper around `fetch`
|
|
10
|
-
- Chain‑of‑responsibility middleware support via `use`
|
|
11
|
-
- Result‑based error handling (values as errors)
|
|
12
|
-
- Built‑in retry, header helpers, query‑string handling, and schema validation (Zod, Arktype, Valibot)
|
|
13
|
-
- Flexible error mapping with `error` and convenience shortcuts
|
|
8
|
+
Zero runtime dependencies. Three response modes. Full error-union types.
|
|
14
9
|
|
|
15
10
|
---
|
|
16
11
|
|
|
17
|
-
##
|
|
12
|
+
## Features
|
|
18
13
|
|
|
19
|
-
|
|
20
|
-
`
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
- **Zero dependencies** — thin wrapper around the platform `fetch` API
|
|
15
|
+
- **Three response modes** — tuple `[data, error]`, `Result` monad, or `throwable` (your choice per call)
|
|
16
|
+
- **Typed error unions** — every error variant is tagged and narrowable at compile time
|
|
17
|
+
- **Custom error mapping** — map any HTTP status code to a structured, typed error object
|
|
18
|
+
- **Retry with back-off** — fixed or dynamic delay, status-code filtering, custom predicates
|
|
19
|
+
- **Schema validation** — validate request bodies and responses via any [StandardSchemaV1](https://github.com/standard-schema/standard-schema) library (Zod, Valibot, Arktype, …)
|
|
20
|
+
- **Middleware** — transform the `RequestInit` for every request via `use()`
|
|
21
|
+
- **Capabilities** — plugin-level interception of the raw `fetch` call (logging, token refresh, tracing)
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
`bash
|
|
26
|
-
yarn add aspi
|
|
27
|
-
`
|
|
23
|
+
---
|
|
28
24
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install aspi
|
|
29
|
+
# or
|
|
30
|
+
yarn add aspi
|
|
31
|
+
# or
|
|
32
|
+
pnpm add aspi
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
TypeScript 5+ is required as a peer dependency.
|
|
33
36
|
|
|
34
37
|
---
|
|
35
38
|
|
|
@@ -38,719 +41,527 @@ pnpm
|
|
|
38
41
|
```ts
|
|
39
42
|
import { Aspi, Result } from 'aspi';
|
|
40
43
|
|
|
41
|
-
// Create a client with a base URL and default headers
|
|
42
44
|
const api = new Aspi({
|
|
43
|
-
baseUrl: 'https://
|
|
44
|
-
headers: {
|
|
45
|
-
'Content-Type': 'application/json',
|
|
46
|
-
},
|
|
45
|
+
baseUrl: 'https://jsonplaceholder.typicode.com',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
if (error)
|
|
59
|
-
if (error.tag === 'aspiError') console.error(error.response.status);
|
|
60
|
-
if (error.tag === 'notFoundError') console.warn(error.data.message);
|
|
61
|
-
if (error.tag === 'jsonParseError') console.error(error.data.message);
|
|
62
|
-
}
|
|
49
|
+
// Tuple mode — default
|
|
50
|
+
const [data, error] = await api
|
|
51
|
+
.get('/todos/1')
|
|
52
|
+
.notFound(() => ({ message: 'Todo not found' }))
|
|
53
|
+
.json<{ id: number; title: string; completed: boolean }>();
|
|
54
|
+
|
|
55
|
+
if (error) {
|
|
56
|
+
if (error.tag === 'aspiError') console.error(error.response.status);
|
|
57
|
+
if (error.tag === 'notFoundError') console.warn(error.data.message);
|
|
58
|
+
if (error.tag === 'jsonParseError') console.error(error.data.message);
|
|
63
59
|
}
|
|
64
60
|
|
|
65
|
-
|
|
61
|
+
if (data) console.log(data.title);
|
|
66
62
|
```
|
|
67
63
|
|
|
68
64
|
---
|
|
69
65
|
|
|
70
|
-
##
|
|
66
|
+
## Response modes
|
|
71
67
|
|
|
72
|
-
|
|
68
|
+
Every request can be consumed in one of three modes. Switch mode by calling `.withResult()` or `.throwable()` before the body-parser method.
|
|
73
69
|
|
|
74
|
-
1.
|
|
75
|
-
- Some utilities throw raw `Error`/`AxiosError`.
|
|
76
|
-
- Others return `{ ok: false, error }` or `null` or a custom union.
|
|
77
|
-
- Callers don’t know whether to use `try/catch`, check `ok`, or both.
|
|
70
|
+
### 1. Tuple mode (default)
|
|
78
71
|
|
|
79
|
-
|
|
80
|
-
- Each service rolls its own `while (attempt <= retries)` loop.
|
|
81
|
-
- Status codes, backoff strategies, and retry limits slowly diverge over time.
|
|
82
|
-
- There is no single place to see “how do we retry HTTP calls in this app?”.
|
|
72
|
+
Returns `[AspiResultOk | null, ErrorUnion | null]`. Familiar to anyone who has used Go-style error handling.
|
|
83
73
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
- Response validation happens deep in the business logic (if at all).
|
|
87
|
-
- JSON parse errors leak as raw `SyntaxError`, not structured errors.
|
|
74
|
+
```ts
|
|
75
|
+
const [data, error] = await api.get('/users/1').json<User>();
|
|
88
76
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
77
|
+
if (error) {
|
|
78
|
+
/* handle */
|
|
79
|
+
}
|
|
80
|
+
console.log(data!.name);
|
|
81
|
+
```
|
|
92
82
|
|
|
93
|
-
|
|
94
|
-
- Generic HTTP clients often expose `any` for responses.
|
|
95
|
-
- Error flows are not encoded in the type system, forcing manual guards and casting.
|
|
83
|
+
### 2. Result mode
|
|
96
84
|
|
|
97
|
-
|
|
85
|
+
Returns a `Result<Ok, ErrorUnion>` tagged union. Use `.withResult()` to enable.
|
|
98
86
|
|
|
99
|
-
|
|
87
|
+
```ts
|
|
88
|
+
const result = await api.get('/users/1').withResult().json<User>();
|
|
100
89
|
|
|
101
|
-
|
|
90
|
+
Result.match(result, {
|
|
91
|
+
onOk: ({ data }) => console.log(data.name),
|
|
92
|
+
onErr: (err) => console.error(err.tag, err),
|
|
93
|
+
});
|
|
94
|
+
```
|
|
102
95
|
|
|
103
|
-
|
|
104
|
-
- `withResult()` → `json/text/blob` return a `Result.Result<Ok, ErrorUnion>`.
|
|
105
|
-
- `throwable()` → `json/text/blob` return `AspiPlainResponse` and throw on failure.
|
|
106
|
-
- Default → `json/text/blob` return `[ok, err]` tuples.
|
|
96
|
+
### 3. Throwable mode
|
|
107
97
|
|
|
108
|
-
|
|
98
|
+
Returns the parsed value directly and throws a typed error on any non-2xx response. Use `.throwable()` to enable.
|
|
109
99
|
|
|
110
|
-
|
|
100
|
+
```ts
|
|
101
|
+
try {
|
|
102
|
+
const { data } = await api.get('/users/1').throwable().json<User>();
|
|
103
|
+
console.log(data.name);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (err.tag === 'aspiError') console.error(err.response.status);
|
|
106
|
+
}
|
|
107
|
+
```
|
|
111
108
|
|
|
112
|
-
|
|
113
|
-
- `retries`: max attempts.
|
|
114
|
-
- `retryDelay`: number or function `(attempt, maxAttempts, request, response) => delayMs`.
|
|
115
|
-
- `retryOn`: list of HTTP status codes that should trigger a retry.
|
|
116
|
-
- `retryWhile`: predicate `(request, response) => boolean` for custom retry conditions.
|
|
117
|
-
- `onRetry`: hook invoked after each retry attempt.
|
|
109
|
+
> `throwable()` and `withResult()` are mutually exclusive — the **last one called wins**.
|
|
118
110
|
|
|
119
|
-
|
|
111
|
+
---
|
|
120
112
|
|
|
121
|
-
|
|
113
|
+
## Error handling
|
|
122
114
|
|
|
123
|
-
|
|
124
|
-
- Validate request bodies with `bodySchema` + `bodyJson` **before** the network call.
|
|
125
|
-
- Validate responses with `schema()` + `json()` **after** JSON parsing.
|
|
115
|
+
### Built-in error variants
|
|
126
116
|
|
|
127
|
-
|
|
117
|
+
Every response mode surfaces the same tagged error variants:
|
|
128
118
|
|
|
129
|
-
|
|
119
|
+
| Tag | When |
|
|
120
|
+
| ---------------- | ------------------------------------------------------------ |
|
|
121
|
+
| `aspiError` | Any non-2xx response with no matching custom handler |
|
|
122
|
+
| `jsonParseError` | Response body could not be parsed as JSON |
|
|
123
|
+
| `parseError` | Response failed schema validation (when `.schema()` is used) |
|
|
124
|
+
| _custom_ | Any tag you define via `.error()` or a convenience shortcut |
|
|
130
125
|
|
|
131
|
-
|
|
126
|
+
### Custom error mapping
|
|
132
127
|
|
|
133
|
-
|
|
128
|
+
Map an HTTP status to a typed, tagged error object. The callback receives the full request and response.
|
|
134
129
|
|
|
135
130
|
```ts
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (err.tag === 'aspiError') console.error(err.response.status);
|
|
147
|
-
if (err.tag === 'notFoundError') console.warn(err.data.message);
|
|
148
|
-
},
|
|
149
|
-
});
|
|
131
|
+
const [data, error] = await api
|
|
132
|
+
.post('/login')
|
|
133
|
+
.bodyJson({ email, password })
|
|
134
|
+
.error('rateLimitedError', 'TOO_MANY_REQUESTS', ({ response }) => ({
|
|
135
|
+
retryAfter: response.response.headers.get('Retry-After'),
|
|
136
|
+
}))
|
|
137
|
+
.json<{ token: string }>();
|
|
138
|
+
|
|
139
|
+
if (error?.tag === 'rateLimitedError') {
|
|
140
|
+
console.warn('Retry after', error.data.retryAfter, 'seconds');
|
|
150
141
|
}
|
|
151
142
|
```
|
|
152
143
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
## Throwable
|
|
144
|
+
### Convenience shortcuts
|
|
156
145
|
|
|
157
|
-
|
|
146
|
+
Pre-built shortcuts for the most common statuses. Each produces a typed error with a predictable tag.
|
|
158
147
|
|
|
159
|
-
|
|
148
|
+
| Method | Status | Error tag |
|
|
149
|
+
| -------------------------- | ------ | ---------------------- |
|
|
150
|
+
| `.notFound(cb)` | 404 | `notFoundError` |
|
|
151
|
+
| `.badRequest(cb)` | 400 | `badRequestError` |
|
|
152
|
+
| `.unauthorized(cb)` | 401 | `unauthorizedError` |
|
|
153
|
+
| `.forbidden(cb)` | 403 | `forbiddenError` |
|
|
154
|
+
| `.conflict(cb)` | 409 | `conflictError` |
|
|
155
|
+
| `.tooManyRequests(cb)` | 429 | `tooManyRequestsError` |
|
|
156
|
+
| `.notImplemented(cb)` | 501 | `notImplementedError` |
|
|
157
|
+
| `.internalServerError(cb)` | 500 | `internalServerError` |
|
|
160
158
|
|
|
161
|
-
|
|
159
|
+
> Note: When calling these on the `Request` object (e.g. `api.get('/…').unauthorised(…)`) the method is spelled `.unauthorised()` (British) and produces an `unauthorisedError` tag. On the `Aspi` instance itself the method is `.unauthorized()` (American). All other shortcuts are spelled identically on both.
|
|
162
160
|
|
|
163
161
|
```ts
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
162
|
+
const [data, error] = await api
|
|
163
|
+
.get('/account')
|
|
164
|
+
.notFound(() => ({ message: 'Account does not exist' }))
|
|
165
|
+
.unauthorized(() => ({ message: 'Please sign in' }))
|
|
166
|
+
.json<Account>();
|
|
167
|
+
|
|
168
|
+
if (error?.tag === 'notFoundError') redirect('/signup');
|
|
169
|
+
if (error?.tag === 'unauthorizedError') redirect('/login');
|
|
170
|
+
```
|
|
170
171
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
172
|
+
### Inspecting `AspiError`
|
|
173
|
+
|
|
174
|
+
The base `aspiError` variant exposes the full request and response, plus an `.ifMatch()` helper for conditional handling.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
if (error?.tag === 'aspiError') {
|
|
178
|
+
console.log(error.response.status); // numeric HTTP status code
|
|
179
|
+
console.log(error.response.statusLabel); // e.g. "NOT_FOUND"
|
|
180
|
+
console.log(error.response.statusText); // raw status text
|
|
181
|
+
console.log(error.request.path); // request path
|
|
182
|
+
|
|
183
|
+
// Run a callback only for a specific status
|
|
184
|
+
error.ifMatch('INTERNAL_SERVER_ERROR', ({ response }) => {
|
|
185
|
+
reportToSentry(response);
|
|
186
|
+
});
|
|
181
187
|
}
|
|
182
188
|
```
|
|
183
189
|
|
|
184
|
-
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Making requests
|
|
185
193
|
|
|
186
|
-
|
|
194
|
+
### HTTP methods
|
|
187
195
|
|
|
188
196
|
```ts
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
197
|
+
api.get('/users');
|
|
198
|
+
api.post('/users');
|
|
199
|
+
api.put('/users/1');
|
|
200
|
+
api.patch('/users/1');
|
|
201
|
+
api.delete('/users/1');
|
|
202
|
+
api.head('/users');
|
|
203
|
+
api.options('/users');
|
|
204
|
+
```
|
|
195
205
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
206
|
+
### Request body
|
|
207
|
+
|
|
208
|
+
Use `.bodyJson()` to send a JSON payload. Pair it with `.bodySchema()` to validate the body before the network call.
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
// Plain JSON body
|
|
212
|
+
const [data, error] = await api
|
|
213
|
+
.post('/users')
|
|
214
|
+
.bodyJson({ name: 'Alice', email: 'alice@example.com' })
|
|
215
|
+
.json<User>();
|
|
216
|
+
|
|
217
|
+
// Validated body (Zod example)
|
|
218
|
+
import { z } from 'zod';
|
|
219
|
+
|
|
220
|
+
const CreateUserSchema = z.object({
|
|
221
|
+
name: z.string().min(1),
|
|
222
|
+
email: z.string().email(),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const [data, error] = await api
|
|
226
|
+
.post('/users')
|
|
227
|
+
.bodySchema(CreateUserSchema) // validate before sending
|
|
228
|
+
.bodyJson({ name: 'Alice', email: 'alice@example.com' })
|
|
229
|
+
.json<User>();
|
|
230
|
+
|
|
231
|
+
// If bodyJson fails validation, error.tag === 'parseError'
|
|
202
232
|
```
|
|
203
233
|
|
|
204
|
-
|
|
234
|
+
### Query parameters
|
|
205
235
|
|
|
206
|
-
|
|
207
|
-
- You want the request to **reject** automatically on HTTP errors, keeping the success path clean.
|
|
208
|
-
- You are integrating Aspi into existing codebases that already rely on exception handling.
|
|
236
|
+
`.setQueryParams()` accepts an object, `URLSearchParams`, an array of tuples, or a raw string.
|
|
209
237
|
|
|
210
|
-
|
|
238
|
+
```ts
|
|
239
|
+
// Object — most common
|
|
240
|
+
api.get('/todos').setQueryParams({ page: '2', limit: '20' }).json();
|
|
241
|
+
|
|
242
|
+
// URLSearchParams
|
|
243
|
+
api
|
|
244
|
+
.get('/todos')
|
|
245
|
+
.setQueryParams(new URLSearchParams({ q: 'typescript' }))
|
|
246
|
+
.json();
|
|
247
|
+
|
|
248
|
+
// Check the resolved URL before sending
|
|
249
|
+
console.log(api.get('/todos').setQueryParams({ page: '2' }).url());
|
|
250
|
+
// → https://api.example.com/todos?page=2
|
|
251
|
+
```
|
|
211
252
|
|
|
212
|
-
|
|
253
|
+
### Headers
|
|
213
254
|
|
|
214
255
|
```ts
|
|
215
|
-
|
|
216
|
-
|
|
256
|
+
// Single header
|
|
257
|
+
api.get('/data').setHeader('X-Request-ID', crypto.randomUUID());
|
|
217
258
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
headers: { 'Content-Type': 'application/json' },
|
|
221
|
-
});
|
|
259
|
+
// Multiple headers
|
|
260
|
+
api.get('/data').setHeaders({ Accept: 'application/json', 'X-Version': '2' });
|
|
222
261
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
.get(`/todos/${id}`)
|
|
226
|
-
.withResult()
|
|
227
|
-
.schema(
|
|
228
|
-
z.object({
|
|
229
|
-
id: z.number(),
|
|
230
|
-
title: z.string(),
|
|
231
|
-
completed: z.boolean(),
|
|
232
|
-
}),
|
|
233
|
-
)
|
|
234
|
-
.json(); // type inferred from the schema
|
|
235
|
-
|
|
236
|
-
Result.match(response, {
|
|
237
|
-
onOk: (data) => console.log('Todo ✅', data),
|
|
238
|
-
onErr: (err) => {
|
|
239
|
-
if (err.tag === 'parseError') {
|
|
240
|
-
const parseErr = err.data as z.ZodError;
|
|
241
|
-
console.error('Validation failed:', parseErr.errors);
|
|
242
|
-
} else {
|
|
243
|
-
console.error('Other error', err);
|
|
244
|
-
}
|
|
245
|
-
},
|
|
246
|
-
});
|
|
247
|
-
}
|
|
262
|
+
// Bearer token shortcut
|
|
263
|
+
api.get('/me').setBearer(accessToken);
|
|
248
264
|
```
|
|
249
265
|
|
|
250
266
|
---
|
|
251
267
|
|
|
252
|
-
## Retry
|
|
268
|
+
## Retry
|
|
269
|
+
|
|
270
|
+
Configure retry behavior globally on the `Aspi` instance, then override per request as needed.
|
|
253
271
|
|
|
254
272
|
```ts
|
|
255
273
|
const api = new Aspi({
|
|
256
|
-
baseUrl: 'https://example.com',
|
|
274
|
+
baseUrl: 'https://api.example.com',
|
|
257
275
|
headers: { 'Content-Type': 'application/json' },
|
|
258
276
|
}).setRetry({
|
|
259
277
|
retries: 3,
|
|
260
|
-
retryDelay:
|
|
261
|
-
retryOn: [
|
|
278
|
+
retryDelay: 500, // fixed 500 ms between attempts
|
|
279
|
+
retryOn: [429, 500, 502, 503, 504],
|
|
262
280
|
});
|
|
263
281
|
|
|
264
|
-
// Override
|
|
265
|
-
api
|
|
266
|
-
.get('/
|
|
267
|
-
.setHeader('Accept', 'application/json')
|
|
282
|
+
// Override for a single request — exponential back-off
|
|
283
|
+
const [data, error] = await api
|
|
284
|
+
.get('/reports/heavy')
|
|
268
285
|
.setRetry({
|
|
269
|
-
|
|
270
|
-
|
|
286
|
+
retryDelay: (remaining, total) => Math.pow(2, total - remaining) * 200,
|
|
287
|
+
retryWhile: (_req, res) => res.status >= 500,
|
|
288
|
+
onRetry: (_req, res) => console.warn('Retrying after', res.status),
|
|
271
289
|
})
|
|
272
290
|
.withResult()
|
|
273
|
-
.json()
|
|
274
|
-
.then((res) =>
|
|
275
|
-
Result.match(res, {
|
|
276
|
-
onOk: (data) => console.log('Got data', data),
|
|
277
|
-
onErr: (err) => console.error('Failed', err),
|
|
278
|
-
}),
|
|
279
|
-
);
|
|
291
|
+
.json<Report>();
|
|
280
292
|
```
|
|
281
293
|
|
|
282
|
-
|
|
294
|
+
### Retry config options
|
|
283
295
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
|
287
|
-
|
|
|
288
|
-
| `
|
|
289
|
-
| `
|
|
290
|
-
| `
|
|
291
|
-
| `setBearer(token)` | Shortcut for `Authorization: Bearer <token>`. |
|
|
292
|
-
| `setRetry(retryConfig)` | Define a global retry strategy (overridable per request). |
|
|
293
|
-
| `setQueryParams(params)` | Replace the request’s query string – accepts object, `URLSearchParams`, array of tuples, or raw string. |
|
|
294
|
-
| `schema(schema)` | Attach a `StandardSchemaV1` validator for the response body. |
|
|
295
|
-
| `use(fn)` | Register a request‑transformer middleware that receives the current `RequestInit` and returns a new one. Returns a new `Aspi` instance typed with the transformed config. |
|
|
296
|
-
| `withResult()` | Switch the request into Result mode (returns a `Result` instead of a tuple). |
|
|
297
|
-
| `throwable()` | Make the request throw on non‑2xx responses (useful for `try / catch` patterns). |
|
|
298
|
-
| `url()` | Get the fully‑qualified URL that will be used for the request. |
|
|
296
|
+
| Option | Type | Description |
|
|
297
|
+
| ------------ | ----------------------------------------------------------- | --------------------------------------------- |
|
|
298
|
+
| `retries` | `number` | Maximum number of retry attempts |
|
|
299
|
+
| `retryDelay` | `number \| (remaining, total, request, response) => number` | Delay in ms, or a function returning one |
|
|
300
|
+
| `retryOn` | `number[]` | HTTP status codes that should trigger a retry |
|
|
301
|
+
| `retryWhile` | `(request, response) => boolean` | Custom predicate — return `true` to retry |
|
|
302
|
+
| `onRetry` | `(request, response) => void` | Hook called after each failed attempt |
|
|
299
303
|
|
|
300
304
|
---
|
|
301
305
|
|
|
302
|
-
##
|
|
306
|
+
## Schema validation
|
|
303
307
|
|
|
304
|
-
Aspi
|
|
305
|
-
|
|
306
|
-
```ts
|
|
307
|
-
api
|
|
308
|
-
.error('badRequestError', 'BAD_REQUEST', (req, res) => ({
|
|
309
|
-
message: 'The request payload is invalid',
|
|
310
|
-
payload: res.body,
|
|
311
|
-
}))
|
|
312
|
-
.error('unauthorisedError', 'UNAUTHORIZED', () => ({
|
|
313
|
-
message: 'You must log in first',
|
|
314
|
-
}));
|
|
315
|
-
```
|
|
308
|
+
Aspi integrates with any library that implements the [StandardSchemaV1](https://github.com/standard-schema/standard-schema) interface, including **Zod**, **Valibot**, and **Arktype**.
|
|
316
309
|
|
|
317
|
-
|
|
310
|
+
Attach a schema with `.schema()` before the body-parser. The inferred output type is used automatically — you don't need to pass a generic.
|
|
318
311
|
|
|
319
312
|
```ts
|
|
320
|
-
|
|
321
|
-
api.tooManyRequests(cb); // 429
|
|
322
|
-
api.conflict(cb); // 409
|
|
323
|
-
api.badRequest(cb); // 400
|
|
324
|
-
api.unauthorised(cb); // 401 (British spelling, matches the Request API)
|
|
325
|
-
api.forbidden(cb); // 403
|
|
326
|
-
api.notImplemented(cb); // 501
|
|
327
|
-
api.internalServerError(cb); // 500
|
|
328
|
-
```
|
|
313
|
+
import { z } from 'zod';
|
|
329
314
|
|
|
330
|
-
|
|
315
|
+
const TodoSchema = z.object({
|
|
316
|
+
id: z.number(),
|
|
317
|
+
title: z.string(),
|
|
318
|
+
completed: z.boolean(),
|
|
319
|
+
});
|
|
331
320
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (err.tag === 'unauthorisedError') {
|
|
343
|
-
console.warn(err.data.message);
|
|
344
|
-
}
|
|
345
|
-
},
|
|
346
|
-
}),
|
|
347
|
-
);
|
|
321
|
+
const result = await api.get('/todos/1').withResult().schema(TodoSchema).json(); // return type is inferred from the schema
|
|
322
|
+
|
|
323
|
+
Result.match(result, {
|
|
324
|
+
onOk: ({ data }) => console.log(data.title), // data: { id: number; title: string; completed: boolean }
|
|
325
|
+
onErr: (err) => {
|
|
326
|
+
if (err.tag === 'parseError') {
|
|
327
|
+
console.error('Validation failed:', err.data); // StandardSchemaV1 issue list
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
});
|
|
348
331
|
```
|
|
349
332
|
|
|
350
333
|
---
|
|
351
334
|
|
|
352
|
-
##
|
|
335
|
+
## Middleware
|
|
336
|
+
|
|
337
|
+
`.use()` registers a request transformer that runs for every request created from the instance. It returns a **new `Aspi` instance** typed with the transformed request shape.
|
|
353
338
|
|
|
354
339
|
```ts
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
// configuration
|
|
370
|
-
setBaseUrl(url: BaseURL): this;
|
|
371
|
-
setHeaders(headers: HeadersInit): this;
|
|
372
|
-
setHeader(key: string, value: string): this;
|
|
373
|
-
setBearer(token: string): this;
|
|
374
|
-
setRetry(cfg: AspiRetryConfig<TRequest>): this;
|
|
375
|
-
setQueryParams(
|
|
376
|
-
params: Record<string, string> | string[][] | string | URLSearchParams,
|
|
377
|
-
): this;
|
|
378
|
-
use<T extends TRequest, U extends TRequest>(
|
|
379
|
-
fn: RequestTransformer<T, U>,
|
|
380
|
-
): Request<U>;
|
|
381
|
-
|
|
382
|
-
// schema validation
|
|
383
|
-
schema<TSchema extends StandardSchemaV1>(
|
|
384
|
-
schema: TSchema,
|
|
385
|
-
): Request<
|
|
386
|
-
Method,
|
|
387
|
-
TRequest,
|
|
388
|
-
Merge<
|
|
389
|
-
Omit<Opts, 'schema'>,
|
|
390
|
-
{
|
|
391
|
-
schema: TSchema;
|
|
392
|
-
error: Merge<
|
|
393
|
-
Opts['error'],
|
|
394
|
-
{
|
|
395
|
-
parseError: CustomError<
|
|
396
|
-
'parseError',
|
|
397
|
-
StandardSchemaV1.FailureResult['issues']
|
|
398
|
-
>;
|
|
399
|
-
}
|
|
400
|
-
>;
|
|
401
|
-
}
|
|
402
|
-
>
|
|
403
|
-
>;
|
|
404
|
-
|
|
405
|
-
// result / throwable toggles
|
|
406
|
-
withResult(): Request<
|
|
407
|
-
Method,
|
|
408
|
-
TRequest,
|
|
409
|
-
Merge<
|
|
410
|
-
Omit<Opts, 'withResult' | 'throwable'>,
|
|
411
|
-
{
|
|
412
|
-
withResult: true;
|
|
413
|
-
throwable: false;
|
|
414
|
-
}
|
|
415
|
-
>
|
|
416
|
-
>;
|
|
417
|
-
throwable(): Request<
|
|
418
|
-
Method,
|
|
419
|
-
TRequest,
|
|
420
|
-
Merge<
|
|
421
|
-
Omit<Opts, 'withResult' | 'throwable'>,
|
|
422
|
-
{
|
|
423
|
-
withResult: false;
|
|
424
|
-
throwable: true;
|
|
425
|
-
}
|
|
426
|
-
>
|
|
427
|
-
>;
|
|
428
|
-
|
|
429
|
-
// custom error handling
|
|
430
|
-
error<Tag extends string, A extends {}>(
|
|
431
|
-
tag: Tag,
|
|
432
|
-
status: HttpErrorStatus,
|
|
433
|
-
cb: CustomErrorCb<TRequest, A>,
|
|
434
|
-
): Request<
|
|
435
|
-
Method,
|
|
436
|
-
TRequest,
|
|
437
|
-
Merge<
|
|
438
|
-
Omit<Opts, 'error'>,
|
|
439
|
-
{
|
|
440
|
-
error: {
|
|
441
|
-
[K in Tag | keyof Opts['error']]: K extends Tag
|
|
442
|
-
? CustomError<Tag, A>
|
|
443
|
-
: Opts['error'][K];
|
|
444
|
-
};
|
|
445
|
-
}
|
|
446
|
-
>
|
|
447
|
-
>;
|
|
448
|
-
notFound<A>(cb: CustomErrorCb<TRequest, A>): this;
|
|
449
|
-
tooManyRequests<A>(cb: CustomErrorCb<TRequest, A>): this;
|
|
450
|
-
conflict<A>(cb: CustomErrorCb<TRequest, A>): this;
|
|
451
|
-
badRequest<A>(cb: CustomErrorCb<TRequest, A>): this;
|
|
452
|
-
unauthorised<A>(cb: CustomErrorCb<TRequest, A>): this;
|
|
453
|
-
forbidden<A>(cb: CustomErrorCb<TRequest, A>): this;
|
|
454
|
-
notImplemented<A>(cb: CustomErrorCb<TRequest, A>): this;
|
|
455
|
-
internalServerError<A>(cb: CustomErrorCb<TRequest, A>): this;
|
|
456
|
-
|
|
457
|
-
// helpers
|
|
458
|
-
url(): string;
|
|
459
|
-
|
|
460
|
-
// response parsers
|
|
461
|
-
json<T extends StandardSchemaV1.InferOutput<Opts['schema']>>(): Promise<
|
|
462
|
-
Opts['withResult'] extends true
|
|
463
|
-
? Result.Result<
|
|
464
|
-
AspiResultOk<TRequest, T>,
|
|
465
|
-
| AspiError<TRequest>
|
|
466
|
-
| (Opts extends { error: any }
|
|
467
|
-
? Opts['error'][keyof Opts['error']]
|
|
468
|
-
: never)
|
|
469
|
-
| JSONParseError
|
|
470
|
-
>
|
|
471
|
-
: Opts['throwable'] extends true
|
|
472
|
-
? AspiPlainResponse<TRequest, T>
|
|
473
|
-
: [
|
|
474
|
-
AspiResultOk<TRequest, T> | null,
|
|
475
|
-
(
|
|
476
|
-
| (
|
|
477
|
-
| AspiError<TRequest>
|
|
478
|
-
| (Opts extends { error: any }
|
|
479
|
-
? Opts['error'][keyof Opts['error']]
|
|
480
|
-
: never)
|
|
481
|
-
| JSONParseError
|
|
482
|
-
)
|
|
483
|
-
| null
|
|
484
|
-
),
|
|
485
|
-
]
|
|
486
|
-
>;
|
|
487
|
-
text(): Promise<
|
|
488
|
-
Opts['withResult'] extends true
|
|
489
|
-
? Result.Result<
|
|
490
|
-
AspiResultOk<TRequest, string>,
|
|
491
|
-
| AspiError<TRequest>
|
|
492
|
-
| (Opts extends { error: any }
|
|
493
|
-
? Opts['error'][keyof Opts['error']]
|
|
494
|
-
: never)
|
|
495
|
-
>
|
|
496
|
-
: Opts['throwable'] extends true
|
|
497
|
-
? AspiPlainResponse<TRequest, string>
|
|
498
|
-
: [
|
|
499
|
-
AspiResultOk<TRequest, string> | null,
|
|
500
|
-
(
|
|
501
|
-
| (
|
|
502
|
-
| AspiError<TRequest>
|
|
503
|
-
| (Opts extends { error: any }
|
|
504
|
-
? Opts['error'][keyof Opts['error']]
|
|
505
|
-
: never)
|
|
506
|
-
)
|
|
507
|
-
| null
|
|
508
|
-
),
|
|
509
|
-
]
|
|
510
|
-
>;
|
|
511
|
-
blob(): Promise<
|
|
512
|
-
Opts['withResult'] extends true
|
|
513
|
-
? Result.Result<
|
|
514
|
-
AspiResultOk<TRequest, Blob>,
|
|
515
|
-
| AspiError<TRequest>
|
|
516
|
-
| (Opts extends { error: any }
|
|
517
|
-
? Opts['error'][keyof Opts['error']]
|
|
518
|
-
: never)
|
|
519
|
-
>
|
|
520
|
-
: Opts['throwable'] extends true
|
|
521
|
-
? AspiPlainResponse<TRequest, Blob>
|
|
522
|
-
: [
|
|
523
|
-
AspiResultOk<TRequest, Blob> | null,
|
|
524
|
-
(
|
|
525
|
-
| (
|
|
526
|
-
| AspiError<TRequest>
|
|
527
|
-
| (Opts extends { error: any }
|
|
528
|
-
? Opts['error'][keyof Opts['error']]
|
|
529
|
-
: never)
|
|
530
|
-
)
|
|
531
|
-
| null
|
|
532
|
-
),
|
|
533
|
-
]
|
|
534
|
-
>;
|
|
535
|
-
}
|
|
340
|
+
// Add a correlation ID to every outgoing request
|
|
341
|
+
const api = new Aspi({ baseUrl: 'https://api.example.com' }).use((req) => ({
|
|
342
|
+
...req,
|
|
343
|
+
headers: {
|
|
344
|
+
...req.headers,
|
|
345
|
+
'X-Correlation-ID': crypto.randomUUID(),
|
|
346
|
+
},
|
|
347
|
+
}));
|
|
348
|
+
|
|
349
|
+
// Chain multiple transformers
|
|
350
|
+
const authedApi = api.use((req) => ({
|
|
351
|
+
...req,
|
|
352
|
+
headers: { ...req.headers, Authorization: `Bearer ${getToken()}` },
|
|
353
|
+
}));
|
|
536
354
|
```
|
|
537
355
|
|
|
538
356
|
---
|
|
539
357
|
|
|
540
|
-
##
|
|
358
|
+
## Capabilities
|
|
541
359
|
|
|
542
|
-
|
|
360
|
+
> **Experimental** — names and behavior may change in minor versions.
|
|
361
|
+
|
|
362
|
+
Capabilities are plugins that wrap the low-level `fetch` call. Unlike middleware (which transforms the `RequestInit`), capabilities can inspect the raw `Response`, call `runner()` multiple times, or return a synthetic response entirely.
|
|
543
363
|
|
|
544
364
|
```ts
|
|
545
|
-
type
|
|
546
|
-
|
|
547
|
-
|
|
365
|
+
import type { Capability } from 'aspi';
|
|
366
|
+
|
|
367
|
+
const loggingCapability: Capability = ({ request }) => ({
|
|
368
|
+
async run(runner) {
|
|
369
|
+
console.log('→', request.requestInit.method, request.path);
|
|
370
|
+
const res = await runner();
|
|
371
|
+
console.log('←', res.status, res.statusText);
|
|
372
|
+
return res;
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const api = new Aspi({ baseUrl: 'https://api.example.com' }).useCapability(
|
|
377
|
+
loggingCapability,
|
|
378
|
+
);
|
|
548
379
|
```
|
|
549
380
|
|
|
550
|
-
|
|
381
|
+
Capabilities are composed in registration order, each wrapping the next.
|
|
551
382
|
|
|
552
383
|
```ts
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
384
|
+
const api = new Aspi({ baseUrl: 'https://api.example.com' })
|
|
385
|
+
.useCapability(loggingCapability)
|
|
386
|
+
.useCapability(tracingCapability)
|
|
387
|
+
.useCapability(tokenRefreshCapability);
|
|
557
388
|
```
|
|
558
389
|
|
|
559
|
-
|
|
390
|
+
### Example: token refresh capability
|
|
560
391
|
|
|
561
392
|
```ts
|
|
562
|
-
|
|
563
|
-
console.log(success.value); // 42
|
|
564
|
-
}
|
|
393
|
+
import type { Capability } from 'aspi';
|
|
565
394
|
|
|
566
|
-
|
|
567
|
-
console.error(failure.error); // "not found"
|
|
568
|
-
}
|
|
395
|
+
let tokens = { access: '', refresh: '' };
|
|
569
396
|
|
|
570
|
-
const
|
|
571
|
-
|
|
397
|
+
const tokenRefreshCapability: Capability = () => {
|
|
398
|
+
let isRefreshing = false;
|
|
572
399
|
|
|
573
|
-
|
|
400
|
+
return {
|
|
401
|
+
async run(runner) {
|
|
402
|
+
const res = await runner();
|
|
403
|
+
if (res.status !== 401 || !tokens.refresh || isRefreshing) return res;
|
|
574
404
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
405
|
+
isRefreshing = true;
|
|
406
|
+
try {
|
|
407
|
+
const refreshRes = await fetch('/auth/refresh', {
|
|
408
|
+
method: 'POST',
|
|
409
|
+
body: JSON.stringify({ refreshToken: tokens.refresh }),
|
|
410
|
+
headers: { 'Content-Type': 'application/json' },
|
|
411
|
+
});
|
|
412
|
+
const body = await refreshRes.json();
|
|
413
|
+
tokens = { access: body.accessToken, refresh: body.refreshToken };
|
|
414
|
+
} finally {
|
|
415
|
+
isRefreshing = false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Retry the original request with the new token
|
|
419
|
+
return runner();
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
};
|
|
578
423
|
```
|
|
579
424
|
|
|
580
|
-
|
|
425
|
+
---
|
|
581
426
|
|
|
582
|
-
|
|
583
|
-
// map value
|
|
584
|
-
const doubled = Result.map(success, (n) => n * 2); // ok(84)
|
|
427
|
+
## Result module
|
|
585
428
|
|
|
586
|
-
|
|
587
|
-
const upperError = Result.mapErr(failure, (e) => e.toUpperCase()); // err("NOT FOUND")
|
|
429
|
+
`aspi` exports a standalone `Result` module — a small tagged-union utility used internally and available for your own code.
|
|
588
430
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
});
|
|
594
|
-
// "Got 42"
|
|
431
|
+
```ts
|
|
432
|
+
import * as Result from 'aspi/result';
|
|
433
|
+
// or
|
|
434
|
+
import { Result } from 'aspi';
|
|
595
435
|
```
|
|
596
436
|
|
|
597
|
-
|
|
598
|
-
When your error type is a union with a tag field, you can use helpers to handle specific variants:
|
|
437
|
+
### Creating results
|
|
599
438
|
|
|
600
439
|
```ts
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
| { tag: 'UNAUTHORIZED' }
|
|
604
|
-
| { tag: 'NOT_FOUND' };
|
|
605
|
-
|
|
606
|
-
const result: Result<number, HttpError> = Result.err({
|
|
607
|
-
tag: 'NOT_FOUND',
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
// Handle a specific tag
|
|
611
|
-
Result.catchError(result, 'NOT_FOUND', (e) => {
|
|
612
|
-
console.log('Missing resource');
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
// Handle multiple tags
|
|
616
|
-
Result.catchErrors(result, {
|
|
617
|
-
BAD_REQUEST: (e) => console.log('Invalid input'),
|
|
618
|
-
UNAUTHORIZED: () => console.log('Please log in'),
|
|
619
|
-
});
|
|
440
|
+
const success = Result.ok(42); // { __tag: 'ok', value: 42 }
|
|
441
|
+
const failure = Result.err('not found'); // { __tag: 'err', error: 'not found' }
|
|
620
442
|
```
|
|
621
443
|
|
|
622
|
-
|
|
444
|
+
### Checking and extracting
|
|
623
445
|
|
|
624
446
|
```ts
|
|
625
|
-
|
|
447
|
+
Result.isOk(success); // true
|
|
448
|
+
Result.isErr(failure); // true
|
|
626
449
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
(cents) => cents / 100,
|
|
630
|
-
(amount) => amount.toFixed(2),
|
|
631
|
-
(str) => `$${str}`,
|
|
632
|
-
);
|
|
633
|
-
// "$123.45"
|
|
634
|
-
```
|
|
450
|
+
Result.getOrNull(success); // 42
|
|
451
|
+
Result.getOrNull(failure); // null
|
|
635
452
|
|
|
636
|
-
|
|
453
|
+
Result.getErrorOrNull(failure); // 'not found'
|
|
454
|
+
Result.getOrElse(failure, 0); // 0
|
|
637
455
|
|
|
638
|
-
|
|
456
|
+
Result.getOrThrow(success); // 42
|
|
457
|
+
Result.getOrThrow(failure); // throws 'not found'
|
|
639
458
|
|
|
640
|
-
|
|
459
|
+
Result.getOrThrowWith(failure, (e) => new Error(e)); // throws Error('not found')
|
|
460
|
+
```
|
|
641
461
|
|
|
642
|
-
|
|
462
|
+
### Transforming
|
|
643
463
|
|
|
644
464
|
```ts
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
// Capability signature
|
|
649
|
-
const myCapability: Capability = ({ request }) => ({
|
|
650
|
-
async run(runner) {
|
|
651
|
-
// Called before fetch
|
|
652
|
-
console.log('→', request.path);
|
|
465
|
+
Result.map(success, (n) => n * 2); // ok(84)
|
|
466
|
+
Result.mapErr(failure, (e) => e.toUpperCase()); // err('NOT FOUND')
|
|
653
467
|
|
|
654
|
-
|
|
468
|
+
// Curried style (useful in pipelines)
|
|
469
|
+
const double = Result.map((n: number) => n * 2);
|
|
470
|
+
double(success); // ok(84)
|
|
471
|
+
```
|
|
655
472
|
|
|
656
|
-
|
|
657
|
-
console.log('←', res.status, res.statusText);
|
|
473
|
+
### Pattern matching
|
|
658
474
|
|
|
659
|
-
|
|
660
|
-
|
|
475
|
+
```ts
|
|
476
|
+
const message = Result.match(result, {
|
|
477
|
+
onOk: ({ data }) => `Loaded ${data.name}`,
|
|
478
|
+
onErr: (err) => `Failed: ${err.tag}`,
|
|
661
479
|
});
|
|
662
480
|
```
|
|
663
481
|
|
|
664
|
-
|
|
665
|
-
|
|
482
|
+
### Handling tagged errors
|
|
483
|
+
|
|
484
|
+
When the error type is a tagged union, use `catchError` and `catchErrors` to handle specific variants and narrow the remaining type.
|
|
666
485
|
|
|
667
486
|
```ts
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
487
|
+
type AppError =
|
|
488
|
+
| { tag: 'notFoundError'; message: string }
|
|
489
|
+
| { tag: 'unauthorizedError' }
|
|
490
|
+
| { tag: 'aspiError'; response: AspiResponse };
|
|
491
|
+
|
|
492
|
+
// Handle one tag
|
|
493
|
+
Result.catchError(result, 'notFoundError', (e) => {
|
|
494
|
+
console.warn(e.message);
|
|
495
|
+
});
|
|
671
496
|
|
|
672
|
-
|
|
497
|
+
// Handle multiple tags
|
|
498
|
+
Result.catchErrors(result, {
|
|
499
|
+
notFoundError: (e) => console.warn(e.message),
|
|
500
|
+
unauthorizedError: () => redirect('/login'),
|
|
501
|
+
});
|
|
673
502
|
```
|
|
674
503
|
|
|
675
|
-
|
|
504
|
+
### Pipe utility
|
|
676
505
|
|
|
677
506
|
```ts
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
.
|
|
507
|
+
const price = Result.pipe(
|
|
508
|
+
1234,
|
|
509
|
+
(cents) => cents / 100,
|
|
510
|
+
(amount) => amount.toFixed(2),
|
|
511
|
+
(str) => `$${str}`,
|
|
512
|
+
);
|
|
513
|
+
// '$12.34'
|
|
682
514
|
```
|
|
683
515
|
|
|
684
|
-
|
|
516
|
+
---
|
|
685
517
|
|
|
686
|
-
|
|
687
|
-
import type { Capability } from './interceptor';
|
|
688
|
-
import { Aspi } from './aspi';
|
|
689
|
-
import * as Result from './result';
|
|
518
|
+
## Global configuration reference
|
|
690
519
|
|
|
691
|
-
|
|
692
|
-
accessToken: null,
|
|
693
|
-
refreshToken: null,
|
|
694
|
-
};
|
|
520
|
+
These methods are available on the `Aspi` instance and affect all requests created from it.
|
|
695
521
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
522
|
+
| Method | Description |
|
|
523
|
+
| ------------------------- | -------------------------------------------- |
|
|
524
|
+
| `setBaseUrl(url)` | Change the base URL |
|
|
525
|
+
| `setHeaders(headers)` | Merge an object of headers |
|
|
526
|
+
| `setHeader(key, value)` | Set a single header |
|
|
527
|
+
| `setBearer(token)` | Shortcut for `Authorization: Bearer <token>` |
|
|
528
|
+
| `setRetry(config)` | Set a global retry strategy |
|
|
529
|
+
| `use(fn)` | Register a request-transformer middleware |
|
|
530
|
+
| `useCapability(cap)` | Register a capability |
|
|
531
|
+
| `withResult()` | Switch all requests to Result mode |
|
|
532
|
+
| `throwable()` | Switch all requests to throwable mode |
|
|
533
|
+
| `.error(tag, status, cb)` | Map an HTTP status to a typed error |
|
|
702
534
|
|
|
703
|
-
|
|
704
|
-
onOk: ({ data }) => {
|
|
705
|
-
tokens = data;
|
|
706
|
-
},
|
|
707
|
-
onErr: (err) => {
|
|
708
|
-
tokens = { accessToken: null, refreshToken: null };
|
|
709
|
-
throw err;
|
|
710
|
-
},
|
|
711
|
-
});
|
|
712
|
-
}
|
|
535
|
+
Per-request methods (`api.get('/…').setQueryParams(…)`, `.schema(…)`, `.bodyJson(…)`, etc.) override the global config for that call only.
|
|
713
536
|
|
|
714
|
-
|
|
715
|
-
let isRefreshing = false;
|
|
537
|
+
---
|
|
716
538
|
|
|
717
|
-
|
|
718
|
-
async run(runner) {
|
|
719
|
-
// First try
|
|
720
|
-
const first = await runner();
|
|
539
|
+
## Contributing
|
|
721
540
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
541
|
+
```bash
|
|
542
|
+
# Install dependencies
|
|
543
|
+
pnpm install
|
|
725
544
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
return first;
|
|
729
|
-
}
|
|
545
|
+
# Run tests in watch mode
|
|
546
|
+
pnpm test
|
|
730
547
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
await refreshTokenRequest(tokens.refreshToken);
|
|
734
|
-
} finally {
|
|
735
|
-
isRefreshing = false;
|
|
736
|
-
}
|
|
548
|
+
# Run tests once (used in CI)
|
|
549
|
+
pnpm test:run
|
|
737
550
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
}
|
|
551
|
+
# Build
|
|
552
|
+
pnpm build
|
|
741
553
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
...request.requestInit.headers,
|
|
745
|
-
Authorization: `Bearer ${tokens.accessToken}`,
|
|
746
|
-
};
|
|
554
|
+
# Type-check
|
|
555
|
+
pnpm lint
|
|
747
556
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
};
|
|
751
|
-
};
|
|
557
|
+
# Format
|
|
558
|
+
pnpm format
|
|
752
559
|
```
|
|
753
560
|
|
|
561
|
+
All CI checks (`pnpm ci`) run test, build, format check, and type-check in sequence. Please ensure they pass before opening a pull request.
|
|
562
|
+
|
|
563
|
+
---
|
|
564
|
+
|
|
754
565
|
## License
|
|
755
566
|
|
|
756
|
-
MIT ©
|
|
567
|
+
MIT © [Harsh Pareek](https://hrshwrites.vercel.app)
|
package/dist/index.cjs
CHANGED
|
@@ -454,7 +454,7 @@ var Request = class {
|
|
|
454
454
|
if (data.issues) {
|
|
455
455
|
this.#bodySchemaIssues = data.issues;
|
|
456
456
|
} else {
|
|
457
|
-
this.#localRequestInit.body = JSON.stringify(
|
|
457
|
+
this.#localRequestInit.body = JSON.stringify(data.value);
|
|
458
458
|
}
|
|
459
459
|
} else {
|
|
460
460
|
this.#localRequestInit.body = JSON.stringify(body);
|
package/dist/index.d.cts
CHANGED
|
@@ -1893,4 +1893,4 @@ declare class Aspi<TRequest extends AspiRequestInit = AspiRequestInit, Opts exte
|
|
|
1893
1893
|
useCapability(capability: Capability<TRequest>): this;
|
|
1894
1894
|
}
|
|
1895
1895
|
|
|
1896
|
-
export { Aspi, type AspiConfigBase, AspiError, type AspiPlainResponse, type AspiRequest, type AspiRequestInit, type AspiRequestInitWithoutBodyAndMethod, type AspiResponse, type AspiResultOk, type AspiRetryConfig, type BaseURL, CustomError, type CustomErrorCb, type ErrorCallbacks, type HttpErrorCodes, type HttpErrorStatus, type HttpMethods, type JSONParseError, type Merge, type Prettify, Request, type RequestOptions, type RequestTransformer, result as Result, getHttpErrorStatus, httpErrors, isAspiError, isCustomError };
|
|
1896
|
+
export { Aspi, type AspiConfigBase, AspiError, type AspiPlainResponse, type AspiRequest, type AspiRequestInit, type AspiRequestInitWithoutBodyAndMethod, type AspiResponse, type AspiResultOk, type AspiRetryConfig, type BaseURL, type Capability, type CapabilityArgs, CustomError, type CustomErrorCb, type ErrorCallbacks, type HttpErrorCodes, type HttpErrorStatus, type HttpMethods, type JSONParseError, type Merge, type Prettify, Request, type RequestOptions, type RequestTransformer, result as Result, getHttpErrorStatus, httpErrors, isAspiError, isCustomError };
|
package/dist/index.d.ts
CHANGED
|
@@ -1893,4 +1893,4 @@ declare class Aspi<TRequest extends AspiRequestInit = AspiRequestInit, Opts exte
|
|
|
1893
1893
|
useCapability(capability: Capability<TRequest>): this;
|
|
1894
1894
|
}
|
|
1895
1895
|
|
|
1896
|
-
export { Aspi, type AspiConfigBase, AspiError, type AspiPlainResponse, type AspiRequest, type AspiRequestInit, type AspiRequestInitWithoutBodyAndMethod, type AspiResponse, type AspiResultOk, type AspiRetryConfig, type BaseURL, CustomError, type CustomErrorCb, type ErrorCallbacks, type HttpErrorCodes, type HttpErrorStatus, type HttpMethods, type JSONParseError, type Merge, type Prettify, Request, type RequestOptions, type RequestTransformer, result as Result, getHttpErrorStatus, httpErrors, isAspiError, isCustomError };
|
|
1896
|
+
export { Aspi, type AspiConfigBase, AspiError, type AspiPlainResponse, type AspiRequest, type AspiRequestInit, type AspiRequestInitWithoutBodyAndMethod, type AspiResponse, type AspiResultOk, type AspiRetryConfig, type BaseURL, type Capability, type CapabilityArgs, CustomError, type CustomErrorCb, type ErrorCallbacks, type HttpErrorCodes, type HttpErrorStatus, type HttpMethods, type JSONParseError, type Merge, type Prettify, Request, type RequestOptions, type RequestTransformer, result as Result, getHttpErrorStatus, httpErrors, isAspiError, isCustomError };
|
package/dist/index.js
CHANGED
|
@@ -426,7 +426,7 @@ var Request = class {
|
|
|
426
426
|
if (data.issues) {
|
|
427
427
|
this.#bodySchemaIssues = data.issues;
|
|
428
428
|
} else {
|
|
429
|
-
this.#localRequestInit.body = JSON.stringify(
|
|
429
|
+
this.#localRequestInit.body = JSON.stringify(data.value);
|
|
430
430
|
}
|
|
431
431
|
} else {
|
|
432
432
|
this.#localRequestInit.body = JSON.stringify(body);
|
package/package.json
CHANGED