@afoures/http-client 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ # HTTP Client
2
+
3
+ The `http_client` function creates a typed API client from a map of endpoints.
4
+
5
+ ## Basic Usage
6
+
7
+ ```typescript
8
+ import { Endpoint, http_client } from '@afoures/http-client'
9
+ import { z } from 'zod'
10
+
11
+ const api = http_client({
12
+ origin: 'https://api.example.com',
13
+ endpoints: {
14
+ users: new Endpoint({
15
+ method: 'GET',
16
+ pathname: '/users',
17
+ data: { schema: z.array(z.object({ id: z.string() })) },
18
+ }),
19
+ },
20
+ })
21
+
22
+ const result = await api.users({})
23
+ ```
24
+
25
+ ## Organizing Endpoints
26
+
27
+ Nest endpoints in objects for logical grouping:
28
+
29
+ ```typescript
30
+ const api = http_client({
31
+ origin: 'https://api.example.com',
32
+ endpoints: {
33
+ users: {
34
+ list: new Endpoint({ method: 'GET', pathname: '/users' }),
35
+ get: new Endpoint({ method: 'GET', pathname: '/users/(:id)' }),
36
+ create: new Endpoint({ method: 'POST', pathname: '/users' }),
37
+ update: new Endpoint({ method: 'PUT', pathname: '/users/(:id)' }),
38
+ delete: new Endpoint({ method: 'DELETE', pathname: '/users/(:id)' }),
39
+ },
40
+ posts: {
41
+ list: new Endpoint({ method: 'GET', pathname: '/posts' }),
42
+ get: new Endpoint({ method: 'GET', pathname: '/posts/(:id)' }),
43
+ comments: {
44
+ list: new Endpoint({ method: 'GET', pathname: '/posts/(:postId)/comments' }),
45
+ create: new Endpoint({ method: 'POST', pathname: '/posts/(:postId)/comments' }),
46
+ },
47
+ },
48
+ },
49
+ })
50
+
51
+ // Fully typed paths
52
+ await api.users.list({})
53
+ await api.users.get({ params: { id: '123' } })
54
+ await api.posts.comments.create({ params: { postId: '1' }, body: { text: 'Nice!' } })
55
+ ```
56
+
57
+ ## Shared Options
58
+
59
+ Provide sync or async default options for all requests:
60
+
61
+ ```typescript
62
+ const api = http_client({
63
+ origin: 'https://api.example.com',
64
+ endpoints: { /* ... */ },
65
+ options: async () => {
66
+ const token = await getAuthToken()
67
+ return {
68
+ headers: {
69
+ Authorization: `Bearer ${token}`,
70
+ },
71
+ }
72
+ },
73
+ })
74
+ ```
75
+
76
+ Options are merged in this order (later overrides earlier):
77
+ 1. `options()` from `http_client`
78
+ 2. Endpoint default options
79
+ 3. Per-request options
80
+
81
+ ## Custom Fetch
82
+
83
+ Provide a custom fetch function for proxying, logging, or modifying requests:
84
+
85
+ ```typescript
86
+ const api = http_client({
87
+ origin: 'https://api.example.com',
88
+ endpoints: { /* ... */ },
89
+ fetch: async (request) => {
90
+ console.log('Request:', request.url)
91
+ const response = await fetch(request)
92
+ console.log('Response:', response.status)
93
+ return response
94
+ },
95
+ })
96
+ ```
97
+
98
+ For testing, use tools like [MSW](https://mswjs.io/) instead of custom fetch.
99
+
100
+ ## Per-Request Options
101
+
102
+ All `RequestInit` options plus custom options can be passed per-request:
103
+
104
+ ```typescript
105
+ const result = await api.users.get({
106
+ params: { id: '123' },
107
+ headers: { 'X-Custom': 'value' },
108
+ signal: abortController.signal,
109
+ timeout: 5000,
110
+ retry: { attempts: 3, delay: 1000 },
111
+ })
112
+ ```
113
+
114
+ ## Headers with Reducers
115
+
116
+ Headers can be functions that receive the current value:
117
+
118
+ ```typescript
119
+ const endpoint = new Endpoint({
120
+ method: 'GET',
121
+ pathname: '/users',
122
+ headers: {
123
+ 'X-Request-ID': (current) => current ?? crypto.randomUUID(),
124
+ },
125
+ })
126
+ ```
127
+
128
+ ## Response Handling
129
+
130
+ All endpoint functions return a union type:
131
+
132
+ ```typescript
133
+ const result = await api.users.get({ params: { id: '123' } })
134
+
135
+ // Can be an error
136
+ if (result instanceof Error) {
137
+ // TimeoutError, NetworkError, SerializationError, etc.
138
+ return
139
+ }
140
+
141
+ // Or a response
142
+ if (result.ok) {
143
+ console.log(result.data)
144
+ } else {
145
+ console.log(result.error)
146
+ }
147
+ ```
@@ -0,0 +1,243 @@
1
+ # Response Parsing
2
+
3
+ Endpoints parse HTTP responses into typed results based on status code.
4
+
5
+ ## Response Types
6
+
7
+ ### Successful Response (20x)
8
+
9
+ ```typescript
10
+ type SuccessfulResponse<Data> = {
11
+ ok: true
12
+ status: 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226
13
+ data: Data
14
+ headers: Headers
15
+ raw_response: Response
16
+ }
17
+ ```
18
+
19
+ ### Redirect (30x)
20
+
21
+ ```typescript
22
+ type RedirectMessage = {
23
+ ok: false
24
+ status: 300 | 301 | 302 | 303 | 304 | 307 | 308
25
+ redirect_to: string | null
26
+ headers: Headers
27
+ raw_response: Response
28
+ }
29
+ ```
30
+
31
+ ### Client Error (40x)
32
+
33
+ ```typescript
34
+ type ClientErrorResponse<Error> = {
35
+ ok: false
36
+ status: 400 | 401 | 402 | 403 | 404 | /* ... */
37
+ error: Error
38
+ headers: Headers
39
+ raw_response: Response
40
+ }
41
+ ```
42
+
43
+ ### Server Error (50x)
44
+
45
+ ```typescript
46
+ type ServerErrorResponse<Error> = {
47
+ ok: false
48
+ status: 500 | 501 | 502 | 503 | 504 | /* ... */
49
+ error: Error
50
+ headers: Headers
51
+ raw_response: Response
52
+ }
53
+ ```
54
+
55
+ ## Data Parser
56
+
57
+ Define a `data` parser for successful responses:
58
+
59
+ ### JSON (Default)
60
+
61
+ ```typescript
62
+ const endpoint = new Endpoint({
63
+ method: 'GET',
64
+ pathname: '/users/(:id)',
65
+ data: {
66
+ schema: z.object({
67
+ id: z.string(),
68
+ name: z.string(),
69
+ }),
70
+ },
71
+ })
72
+
73
+ const result = await endpoint.parse_response(response)
74
+ if (result.ok) {
75
+ console.log(result.data) // { id: string, name: string }
76
+ }
77
+ ```
78
+
79
+ ### Text
80
+
81
+ ```typescript
82
+ const endpoint = new Endpoint({
83
+ method: 'GET',
84
+ pathname: '/health',
85
+ data: {
86
+ schema: z.string(),
87
+ deserialization: 'text',
88
+ },
89
+ })
90
+ ```
91
+
92
+ ### Custom Deserialization
93
+
94
+ ```typescript
95
+ const endpoint = new Endpoint({
96
+ method: 'GET',
97
+ pathname: '/data',
98
+ data: {
99
+ schema: z.object({ value: z.number() }),
100
+ deserialization: async (body) => {
101
+ const text = await new Response(body).text()
102
+ return JSON.parse(text)
103
+ },
104
+ },
105
+ })
106
+ ```
107
+
108
+ ### 204 No Content
109
+
110
+ For endpoints that return no content:
111
+
112
+ ```typescript
113
+ const endpoint = new Endpoint({
114
+ method: 'DELETE',
115
+ pathname: '/users/(:id)',
116
+ })
117
+
118
+ const result = await endpoint.parse_response(response)
119
+ // result.ok === true, result.status === 204, result.data === null
120
+ ```
121
+
122
+ ## Error Parser
123
+
124
+ Define an `error` parser for error responses:
125
+
126
+ ### JSON
127
+
128
+ ```typescript
129
+ const endpoint = new Endpoint({
130
+ method: 'POST',
131
+ pathname: '/users',
132
+ body: { schema: z.object({ name: z.string() }) },
133
+ error: {
134
+ schema: z.object({
135
+ message: z.string(),
136
+ code: z.string(),
137
+ }),
138
+ deserialization: 'json',
139
+ },
140
+ })
141
+
142
+ const result = await endpoint.parse_response(response)
143
+ if (!result.ok && result.status === 400) {
144
+ console.log(result.error.message)
145
+ console.log(result.error.code)
146
+ }
147
+ ```
148
+
149
+ ### Text
150
+
151
+ ```typescript
152
+ const endpoint = new Endpoint({
153
+ method: 'GET',
154
+ pathname: '/users/(:id)',
155
+ error: {
156
+ schema: z.string(),
157
+ deserialization: 'text',
158
+ },
159
+ })
160
+
161
+ const result = await endpoint.parse_response(response)
162
+ if (!result.ok && result.status === 404) {
163
+ console.log(result.error) // "Not Found"
164
+ }
165
+ ```
166
+
167
+ ### Default (No Schema)
168
+
169
+ Without an error parser, errors default to text:
170
+
171
+ ```typescript
172
+ const endpoint = new Endpoint({
173
+ method: 'GET',
174
+ pathname: '/users/(:id)',
175
+ })
176
+
177
+ const result = await endpoint.parse_response(response)
178
+ if (!result.ok) {
179
+ console.log(typeof result.error) // "string"
180
+ }
181
+ ```
182
+
183
+ ## Schema Transforms
184
+
185
+ Schemas can transform response data:
186
+
187
+ ```typescript
188
+ const endpoint = new Endpoint({
189
+ method: 'GET',
190
+ pathname: '/users/(:id)',
191
+ data: {
192
+ schema: z.object({
193
+ name: z.string().transform(s => s.toUpperCase()),
194
+ createdAt: z.string().transform(s => new Date(s)),
195
+ }),
196
+ },
197
+ })
198
+
199
+ const result = await endpoint.parse_response(response)
200
+ if (result.ok) {
201
+ console.log(result.data.name) // uppercase string
202
+ console.log(result.data.createdAt) // Date object
203
+ }
204
+ ```
205
+
206
+ ## Deserialization Errors
207
+
208
+ If response parsing fails validation, a `DeserializationError` is returned:
209
+
210
+ ```typescript
211
+ const result = await endpoint.parse_response(response)
212
+
213
+ if (result instanceof DeserializationError) {
214
+ console.log(result.message) // "Response deserialization failed"
215
+ console.log(result.cause) // Schema validation issues
216
+ }
217
+ ```
218
+
219
+ ## Handling All Cases
220
+
221
+ ```typescript
222
+ const result = await api.users.get({ params: { id: '123' } })
223
+
224
+ if (result instanceof Error) {
225
+ // UnexpectedError, NetworkError, TimeoutError, etc.
226
+ console.log(result.message)
227
+ return
228
+ }
229
+
230
+ if (result.ok) {
231
+ // 20x success
232
+ console.log(result.data)
233
+ } else if (result.status >= 300 && result.status < 400) {
234
+ // Redirect
235
+ console.log(result.redirect_to)
236
+ } else if (result.status >= 400 && result.status < 500) {
237
+ // Client error
238
+ console.log(result.error)
239
+ } else {
240
+ // Server error
241
+ console.log(result.error)
242
+ }
243
+ ```
@@ -0,0 +1,197 @@
1
+ # Retry Policy
2
+
3
+ Configure automatic retries for failed requests.
4
+
5
+ ## Configuration
6
+
7
+ ```typescript
8
+ type RetryPolicy = {
9
+ attempts?: number | ((ctx: { request: Request }) => number | Promise<number>)
10
+ delay?: number | ((ctx: { request: Request; response?: Response; error?: Error; attempt: number }) => number | Promise<number>)
11
+ when?: (ctx: { request: Request; response?: Response; error?: Error }) => boolean | Promise<boolean>
12
+ }
13
+ ```
14
+
15
+ ## Basic Usage
16
+
17
+ ### Fixed Attempts and Delay
18
+
19
+ ```typescript
20
+ const result = await api.users.get({
21
+ params: { id: '123' },
22
+ retry: {
23
+ attempts: 3,
24
+ delay: 1000, // 1 second
25
+ },
26
+ })
27
+ ```
28
+
29
+ ### Conditional Retry
30
+
31
+ By default, retries on non-OK responses. Customize with `when`:
32
+
33
+ ```typescript
34
+ const result = await api.users.get({
35
+ params: { id: '123' },
36
+ retry: {
37
+ attempts: 3,
38
+ delay: 1000,
39
+ when: ({ response, error }) => {
40
+ // Retry on server errors or network failures
41
+ if (error) return true
42
+ if (response && response.status >= 500) return true
43
+ return false
44
+ },
45
+ },
46
+ })
47
+ ```
48
+
49
+ ### Retry on Specific Status
50
+
51
+ ```typescript
52
+ const result = await api.users.get({
53
+ params: { id: '123' },
54
+ retry: {
55
+ attempts: 5,
56
+ delay: 2000,
57
+ when: ({ response }) => {
58
+ if (!response) return false
59
+ return response.status === 503 // Service Unavailable
60
+ },
61
+ },
62
+ })
63
+ ```
64
+
65
+ ## Exponential Backoff
66
+
67
+ Use a delay function for exponential backoff:
68
+
69
+ ```typescript
70
+ const result = await api.users.get({
71
+ params: { id: '123' },
72
+ retry: {
73
+ attempts: 5,
74
+ delay: ({ attempt }) => Math.min(1000 * Math.pow(2, attempt), 30000),
75
+ when: ({ error }) => !!error,
76
+ },
77
+ })
78
+ ```
79
+
80
+ ## Retry on All GET Requests
81
+
82
+ ```typescript
83
+ const endpoint = new Endpoint({
84
+ method: 'GET',
85
+ pathname: '/users',
86
+ retry: {
87
+ attempts: 3,
88
+ delay: 1000,
89
+ when: ({ request }) => request.method === 'GET',
90
+ },
91
+ })
92
+ ```
93
+
94
+ ## Dynamic Attempts
95
+
96
+ Determine max attempts dynamically:
97
+
98
+ ```typescript
99
+ const result = await api.users.get({
100
+ params: { id: '123' },
101
+ retry: {
102
+ attempts: ({ request }) => {
103
+ // Check custom header or metadata
104
+ const priority = request.headers.get('X-Priority')
105
+ return priority === 'high' ? 5 : 2
106
+ },
107
+ delay: 1000,
108
+ },
109
+ })
110
+ ```
111
+
112
+ ## Context Information
113
+
114
+ The `when` and `delay` functions receive context about the request:
115
+
116
+ ```typescript
117
+ retry: {
118
+ when: ({ request, response, error }) => {
119
+ // request: The Request object
120
+ // response: The Response if received, undefined if network error
121
+ // error: NetworkError, TimeoutError, etc. if occurred
122
+ return true
123
+ },
124
+ delay: ({ request, response, error, attempt }) => {
125
+ // attempt: Current attempt number (1-indexed)
126
+ return attempt * 1000
127
+ },
128
+ }
129
+ ```
130
+
131
+ ## Default Behavior
132
+
133
+ Without a `when` condition, retries on non-OK responses:
134
+
135
+ ```typescript
136
+ retry: {
137
+ attempts: 3,
138
+ delay: 0,
139
+ // when: defaults to ({ response }) => response?.ok === false
140
+ }
141
+ ```
142
+
143
+ ## Endpoint-Level Retry
144
+
145
+ Set default retry on the endpoint:
146
+
147
+ ```typescript
148
+ const endpoint = new Endpoint({
149
+ method: 'GET',
150
+ pathname: '/users',
151
+ retry: {
152
+ attempts: 3,
153
+ delay: 1000,
154
+ },
155
+ })
156
+ ```
157
+
158
+ Per-request retry overrides endpoint defaults.
159
+
160
+ ## AbortSignal with Retry
161
+
162
+ Retries respect `AbortSignal`:
163
+
164
+ ```typescript
165
+ const controller = new AbortController()
166
+
167
+ const result = await api.users.get({
168
+ params: { id: '123' },
169
+ signal: controller.signal,
170
+ retry: { attempts: 10, delay: 1000 },
171
+ })
172
+
173
+ // Call controller.abort() to cancel retries
174
+ ```
175
+
176
+ ## Example: Resilient API Client
177
+
178
+ ```typescript
179
+ const api = http_client({
180
+ origin: 'https://api.example.com',
181
+ endpoints: {
182
+ users: new Endpoint({
183
+ method: 'GET',
184
+ pathname: '/users',
185
+ retry: {
186
+ attempts: 3,
187
+ delay: ({ attempt }) => Math.min(100 * Math.pow(2, attempt), 5000),
188
+ when: ({ response, error }) => {
189
+ if (error) return true
190
+ if (!response) return false
191
+ return response.status >= 500 || response.status === 429
192
+ },
193
+ },
194
+ }),
195
+ },
196
+ })
197
+ ```