@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 CHANGED
@@ -1,361 +1,1187 @@
1
1
  # TypeFetch
2
2
 
3
- TypeFetch is a strongly-typed HTTP client built on **TypeScript** and **Zod**.
3
+ **TypeFetch** is a strongly typed HTTP client for TypeScript projects, built around **Zod** contracts.
4
4
 
5
- You define your API once using Zod schemas, and TypeFetch generates a fully type-safe client with:
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
- - End-to-end type safety
8
- - Structured request support: `{ path, query, body, headers }`
9
- - Automatic URL handling (path parameters, query string, JSON body)
10
- - Middleware pipeline (logging, retry, cache, auth, custom)
11
- - Mock mode for development
12
- - Dynamic token providers
13
- - Response wrappers for consistent API envelopes
14
- - Unified error system (`RichError`)
15
- - Optional `form-data` body support for file uploads
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
- ### 1. Type-Safe API Client
32
-
33
- Define your API with Zod schemas and get full type safety for request and response types.
43
+ ```bash
44
+ yarn add @tahanabavi/typefetch zod
45
+ ```
34
46
 
35
- Each endpoint has:
47
+ Or with pnpm:
36
48
 
37
- - `method`: HTTP verb (`GET | POST | PUT | PATCH | DELETE`)
38
- - `path`: path template (e.g. `/users/:id`)
39
- - `auth?`: whether a token is required
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
- ### 2. Structured Request Shape
53
+ ---
47
54
 
48
- The recommended request shape is:
55
+ ## Quick Start
49
56
 
50
57
  ```ts
51
- z.object({
52
- path: z.object({ ... }).optional(), // URL params → /users/:id
53
- query: z.object({ ... }).optional(), // query string → ?page=1
54
- body: z.object({ ... }).optional(), // JSON body
55
- headers: z.record(z.string()).optional(), // per-call extra headers
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
- TypeFetch will:
80
+ const client = new ApiClient(
81
+ {
82
+ baseUrl: "https://api.example.com",
83
+ tokenProvider: async () => "your-token",
84
+ },
85
+ contracts,
86
+ );
60
87
 
61
- - Replace `:param` segments in the path using `path`
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
- ### 3. Backward Compatibility
90
+ const api = client.modules;
70
91
 
71
- If your `request` schema is **flat** (e.g. `z.object({ name: z.string() })`) and does **not** contain `path`, `query`, `body`, or `headers`, TypeFetch treats the entire object as the request body for non-GET methods.
92
+ const user = await api.user.getUser({
93
+ path: { id: "123" },
94
+ });
72
95
 
73
- This makes migration to the structured format incremental and safe.
96
+ console.log(user.name);
97
+ ```
74
98
 
75
99
  ---
76
100
 
77
101
  ## Defining API Contracts
78
102
 
