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 CHANGED
@@ -1,35 +1,38 @@
1
- # Aspi
1
+ # aspi
2
2
 
3
- A tiny, type‑safe wrapper around the native **fetch** API that gives you a clean, monadic interface for HTTP requests.
4
- It ships with **zero runtime dependencies**, a **tiny bundle size**, and full **TypeScript** support out of the box.
3
+ [![Bundle Size](https://img.shields.io/bundlephobia/minzip/aspi)](https://bundlephobia.com/package/aspi)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
5
5
 
6
- **Why use Aspi?**
6
+ A tiny, type-safe HTTP client for TypeScript built on native `fetch`.
7
7
 
8
- - End‑to‑end TypeScript typings (request + response)
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
- ## Installation
12
+ ## Features
18
13
 
19
- npm
20
- `bash
21
- npm install aspi
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
- yarn
25
- `bash
26
- yarn add aspi
27
- `
23
+ ---
28
24
 
29
- pnpm
30
- `bash
31
- pnpm add aspi
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://api.example.com',
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
- // Simple GET request – returns a tuple [value, error]
50
- async function getTodo(id: number) {
51
- const [value, error] = await api
52
- .get(`/todos/${id}`)
53
- .setQueryParams({ include: 'details' }) // optional query string
54
- .notFound(() => ({ message: 'Todo not found' }))
55
- .json<{ id: number; title: string; completed: boolean }>();
56
-
57
- if (value) console.log('Todo:', value);
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
- getTodo(1);
61
+ if (data) console.log(data.title);
66
62
  ```
67
63
 
68
64
  ---
69
65
 
70
- ## Why Aspi?
66
+ ## Response modes
71
67
 
72
- Most real‑world codebases end up with one or more of these issues:
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. **Inconsistent error handling**
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
- 2. **Retry logic duplicated everywhere**
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
- 3. **Validation pushed far from the network boundary**
85
- - Request payloads are sometimes validated, sometimes not.
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
- 4. **Configuration scattered across factories and interceptors**
90
- - Base URL helpers, auth decorators, error mappers, retry plugins, and logging interceptors all live in different files.
91
- - Global state / interceptors can make it hard to tell what a given request will actually do.
77
+ if (error) {
78
+ /* handle */
79
+ }
80
+ console.log(data!.name);
81
+ ```
92
82
 
93
- 5. **Type systems are bolted on, not designed in**
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
- ## How Aspi fixes them
85
+ Returns a `Result<Ok, ErrorUnion>` tagged union. Use `.withResult()` to enable.
98
86
 
99
- Aspi’s design centers around three things:
87
+ ```ts
88
+ const result = await api.get('/users/1').withResult().json<User>();
100
89
 
101
- 1. **Mode‑driven responses**
90
+ Result.match(result, {
91
+ onOk: ({ data }) => console.log(data.name),
92
+ onErr: (err) => console.error(err.tag, err),
93
+ });
94
+ ```
102
95
 
103
- You decide at call‑site how you want to consume responses:
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
- All error variants are **tagged** so they can be safely narrowed by `error.tag`.
98
+ Returns the parsed value directly and throws a typed error on any non-2xx response. Use `.throwable()` to enable.
109
99
 
110
- 2. **Centralized, configurable retry layer**
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
- Retry behavior is described declaratively:
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
- This configuration can be applied globally (`Aspi.setRetry`) and overridden per request (`Request.setRetry`).
111
+ ---
120
112
 
121
- 3. **Validation at the transport boundary**
113
+ ## Error handling
122
114
 
123
- Using a `StandardSchemaV1` interface, Aspi integrates with schema libraries (e.g. Zod, Valibot) to:
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
- These failures appear as tagged `parseError` values with structured issue lists, not random runtime exceptions.
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
- ## Using the `Result` monad
126
+ ### Custom error mapping
132
127
 
133
- If you prefer a single `Result` value instead of a tuple, call **`.withResult()`** before a body‑parser method.
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
- async function getTodoResult(id: number) {
137
- const response = await api
138
- .get(`/todos/${id}`)
139
- .notFound(() => ({ message: 'Todo not found' }))
140
- .withResult() // enable Result mode
141
- .json<{ id: number; title: string; completed: boolean }>();
142
-
143
- Result.match(response, {
144
- onOk: (data) => console.log('', data),
145
- onErr: (err) => {
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
- The `throwable()` toggle makes a request **throw** on any non‑2xx HTTP response, allowing you to use the familiar `try / catch` pattern instead of dealing with tuples or `Result` objects.
146
+ Pre-built shortcuts for the most common statuses. Each produces a typed error with a predictable tag.
158
147
 
159
- When a request is in _throwable_ mode, the body‑parser methods (`json()`, `text()`, `blob()`) resolve with the parsed value directly. If the response status indicates an error, the promise is rejected with a typed Aspi error (e.g., `aspiError`, `unauthorisedError`, `jsonParseError`, …).
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
- #### Basic usage
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
- // Using throwable with async/await + try/catch
165
- try {
166
- const todo = await api
167
- .get('/todos/1')
168
- .throwable() // <─ enable throwable mode
169
- .json<{ id: number; title: string; completed: boolean }>(); // returns the parsed JSON
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
- console.log('✅ Todo:', todo);
172
- } catch (err) {
173
- // `err` is a typed Aspi error
174
- if (err.tag === 'aspiError') {
175
- console.error('HTTP error:', err.response.status);
176
- } else if (err.tag === 'jsonParseError') {
177
- console.error('Invalid JSON:', err.data.message);
178
- } else {
179
- console.error('Unexpected error:', err);
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
- #### Interaction with `withResult()`
190
+ ---
191
+
192
+ ## Making requests
185
193
 
186
- `throwable()` and `withResult()` are _mutually exclusive_ – the last toggle applied wins.
194
+ ### HTTP methods
187
195
 
188
196
  ```ts
189
- // Result mode wins (throwable is ignored)
190
- const result = await api
191
- .post('/login')
192
- .withResult() // ignored because throwable was called later
193
- .throwable() // enables throwable mode
194
- .json<{ token: string }>();
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
- // Throwable mode wins (Result is ignored)
197
- const data = await api
198
- .get('/profile')
199
- .throwable() // ignored because withResult was called later
200
- .withResult() // enables Result mode
201
- .json();
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
- #### When to use `throwable()`
234
+ ### Query parameters
205
235
 
206
- - You prefer native `try / catch` flow over tuple/result handling.
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
- `throwable()` gives you the flexibility to choose the error‑handling style that best fits your project.
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
- ## Schema validation (Zod example)
253
+ ### Headers
213
254
 
214
255
  ```ts
215
- import { z } from 'zod';
216
- import { Aspi, Result } from 'aspi';
256
+ // Single header
257
+ api.get('/data').setHeader('X-Request-ID', crypto.randomUUID());
217
258
 
218
- const api = new Aspi({
219
- baseUrl: 'https://jsonplaceholder.typicode.com',
220
- headers: { 'Content-Type': 'application/json' },
221
- });
259
+ // Multiple headers
260
+ api.get('/data').setHeaders({ Accept: 'application/json', 'X-Version': '2' });
222
261
 
223
- async function getValidatedTodo(id: number) {
224
- const response = await api
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 & back‑off
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: 1000, // simple fixed delay
261
- retryOn: [404, 500], // retry on specific status codes
278
+ retryDelay: 500, // fixed 500 ms between attempts
279
+ retryOn: [429, 500, 502, 503, 504],
262
280
  });
263
281
 
264
- // Override retry options for a single request
265
- api
266
- .get('/todos/1')
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
- // exponential back‑off for this call only
270
- retryDelay: (attempt) => Math.pow(2, attempt) * 1000,
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
- ## Global configuration helpers
285
-
286
- | Method | Description |
287
- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
288
- | `setBaseUrl(url)` | Change the base URL for all subsequent requests. |
289
- | `setHeaders(headers)` | Merge an object of headers with any existing ones. |
290
- | `setHeader(key, value)` | Set a single header. |
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
- ## Custom error handling
306
+ ## Schema validation
303
307
 
304
- Aspi lets you map **any HTTP status** to a typed error object that can be pattern‑matched later.
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
- Convenient shortcuts are provided for the most common statuses (each forwards to `error` internally and augments the generic `Opts['error']` type):
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
- api.notFound(cb); // 404
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
- These helpers allow you to write:
315
+ const TodoSchema = z.object({
316
+ id: z.number(),
317
+ title: z.string(),
318
+ completed: z.boolean(),
319
+ });
331
320
 
332
- ```ts
333
- api
334
- .get('/secret')
335
- .unauthorised(() => ({ message: 'You need a token' }))
336
- .withResult()
337
- .json()
338
- .then((res) =>
339
- Result.match(res, {
340
- onOk: (data) => console.log(data),
341
- onErr: (err) => {
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
- ## API reference (selected)
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
- class Request<
356
- Method extends HttpMethods,
357
- TRequest extends AspiRequestInitWithBody = AspiRequestInit,
358
- Opts extends Record<any, any> = { error: {} },
359
- > {
360
- // core request factories
361
- get(path: string): Request<'GET', TRequest, Opts>;
362
- post(path: string): Request<'POST', TRequest, Opts>;
363
- put(path: string): Request<'PUT', TRequest, Opts>;
364
- patch(path: string): Request<'PATCH', TRequest, Opts>;
365
- delete(path: string): Request<'DELETE', TRequest, Opts>;
366
- head(path: string): Request<'HEAD', TRequest, Opts>;
367
- options(path: string): Request<'OPTIONS', TRequest, Opts>;
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
- ## Result utilities
358
+ ## Capabilities
541
359
 
542
- `Result<T, E>` is a small tagged-union helper used throughout Aspi to represent success or failure without throwing.
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 Ok<T> = { __tag: 'ok'; value: T };
546
- type Err<E> = { __tag: 'err'; error: E };
547
- type Result<T, E> = Ok<T> | Err<E>;
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
- Creating Results
381
+ Capabilities are composed in registration order, each wrapping the next.
551
382
 
552
383
  ```ts
553
- import * as Result from './result';
554
-
555
- const success = Result.ok(42);
556
- const failure = Result.err('not found');
384
+ const api = new Aspi({ baseUrl: 'https://api.example.com' })
385
+ .useCapability(loggingCapability)
386
+ .useCapability(tracingCapability)
387
+ .useCapability(tokenRefreshCapability);
557
388
  ```
558
389
 
559
- Checking and Extracting
390
+ ### Example: token refresh capability
560
391
 
561
392
  ```ts
562
- if (Result.isOk(success)) {
563
- console.log(success.value); // 42
564
- }
393
+ import type { Capability } from 'aspi';
565
394
 
566
- if (Result.isErr(failure)) {
567
- console.error(failure.error); // "not found"
568
- }
395
+ let tokens = { access: '', refresh: '' };
569
396
 
570
- const valueOrNull = Result.getOrNull(success); // 42 | null
571
- const errorOrNull = Result.getErrorOrNull(failure); // "not found" | null
397
+ const tokenRefreshCapability: Capability = () => {
398
+ let isRefreshing = false;
572
399
 
573
- const valueOrFallback = Result.getOrElse(failure, 0); // 0
400
+ return {
401
+ async run(runner) {
402
+ const res = await runner();
403
+ if (res.status !== 401 || !tokens.refresh || isRefreshing) return res;
574
404
 
575
- // Throwing
576
- const mustHaveValue = Result.getOrThrow(success); // 42
577
- // Result.getOrThrow(failure) throws "not found"
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
- Transforming
425
+ ---
581
426
 
582
- ```ts
583
- // map value
584
- const doubled = Result.map(success, (n) => n * 2); // ok(84)
427
+ ## Result module
585
428
 
586
- // map error
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
- // pattern matching
590
- const message = Result.match(success, {
591
- onOk: (n) => `Got ${n}`,
592
- onErr: (e) => `Error: ${e}`,
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
- Tagged error helpers
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
- type HttpError =
602
- | { tag: 'BAD_REQUEST'; details?: string }
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
- Pipe Utility
444
+ ### Checking and extracting
623
445
 
624
446
  ```ts
625
- import { pipe } from './result';
447
+ Result.isOk(success); // true
448
+ Result.isErr(failure); // true
626
449
 
627
- const label = pipe(
628
- 12345,
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
- ## Experimental capabilities
456
+ Result.getOrThrow(success); // 42
457
+ Result.getOrThrow(failure); // throws 'not found'
639
458
 
640
- > **Experimental:** This API is still evolving. Names and behavior may change in minor versions.
459
+ Result.getOrThrowWith(failure, (e) => new Error(e)); // throws Error('not found')
460
+ ```
641
461
 
642
- Capabilities are small plugins that wrap the low‑level fetch call for each request. They let you implement cross‑cutting behavior (logging, retries, token refresh, tracing, etc.) without changing Aspi core.
462
+ ### Transforming
643
463
 
644
464
  ```ts
645
- import type { Capability } from './interceptor';
646
- import { Aspi } from './aspi';
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
- const res = await runner(); // performs fetch(url, requestInit)
468
+ // Curried style (useful in pipelines)
469
+ const double = Result.map((n: number) => n * 2);
470
+ double(success); // ok(84)
471
+ ```
655
472
 
656
- // Called after fetch
657
- console.log('←', res.status, res.statusText);
473
+ ### Pattern matching
658
474
 
659
- return res;
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
- Registering capabilities
665
- Capabilities are attached at the Aspi client level and apply to all requests created from that instance:
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
- const api = new Aspi({ baseUrl: 'https://api.example.com' }).useCapability(
669
- myCapability,
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
- const user = await api.get('/users/1').throwable().json<User>();
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
- You can register multiple capabilities; they execute in the order they were added, each wrapping the next:
504
+ ### Pipe utility
676
505
 
677
506
  ```ts
678
- const api = new Aspi({ baseUrl: 'https://api.example.com' })
679
- .useCapability(loggingCapability)
680
- .useCapability(tracingCapability)
681
- .useCapability(refreshTokenCapability);
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
- Example: token refresh (simplified)
516
+ ---
685
517
 
686
- ```ts
687
- import type { Capability } from './interceptor';
688
- import { Aspi } from './aspi';
689
- import * as Result from './result';
518
+ ## Global configuration reference
690
519
 
691
- let tokens: { accessToken: string | null; refreshToken: string | null } = {
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
- async function refreshTokenRequest(refreshToken: string) {
697
- const res = await new Aspi({ baseUrl: 'https://auth.example.com' })
698
- .post('/refresh')
699
- .bodyJson({ refreshToken })
700
- .withResult()
701
- .json<{ accessToken: string; refreshToken: string }>();
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
- await Result.match(res, {
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
- export const refreshTokenCapability: Capability = ({ request }) => {
715
- let isRefreshing = false;
537
+ ---
716
538
 
717
- return {
718
- async run(runner) {
719
- // First try
720
- const first = await runner();
539
+ ## Contributing
721
540
 
722
- if (first.status !== 401) {
723
- return first;
724
- }
541
+ ```bash
542
+ # Install dependencies
543
+ pnpm install
725
544
 
726
- // No refresh token just propagate 401
727
- if (!tokens.refreshToken || isRefreshing) {
728
- return first;
729
- }
545
+ # Run tests in watch mode
546
+ pnpm test
730
547
 
731
- isRefreshing = true;
732
- try {
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
- if (!tokens.accessToken) {
739
- return first;
740
- }
551
+ # Build
552
+ pnpm build
741
553
 
742
- // Inject new Authorization header and retry once
743
- request.requestInit.headers = {
744
- ...request.requestInit.headers,
745
- Authorization: `Bearer ${tokens.accessToken}`,
746
- };
554
+ # Type-check
555
+ pnpm lint
747
556
 
748
- return runner();
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 © Aspi contributors
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(body);
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(body);
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aspi",
3
3
  "description": "Rest API client for typescript projects with chain of responsibility design pattern.",
4
- "version": "2.2.1",
4
+ "version": "2.4.0",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
7
7
  "devDependencies": {