@tahanabavi/typefetch 1.4.1 → 1.5.6

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,137 +1,302 @@
1
1
  # TypeFetch
2
2
 
3
- TypeFetch is a production-grade, strongly-typed HTTP client built on
4
- **TypeScript** and **Zod**.
5
-
6
- Define your API once using Zod schemas, and TypeFetch generates a fully
7
- type-safe client with:
8
-
9
- - End-to-end type safety
10
- - Structured request support: `{ path, query, body, headers }`
11
- - Automatic URL handling (path parameters, query string, JSON body)
12
- - Middleware pipeline (logging, retry, cache, auth, custom)
13
- - Built-in retry engine with backoff strategies
14
- - Timeout & AbortController support
15
- - Mock mode for development
16
- - Dynamic token providers
17
- - Response wrappers for consistent API envelopes
18
- - Unified error system (`RichError`)
19
- - Optional `form-data` body support for file uploads
20
- - Concurrency-safe request handling
21
- - Production-grade validation and error normalization
3
+ **TypeFetch** is a strongly typed HTTP client for TypeScript projects, built around **Zod** contracts.
4
+
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
+
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
22
32
 
23
33
  ---
24
34
 
25
35
  ## Installation
26
36
 
27
37
  ```bash
28
- npm install @tahanabavi/typefetch
29
- # or
30
- yarn add @tahanabavi/typefetch
38
+ npm install @tahanabavi/typefetch zod
31
39
  ```
32
40
 
33
- ---
41
+ Or with Yarn:
34
42
 
35
- # What's New / Updated
43
+ ```bash
44
+ yarn add @tahanabavi/typefetch zod
45
+ ```
36
46
 
37
- ## 1. Advanced Retry Engine
47
+ Or with pnpm:
38
48
 
39
- TypeFetch now includes:
49
+ ```bash
50
+ pnpm add @tahanabavi/typefetch zod
51
+ ```
40
52
 
41
- - Configurable `maxRetries`
42
- - Custom `retryCondition`
43
- - Built-in backoff strategies:
44
- - `fixed`
45
- - `exponential`
46
- - Fully normalized retry errors
53
+ ---
47
54
 
48
- Example:
55
+ ## Quick Start
49
56
 
50
57
  ```ts
51
- client.setRetryConfig({
52
- maxRetries: 3,
53
- backoff: "exponential",
54
- retryCondition: (err) => err.status === 500,
55
- });
56
- ```
58
+ import { z } from "zod";
59
+ import { ApiClient } from "@tahanabavi/typefetch";
57
60
 
58
- ---
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;
79
+
80
+ const client = new ApiClient(
81
+ {
82
+ baseUrl: "https://api.example.com",
83
+ tokenProvider: async () => "your-token",
84
+ },
85
+ contracts,
86
+ );
59
87
 
60
- ## 2. Backoff Strategies
88
+ client.init();
61
89
 
62
- Supported strategies:
90
+ const api = client.modules;
63
91
 
64
- - **fixed** constant delay
65
- - **exponential** 100ms, 200ms, 400ms...
92
+ const user = await api.user.getUser({
93
+ path: { id: "123" },
94
+ });
66
95
 
67
- Backoff is applied automatically between retries.
96
+ console.log(user.name);
97
+ ```
68
98
 
69
99
  ---
70
100
 
71
- ## 3. Timeout & Abort Support
101
+ ## Defining API Contracts
72
102
 
73
- Per-request timeout:
103
+ A TypeFetch contract is a grouped object of modules and endpoints.
74
104
 
75
105
  ```ts
76
- await api.user.getUser({ path: { id: "123" } }, { timeout: 5000 });
106
+ const contracts = {
107
+ user: {
108
+ getUser: {
109
+ method: "GET",
110
+ path: "/users/:id",
111
+ request: z.object({
112
+ path: z.object({
113
+ id: z.string(),
114
+ }),
115
+ }),
116
+ response: z.object({
117
+ id: z.string(),
118
+ name: z.string(),
119
+ }),
120
+ },
121
+
122
+ createUser: {
123
+ method: "POST",
124
+ path: "/users",
125
+ request: z.object({
126
+ body: z.object({
127
+ name: z.string(),
128
+ email: z.string().email(),
129
+ }),
130
+ }),
131
+ response: z.object({
132
+ id: z.string(),
133
+ name: z.string(),
134
+ email: z.string(),
135
+ }),
136
+ },
137
+ },
138
+ } as const;
77
139
  ```
78
140
 
79
- Internally uses `AbortController` for safe cancellation.
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
+ ```
80
155
 
