aspi 1.3.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +464 -209
- package/dist/index.cjs +548 -149
- package/dist/index.d.cts +583 -138
- package/dist/index.d.ts +583 -138
- package/dist/index.js +548 -149
- package/package.json +13 -14
package/README.md
CHANGED
|
@@ -1,109 +1,227 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Aspi
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
**Why use Aspi?**
|
|
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
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
npm
|
|
20
|
+
`bash
|
|
21
|
+
npm install aspi
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
yarn
|
|
25
|
+
`bash
|
|
26
|
+
yarn add aspi
|
|
27
|
+
`
|
|
28
|
+
|
|
29
|
+
pnpm
|
|
30
|
+
`bash
|
|
31
|
+
pnpm add aspi
|
|
32
|
+
`
|
|
6
33
|
|
|
7
|
-
|
|
8
|
-
- 📦 Very small bundle size
|
|
9
|
-
- 🚀 Built on top of native fetch API
|
|
10
|
-
- 📦 No dependencies
|
|
11
|
-
- ⛓️ Chain of responsibility pattern
|
|
12
|
-
- 🧮 Monadic API
|
|
13
|
-
- ⚠️ Errors as values with Result type
|
|
14
|
-
- 🔍 Errors comes with support for pattern matching
|
|
15
|
-
- 🔄 Retry support
|
|
16
|
-
- 📜 Schema validation support - Zod, Arktype etc.
|
|
34
|
+
---
|
|
17
35
|
|
|
18
|
-
##
|
|
36
|
+
## Quick start
|
|
19
37
|
|
|
20
|
-
```
|
|
21
|
-
import {
|
|
38
|
+
```ts
|
|
39
|
+
import { Aspi, Result } from 'aspi';
|
|
22
40
|
|
|
23
|
-
|
|
41
|
+
// Create a client with a base URL and default headers
|
|
42
|
+
const api = new Aspi({
|
|
24
43
|
baseUrl: 'https://api.example.com',
|
|
25
44
|
headers: {
|
|
26
45
|
'Content-Type': 'application/json',
|
|
27
46
|
},
|
|
28
47
|
});
|
|
29
48
|
|
|
30
|
-
|
|
31
|
-
|
|
49
|
+
// Simple GET request – returns a tuple [value, error]
|
|
50
|
+
async function getTodo(id: number) {
|
|
51
|
+
const [value, error] = await api
|
|
32
52
|
.get(`/todos/${id}`)
|
|
33
|
-
.
|
|
34
|
-
|
|
35
|
-
})
|
|
36
|
-
.json<{
|
|
37
|
-
id: number;
|
|
38
|
-
title: string;
|
|
39
|
-
completed: boolean;
|
|
40
|
-
}>();
|
|
41
|
-
|
|
42
|
-
if (value) {
|
|
43
|
-
console.log(value);
|
|
44
|
-
}
|
|
53
|
+
.setQueryParams({ include: 'details' }) // optional query string
|
|
54
|
+
.notFound(() => ({ message: 'Todo not found' }))
|
|
55
|
+
.json<{ id: number; title: string; completed: boolean }>();
|
|
45
56
|
|
|
57
|
+
if (value) console.log('Todo:', value);
|
|
46
58
|
if (error) {
|
|
47
|
-
if (error.tag === 'aspiError')
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
console.log(error.data.message);
|
|
51
|
-
}
|
|
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);
|
|
52
62
|
}
|
|
53
|
-
}
|
|
63
|
+
}
|
|
54
64
|
|
|
55
|
-
|
|
65
|
+
getTodo(1);
|
|
56
66
|
```
|
|
57
67
|
|
|
58
|
-
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Why Aspi?
|
|
71
|
+
|
|
72
|
+
Most real‑world codebases end up with one or more of these issues:
|
|
73
|
+
|
|
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.
|
|
78
|
+
|
|
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?”.
|
|
83
|
+
|
|
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.
|
|
88
|
+
|
|
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.
|
|
92
|
+
|
|
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.
|
|
96
|
+
|
|
97
|
+
## How Aspi fixes them
|
|
98
|
+
|
|
99
|
+
Aspi’s design centers around three things:
|
|
100
|
+
|
|
101
|
+
1. **Mode‑driven responses**
|
|
102
|
+
|
|
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.
|
|
107
|
+
|
|
108
|
+
All error variants are **tagged** so they can be safely narrowed by `error.tag`.
|
|
109
|
+
|
|
110
|
+
2. **Centralized, configurable retry layer**
|
|
111
|
+
|
|
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.
|
|
118
|
+
|
|
119
|
+
This configuration can be applied globally (`Aspi.setRetry`) and overridden per request (`Request.setRetry`).
|
|
120
|
+
|
|
121
|
+
3. **Validation at the transport boundary**
|
|
122
|
+
|
|
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.
|
|
126
|
+
|
|
127
|
+
These failures appear as tagged `parseError` values with structured issue lists, not random runtime exceptions.
|
|
59
128
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Using the `Result` monad
|
|
132
|
+
|
|
133
|
+
If you prefer a single `Result` value instead of a tuple, call **`.withResult()`** before a body‑parser method.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
async function getTodoResult(id: number) {
|
|
137
|
+
const response = await api
|
|
63
138
|
.get(`/todos/${id}`)
|
|
64
|
-
.notFound(() => ({
|
|
65
|
-
|
|
66
|
-
})
|
|
67
|
-
.withResult()
|
|
68
|
-
.json<{
|
|
69
|
-
id: number;
|
|
70
|
-
title: string;
|
|
71
|
-
completed: boolean;
|
|
72
|
-
}>();
|
|
139
|
+
.notFound(() => ({ message: 'Todo not found' }))
|
|
140
|
+
.withResult() // enable Result mode
|
|
141
|
+
.json<{ id: number; title: string; completed: boolean }>();
|
|
73
142
|
|
|
74
143
|
Result.match(response, {
|
|
75
|
-
onOk: (data) =>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (error.tag === 'aspiError') {
|
|
80
|
-
console.error(error.response.status);
|
|
81
|
-
} else if (error.tag === 'notFoundError') {
|
|
82
|
-
console.log(error.data.message);
|
|
83
|
-
}
|
|
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);
|
|
84
148
|
},
|
|
85
149
|
});
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Throwable
|
|
156
|
+
|
|
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.
|
|
158
|
+
|
|
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`, …).
|
|
160
|
+
|
|
161
|
+
#### Basic usage
|
|
86
162
|
|
|
87
|
-
|
|
88
|
-
|
|
163
|
+
```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
|
|
170
|
+
|
|
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
|
+
}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
#### Interaction with `withResult()`
|
|
185
|
+
|
|
186
|
+
`throwable()` and `withResult()` are _mutually exclusive_ – the last toggle applied wins.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// Result mode wins (throwable is ignored)
|
|
190
|
+
const result = await api
|
|
191
|
+
.post('/login')
|
|
192
|
+
.withResult() // enables Result mode
|
|
193
|
+
.throwable() // ignored because withResult was called later
|
|
194
|
+
.json<{ token: string }>();
|
|
195
|
+
|
|
196
|
+
// Throwable mode wins (Result is ignored)
|
|
197
|
+
const data = await api
|
|
198
|
+
.get('/profile')
|
|
199
|
+
.throwable() // enables throwable mode
|
|
200
|
+
.withResult() // ignored because throwable was called later
|
|
201
|
+
.json();
|
|
89
202
|
```
|
|
90
203
|
|
|
91
|
-
|
|
204
|
+
#### When to use `throwable()`
|
|
92
205
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
96
209
|
|
|
97
|
-
|
|
98
|
-
|
|
210
|
+
`throwable()` gives you the flexibility to choose the error‑handling style that best fits your project.
|
|
211
|
+
|
|
212
|
+
## Schema validation (Zod example)
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
import { z } from 'zod';
|
|
216
|
+
import { Aspi, Result } from 'aspi';
|
|
217
|
+
|
|
218
|
+
const api = new Aspi({
|
|
99
219
|
baseUrl: 'https://jsonplaceholder.typicode.com',
|
|
100
|
-
headers: {
|
|
101
|
-
'Content-Type': 'application/json',
|
|
102
|
-
},
|
|
220
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
221
|
});
|
|
104
222
|
|
|
105
|
-
|
|
106
|
-
const response = await
|
|
223
|
+
async function getValidatedTodo(id: number) {
|
|
224
|
+
const response = await api
|
|
107
225
|
.get(`/todos/${id}`)
|
|
108
226
|
.withResult()
|
|
109
227
|
.schema(
|
|
@@ -113,175 +231,312 @@ const getTodo = async (id: number) => {
|
|
|
113
231
|
completed: z.boolean(),
|
|
114
232
|
}),
|
|
115
233
|
)
|
|
116
|
-
.json();
|
|
234
|
+
.json(); // type inferred from the schema
|
|
117
235
|
|
|
118
236
|
Result.match(response, {
|
|
119
|
-
onOk: (data) =>
|
|
120
|
-
console.log(data);
|
|
121
|
-
},
|
|
237
|
+
onOk: (data) => console.log('Todo ✅', data),
|
|
122
238
|
onErr: (err) => {
|
|
123
239
|
if (err.tag === 'parseError') {
|
|
124
|
-
const
|
|
125
|
-
console.error(
|
|
240
|
+
const parseErr = err.data as z.ZodError;
|
|
241
|
+
console.error('Validation failed:', parseErr.errors);
|
|
126
242
|
} else {
|
|
127
|
-
|
|
243
|
+
console.error('Other error', err);
|
|
128
244
|
}
|
|
129
245
|
},
|
|
130
246
|
});
|
|
131
|
-
}
|
|
247
|
+
}
|
|
132
248
|
```
|
|
133
249
|
|
|
134
|
-
|
|
250
|
+
---
|
|
135
251
|
|
|
136
|
-
|
|
137
|
-
import { aspi, Result } from 'aspi';
|
|
252
|
+
## Retry & back‑off
|
|
138
253
|
|
|
139
|
-
|
|
254
|
+
```ts
|
|
255
|
+
const api = new Aspi({
|
|
140
256
|
baseUrl: 'https://example.com',
|
|
141
|
-
headers: {
|
|
142
|
-
'Content-Type': 'application/json',
|
|
143
|
-
},
|
|
257
|
+
headers: { 'Content-Type': 'application/json' },
|
|
144
258
|
}).setRetry({
|
|
145
259
|
retries: 3,
|
|
146
|
-
retryDelay: 1000,
|
|
147
|
-
// retry on
|
|
148
|
-
retryOn: [404],
|
|
260
|
+
retryDelay: 1000, // simple fixed delay
|
|
261
|
+
retryOn: [404, 500], // retry on specific status codes
|
|
149
262
|
});
|
|
150
263
|
|
|
151
|
-
//
|
|
152
|
-
|
|
264
|
+
// Override retry options for a single request
|
|
265
|
+
api
|
|
153
266
|
.get('/todos/1')
|
|
154
|
-
.setHeader('
|
|
155
|
-
// Updating retry options for this request
|
|
267
|
+
.setHeader('Accept', 'application/json')
|
|
156
268
|
.setRetry({
|
|
157
|
-
//
|
|
158
|
-
retryDelay: (
|
|
269
|
+
// exponential back‑off for this call only
|
|
270
|
+
retryDelay: (attempt) => Math.pow(2, attempt) * 1000,
|
|
159
271
|
})
|
|
160
272
|
.withResult()
|
|
161
273
|
.json()
|
|
162
|
-
.then((
|
|
163
|
-
Result.match(
|
|
164
|
-
onOk: (data) =>
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (error.tag === 'aspiError') {
|
|
169
|
-
console.error(error.response);
|
|
170
|
-
} else if (error.tag === 'notFoundError') {
|
|
171
|
-
console.log(error.data.message);
|
|
172
|
-
}
|
|
173
|
-
},
|
|
174
|
-
});
|
|
175
|
-
});
|
|
274
|
+
.then((res) =>
|
|
275
|
+
Result.match(res, {
|
|
276
|
+
onOk: (data) => console.log('Got data', data),
|
|
277
|
+
onErr: (err) => console.error('Failed', err),
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
176
280
|
```
|
|
177
281
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
282
|
+
---
|
|
283
|
+
|
|
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. |
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Custom error handling
|
|
303
|
+
|
|
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
|
+
}));
|
|
182
315
|
```
|
|
183
316
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
#### Error handling
|
|
196
|
-
|
|
197
|
-
- The error handling is done using the `Result` type, which is a union type of `Ok` and `Err` type.
|
|
198
|
-
- When called `json` method on the response, it will return either the AspiSuccessOk with the data or AspiError with the error as well as JSON parsing error.
|
|
199
|
-
- Additionally, user can define custom errors to handle specific http status codes, those errors can be pattern matched using any pattern matching library.
|
|
200
|
-
|
|
201
|
-
#### API Descriptions
|
|
202
|
-
|
|
203
|
-
##### WithResult
|
|
204
|
-
|
|
205
|
-
By default, the response is not wrapped in the Result type. It will be a tuple of the value and error. both can be null but only one will be non-null at a time. If you want the response to be wrapped in the Result type, you can call `withResult` method on the response.
|
|
206
|
-
|
|
207
|
-
```typescript
|
|
208
|
-
const response = await new Aspi({ baseUrl: '...' })
|
|
209
|
-
.get('...')
|
|
210
|
-
.json<{ data: any }>();
|
|
211
|
-
|
|
212
|
-
// [AspiResultOk<AspiRequestInit, { data: any; }> | null, JSONParseError | AspiError<AspiRequestInit> | null]
|
|
317
|
+
Convenient shortcuts are provided for the most common statuses (each forwards to `error` internally and augments the generic `Opts['error']` type):
|
|
318
|
+
|
|
319
|
+
```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
|
|
213
328
|
```
|
|
214
329
|
|
|
215
|
-
|
|
330
|
+
These helpers allow you to write:
|
|
216
331
|
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
.get('
|
|
332
|
+
```ts
|
|
333
|
+
api
|
|
334
|
+
.get('/secret')
|
|
335
|
+
.unauthorised(() => ({ message: 'You need a token' }))
|
|
220
336
|
.withResult()
|
|
221
|
-
.json
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
);
|
|
224
348
|
```
|
|
225
349
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
),
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
## API reference (selected)
|
|
353
|
+
|
|
354
|
+
```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
|
+
}
|
|
242
536
|
```
|
|
243
537
|
|
|
244
|
-
|
|
538
|
+
---
|
|
245
539
|
|
|
246
|
-
|
|
540
|
+
## License
|
|
247
541
|
|
|
248
|
-
|
|
249
|
-
import { aspi, Result } from 'aspi';
|
|
250
|
-
import { z, ZodError } from 'zod';
|
|
251
|
-
|
|
252
|
-
// JSON Placeholder API Client
|
|
253
|
-
const apiClient = new Aspi({
|
|
254
|
-
baseUrl: 'https://jsonplaceholder.typicode.com',
|
|
255
|
-
headers: {
|
|
256
|
-
'Content-Type': 'application/json',
|
|
257
|
-
},
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
const getTodo = async (id: number) => {
|
|
261
|
-
const response = await apiClient
|
|
262
|
-
.get(`/todos/${id}`)
|
|
263
|
-
.withResult()
|
|
264
|
-
.schema(
|
|
265
|
-
z.object({
|
|
266
|
-
id: z.number(),
|
|
267
|
-
title: z.string(),
|
|
268
|
-
completed: z.boolean(),
|
|
269
|
-
}),
|
|
270
|
-
)
|
|
271
|
-
.json();
|
|
272
|
-
|
|
273
|
-
Result.match(response, {
|
|
274
|
-
onOk: (data) => {
|
|
275
|
-
console.log(data);
|
|
276
|
-
},
|
|
277
|
-
onErr: (err) => {
|
|
278
|
-
if (err.tag === 'parseError') {
|
|
279
|
-
const error = err.data as ZodError;
|
|
280
|
-
console.error(error.errors);
|
|
281
|
-
} else {
|
|
282
|
-
// do something else
|
|
283
|
-
}
|
|
284
|
-
},
|
|
285
|
-
});
|
|
286
|
-
};
|
|
287
|
-
```
|
|
542
|
+
MIT © Aspi contributors
|