@tahanabavi/typefetch 1.3.0 → 1.5.3
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 +1038 -212
- package/dist/index.d.mts +293 -96
- package/dist/index.d.ts +293 -96
- package/dist/index.js +596 -4332
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +561 -4316
- package/dist/index.mjs.map +1 -1
- package/package.json +49 -15
package/README.md
CHANGED
|
@@ -1,361 +1,1187 @@
|
|
|
1
1
|
# TypeFetch
|
|
2
2
|
|
|
3
|
-
TypeFetch is a strongly
|
|
3
|
+
**TypeFetch** is a strongly typed HTTP client for TypeScript projects, built around **Zod** contracts.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Define your API once with Zod schemas, then TypeFetch generates a fully typed client with request validation, response validation, middleware support, retries, mock data, response wrappers, token handling, and structured request support.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
```ts
|
|
8
|
+
const user = await api.user.getUser({
|
|
9
|
+
path: { id: "123" },
|
|
10
|
+
});
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
* End-to-end TypeScript inference from Zod schemas
|
|
18
|
+
* Runtime request and response validation
|
|
19
|
+
* Structured request model: `{ path, query, body, headers }`
|
|
20
|
+
* Automatic path parameter injection
|
|
21
|
+
* Automatic query string generation
|
|
22
|
+
* JSON and `form-data` request bodies
|
|
23
|
+
* Middleware pipeline
|
|
24
|
+
* Built-in retry engine with backoff strategies
|
|
25
|
+
* Timeout and `AbortController` support
|
|
26
|
+
* Static tokens and dynamic token providers
|
|
27
|
+
* Mock mode for development and testing
|
|
28
|
+
* Response wrapper support for API envelopes
|
|
29
|
+
* Normalized error handling with `RichError`
|
|
30
|
+
* Field-level encryption middleware
|
|
31
|
+
* Backward-compatible flat request schemas
|
|
16
32
|
|
|
17
33
|
---
|
|
18
34
|
|
|
19
35
|
## Installation
|
|
20
36
|
|
|
21
37
|
```bash
|
|
22
|
-
npm install @tahanabavi/typefetch
|
|
23
|
-
# or
|
|
24
|
-
yarn add @tahanabavi/typefetch
|
|
38
|
+
npm install @tahanabavi/typefetch zod
|
|
25
39
|
```
|
|
26
40
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
## Core Concepts
|
|
41
|
+
Or with Yarn:
|
|
30
42
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
```bash
|
|
44
|
+
yarn add @tahanabavi/typefetch zod
|
|
45
|
+
```
|
|
34
46
|
|
|
35
|
-
|
|
47
|
+
Or with pnpm:
|
|
36
48
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
- `request`: Zod schema for the request
|
|
41
|
-
- `response`: Zod schema for the response
|
|
42
|
-
- `mockData?`: static or dynamic mock response
|
|
43
|
-
- `headers?`: static or function-based default headers
|
|
44
|
-
- `bodyType?`: `"json"` (default) or `"form-data"`
|
|
49
|
+
```bash
|
|
50
|
+
pnpm add @tahanabavi/typefetch zod
|
|
51
|
+
```
|
|
45
52
|
|
|
46
|
-
|
|
53
|
+
---
|
|
47
54
|
|
|
48
|
-
|
|
55
|
+
## Quick Start
|
|
49
56
|
|
|
50
57
|
```ts
|
|
51
|
-
z
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
import { z } from "zod";
|
|
59
|
+
import { ApiClient } from "@tahanabavi/typefetch";
|
|
60
|
+
|
|
61
|
+
const contracts = {
|
|
62
|
+
user: {
|
|
63
|
+
getUser: {
|
|
64
|
+
method: "GET",
|
|
65
|
+
path: "/users/:id",
|
|
66
|
+
auth: true,
|
|
67
|
+
request: z.object({
|
|
68
|
+
path: z.object({
|
|
69
|
+
id: z.string(),
|
|
70
|
+
}),
|
|
71
|
+
}),
|
|
72
|
+
response: z.object({
|
|
73
|
+
id: z.string(),
|
|
74
|
+
name: z.string(),
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
} as const;
|
|
58
79
|
|
|
59
|
-
|
|
80
|
+
const client = new ApiClient(
|
|
81
|
+
{
|
|
82
|
+
baseUrl: "https://api.example.com",
|
|
83
|
+
tokenProvider: async () => "your-token",
|
|
84
|
+
},
|
|
85
|
+
contracts,
|
|
86
|
+
);
|
|
60
87
|
|
|
61
|
-
|
|
62
|
-
- Build query string from `query`
|
|
63
|
-
- Serialize `body` as JSON (or `FormData` if `bodyType: "form-data"`)
|
|
64
|
-
- Merge headers from:
|
|
65
|
-
- auth (Authorization)
|
|
66
|
-
- endpoint-level `headers`
|
|
67
|
-
- per-call `headers` in the request (highest priority)
|
|
88
|
+
client.init();
|
|
68
89
|
|
|
69
|
-
|
|
90
|
+
const api = client.modules;
|
|
70
91
|
|
|
71
|
-
|
|
92
|
+
const user = await api.user.getUser({
|
|
93
|
+
path: { id: "123" },
|
|
94
|
+
});
|
|
72
95
|
|
|
73
|
-
|
|
96
|
+
console.log(user.name);
|
|
97
|
+
```
|
|
74
98
|
|
|
75
99
|
---
|
|
76
100
|
|
|
77
101
|
## Defining API Contracts
|
|
78
102
|
|
|
79
|
-
|
|
103
|
+
A TypeFetch contract is a grouped object of modules and endpoints.
|
|
80
104
|
|
|
81
105
|
```ts
|
|
82
|
-
import { z } from "zod";
|
|
83
|
-
import { Contracts, EndpointDef } from "@tahanabavi/typefetch";
|
|
84
|
-
|
|
85
106
|
const contracts = {
|
|
86
107
|
user: {
|
|
87
108
|
getUser: {
|
|
88
109
|
method: "GET",
|
|
89
110
|
path: "/users/:id",
|
|
90
|
-
auth: true,
|
|
91
111
|
request: z.object({
|
|
92
|
-
path: z.object({
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
headers: z.record(z.string()).optional(),
|
|
112
|
+
path: z.object({
|
|
113
|
+
id: z.string(),
|
|
114
|
+
}),
|
|
96
115
|
}),
|
|
97
116
|
response: z.object({
|
|
98
117
|
id: z.string(),
|
|
99
118
|
name: z.string(),
|
|
100
119
|
}),
|
|
101
|
-
mockData: { id: "1", name: "John Doe" },
|
|
102
120
|
},
|
|
103
121
|
|
|
104
122
|
createUser: {
|
|
105
123
|
method: "POST",
|
|
106
124
|
path: "/users",
|
|
107
|
-
auth: true,
|
|
108
125
|
request: z.object({
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
name: z.string(),
|
|
114
|
-
})
|
|
115
|
-
.optional(),
|
|
116
|
-
headers: z.record(z.string()).optional(),
|
|
126
|
+
body: z.object({
|
|
127
|
+
name: z.string(),
|
|
128
|
+
email: z.string().email(),
|
|
129
|
+
}),
|
|
117
130
|
}),
|
|
118
131
|
response: z.object({
|
|
119
132
|
id: z.string(),
|
|
120
133
|
name: z.string(),
|
|
121
|
-
|
|
122
|
-
mockData: () => ({
|
|
123
|
-
id: Math.random().toString(36).slice(2),
|
|
124
|
-
name: "Mock User",
|
|
134
|
+
email: z.string(),
|
|
125
135
|
}),
|
|
126
136
|
},
|
|
127
137
|
},
|
|
128
138
|
} as const;
|
|
129
139
|
```
|
|
130
140
|
|
|
131
|
-
|
|
141
|
+
After calling `client.init()`, TypeFetch generates typed methods:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
await api.user.getUser({
|
|
145
|
+
path: { id: "123" },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await api.user.createUser({
|
|
149
|
+
body: {
|
|
150
|
+
name: "Taha",
|
|
151
|
+
email: "taha@example.com",
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
```
|
|
132
155
|
|
|
133
156
|
---
|
|
134
157
|
|
|
135
|
-
##
|
|
158
|
+
## Structured Request Model
|
|
159
|
+
|
|
160
|
+
The recommended request shape is:
|
|
136
161
|
|
|
137
162
|
```ts
|
|
138
|
-
|
|
139
|
-
|
|
163
|
+
z.object({
|
|
164
|
+
path: z.object({}).optional(),
|
|
165
|
+
query: z.object({}).optional(),
|
|
166
|
+
body: z.any().optional(),
|
|
167
|
+
headers: z.record(z.string()).optional(),
|
|
168
|
+
});
|
|
169
|
+
```
|
|
140
170
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
171
|
+
Each section has a specific purpose.
|
|
172
|
+
|
|
173
|
+
| Key | Purpose |
|
|
174
|
+
| --------- | ------------------------------------------ |
|
|
175
|
+
| `path` | Replaces path parameters like `/users/:id` |
|
|
176
|
+
| `query` | Builds the query string |
|
|
177
|
+
| `body` | Sent as the JSON or `form-data` body |
|
|
178
|
+
| `headers` | Per-request headers |
|
|
179
|
+
|
|
180
|
+
Example:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
const contracts = {
|
|
184
|
+
user: {
|
|
185
|
+
updateUser: {
|
|
186
|
+
method: "PATCH",
|
|
187
|
+
path: "/users/:id",
|
|
188
|
+
request: z.object({
|
|
189
|
+
path: z.object({
|
|
190
|
+
id: z.string(),
|
|
191
|
+
}),
|
|
192
|
+
query: z.object({
|
|
193
|
+
notify: z.boolean().optional(),
|
|
194
|
+
}).optional(),
|
|
195
|
+
body: z.object({
|
|
196
|
+
name: z.string(),
|
|
197
|
+
}),
|
|
198
|
+
headers: z.record(z.string()).optional(),
|
|
199
|
+
}),
|
|
200
|
+
response: z.object({
|
|
201
|
+
id: z.string(),
|
|
202
|
+
name: z.string(),
|
|
203
|
+
}),
|
|
204
|
+
},
|
|
146
205
|
},
|
|
147
|
-
|
|
148
|
-
|
|
206
|
+
} as const;
|
|
207
|
+
```
|
|
149
208
|
|
|
150
|
-
|
|
209
|
+
Usage:
|
|
151
210
|
|
|
152
|
-
|
|
211
|
+
```ts
|
|
212
|
+
await api.user.updateUser({
|
|
213
|
+
path: { id: "123" },
|
|
214
|
+
query: { notify: true },
|
|
215
|
+
headers: {
|
|
216
|
+
"X-Tenant": "main",
|
|
217
|
+
},
|
|
218
|
+
body: {
|
|
219
|
+
name: "Taha",
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
TypeFetch sends:
|
|
153
225
|
|
|
154
|
-
|
|
155
|
-
|
|
226
|
+
```ts
|
|
227
|
+
PATCH /users/123?notify=true
|
|
156
228
|
```
|
|
157
229
|
|
|
158
|
-
|
|
159
|
-
- `api.user.getUser` and `api.user.createUser` are fully typed from the Zod schemas.
|
|
230
|
+
With body:
|
|
160
231
|
|
|
161
|
-
|
|
232
|
+
```json
|
|
233
|
+
{
|
|
234
|
+
"name": "Taha"
|
|
235
|
+
}
|
|
236
|
+
```
|
|
162
237
|
|
|
163
|
-
|
|
238
|
+
---
|
|
164
239
|
|
|
165
|
-
|
|
240
|
+
## Request Schema Helper
|
|
166
241
|
|
|
167
|
-
|
|
242
|
+
You can use `makeRequestSchema` to make structured request schemas easier to write.
|
|
168
243
|
|
|
169
244
|
```ts
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
245
|
+
import { z } from "zod";
|
|
246
|
+
import { makeRequestSchema } from "@tahanabavi/typefetch";
|
|
247
|
+
|
|
248
|
+
const updateUserRequest = makeRequestSchema<
|
|
249
|
+
{ id: z.ZodString },
|
|
250
|
+
{ notify: z.ZodOptional<z.ZodBoolean> },
|
|
251
|
+
z.ZodObject<{
|
|
252
|
+
name: z.ZodString;
|
|
253
|
+
}>
|
|
254
|
+
>()({
|
|
255
|
+
path: z.object({
|
|
256
|
+
id: z.string(),
|
|
257
|
+
}),
|
|
258
|
+
query: z.object({
|
|
259
|
+
notify: z.boolean().optional(),
|
|
260
|
+
}),
|
|
261
|
+
body: z.object({
|
|
262
|
+
name: z.string(),
|
|
263
|
+
}),
|
|
264
|
+
headers: z.record(z.string()).optional(),
|
|
175
265
|
});
|
|
176
266
|
```
|
|
177
267
|
|
|
178
|
-
|
|
268
|
+
Use it inside an endpoint:
|
|
179
269
|
|
|
180
270
|
```ts
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
271
|
+
const contracts = {
|
|
272
|
+
user: {
|
|
273
|
+
updateUser: {
|
|
274
|
+
method: "PATCH",
|
|
275
|
+
path: "/users/:id",
|
|
276
|
+
request: updateUserRequest,
|
|
277
|
+
response: z.object({
|
|
278
|
+
id: z.string(),
|
|
279
|
+
name: z.string(),
|
|
280
|
+
}),
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
} as const;
|
|
284
|
+
```
|
|
187
285
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Backward Compatibility
|
|
289
|
+
|
|
290
|
+
Flat request schemas are still supported.
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
const contracts = {
|
|
294
|
+
user: {
|
|
295
|
+
createUser: {
|
|
296
|
+
method: "POST",
|
|
297
|
+
path: "/users",
|
|
298
|
+
request: z.object({
|
|
299
|
+
name: z.string(),
|
|
300
|
+
}),
|
|
301
|
+
response: z.object({
|
|
302
|
+
id: z.string(),
|
|
303
|
+
name: z.string(),
|
|
304
|
+
}),
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
} as const;
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Usage:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
await api.user.createUser({
|
|
314
|
+
name: "Taha",
|
|
197
315
|
});
|
|
198
316
|
```
|
|
199
317
|
|
|
200
|
-
|
|
201
|
-
- **`retryMiddleware`** – retries failed requests with configurable `maxRetries` and `delay`.
|
|
202
|
-
- **`cacheMiddleware`** – caches GET responses in-memory per URL with `ttl` (ms).
|
|
203
|
-
- **`authMiddleware`** – can refresh tokens and inject `Authorization` headers before the request.
|
|
318
|
+
For non-`GET` requests, the full flat input is sent as the JSON body.
|
|
204
319
|
|
|
205
|
-
|
|
320
|
+
For `GET` requests, flat input is validated but no body is sent.
|
|
206
321
|
|
|
207
|
-
|
|
322
|
+
---
|
|
208
323
|
|
|
209
|
-
|
|
324
|
+
## Creating the Client
|
|
210
325
|
|
|
211
326
|
```ts
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
client
|
|
327
|
+
import { ApiClient } from "@tahanabavi/typefetch";
|
|
328
|
+
|
|
329
|
+
const client = new ApiClient(
|
|
330
|
+
{
|
|
331
|
+
baseUrl: "https://api.example.com",
|
|
332
|
+
},
|
|
333
|
+
contracts,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
client.init();
|
|
337
|
+
|
|
338
|
+
const api = client.modules;
|
|
215
339
|
```
|
|
216
340
|
|
|
217
|
-
|
|
341
|
+
Always call `client.init()` before using `client.modules`.
|
|
218
342
|
|
|
219
343
|
---
|
|
220
344
|
|
|
221
|
-
##
|
|
222
|
-
|
|
223
|
-
You can apply a global transformation to all successful responses:
|
|
345
|
+
## Client Configuration
|
|
224
346
|
|
|
225
347
|
```ts
|
|
226
|
-
client
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
348
|
+
const client = new ApiClient(
|
|
349
|
+
{
|
|
350
|
+
baseUrl: "https://api.example.com",
|
|
351
|
+
token: "static-token",
|
|
352
|
+
tokenProvider: async () => "dynamic-token",
|
|
353
|
+
useMockData: false,
|
|
354
|
+
mockDelay: {
|
|
355
|
+
min: 200,
|
|
356
|
+
max: 1000,
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
contracts,
|
|
360
|
+
);
|
|
232
361
|
```
|
|
233
362
|
|
|
234
|
-
|
|
363
|
+
| Option | Type | Description |
|
|
364
|
+
| --------------- | --------------------------------- | ---------------------- |
|
|
365
|
+
| `baseUrl` | `string` | Base API URL |
|
|
366
|
+
| `token` | `string` | Static bearer token |
|
|
367
|
+
| `tokenProvider` | `() => string \| Promise<string>` | Dynamic token provider |
|
|
368
|
+
| `useMockData` | `boolean` | Enables mock mode |
|
|
369
|
+
| `mockDelay` | `{ min: number; max: number }` | Simulated mock latency |
|
|
235
370
|
|
|
236
|
-
|
|
237
|
-
2. Optional response wrapper has been unwrapped
|
|
238
|
-
3. The response has been validated with the endpoint’s Zod schema
|
|
371
|
+
When both `token` and `tokenProvider` are provided, `tokenProvider` takes priority.
|
|
239
372
|
|
|
240
373
|
---
|
|
241
374
|
|
|
242
|
-
##
|
|
375
|
+
## Authentication
|
|
243
376
|
|
|
244
|
-
|
|
377
|
+
Set `auth: true` on endpoints that require an authorization token.
|
|
245
378
|
|
|
246
|
-
```
|
|
247
|
-
{
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
379
|
+
```ts
|
|
380
|
+
const contracts = {
|
|
381
|
+
user: {
|
|
382
|
+
getProfile: {
|
|
383
|
+
method: "GET",
|
|
384
|
+
path: "/profile",
|
|
385
|
+
auth: true,
|
|
386
|
+
request: z.object({}),
|
|
387
|
+
response: z.object({
|
|
388
|
+
id: z.string(),
|
|
389
|
+
name: z.string(),
|
|
390
|
+
}),
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
} as const;
|
|
253
394
|
```
|
|
254
395
|
|
|
255
|
-
|
|
396
|
+
Use a static token:
|
|
256
397
|
|
|
257
398
|
```ts
|
|
258
|
-
|
|
399
|
+
const client = new ApiClient(
|
|
400
|
+
{
|
|
401
|
+
baseUrl: "https://api.example.com",
|
|
402
|
+
token: "my-token",
|
|
403
|
+
},
|
|
404
|
+
contracts,
|
|
405
|
+
);
|
|
406
|
+
```
|
|
259
407
|
|
|
260
|
-
|
|
261
|
-
z.union([
|
|
262
|
-
z.object({
|
|
263
|
-
success: z.literal(true),
|
|
264
|
-
data: successResponse,
|
|
265
|
-
timestamp: z.string(),
|
|
266
|
-
requestId: z.string(),
|
|
267
|
-
}),
|
|
268
|
-
z.object({
|
|
269
|
-
success: z.literal(false),
|
|
270
|
-
message: z.string(),
|
|
271
|
-
code: z.number(),
|
|
272
|
-
timestamp: z.string(),
|
|
273
|
-
requestId: z.string(),
|
|
274
|
-
}),
|
|
275
|
-
]);
|
|
408
|
+
Or use a dynamic token provider:
|
|
276
409
|
|
|
277
|
-
|
|
410
|
+
```ts
|
|
411
|
+
const client = new ApiClient(
|
|
412
|
+
{
|
|
413
|
+
baseUrl: "https://api.example.com",
|
|
414
|
+
tokenProvider: async () => {
|
|
415
|
+
return localStorage.getItem("token") ?? "";
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
contracts,
|
|
419
|
+
);
|
|
278
420
|
```
|
|
279
421
|
|
|
280
|
-
|
|
281
|
-
|
|
422
|
+
You can also set the token provider later:
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
client.setTokenProvider(async () => "new-token");
|
|
426
|
+
```
|
|
282
427
|
|
|
283
428
|
---
|
|
284
429
|
|
|
285
|
-
##
|
|
430
|
+
## Middleware System
|
|
431
|
+
|
|
432
|
+
TypeFetch supports middleware for logging, authentication, caching, retries, encryption, and custom request behavior.
|
|
286
433
|
|
|
287
434
|
```ts
|
|
288
|
-
client.
|
|
289
|
-
console.
|
|
435
|
+
client.use(async (ctx, next) => {
|
|
436
|
+
console.log("Request:", ctx.url);
|
|
437
|
+
|
|
438
|
+
const response = await next();
|
|
439
|
+
|
|
440
|
+
console.log("Response:", response.status);
|
|
441
|
+
|
|
442
|
+
return response;
|
|
290
443
|
});
|
|
291
444
|
```
|
|
292
445
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
You can still handle errors per-call with `try/catch`:
|
|
446
|
+
Middlewares run in registration order before the request, then unwind in reverse order after the response.
|
|
296
447
|
|
|
297
448
|
```ts
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
449
|
+
client.use(firstMiddleware);
|
|
450
|
+
client.use(secondMiddleware);
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Execution flow:
|
|
454
|
+
|
|
455
|
+
```txt
|
|
456
|
+
firstMiddleware before
|
|
457
|
+
secondMiddleware before
|
|
458
|
+
fetch
|
|
459
|
+
secondMiddleware after
|
|
460
|
+
firstMiddleware after
|
|
303
461
|
```
|
|
304
462
|
|
|
305
463
|
---
|
|
306
464
|
|
|
307
|
-
##
|
|
465
|
+
## Built-in Middlewares
|
|
308
466
|
|
|
309
|
-
|
|
467
|
+
Depending on how you export your middlewares, they can be registered directly or as factories.
|
|
468
|
+
|
|
469
|
+
Direct middleware example:
|
|
310
470
|
|
|
311
471
|
```ts
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
file: z.any(), // or z.instanceof(File) in browser
|
|
317
|
-
}),
|
|
318
|
-
headers: z.record(z.string()).optional(),
|
|
472
|
+
client.use(loggingMiddleware, {
|
|
473
|
+
debug: true,
|
|
474
|
+
logRequest: true,
|
|
475
|
+
logResponse: true,
|
|
319
476
|
});
|
|
477
|
+
```
|
|
320
478
|
|
|
321
|
-
|
|
322
|
-
url: z.string(),
|
|
323
|
-
});
|
|
479
|
+
Factory middleware example:
|
|
324
480
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
method: "POST",
|
|
329
|
-
path: "/users/avatar",
|
|
330
|
-
auth: true,
|
|
331
|
-
bodyType: "form-data",
|
|
332
|
-
request: uploadAvatarRequest,
|
|
333
|
-
response: uploadAvatarResponse,
|
|
334
|
-
},
|
|
335
|
-
},
|
|
336
|
-
} as const;
|
|
481
|
+
```ts
|
|
482
|
+
client.use(cacheMiddleware({ ttl: 60_000 }));
|
|
483
|
+
client.use(retryMiddleware({ maxRetries: 3, delay: 300 }));
|
|
337
484
|
```
|
|
338
485
|
|
|
339
|
-
|
|
486
|
+
---
|
|
487
|
+
|
|
488
|
+
## Retry Engine
|
|
489
|
+
|
|
490
|
+
TypeFetch includes a built-in retry engine on the client.
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
client.setRetryConfig({
|
|
494
|
+
maxRetries: 3,
|
|
495
|
+
backoff: "exponential",
|
|
496
|
+
retryCondition: (error, attempt) => {
|
|
497
|
+
return error.status !== undefined && error.status >= 500;
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Supported backoff strategies:
|
|
503
|
+
|
|
504
|
+
| Strategy | Behavior |
|
|
505
|
+
| ------------- | ------------------------------ |
|
|
506
|
+
| `fixed` | Same delay each retry |
|
|
507
|
+
| `linear` | Delay increases linearly |
|
|
508
|
+
| `exponential` | Delay doubles after each retry |
|
|
509
|
+
|
|
510
|
+
Example:
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
client.setRetryConfig({
|
|
514
|
+
maxRetries: 3,
|
|
515
|
+
backoff: "fixed",
|
|
516
|
+
});
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
## Timeout and Abort Support
|
|
522
|
+
|
|
523
|
+
Each request can receive per-call options.
|
|
524
|
+
|
|
525
|
+
```ts
|
|
526
|
+
await api.user.getUser(
|
|
527
|
+
{
|
|
528
|
+
path: { id: "123" },
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
timeout: 5000,
|
|
532
|
+
},
|
|
533
|
+
);
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
You can also pass an external `AbortSignal`.
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
const controller = new AbortController();
|
|
540
|
+
|
|
541
|
+
await api.user.getUser(
|
|
542
|
+
{
|
|
543
|
+
path: { id: "123" },
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
signal: controller.signal,
|
|
547
|
+
},
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
controller.abort();
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
---
|
|
554
|
+
|
|
555
|
+
## Mock Mode
|
|
556
|
+
|
|
557
|
+
Mock mode lets you return endpoint-level mock data instead of calling the network.
|
|
558
|
+
|
|
559
|
+
```ts
|
|
560
|
+
const contracts = {
|
|
561
|
+
user: {
|
|
562
|
+
getUser: {
|
|
563
|
+
method: "GET",
|
|
564
|
+
path: "/users/:id",
|
|
565
|
+
request: z.object({
|
|
566
|
+
path: z.object({
|
|
567
|
+
id: z.string(),
|
|
568
|
+
}),
|
|
569
|
+
}),
|
|
570
|
+
response: z.object({
|
|
571
|
+
id: z.string(),
|
|
572
|
+
name: z.string(),
|
|
573
|
+
}),
|
|
574
|
+
mockData: {
|
|
575
|
+
id: "mock-1",
|
|
576
|
+
name: "Mock User",
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
} as const;
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
Enable mock mode:
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
client.setMockMode(true, {
|
|
587
|
+
min: 200,
|
|
588
|
+
max: 1000,
|
|
589
|
+
});
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
Dynamic mock data is also supported:
|
|
593
|
+
|
|
594
|
+
```ts
|
|
595
|
+
mockData: () => ({
|
|
596
|
+
id: crypto.randomUUID(),
|
|
597
|
+
name: "Dynamic Mock User",
|
|
598
|
+
});
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
Mock responses are still validated against the endpoint response schema.
|
|
602
|
+
|
|
603
|
+
---
|
|
604
|
+
|
|
605
|
+
## Response Wrappers
|
|
606
|
+
|
|
607
|
+
Many APIs return wrapped responses.
|
|
608
|
+
|
|
609
|
+
```json
|
|
610
|
+
{
|
|
611
|
+
"success": true,
|
|
612
|
+
"data": {
|
|
613
|
+
"id": "123",
|
|
614
|
+
"name": "Taha"
|
|
615
|
+
},
|
|
616
|
+
"timestamp": "2026-01-01T00:00:00.000Z"
|
|
617
|
+
}
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
TypeFetch can validate and unwrap these responses.
|
|
621
|
+
|
|
622
|
+
```ts
|
|
623
|
+
import { z } from "zod";
|
|
624
|
+
|
|
625
|
+
client.setResponseWrapper((successResponse) =>
|
|
626
|
+
z.union([
|
|
627
|
+
z.object({
|
|
628
|
+
success: z.literal(true),
|
|
629
|
+
data: successResponse,
|
|
630
|
+
timestamp: z.string().optional(),
|
|
631
|
+
requestId: z.string().optional(),
|
|
632
|
+
}),
|
|
633
|
+
z.object({
|
|
634
|
+
success: z.literal(false),
|
|
635
|
+
message: z.string(),
|
|
636
|
+
code: z.number().optional(),
|
|
637
|
+
timestamp: z.string().optional(),
|
|
638
|
+
requestId: z.string().optional(),
|
|
639
|
+
}),
|
|
640
|
+
]),
|
|
641
|
+
);
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
Successful responses return only `data`.
|
|
645
|
+
|
|
646
|
+
Failed wrapped responses throw `RichError`.
|
|
647
|
+
|
|
648
|
+
---
|
|
649
|
+
|
|
650
|
+
## Error Handling
|
|
651
|
+
|
|
652
|
+
TypeFetch normalizes errors into `RichError`.
|
|
653
|
+
|
|
654
|
+
```ts
|
|
655
|
+
client.onError((error) => {
|
|
656
|
+
console.error(error.message);
|
|
657
|
+
console.error(error.status);
|
|
658
|
+
console.error(error.code);
|
|
659
|
+
});
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
`RichError` may include:
|
|
663
|
+
|
|
664
|
+
```ts
|
|
665
|
+
{
|
|
666
|
+
message: string;
|
|
667
|
+
status?: number;
|
|
668
|
+
code?: string;
|
|
669
|
+
title?: string;
|
|
670
|
+
detail?: string;
|
|
671
|
+
errors?: Record<string, string[]>;
|
|
672
|
+
}
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
Handled error types include:
|
|
676
|
+
|
|
677
|
+
* HTTP errors
|
|
678
|
+
* Validation errors
|
|
679
|
+
* Wrapped API errors
|
|
680
|
+
* Missing token errors
|
|
681
|
+
* Network errors
|
|
682
|
+
* Timeout errors
|
|
683
|
+
* Retry exhaustion
|
|
684
|
+
|
|
685
|
+
Example:
|
|
686
|
+
|
|
687
|
+
```ts
|
|
688
|
+
try {
|
|
689
|
+
await api.user.getUser({
|
|
690
|
+
path: { id: "missing" },
|
|
691
|
+
});
|
|
692
|
+
} catch (error) {
|
|
693
|
+
if (error instanceof RichError) {
|
|
694
|
+
console.error(error.status, error.message);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
---
|
|
700
|
+
|
|
701
|
+
## File Uploads
|
|
702
|
+
|
|
703
|
+
Set `bodyType: "form-data"` on an endpoint.
|
|
704
|
+
|
|
705
|
+
```ts
|
|
706
|
+
const contracts = {
|
|
707
|
+
user: {
|
|
708
|
+
uploadAvatar: {
|
|
709
|
+
method: "POST",
|
|
710
|
+
path: "/users/:id/avatar",
|
|
711
|
+
bodyType: "form-data",
|
|
712
|
+
request: z.object({
|
|
713
|
+
path: z.object({
|
|
714
|
+
id: z.string(),
|
|
715
|
+
}),
|
|
716
|
+
body: z.object({
|
|
717
|
+
file: z.instanceof(File),
|
|
718
|
+
alt: z.string().optional(),
|
|
719
|
+
}),
|
|
720
|
+
}),
|
|
721
|
+
response: z.object({
|
|
722
|
+
uploaded: z.boolean(),
|
|
723
|
+
}),
|
|
724
|
+
},
|
|
725
|
+
},
|
|
726
|
+
} as const;
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
Usage:
|
|
340
730
|
|
|
341
731
|
```ts
|
|
342
|
-
const file = input.files?.[0];
|
|
343
732
|
await api.user.uploadAvatar({
|
|
344
|
-
|
|
733
|
+
path: { id: "123" },
|
|
734
|
+
body: {
|
|
735
|
+
file,
|
|
736
|
+
alt: "Profile avatar",
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
When using `form-data`, TypeFetch does not force the `Content-Type: application/json` header.
|
|
742
|
+
|
|
743
|
+
---
|
|
744
|
+
|
|
745
|
+
## Encryption Middleware
|
|
746
|
+
|
|
747
|
+
TypeFetch includes optional field-level encryption through `encryptionMiddleware`.
|
|
748
|
+
|
|
749
|
+
It can:
|
|
750
|
+
|
|
751
|
+
* Encrypt selected request body fields
|
|
752
|
+
* Decrypt selected response fields
|
|
753
|
+
* Process deeply nested objects
|
|
754
|
+
* Process arrays
|
|
755
|
+
* Use different encryption methods per field
|
|
756
|
+
* Support custom encryption and decryption handlers
|
|
757
|
+
|
|
758
|
+
Supported methods:
|
|
759
|
+
|
|
760
|
+
```ts
|
|
761
|
+
type EncryptionMethod = "AES" | "DES" | "RSA" | "Base64" | "Custom";
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
### Registering the Middleware
|
|
765
|
+
|
|
766
|
+
```ts
|
|
767
|
+
import { encryptionMiddleware } from "@tahanabavi/typefetch/middlewares";
|
|
768
|
+
|
|
769
|
+
client.use(encryptionMiddleware, {
|
|
770
|
+
keyProvider: async () => ({
|
|
771
|
+
type: "symmetric",
|
|
772
|
+
key: "my-secret-key",
|
|
773
|
+
}),
|
|
774
|
+
});
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
### Endpoint Encryption Config
|
|
778
|
+
|
|
779
|
+
```ts
|
|
780
|
+
const contracts = {
|
|
781
|
+
secure: {
|
|
782
|
+
createSecret: {
|
|
783
|
+
method: "POST",
|
|
784
|
+
path: "/secure",
|
|
785
|
+
request: z.object({
|
|
786
|
+
body: z.object({
|
|
787
|
+
secret: z.string(),
|
|
788
|
+
profile: z.object({
|
|
789
|
+
pin: z.string(),
|
|
790
|
+
}),
|
|
791
|
+
}),
|
|
792
|
+
}),
|
|
793
|
+
response: z.object({
|
|
794
|
+
id: z.string(),
|
|
795
|
+
token: z.string(),
|
|
796
|
+
}),
|
|
797
|
+
encryption: {
|
|
798
|
+
method: "AES",
|
|
799
|
+
request: {
|
|
800
|
+
secret: true,
|
|
801
|
+
profile: {
|
|
802
|
+
pin: "Base64",
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
response: {
|
|
806
|
+
token: true,
|
|
807
|
+
},
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
} as const;
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
Usage:
|
|
815
|
+
|
|
816
|
+
```ts
|
|
817
|
+
await api.secure.createSecret({
|
|
818
|
+
body: {
|
|
819
|
+
secret: "private-value",
|
|
820
|
+
profile: {
|
|
821
|
+
pin: "1234",
|
|
822
|
+
},
|
|
823
|
+
},
|
|
345
824
|
});
|
|
346
825
|
```
|
|
347
826
|
|
|
348
|
-
|
|
827
|
+
Before the request is sent:
|
|
828
|
+
|
|
829
|
+
* `secret` is encrypted with AES
|
|
830
|
+
* `profile.pin` is encoded with Base64
|
|
831
|
+
|
|
832
|
+
After the response is received:
|
|
833
|
+
|
|
834
|
+
* `token` is decrypted with AES
|
|
835
|
+
|
|
836
|
+
### Separate Request and Response Methods
|
|
837
|
+
|
|
838
|
+
```ts
|
|
839
|
+
encryption: {
|
|
840
|
+
method: {
|
|
841
|
+
request: "RSA",
|
|
842
|
+
response: "AES",
|
|
843
|
+
},
|
|
844
|
+
request: {
|
|
845
|
+
password: true,
|
|
846
|
+
},
|
|
847
|
+
response: {
|
|
848
|
+
token: true,
|
|
849
|
+
},
|
|
850
|
+
}
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
### Custom Encryption
|
|
854
|
+
|
|
855
|
+
```ts
|
|
856
|
+
client.use(encryptionMiddleware, {
|
|
857
|
+
keyProvider: async () => ({
|
|
858
|
+
type: "symmetric",
|
|
859
|
+
key: "custom-key",
|
|
860
|
+
}),
|
|
861
|
+
customHandlers: {
|
|
862
|
+
encrypt: async (value, key) => {
|
|
863
|
+
return `encrypted:${value}`;
|
|
864
|
+
},
|
|
865
|
+
decrypt: async (value, key) => {
|
|
866
|
+
return value.replace("encrypted:", "");
|
|
867
|
+
},
|
|
868
|
+
},
|
|
869
|
+
});
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
Endpoint config:
|
|
873
|
+
|
|
874
|
+
```ts
|
|
875
|
+
encryption: {
|
|
876
|
+
method: "Custom",
|
|
877
|
+
request: {
|
|
878
|
+
secret: true,
|
|
879
|
+
},
|
|
880
|
+
response: {
|
|
881
|
+
token: true,
|
|
882
|
+
},
|
|
883
|
+
}
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
### Fail-Closed Behavior
|
|
887
|
+
|
|
888
|
+
By default, encryption should fail closed.
|
|
889
|
+
|
|
890
|
+
That means if request encryption fails, the request is not sent as plaintext.
|
|
891
|
+
|
|
892
|
+
```ts
|
|
893
|
+
client.use(encryptionMiddleware, {
|
|
894
|
+
keyProvider: async () => ({
|
|
895
|
+
type: "symmetric",
|
|
896
|
+
key: "secret",
|
|
897
|
+
}),
|
|
898
|
+
failClosed: true,
|
|
899
|
+
});
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
For debugging, you may disable fail-closed behavior:
|
|
903
|
+
|
|
904
|
+
```ts
|
|
905
|
+
client.use(encryptionMiddleware, {
|
|
906
|
+
keyProvider: async () => ({
|
|
907
|
+
type: "symmetric",
|
|
908
|
+
key: "secret",
|
|
909
|
+
}),
|
|
910
|
+
failClosed: false,
|
|
911
|
+
});
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
Use `failClosed: false` carefully.
|
|
915
|
+
|
|
916
|
+
---
|
|
917
|
+
|
|
918
|
+
## Encryption Maps
|
|
919
|
+
|
|
920
|
+
Encryption maps describe which fields should be transformed.
|
|
921
|
+
|
|
922
|
+
```ts
|
|
923
|
+
encryption: {
|
|
924
|
+
method: "AES",
|
|
925
|
+
request: {
|
|
926
|
+
password: true,
|
|
927
|
+
profile: {
|
|
928
|
+
ssn: true,
|
|
929
|
+
},
|
|
930
|
+
metadata: {
|
|
931
|
+
publicValue: false,
|
|
932
|
+
},
|
|
933
|
+
},
|
|
934
|
+
}
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
Map values:
|
|
938
|
+
|
|
939
|
+
| Value | Behavior |
|
|
940
|
+
| ---------- | ------------------------------------ |
|
|
941
|
+
| `true` | Encrypt/decrypt using default method |
|
|
942
|
+
| `false` | Skip field |
|
|
943
|
+
| `"AES"` | Use AES for this field |
|
|
944
|
+
| `"DES"` | Use DES for this field |
|
|
945
|
+
| `"RSA"` | Use RSA for this field |
|
|
946
|
+
| `"Base64"` | Use Base64 for this field |
|
|
947
|
+
| `"Custom"` | Use custom handler |
|
|
948
|
+
| `{ ... }` | Recursively process object |
|
|
949
|
+
| `[ ... ]` | Process array items |
|
|
950
|
+
|
|
951
|
+
Array example:
|
|
952
|
+
|
|
953
|
+
```ts
|
|
954
|
+
encryption: {
|
|
955
|
+
method: "AES",
|
|
956
|
+
request: {
|
|
957
|
+
users: [
|
|
958
|
+
{
|
|
959
|
+
password: true,
|
|
960
|
+
},
|
|
961
|
+
],
|
|
962
|
+
},
|
|
963
|
+
}
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
This applies the first array map to every item unless an index-specific map exists.
|
|
967
|
+
|
|
968
|
+
---
|
|
969
|
+
|
|
970
|
+
## Endpoint-Level Headers
|
|
971
|
+
|
|
972
|
+
Headers can be defined on the endpoint.
|
|
973
|
+
|
|
974
|
+
```ts
|
|
975
|
+
const contracts = {
|
|
976
|
+
user: {
|
|
977
|
+
createUser: {
|
|
978
|
+
method: "POST",
|
|
979
|
+
path: "/users",
|
|
980
|
+
request: z.object({
|
|
981
|
+
body: z.object({
|
|
982
|
+
name: z.string(),
|
|
983
|
+
}),
|
|
984
|
+
}),
|
|
985
|
+
response: z.object({
|
|
986
|
+
id: z.string(),
|
|
987
|
+
name: z.string(),
|
|
988
|
+
}),
|
|
989
|
+
headers: {
|
|
990
|
+
"X-App": "typefetch",
|
|
991
|
+
},
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
} as const;
|
|
995
|
+
```
|
|
996
|
+
|
|
997
|
+
Headers can also be generated from input:
|
|
998
|
+
|
|
999
|
+
```ts
|
|
1000
|
+
headers: (input) => ({
|
|
1001
|
+
"X-Tenant": input.headers?.["X-Tenant"] ?? "default",
|
|
1002
|
+
});
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
Per-request headers can be passed through the structured request input:
|
|
1006
|
+
|
|
1007
|
+
```ts
|
|
1008
|
+
await api.user.createUser({
|
|
1009
|
+
headers: {
|
|
1010
|
+
"X-Request-ID": "req-123",
|
|
1011
|
+
},
|
|
1012
|
+
body: {
|
|
1013
|
+
name: "Taha",
|
|
1014
|
+
},
|
|
1015
|
+
});
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
---
|
|
1019
|
+
|
|
1020
|
+
## Custom Middleware
|
|
1021
|
+
|
|
1022
|
+
A middleware receives:
|
|
1023
|
+
|
|
1024
|
+
```ts
|
|
1025
|
+
type Middleware = (
|
|
1026
|
+
ctx: MiddlewareContext,
|
|
1027
|
+
next: () => Promise<Response>,
|
|
1028
|
+
options?: unknown,
|
|
1029
|
+
) => Promise<Response>;
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
Example:
|
|
1033
|
+
|
|
1034
|
+
```ts
|
|
1035
|
+
client.use(async (ctx, next) => {
|
|
1036
|
+
const startedAt = Date.now();
|
|
1037
|
+
|
|
1038
|
+
const response = await next();
|
|
1039
|
+
|
|
1040
|
+
console.log(`${ctx.init.method} ${ctx.url} took ${Date.now() - startedAt}ms`);
|
|
1041
|
+
|
|
1042
|
+
return response;
|
|
1043
|
+
});
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
With options:
|
|
1047
|
+
|
|
1048
|
+
```ts
|
|
1049
|
+
const timingMiddleware = async (ctx, next, options) => {
|
|
1050
|
+
const response = await next();
|
|
1051
|
+
|
|
1052
|
+
if (options?.debug) {
|
|
1053
|
+
console.log("Timing middleware enabled");
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return response;
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
client.use(timingMiddleware, {
|
|
1060
|
+
debug: true,
|
|
1061
|
+
});
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
---
|
|
1065
|
+
|
|
1066
|
+
## Type Inference
|
|
1067
|
+
|
|
1068
|
+
TypeFetch infers endpoint input and output types automatically from Zod schemas.
|
|
1069
|
+
|
|
1070
|
+
```ts
|
|
1071
|
+
const user = await api.user.getUser({
|
|
1072
|
+
path: {
|
|
1073
|
+
id: "123",
|
|
1074
|
+
},
|
|
1075
|
+
});
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
`user` is inferred as:
|
|
1079
|
+
|
|
1080
|
+
```ts
|
|
1081
|
+
{
|
|
1082
|
+
id: string;
|
|
1083
|
+
name: string;
|
|
1084
|
+
}
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
Invalid input fails at compile time when possible and at runtime through Zod validation.
|
|
1088
|
+
|
|
1089
|
+
---
|
|
1090
|
+
|
|
1091
|
+
## Recommended Project Structure
|
|
1092
|
+
|
|
1093
|
+
```txt
|
|
1094
|
+
src/
|
|
1095
|
+
api/
|
|
1096
|
+
contracts.ts
|
|
1097
|
+
client.ts
|
|
1098
|
+
middlewares/
|
|
1099
|
+
logging.ts
|
|
1100
|
+
retry.ts
|
|
1101
|
+
cache.ts
|
|
1102
|
+
auth.ts
|
|
1103
|
+
encryption.ts
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
Example:
|
|
1107
|
+
|
|
1108
|
+
```ts
|
|
1109
|
+
// api/client.ts
|
|
1110
|
+
import { ApiClient } from "@tahanabavi/typefetch";
|
|
1111
|
+
import { contracts } from "./contracts";
|
|
1112
|
+
|
|
1113
|
+
export const client = new ApiClient(
|
|
1114
|
+
{
|
|
1115
|
+
baseUrl: import.meta.env.VITE_API_URL,
|
|
1116
|
+
tokenProvider: async () => localStorage.getItem("token") ?? "",
|
|
1117
|
+
},
|
|
1118
|
+
contracts,
|
|
1119
|
+
);
|
|
1120
|
+
|
|
1121
|
+
client.init();
|
|
1122
|
+
|
|
1123
|
+
export const api = client.modules;
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
---
|
|
1127
|
+
|
|
1128
|
+
## Testing
|
|
1129
|
+
|
|
1130
|
+
TypeFetch is designed to be easy to test with mocked `fetch`.
|
|
1131
|
+
|
|
1132
|
+
Example:
|
|
1133
|
+
|
|
1134
|
+
```ts
|
|
1135
|
+
global.fetch = vi.fn();
|
|
1136
|
+
|
|
1137
|
+
(fetch as any).mockResolvedValueOnce({
|
|
1138
|
+
ok: true,
|
|
1139
|
+
json: async () => ({
|
|
1140
|
+
id: "1",
|
|
1141
|
+
name: "Taha",
|
|
1142
|
+
}),
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
const user = await api.user.getUser({
|
|
1146
|
+
path: {
|
|
1147
|
+
id: "1",
|
|
1148
|
+
},
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
expect(user.name).toBe("Taha");
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
Recommended test coverage:
|
|
1155
|
+
|
|
1156
|
+
* Request validation
|
|
1157
|
+
* Response validation
|
|
1158
|
+
* Path parameter handling
|
|
1159
|
+
* Query string generation
|
|
1160
|
+
* JSON body serialization
|
|
1161
|
+
* Header merging
|
|
1162
|
+
* Auth token injection
|
|
1163
|
+
* Token provider behavior
|
|
1164
|
+
* Middleware execution order
|
|
1165
|
+
* Retry behavior
|
|
1166
|
+
* Timeout and abort behavior
|
|
1167
|
+
* Mock mode
|
|
1168
|
+
* Response wrappers
|
|
1169
|
+
* Error normalization
|
|
1170
|
+
* Encryption middleware
|
|
349
1171
|
|
|
350
1172
|
---
|
|
351
1173
|
|
|
352
1174
|
## Notes
|
|
353
1175
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
1176
|
+
* Always call `client.init()` before using `client.modules`.
|
|
1177
|
+
* All request inputs are validated with Zod.
|
|
1178
|
+
* All successful responses are validated with Zod.
|
|
1179
|
+
* Structured request schemas are recommended for new APIs.
|
|
1180
|
+
* Flat request schemas are still supported for backward compatibility.
|
|
1181
|
+
* `GET` requests do not send a body.
|
|
1182
|
+
* `form-data` endpoints should use `bodyType: "form-data"`.
|
|
1183
|
+
* Auth tokens are only required for endpoints with `auth: true`.
|
|
1184
|
+
* Mock data bypasses network calls but still validates responses.
|
|
359
1185
|
|
|
360
1186
|
---
|
|
361
1187
|
|