81
156
  ---
82
157
 
83
- ## 4. Structured Request Model (Canonical Format)
158
+ ## Structured Request Model
159
+
160
+ The recommended request shape is:
84
161
 
85
162
  ```ts
86
163
  z.object({
87
- path: z.object({...}).optional(),
88
- query: z.object({...}).optional(),
89
- body: z.object({...}).optional(),
90
- headers: z.record(z.string()).optional(),
91
- })
164
+ path: z.object({}).optional(),
165
+ query: z.object({}).optional(),
166
+ body: z.any().optional(),
167
+ headers: z.record(z.string(), z.string()).optional(),
168
+ });
92
169
  ```
93
170
 
94
- TypeFetch automatically:
171
+ Each section has a specific purpose.
95
172
 
96
- - Injects path params
97
- - Builds query string
98
- - Serializes JSON body
99
- - Merges headers in priority order:
100
- 1. auth
101
- 2. endpoint-level headers
102
- 3. per-call headers
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 |
103
179
 
104
- ---
180
+ Example:
105
181
 
106
- ## 5. Backward Compatibility
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(), z.string()).optional(),
199
+ }),
200
+ response: z.object({
201
+ id: z.string(),
202
+ name: z.string(),
203
+ }),
204
+ },
205
+ },
206
+ } as const;
207
+ ```
107
208
 
108
- Flat request schemas still work:
209
+ Usage:
109
210
 
110
211
  ```ts
111
- z.object({
112
- name: z.string(),
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
+ },
113
221
  });
114
222
  ```
115
223
 
116
- For non-GET requests, the entire object becomes the JSON body.
224
+ TypeFetch sends:
225
+
226
+ ```ts
227
+ PATCH /users/123?notify=true
228
+ ```
229
+
230
+ With body:
231
+
232
+ ```json
233
+ {
234
+ "name": "Taha"
235
+ }
236
+ ```
117
237
 
118
238
  ---
119
239
 
120
- # Defining API Contracts
240
+ ## Request Schema Helper
121
241
 
122
- Example:
242
+ You can use `makeRequestSchema` to make structured request schemas easier to write.
123
243
 
124
244
  ```ts