79
- Example contract definition:
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({ id: z.string() }).optional(),
93
- query: z.object({}).optional(),
94
- body: z.never().optional(),
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
- path: z.object({}).optional(),
110
- query: z.object({}).optional(),
111
- body: z
112
- .object({
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
- You **do not** have to use these explicit generic annotations if you don’t want to – they are shown here only for clarity. In most cases, simple `as const` + inference is enough.
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
- ## Using `ApiClient`
158
+ ## Structured Request Model
159
+
160
+ The recommended request shape is:
136
161
 
137
162
  ```ts
138
- import { ApiClient } from "@tahanabavi/typefetch";
139
- import { contracts } from "./contracts";
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
- const client = new ApiClient(
142
- {
143
- baseUrl: "https://api.example.com",
144
- tokenProvider: () => "dynamic-token", // or undefined for public endpoints
145
- useMockData: false,
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
- contracts
148
- );
206
+ } as const;
207
+ ```
149
208
 
150
- client.init();
209
+ Usage:
151
210
 
152
- const api = client.modules;
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
- const user = await api.user.getUser({ path: { id: "123" } });
155
- const created = await api.user.createUser({ body: { name: "Alice" } });
226
+ ```ts
227
+ PATCH /users/123?notify=true
156
228
  ```
157
229
 
158
- - `client.init()` builds the typed `modules` API using your contracts.
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
- ## Middlewares
238
+ ---
164
239
 
165
- Middlewares allow you to hook into the request/response lifecycle.
240
+ ## Request Schema Helper
166
241
 
167
- ### Custom Middleware Example
242
+ You can use `makeRequestSchema` to make structured request schemas easier to write.
168
243
 
169
244
  ```ts
170
- client.use(async (ctx, next) => {
171
- console.log("Request to:", ctx.url);
172
- const res = await next();
173
- console.log("Response:", res.status);
174
- return res;
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
- ### Built-in Middlewares
268
+ Use it inside an endpoint:
179
269
 
180
270
  ```ts
181
- import {
182
- loggingMiddleware,
183
- retryMiddleware,
184
- cacheMiddleware,
185
- authMiddleware,
186
- } from "@tahanabavi/typefetch/middlewares";
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
- client.use(loggingMiddleware, {
189
- logRequest: true,
190
- logResponse: true,
191
- debug: true,
192
- });
193
- client.use(retryMiddleware, { maxRetries: 3, delay: 100 });
194
- client.use(cacheMiddleware, { ttl: 60000 });
195
- client.use(authMiddleware, {
196
- refreshToken: async () => "refreshed-token",
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
- - **`loggingMiddleware`** logs requests and responses (controlled by `debug`, `logRequest`, `logResponse`).
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
- ## Mock Mode
322
+ ---
208
323
 
209
- Enable or disable mock mode globally:
324
+ ## Creating the Client
210
325
 
211
326
  ```ts
212
- client.setMockMode(true, { min: 200, max: 1000 }); // simulate network delay
213
- // ...
214
- client.setMockMode(false);
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
- When mock mode is enabled and `endpoint.mockData` is defined, requests will return mock data instead of hitting the network. The response wrapper and response transform still apply.
341
+ Always call `client.init()` before using `client.modules`.
218
342
 
219
343
  ---
220
344
 
221
- ## Response Transformation
222
-
223
- You can apply a global transformation to all successful responses:
345
+ ## Client Configuration
224
346
 
225
347
  ```ts
226
- client.useResponseTransform((data) => {
227
- return {
228
- ...data,
229
- transformedAt: new Date().toISOString(),
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
- This runs after:
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
- 1. The HTTP call succeeds (`res.ok` is true)
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
- ## Response Wrapper Example
375
+ ## Authentication
243
376
 
244
- For APIs that wrap responses like this:
377
+ Set `auth: true` on endpoints that require an authorization token.
245
378
 
246
- ```json
247
- {
248
- "success": true,
249
- "data": { ... },
250
- "timestamp": "...",
251
- "requestId": "..."
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
- You can define a single wrapper schema:
396
+ Use a static token:
256
397
 
257
398
  ```ts
258
- import { z } from "zod";
399
+ const client = new ApiClient(
400
+ {
401
+ baseUrl: "https://api.example.com",
402
+ token: "my-token",
403
+ },
404
+ contracts,
405
+ );
406
+ ```
259
407
 
260
- const wrapper = (successResponse: z.ZodTypeAny) =>
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
- client.setResponseWrapper(wrapper);
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
- On `success: true`, `data` is passed to the endpoint’s `response` schema.
281
- On `success: false`, a `RichError` is thrown with normalized information.
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
- ## Error Handling
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.onError((err) => {
289
- console.error("API Error:", err.message, err.status, err.code);
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
- Errors are normalized into `RichError` (or your custom type if you change the generic). Zod validation errors are also wrapped into a `VALIDATION_ERROR` with a readable message.
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
- try {
299
- const user = await api.user.getUser({ path: { id: "123" } });
300
- } catch (err) {
301
- // err is RichError
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
- ## File Uploads (`form-data`)
465
+ ## Built-in Middlewares
308
466
 
309
- For endpoints that need file upload, set `bodyType: "form-data"` and put file(s) inside `body`:
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
- const uploadAvatarRequest = z.object({
313
- path: z.object({}).optional(),
314
- query: z.object({}).optional(),
315
- body: z.object({
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
- const uploadAvatarResponse = z.object({
322
- url: z.string(),
323
- });
479
+ Factory middleware example:
324
480
 
325
- const contracts = {
326
- user: {
327
- uploadAvatar: {
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
- Usage:
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
- body: { file },
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
- The client will build a `FormData` object and let the browser set the `Content-Type` header.
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
- - Always call `client.init()` before using `client.modules`.
355
- - Middlewares execute in **reverse registration order** (last registered runs first).
356
- - Endpoints with `auth: true` require a valid token from `token` or `tokenProvider`.
357
- - All responses are parsed and validated by Zod using each endpoint’s `response` schema.
358
- - Structured `{ path, query, body, headers }` shape is the canonical model; flat request schemas are still supported for backwards compatibility.
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