125
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(), z.string()).optional(),
265
+ });
266
+ ```
267
+
268
+ Use it inside an endpoint:
126
269
 
270
+ ```ts
127
271
  const contracts = {
128
272
  user: {
129
- getUser: {
130
- method: "GET",
273
+ updateUser: {
274
+ method: "PATCH",
131
275
  path: "/users/:id",
132
- auth: true,
276
+ request: updateUserRequest,
277
+ response: z.object({
278
+ id: z.string(),
279
+ name: z.string(),
280
+ }),
281
+ },
282
+ },
283
+ } as const;
284
+ ```
285
+
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",
133
298
  request: z.object({
134
- path: z.object({ id: z.string() }),
299
+ name: z.string(),
135
300
  }),
136
301
  response: z.object({
137
302
  id: z.string(),
@@ -142,9 +307,21 @@ const contracts = {
142
307
  } as const;
143
308
  ```
144
309
 
310
+ Usage:
311
+
312
+ ```ts
313
+ await api.user.createUser({
314
+ name: "Taha",
315
+ });
316
+ ```
317
+
318
+ For non-`GET` requests, the full flat input is sent as the JSON body.
319
+
320
+ For `GET` requests, flat input is validated but no body is sent.
321
+
145
322
  ---
146
323
 
147
- # Using ApiClient
324
+ ## Creating the Client
148
325
 
149
326
  ```ts
150
327
  import { ApiClient } from "@tahanabavi/typefetch";
@@ -152,7 +329,6 @@ import { ApiClient } from "@tahanabavi/typefetch";
152
329
  const client = new ApiClient(
153
330
  {
154
331
  baseUrl: "https://api.example.com",
155
- tokenProvider: async () => "dynamic-token",
156
332
  },
157
333
  contracts,
158
334
  );
@@ -160,144 +336,855 @@ const client = new ApiClient(
160
336
  client.init();
161
337
 
162
338
  const api = client.modules;
339
+ ```
340
+
341
+ Always call `client.init()` before using `client.modules`.
342
+
343
+ ---
344
+
345
+ ## Client Configuration
163
346
 
164
- const user = await api.user.getUser({ path: { id: "123" } });
347
+ ```ts
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
+ );
165
361
  ```
166
362
 
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 |
370
+
371
+ When both `token` and `tokenProvider` are provided, `tokenProvider` takes priority.
372
+
167
373
  ---
168
374
 
169
- # Middleware System
375
+ ## Authentication
170
376
 
171
- Middlewares execute in reverse registration order.
377
+ Set `auth: true` on endpoints that require an authorization token.
172
378
 
173
- ## Custom Middleware
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;
394
+ ```
395
+
396
+ Use a static token:
397
+
398
+ ```ts
399
+ const client = new ApiClient(
400
+ {
401
+ baseUrl: "https://api.example.com",
402
+ token: "my-token",
403
+ },
404
+ contracts,
405
+ );
406
+ ```
407
+
408
+ Or use a dynamic token provider:
409
+
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
+ );
420
+ ```
421
+
422
+ You can also set the token provider later:
423
+
424
+ ```ts
425
+ client.setTokenProvider(async () => "new-token");
426
+ ```
427
+
428
+ ---
429
+
430
+ ## Middleware System
431
+
432
+ TypeFetch supports middleware for logging, authentication, caching, retries, encryption, and custom request behavior.
174
433
 
175
434
  ```ts
176
435
  client.use(async (ctx, next) => {
177
436
  console.log("Request:", ctx.url);
178
- const res = await next();
179
- console.log("Response:", res.status);
180
- return res;
437
+
438
+ const response = await next();
439
+
440
+ console.log("Response:", response.status);
441
+
442
+ return response;
181
443
  });
182
444
  ```
183
445
 
446
+ Middlewares run in registration order before the request, then unwind in reverse order after the response.
447
+
448
+ ```ts
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
461
+ ```
462
+
463
+ ---
464
+
184
465
  ## Built-in Middlewares
185
466
 
186
- - `loggingMiddleware`
187
- - `retryMiddleware`
188
- - `cacheMiddleware`
189
- - `authMiddleware`
467
+ Depending on how you export your middlewares, they can be registered directly or as factories.
468
+
469
+ Direct middleware example:
470
+
471
+ ```ts
472
+ client.use(loggingMiddleware, {
473
+ debug: true,
474
+ logRequest: true,
475
+ logResponse: true,
476
+ });
477
+ ```
478
+
479
+ Factory middleware example:
480
+
481
+ ```ts
482
+ client.use(cacheMiddleware({ ttl: 60_000 }));
483
+ client.use(retryMiddleware({ maxRetries: 3, delay: 300 }));
484
+ ```
485
+
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 |
190
509
 
191
510
  Example:
192
511
 
193
512
  ```ts
194
- client.use(loggingMiddleware);
195
- client.use(retryMiddleware, { maxRetries: 3 });
196
- client.use(cacheMiddleware, { ttl: 60000 });
197
- client.use(authMiddleware);
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();
198
551
  ```
199
552
 
200
553
  ---
201
554
 
202
- # Mock Mode
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:
203
593
 
204
594
  ```ts
205
- client.setMockMode(true, { min: 200, max: 1000 });
595
+ mockData: () => ({
596
+ id: crypto.randomUUID(),
597
+ name: "Dynamic Mock User",
598
+ });
206
599
  ```
207
600
 
208
- - Returns `mockData` instead of calling network
209
- - Still applies response validation and wrapper
601
+ Mock responses are still validated against the endpoint response schema.
210
602
 
211
603
  ---
212
604
 
213
- # Response Wrapper
605
+ ## Response Wrappers
214
606
 
215
- Supports envelope APIs:
607
+ Many APIs return wrapped responses.
216
608
 
217
609
  ```json
218
610
  {
219
611
  "success": true,
220
- "data": {...},
221
- "timestamp": "..."
612
+ "data": {
613
+ "id": "123",
614
+ "name": "Taha"
615
+ },
616
+ "timestamp": "2026-01-01T00:00:00.000Z"
222
617
  }
223
618
  ```
224
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
+
225
685
  Example:
226
686
 
227
687
  ```ts
228
- client.setResponseWrapper(wrapperSchema);
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:
730
+
731
+ ```ts
732
+ await api.user.uploadAvatar({
733
+ path: { id: "123" },
734
+ body: {
735
+ file,
736
+ alt: "Profile avatar",
737
+ },
738
+ });
229
739
  ```
230
740
 
231
- On failure, throws normalized `RichError`.
741
+ When using `form-data`, TypeFetch does not force the `Content-Type: application/json` header.
232
742
 
233
743
  ---
234
744
 
235
- # Error Handling
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
+ },
824
+ });
825
+ ```
826
+
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:
236
833
 
237
- All errors are normalized into `RichError`:
834
+ * `token` is decrypted with AES
238
835
 
239
- - HTTP errors
240
- - Network failures
241
- - Validation errors
242
- - Timeout errors
243
- - Retry exhaustion
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
+ ```
244
852
 
245
- Global handler:
853
+ ### Custom Encryption
246
854
 
247
855
  ```ts
248
- client.onError((err) => {
249
- console.error(err.message, err.status);
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
+ },
250
869
  });
251
870
  ```
252
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
+
253
916
  ---
254
917
 
255
- # File Uploads (FormData)
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 |
256
950
 
257
- Set `bodyType: "form-data"` in endpoint definition.
951
+ Array example:
952
+
953
+ ```ts
954
+ encryption: {
955
+ method: "AES",
956
+ request: {
957
+ users: [
958
+ {
959
+ password: true,
960
+ },
961
+ ],
962
+ },
963
+ }
964
+ ```
258
965
 
259
- TypeFetch builds `FormData` automatically.
966
+ This applies the first array map to every item unless an index-specific map exists.
260
967
 
261
968
  ---
262
969
 
263
- # Concurrency Safety
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
+ ```
264
1004
 
265
- TypeFetch safely handles parallel requests:
1005
+ Per-request headers can be passed through the structured request input:
266
1006
 
267
- - No shared mutable state issues
268
- - Independent retry cycles
269
- - Independent AbortControllers
1007
+ ```ts
1008
+ await api.user.createUser({
1009
+ headers: {
1010
+ "X-Request-ID": "req-123",
1011
+ },
1012
+ body: {
1013
+ name: "Taha",
1014
+ },
1015
+ });
1016
+ ```
270
1017
 
271
1018
  ---
272
1019
 
273
- # Production-Grade Test Coverage
1020
+ ## Custom Middleware
1021
+
1022
+ A middleware receives:
274
1023
 
275
- The project now includes:
1024
+ ```ts
1025
+ type Middleware = (
1026
+ ctx: MiddlewareContext,
1027
+ next: () => Promise<Response>,
1028
+ options?: unknown,
1029
+ ) => Promise<Response>;
1030
+ ```
1031
+
1032
+ Example:
276
1033
 
277
- - Validation tests
278
- - Middleware tests
279
- - Retry tests
280
- - Backoff timing tests
281
- - Timeout & abort tests
282
- - Concurrency tests
283
- - Error propagation tests
284
- - Mock mode tests
285
- - TokenProvider tests
286
- - Edge case handling tests
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
+ ```
287
1153
 
288
- Suitable for publishing as a production SDK.
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
289
1171
 
290
1172
  ---
291
1173
 
292
- # Notes
1174
+ ## Notes
293
1175
 
294
- - Always call `client.init()` before using modules.
295
- - All responses are validated via Zod.
296
- - Structured request shape is recommended.
297
- - Retry + Timeout can be combined safely.
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.
298
1185
 
299
1186
  ---
300
1187
 
301
- # License
1188
+ ## License
302
1189
 
303
1190
  MIT