hanzio 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +24 -10
- package/dist/api-wrapper/endpoint.d.ts +8 -5
- package/dist/api-wrapper/transport.d.ts +1 -0
- package/dist/api-wrapper/types.d.ts +7 -0
- package/dist/{index-j1ybktbr.js → index-zbj9s3s3.js} +6 -4
- package/dist/src/api-wrapper/index.js +1 -1
- package/dist/src/index.js +8 -2
- package/dist/src/secrets/index.js +1 -1
- package/dist/typedswitch/index.d.ts +55 -34
- package/package.json +11 -3
- package/src/api-wrapper/README.md +741 -0
- package/src/api-wrapper/SKILL.md +256 -0
- package/src/api-wrapper/create-client.ts +9 -3
- package/src/api-wrapper/endpoint.ts +19 -5
- package/src/api-wrapper/transport.ts +6 -2
- package/src/api-wrapper/types.ts +13 -0
- package/src/crypto/README.md +144 -0
- package/src/format/README.md +120 -0
- package/src/i18n/README.md +65 -0
- package/src/jwt/README.md +70 -0
- package/src/p-queue/README.md +237 -0
- package/src/secrets/README.md +413 -0
- package/src/sitemap/README.md +61 -0
- package/src/storage/README.md +71 -0
- package/src/typedswitch/README.md +335 -0
- package/src/typedswitch/index.ts +97 -83
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
# hanzio/api-wrapper
|
|
2
|
+
|
|
3
|
+
Typed HTTP clients from Zod schemas: `createApiClient` validates requests and
|
|
4
|
+
responses, retries with exponential backoff, and throws structured errors.
|
|
5
|
+
|
|
6
|
+
Building a new wrapper with an AI agent? [SKILL.md](./SKILL.md) has the
|
|
7
|
+
step-by-step workflow and folder conventions. It ships with the package: copy
|
|
8
|
+
`node_modules/hanzio/src/api-wrapper/SKILL.md` to
|
|
9
|
+
`.claude/skills/hanzio-api-wrapper/SKILL.md` (or `.agents/skills/…`), or point
|
|
10
|
+
your agent at it.
|
|
11
|
+
|
|
12
|
+
## Quick Start
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { createApiClient, defineEndpoint } from 'hanzio/api-wrapper'
|
|
16
|
+
import { z } from 'zod'
|
|
17
|
+
|
|
18
|
+
const usersGet = defineEndpoint({
|
|
19
|
+
method: 'GET',
|
|
20
|
+
path: '/users/:userId',
|
|
21
|
+
resSchema: z.object({ id: z.number(), name: z.string(), email: z.string() })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export const exampleApi = createApiClient({
|
|
25
|
+
name: 'exampleApi',
|
|
26
|
+
baseApiUrls: { default: 'https://api.example.com' },
|
|
27
|
+
defaultHeaders: () => ({ Authorization: `Bearer ${getSecret('EXAMPLE_TOKEN')}` }),
|
|
28
|
+
endpoints: { usersGet }
|
|
29
|
+
})
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Defining Endpoints
|
|
33
|
+
|
|
34
|
+
`defineEndpoint` infers everything from the definition:
|
|
35
|
+
|
|
36
|
+
- **Path params:** a literal `path` types its `:params`, so `reqParams` is
|
|
37
|
+
required and checked even without a `reqParamsSchema`
|
|
38
|
+
(`'/users/:userId'` → `{ userId: string | number }`).
|
|
39
|
+
- **Request types are schema inputs:** callers pass what the schema accepts
|
|
40
|
+
(`z.input`), so fields with `.default()` are optional and
|
|
41
|
+
`z.string().transform(Number)` takes a string. The response is the parsed
|
|
42
|
+
output (`z.output`).
|
|
43
|
+
- **`resFormatter` is checked:** it must return what `resSchema` parses.
|
|
44
|
+
|
|
45
|
+
`satisfies ApiEndpoint` still works, but it widens `path` to `string` (so
|
|
46
|
+
params fall back to an optional `Record<string, string | number>`) and leaves
|
|
47
|
+
`resFormatter` unchecked.
|
|
48
|
+
|
|
49
|
+
Object and array bodies are sent as JSON unless you set another
|
|
50
|
+
`Content-Type`; `reqBodyFormat: 'form-data'` sends `FormData`.
|
|
51
|
+
|
|
52
|
+
## Calling Endpoints
|
|
53
|
+
|
|
54
|
+
`createApiClient` returns a client with both a generic `request` method and
|
|
55
|
+
per-endpoint methods (created via Proxy) for autocomplete.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
await exampleApi.request('usersGet', { reqParams: { userId: 'u1' } })
|
|
59
|
+
await exampleApi.usersGet({ reqParams: { userId: 'u1' } })
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
When a schema is defined for `reqParams`, `reqBody`, `reqQuery`, or
|
|
63
|
+
`reqHeaders` (or the path has `:params`), the corresponding input field is
|
|
64
|
+
**required at the type level**. Endpoint and action names can't be `request`,
|
|
65
|
+
`cache` or `safe`; the client reserves them.
|
|
66
|
+
|
|
67
|
+
### Handling Errors Without try/catch
|
|
68
|
+
|
|
69
|
+
`api.safe` has the same endpoints and actions but resolves to a `Result`
|
|
70
|
+
instead of throwing `ApiError`s. Narrow on `error.category`. Other errors, such
|
|
71
|
+
as bugs in hooks or middleware, still throw.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
const result = await exampleApi.safe.usersGet({ reqParams: { userId: 'u1' } })
|
|
75
|
+
if (!result.ok) {
|
|
76
|
+
if (result.error.category === 'client_error') return null
|
|
77
|
+
throw result.error
|
|
78
|
+
}
|
|
79
|
+
result.data.data // parsed response
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Type Helpers
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import type {
|
|
86
|
+
AnyApiClient,
|
|
87
|
+
EndpointRequest,
|
|
88
|
+
EndpointResponse,
|
|
89
|
+
InferRequest,
|
|
90
|
+
InferResponse
|
|
91
|
+
} from 'hanzio/api-wrapper'
|
|
92
|
+
|
|
93
|
+
type User = InferResponse<typeof exampleApi, 'usersGet'> // response data
|
|
94
|
+
type UsersGetInput = InferRequest<typeof exampleApi, 'usersGet'>
|
|
95
|
+
type Same = EndpointResponse<typeof usersGet> // from the endpoint definition
|
|
96
|
+
type Input = EndpointRequest<typeof usersGet>
|
|
97
|
+
|
|
98
|
+
// Helpers that take whichever client they're given:
|
|
99
|
+
async function ping(api: AnyApiClient) {
|
|
100
|
+
return api.safe.health?.()
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Typed `meta`
|
|
105
|
+
|
|
106
|
+
`meta` is `Record<string, unknown>` by default. Augment `ApiRequestMeta` once
|
|
107
|
+
to type it on every call, action, hook, middleware and error context:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
declare module 'hanzio/api-wrapper' {
|
|
111
|
+
interface ApiRequestMeta {
|
|
112
|
+
traceId: string
|
|
113
|
+
tenantId?: string
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await exampleApi.usersGet({ reqParams: { userId: 'u1' }, meta: { traceId } })
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Query Parameters
|
|
121
|
+
|
|
122
|
+
`reqQuery` values may be strings, numbers, booleans, or arrays of those. Arrays
|
|
123
|
+
repeat the key, and `null`/`undefined` values are skipped:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
await api.search({ reqQuery: { tag: ['a', 'b'], q: undefined } })
|
|
127
|
+
// GET /search?tag=a&tag=b
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Values are URL-encoded unless the endpoint sets `doNotEncodeQueryParams: true`.
|
|
131
|
+
APIs that expect `tag=a,b` or `tag[]=a` need the value joined or the key renamed
|
|
132
|
+
in `reqQuery` before the call.
|
|
133
|
+
|
|
134
|
+
### Per-Call Overrides
|
|
135
|
+
|
|
136
|
+
Every `RequestInput` accepts these extra options:
|
|
137
|
+
|
|
138
|
+
| Option | Description |
|
|
139
|
+
| --- | --- |
|
|
140
|
+
| `signal` | `AbortSignal` to cancel the request from the caller. |
|
|
141
|
+
| `timeoutMs` | Override the client-level timeout for this call. |
|
|
142
|
+
| `retries` | Override the retry count for this call. |
|
|
143
|
+
| `fetch` | Override the fetch implementation for this call. |
|
|
144
|
+
| `meta` | Arbitrary metadata forwarded to hooks and error context. |
|
|
145
|
+
| `url` | Bypass `baseApiUrls`/`path` and call this absolute URL. |
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const controller = new AbortController()
|
|
149
|
+
const result = await api.usersGet({
|
|
150
|
+
reqParams: { userId: 'u1' },
|
|
151
|
+
signal: controller.signal,
|
|
152
|
+
timeoutMs: 5000,
|
|
153
|
+
retries: 0,
|
|
154
|
+
meta: { traceId: 'abc-123' }
|
|
155
|
+
})
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Response Shape
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
type ApiWrapperResponse<T> = {
|
|
162
|
+
data: T // validated response body
|
|
163
|
+
httpStatus: number
|
|
164
|
+
retryCount: number
|
|
165
|
+
responseTimeMs: number
|
|
166
|
+
responseSizeMb: number
|
|
167
|
+
requestBodySizeMb: number
|
|
168
|
+
headers: Record<string, string> // final attempt, lowercase names
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Client Configuration
|
|
173
|
+
|
|
174
|
+
In addition to `baseApiUrls`, `defaultHeaders`, and `endpoints`, `createApiClient`
|
|
175
|
+
accepts:
|
|
176
|
+
|
|
177
|
+
| Option | Description |
|
|
178
|
+
| --- | --- |
|
|
179
|
+
| `name` | Prefix used in error/log context, e.g. `exampleApi.usersGet`. |
|
|
180
|
+
| `defaultHeaders` | Static headers, or a (sync or async) function resolved on every request, e.g. `() => ({ Authorization: \`Bearer ${getSecret('TOKEN')}\` })`. |
|
|
181
|
+
| `defaultBaseApiUrl` | Key in `baseApiUrls` to use when an endpoint doesn't specify one. |
|
|
182
|
+
| `timeoutMs` | Default per-request timeout (uses `AbortController`). Applied **per attempt** — each retry gets its own timeout window, not a cumulative budget across retries. |
|
|
183
|
+
| `retries` | Default retry count (default `3`). |
|
|
184
|
+
| `retryDelayMs` | Delay between retries. `number \| (attempt) => number`, where `attempt` is `0` before the first retry. Default: exponential backoff with full jitter, a random delay in `[0, 300 * 2^attempt]` ms, capped at `maxRetryDelayMs`. |
|
|
185
|
+
| `maxRetryDelayMs` | Upper bound on retry delay (default `30000`). Caps the default backoff, `retryDelayMs`, and `Retry-After` headers on `429`/`503` responses. |
|
|
186
|
+
| `shouldRetry` | `(ctx) => boolean` to override the default retry policy. |
|
|
187
|
+
| `fetch` | Custom fetch implementation, typed as `FetchLike` (`(input, init?) => Promise<Response>`), so plain functions and mocks work without Bun's `preconnect`. |
|
|
188
|
+
| `use` | Middleware around each whole request (see [Middleware](#middleware)). |
|
|
189
|
+
| `checkResponse` | `(data, ctx) => void` run after `resSchema` validation on every attempt. Throw to reject a response that is an error in disguise (see [Rejecting 200 responses](#rejecting-200-responses)). Endpoints can replace or skip it. |
|
|
190
|
+
| `logger` | `Pick<Console, 'debug' \| 'error'>`. Each failed request is logged once with a categorized summary (see [Debugging Failed Requests](#debugging-failed-requests)). |
|
|
191
|
+
| `onRequest` | Hook that runs once per request, before the first attempt. May return `{ headers, body }`; returned `headers` **replace** the outgoing headers, so spread `context.headers` to add one. For auth, prefer a `defaultHeaders` function. |
|
|
192
|
+
| `onResponse` | Hook that runs after each response is received (called per attempt). |
|
|
193
|
+
| `onRetry` | Hook called before each retry with `{ delayMs, nextAttempt, ... }`. |
|
|
194
|
+
| `onError` | Hook called when an `ApiError` is about to be thrown. |
|
|
195
|
+
| `redact` | Optional `(context: ApiErrorContext) => ApiErrorContext` applied before `logger` / `onError` and before errors store context. Built-in default redaction strips sensitive headers (`authorization`, `cookie`, `set-cookie`) and common secret-like body keys (`password`, `token`, `accessToken`, `refreshToken`, `secret`). Override wholly or layer on top of the default by copying fields you need from the incoming context. |
|
|
196
|
+
|
|
197
|
+
The default retry policy retries network errors and `5xx`/`429` responses, and
|
|
198
|
+
**does not retry** validation, abort, config, or `4xx` errors. `Retry-After`
|
|
199
|
+
headers on `429`/`503` are respected up to `maxRetryDelayMs`. Aborting the
|
|
200
|
+
caller's `signal` while waiting between retries throws `RequestAbortedError`
|
|
201
|
+
immediately.
|
|
202
|
+
|
|
203
|
+
### Middleware
|
|
204
|
+
|
|
205
|
+
`use` takes middleware that wraps one logical request: `onRequest`, every
|
|
206
|
+
retry attempt, validation and `checkResponse`. They run in array order (the
|
|
207
|
+
first is outermost), see the final `ApiWrapperResponse` (`httpStatus`,
|
|
208
|
+
`retryCount`, sizes, timing), and see thrown errors after redaction and
|
|
209
|
+
`onError`. Input validation and config errors (unknown endpoint, missing path
|
|
210
|
+
parameter) are thrown before middleware runs. Middleware applies to endpoint
|
|
211
|
+
calls made inside actions too.
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
import type { ApiMiddleware } from 'hanzio/api-wrapper'
|
|
215
|
+
|
|
216
|
+
const timing: ApiMiddleware = async (ctx, next) => {
|
|
217
|
+
const result = await next()
|
|
218
|
+
metrics.histogram('api.duration', result.responseTimeMs, {
|
|
219
|
+
endpoint: `${ctx.client}.${ctx.endpoint}`,
|
|
220
|
+
retries: result.retryCount
|
|
221
|
+
})
|
|
222
|
+
return result
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const api = createApiClient({ name: 'stream', use: [timing], ...config })
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`ctx` is `{ client?, endpoint, method, url, headers, meta? }`, where
|
|
229
|
+
`endpoint` is the endpoint key and `client` is the client's `name`. Mutating
|
|
230
|
+
`ctx.headers` before calling `next()` adds headers to the outgoing request.
|
|
231
|
+
|
|
232
|
+
### OpenTelemetry
|
|
233
|
+
|
|
234
|
+
`otelMiddleware` creates one CLIENT span per logical request (covering every
|
|
235
|
+
retry, validation and `checkResponse`), named `client.endpoint`, with the
|
|
236
|
+
standard HTTP attributes (`http.request.method`, `url.full`,
|
|
237
|
+
`server.address`, `http.response.status_code`, `http.request.resend_count`,
|
|
238
|
+
`error.type`) plus `api.error.category`. Failures get `recordException` and an
|
|
239
|
+
ERROR status. It's started as the active span, so auto-instrumented fetch
|
|
240
|
+
attempts become child spans.
|
|
241
|
+
|
|
242
|
+
hanzio does not depend on OpenTelemetry: `otelMiddleware` accepts any object
|
|
243
|
+
shaped like a tracer, and you pass your own `@opentelemetry/api` tracer.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { context, propagation, trace } from '@opentelemetry/api'
|
|
247
|
+
import { createApiClient, otelMiddleware } from 'hanzio/api-wrapper'
|
|
248
|
+
|
|
249
|
+
const api = createApiClient({
|
|
250
|
+
name: 'stream',
|
|
251
|
+
use: [
|
|
252
|
+
otelMiddleware(trace.getTracer('app'), {
|
|
253
|
+
// Only needed when fetch isn't auto-instrumented:
|
|
254
|
+
inject: (headers) => propagation.inject(context.active(), headers)
|
|
255
|
+
})
|
|
256
|
+
],
|
|
257
|
+
...config
|
|
258
|
+
})
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
| Option | Description |
|
|
262
|
+
| --- | --- |
|
|
263
|
+
| `spanName` | `(ctx) => string`. Default `client.endpoint`. |
|
|
264
|
+
| `attributes` | `(ctx) => attributes` added when the span starts. |
|
|
265
|
+
| `includeQuery` | Default `true`: `url.full` keeps the query with sensitive values (`api_key`, `token`, `signature`, …) redacted. `false` drops the query string. |
|
|
266
|
+
| `inject` | Adds trace-context headers (`traceparent`) to the request. |
|
|
267
|
+
|
|
268
|
+
## Debugging Failed Requests
|
|
269
|
+
|
|
270
|
+
Every failed request is logged once through `logger.error(message, attributes)`
|
|
271
|
+
with a headline that says where it failed, the request line, and the details
|
|
272
|
+
needed to fix it:
|
|
273
|
+
|
|
274
|
+
```
|
|
275
|
+
✗ github.usersGet failed: output validation failed: the response doesn't match resSchema
|
|
276
|
+
GET https://api.github.com/users/1 · attempt 1/4 · 212ms
|
|
277
|
+
[1] age: Invalid input: expected number, received string
|
|
278
|
+
(expected number, received string)
|
|
279
|
+
value: "old"
|
|
280
|
+
fix: update resSchema (or resFormatter), or mark changed fields optional
|
|
281
|
+
|
|
282
|
+
✗ github.usersGet failed: server error (HTTP 503): the API failed while handling the request
|
|
283
|
+
GET https://api.github.com/users/1 · attempt 4/4 · 2381ms · request id req_42
|
|
284
|
+
response: {"message":"Service Unavailable"}
|
|
285
|
+
|
|
286
|
+
✗ github.usersCreate failed: input validation failed: the caller's body doesn't match reqBodySchema (nothing was sent)
|
|
287
|
+
POST /users · 1ms
|
|
288
|
+
[1] email: Invalid email address
|
|
289
|
+
value: "nope"
|
|
290
|
+
…
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
| Category | Headline | Details |
|
|
294
|
+
| --- | --- | --- |
|
|
295
|
+
| `server_error` | `server error (HTTP 5xx): the API failed while handling the request` | response body, request id |
|
|
296
|
+
| `client_error` | `request rejected (HTTP 4xx): …` with a hint for 401/403, 404 and 429 | response body, request id |
|
|
297
|
+
| `input_validation` | `input validation failed: the caller's body doesn't match reqBodySchema (nothing was sent)` | each issue with path and value |
|
|
298
|
+
| `output_validation` | `output validation failed: the response doesn't match resSchema` (or `…is not valid JSON`) | each issue with path and value, fix hint |
|
|
299
|
+
| `response_rejected` | `response rejected by checkResponse: …` | response preview |
|
|
300
|
+
| `network` / `timeout` / `aborted` / `config` / `action` | what happened | cause for actions |
|
|
301
|
+
|
|
302
|
+
The attributes are flat and primitive (`category`, `endpoint`, `method`,
|
|
303
|
+
`url`, `status`, `attempt`, `maxRetries`, `elapsedMs`, `requestId`,
|
|
304
|
+
`issueCount`, `issues`, `target`, `timeoutMs`), so structured loggers can
|
|
305
|
+
filter on them. Every `ApiError` also has `error.category`, and
|
|
306
|
+
`describeApiError(error)` / `apiErrorLogAttributes(error)` are exported for
|
|
307
|
+
your own error handlers.
|
|
308
|
+
|
|
309
|
+
Logs never contain credentials. Sensitive headers and body keys are redacted
|
|
310
|
+
(see `redact`), and so are sensitive query parameters in URLs and error
|
|
311
|
+
messages. Debug logs (`API request`) only include the endpoint, method,
|
|
312
|
+
redacted URL and attempt number.
|
|
313
|
+
|
|
314
|
+
### Rejecting 200 responses
|
|
315
|
+
|
|
316
|
+
Some APIs return HTTP 200 with an error envelope (Cloudflare:
|
|
317
|
+
`{ success: false, errors }`). `checkResponse` runs after `resSchema`
|
|
318
|
+
validation on every attempt; anything it throws becomes an `ApiResponseError`
|
|
319
|
+
carrying the request context and the response `data`, and goes through
|
|
320
|
+
redaction and `onError` like any other error. It is not retried by default,
|
|
321
|
+
but a custom `shouldRetry` can opt in.
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
const api = createApiClient({
|
|
325
|
+
...config,
|
|
326
|
+
checkResponse: (data) => {
|
|
327
|
+
const body = data as { success: boolean; errors?: { message: string }[] }
|
|
328
|
+
if (!body.success) {
|
|
329
|
+
throw new Error(body.errors?.map((e) => e.message).join(', '))
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
shouldRetry: (ctx) =>
|
|
333
|
+
isApiResponseError(ctx.error) || defaultShouldRetry(ctx)
|
|
334
|
+
})
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
An endpoint whose response has no envelope sets its own `checkResponse`,
|
|
338
|
+
which replaces the client's, or `checkResponse: false` to skip it. With
|
|
339
|
+
`defineEndpoint`, its `data` is the parsed `resSchema` output.
|
|
340
|
+
|
|
341
|
+
## Testing
|
|
342
|
+
|
|
343
|
+
`hanzio/api-wrapper/testing` has a test-runner-agnostic fetch mock to pass as
|
|
344
|
+
the client's `fetch` option, so tests never replace `globalThis.fetch`.
|
|
345
|
+
`calls` records every `Request`, with bodies still readable.
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
import { createMockFetch, jsonResponse } from 'hanzio/api-wrapper/testing'
|
|
349
|
+
|
|
350
|
+
const fetch = createMockFetch((request, callIndex) =>
|
|
351
|
+
request.url.endsWith('/users/1')
|
|
352
|
+
? jsonResponse({ id: 1, name: 'Ada' })
|
|
353
|
+
: jsonResponse({ error: 'not found' }, { status: 404 })
|
|
354
|
+
)
|
|
355
|
+
const api = createApiClient({ ...config, fetch })
|
|
356
|
+
|
|
357
|
+
await api.usersGet({ reqParams: { userId: '1' } })
|
|
358
|
+
expect(fetch.calls[0]?.headers.get('authorization')).toBe('Bearer token')
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
## Pagination
|
|
362
|
+
|
|
363
|
+
`paginate(fetchPage, getNext, { maxPages?, signal? })` is an async generator
|
|
364
|
+
that works with any cursor type. `fetchPage` receives `undefined` first, then
|
|
365
|
+
whatever `getNext` returned; iteration stops when `getNext` returns `null` or
|
|
366
|
+
`undefined`. Pages are fetched lazily, so `break` stops further requests.
|
|
367
|
+
`signal` is checked before each page (forward it to the request as well to
|
|
368
|
+
cancel the in-flight one).
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
import { paginate } from 'hanzio/api-wrapper'
|
|
372
|
+
|
|
373
|
+
// Cursor-based
|
|
374
|
+
for await (const res of paginate(
|
|
375
|
+
(cursor?: string) => api.eventsList({ reqQuery: { cursor } }),
|
|
376
|
+
(res) => res.data.next_cursor
|
|
377
|
+
)) {
|
|
378
|
+
for (const event of res.data.items) handle(event)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Page numbers, collected into one array
|
|
382
|
+
const pages = await Array.fromAsync(
|
|
383
|
+
paginate(
|
|
384
|
+
(page: number = 1) => api.usersList({ reqQuery: { page } }),
|
|
385
|
+
(res) =>
|
|
386
|
+
res.data.current_page < res.data.last_page
|
|
387
|
+
? res.data.current_page + 1
|
|
388
|
+
: null,
|
|
389
|
+
{ maxPages: 50 }
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
const users = pages.flatMap((res) => res.data.data)
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
## Errors
|
|
396
|
+
|
|
397
|
+
All errors thrown by the client extend `ApiError`, which carries an
|
|
398
|
+
`ApiErrorContext` with everything needed to diagnose the failure:
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
type ApiErrorContext = {
|
|
402
|
+
endpoint: string // e.g. "exampleApi.usersGet"
|
|
403
|
+
method: HttpMethod
|
|
404
|
+
url: string // full URL with query string
|
|
405
|
+
attempt: number
|
|
406
|
+
maxRetries: number
|
|
407
|
+
elapsedMs: number
|
|
408
|
+
requestHeaders?: Record<string, string>
|
|
409
|
+
requestBody?: unknown
|
|
410
|
+
meta?: Record<string, unknown>
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
| Class | Thrown when |
|
|
415
|
+
| --- | --- |
|
|
416
|
+
| `HttpResponseError` | Response status >= 400. Exposes `status`, `body`, `bodyJson` (auto-parsed), `responseHeaders`, `requestId` (from `x-request-id`/`x-correlation-id`). |
|
|
417
|
+
| `ResponseValidationError` | Response failed `resSchema` validation, or `application/json` body wasn't valid JSON. Exposes `issues: FormattedZodIssue[]`, `rawResponse`. |
|
|
418
|
+
| `RequestValidationError` | Caller-supplied `reqBody`/`reqQuery`/`reqParams`/`reqHeaders` failed schema validation. Exposes `target`, `issues`, `rawInput`. |
|
|
419
|
+
| `RequestTimeoutError` | Internal `timeoutMs` exceeded. Exposes `timeoutMs`. |
|
|
420
|
+
| `RequestAbortedError` | The caller's `signal` aborted the request or the wait before a retry. |
|
|
421
|
+
| `NetworkError` | `fetch` itself threw (DNS, TCP, etc.). |
|
|
422
|
+
| `ConfigError` | Misconfiguration: unknown endpoint, missing base URL, missing path parameter, invalid action input, or plain-object body without JSON content type. |
|
|
423
|
+
| `ApiResponseError` | `checkResponse` rejected a response that passed validation. Exposes `data`; the original throw is the `cause`. |
|
|
424
|
+
| `ActionError` | A composite action handler threw a non-`ApiError`. Wraps the original cause. `ApiError` subclasses thrown inside actions propagate unchanged. |
|
|
425
|
+
|
|
426
|
+
Type guards are exported for all of them: `isApiError`, `isHttpResponseError`,
|
|
427
|
+
`isResponseValidationError`, `isRequestValidationError`,
|
|
428
|
+
`isRequestTimeoutError`, `isRequestAbortedError`, `isNetworkError`,
|
|
429
|
+
`isConfigError`, `isApiResponseError`, `isActionError`.
|
|
430
|
+
|
|
431
|
+
`isNonRetryableApiError` returns `true` for errors that the default retry
|
|
432
|
+
policy will not retry (`HttpResponseError`, `ResponseValidationError`,
|
|
433
|
+
`ApiResponseError`, `RequestValidationError`, `ConfigError`,
|
|
434
|
+
`RequestAbortedError`).
|
|
435
|
+
|
|
436
|
+
Every `ApiError` implements `toJSON()` so loggers (Sentry, Datadog) capture the
|
|
437
|
+
full structured payload, not just the message.
|
|
438
|
+
|
|
439
|
+
### Zod Validation Errors
|
|
440
|
+
|
|
441
|
+
`ResponseValidationError` and `RequestValidationError` produce multi-line
|
|
442
|
+
messages that point straight at the schema fix:
|
|
443
|
+
|
|
444
|
+
```
|
|
445
|
+
Response validation failed (GET https://api.example.com/things/1):
|
|
446
|
+
[1] id: Expected number, received string
|
|
447
|
+
(expected number, received string)
|
|
448
|
+
value: "oops"
|
|
449
|
+
[2] tags[1]: Expected string, received number
|
|
450
|
+
value: 42
|
|
451
|
+
[3] nested.count: Expected number, received string
|
|
452
|
+
value: "not-a-number"
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
For programmatic handling, use the `issues` field:
|
|
456
|
+
|
|
457
|
+
```ts
|
|
458
|
+
try {
|
|
459
|
+
await api.getThing({ reqParams: { id: 1 } })
|
|
460
|
+
} catch (err) {
|
|
461
|
+
if (isResponseValidationError(err)) {
|
|
462
|
+
for (const issue of err.issues) {
|
|
463
|
+
// issue.path -> "nested.count"
|
|
464
|
+
// issue.expected -> "number"
|
|
465
|
+
// issue.received -> "string"
|
|
466
|
+
// issue.value -> "not-a-number"
|
|
467
|
+
// issue.valuePreview, issue.code, issue.message, issue.extra
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
### Example: handling errors
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
import {
|
|
477
|
+
isHttpResponseError,
|
|
478
|
+
isResponseValidationError,
|
|
479
|
+
isRequestTimeoutError
|
|
480
|
+
} from 'hanzio/api-wrapper'
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
await api.usersGet({ reqParams: { userId: 'u1' } })
|
|
484
|
+
} catch (err) {
|
|
485
|
+
if (isHttpResponseError(err)) {
|
|
486
|
+
if (err.status === 404) return null
|
|
487
|
+
console.error(err.status, err.bodyJson, err.requestId, err.context)
|
|
488
|
+
} else if (isResponseValidationError(err)) {
|
|
489
|
+
console.error('Schema drift:', err.issues, err.context)
|
|
490
|
+
} else if (isRequestTimeoutError(err)) {
|
|
491
|
+
console.error(`Timed out after ${err.timeoutMs}ms`)
|
|
492
|
+
} else {
|
|
493
|
+
throw err
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
## Composite Actions
|
|
499
|
+
|
|
500
|
+
When a flow spans multiple endpoints (e.g. login then fetch profile, or
|
|
501
|
+
fetch + transform), define it as an **action** next to your endpoints instead
|
|
502
|
+
of writing a wrapper file. Actions are invoked with the same `api.x(...)`
|
|
503
|
+
ergonomics as endpoints and have access to the full client inside their
|
|
504
|
+
handler.
|
|
505
|
+
|
|
506
|
+
Use the **factory form** of `actions` so the `api` inside your handlers is
|
|
507
|
+
fully typed against your endpoints:
|
|
508
|
+
|
|
509
|
+
```ts
|
|
510
|
+
import { createApiClient } from 'hanzio/api-wrapper'
|
|
511
|
+
|
|
512
|
+
const api = createApiClient({
|
|
513
|
+
baseApiUrls: { default: 'https://api.example.com' },
|
|
514
|
+
endpoints: { login, usersGet },
|
|
515
|
+
actions: ({ defineAction }) => ({
|
|
516
|
+
loginAndGetMe: defineAction<{ email: string; password: string }>()({
|
|
517
|
+
handler: async ({ input, api, signal }) => {
|
|
518
|
+
// `api` is typed as ApiClient<typeof endpoints>
|
|
519
|
+
const auth = await api.login({ reqBody: input, signal })
|
|
520
|
+
const me = await api.usersGet({
|
|
521
|
+
reqParams: { userId: auth.data.userId },
|
|
522
|
+
reqHeaders: { Authorization: `Bearer ${auth.data.token}` },
|
|
523
|
+
signal
|
|
524
|
+
})
|
|
525
|
+
return { id: me.data.id, name: me.data.name }
|
|
526
|
+
}
|
|
527
|
+
})
|
|
528
|
+
})
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
const me = await api.loginAndGetMe({ email, password })
|
|
532
|
+
// also: await api.request('loginAndGetMe', { email, password })
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
The factory receives `{ api, defineAction }`. `defineAction` returned by the
|
|
536
|
+
factory is bound to your endpoints, so `ctx.api` (and `ctx.input`,
|
|
537
|
+
`ctx.cache`, etc.) are all properly typed. There is also a top-level
|
|
538
|
+
`defineAction` import for ad-hoc usage outside `createApiClient`, but its
|
|
539
|
+
`ctx.api` is typed as `any` — prefer the factory form.
|
|
540
|
+
|
|
541
|
+
### `defineAction`
|
|
542
|
+
|
|
543
|
+
Pass an `input` schema to validate action input at runtime. Callers pass the
|
|
544
|
+
schema's input type, the handler receives the parsed output, and invalid input
|
|
545
|
+
throws `RequestValidationError` (target `input`, category `input_validation`)
|
|
546
|
+
before the handler runs. Without a schema, the input type is a
|
|
547
|
+
TypeScript-only contract.
|
|
548
|
+
|
|
549
|
+
**Examples:**
|
|
550
|
+
|
|
551
|
+
```ts
|
|
552
|
+
// Validated input
|
|
553
|
+
defineAction({
|
|
554
|
+
input: z.object({ id: z.coerce.number() }),
|
|
555
|
+
handler: ({ input }) => input.id // number
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
// Typed-only input
|
|
559
|
+
defineAction<{ id: number }>()({
|
|
560
|
+
handler: ({ input }) => input.id
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
// No input
|
|
564
|
+
defineAction({
|
|
565
|
+
handler: () => 'ok'
|
|
566
|
+
})
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
`ctx` provides:
|
|
570
|
+
|
|
571
|
+
| Field | Description |
|
|
572
|
+
| --- | --- |
|
|
573
|
+
| `input` | The caller input: parsed by the `input` schema, or typed via the generic on `defineAction`. |
|
|
574
|
+
| `api` | The full client — endpoints and other actions. Calls inside the handler benefit from the same hooks/retries/errors as direct calls. |
|
|
575
|
+
| `signal` | Caller's `AbortSignal`. Forward it into inner `api.endpoint({ signal })` calls to make cancellation work. |
|
|
576
|
+
| `meta` | Arbitrary metadata passed by the caller. |
|
|
577
|
+
| `logger` | Same logger as the client. |
|
|
578
|
+
| `cache` | A shared cache (see below). |
|
|
579
|
+
|
|
580
|
+
The action's **return type is inferred from the handler**. Errors thrown
|
|
581
|
+
inside the handler that are already `ApiError` subclasses propagate as-is;
|
|
582
|
+
anything else is wrapped in `ActionError`.
|
|
583
|
+
|
|
584
|
+
Per-call options:
|
|
585
|
+
|
|
586
|
+
```ts
|
|
587
|
+
await api.loginAndGetMe({ email, password }, { signal, meta })
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
Action names must not collide with endpoint names — a `ConfigError` is
|
|
591
|
+
thrown at `createApiClient` time if they do.
|
|
592
|
+
|
|
593
|
+
### Sharing Auth (and Anything Else) Across Actions
|
|
594
|
+
|
|
595
|
+
`ctx.cache` is a shared key/value cache scoped to the client. The first
|
|
596
|
+
`cache(key, fn)` call runs `fn`; subsequent callers get the cached value.
|
|
597
|
+
Concurrent callers requesting the same key share a single in-flight promise,
|
|
598
|
+
so login isn't fired twice.
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
createApiClient({
|
|
602
|
+
baseApiUrls: { default: 'https://api.example.com' },
|
|
603
|
+
endpoints: { login, usersGet },
|
|
604
|
+
actions: ({ defineAction }) => ({
|
|
605
|
+
usersGet: defineAction<{ id: number }>()({
|
|
606
|
+
handler: async ({ input, api, cache, signal }) => {
|
|
607
|
+
const token = await cache(
|
|
608
|
+
'authToken',
|
|
609
|
+
async () => {
|
|
610
|
+
const res = await api.login({
|
|
611
|
+
reqBody: { email, password },
|
|
612
|
+
signal
|
|
613
|
+
})
|
|
614
|
+
return res.data.token
|
|
615
|
+
},
|
|
616
|
+
{ ttlMs: 50 * 60 * 1000 } // optional; without it, lives for the client
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
try {
|
|
620
|
+
return await api.usersGet({
|
|
621
|
+
reqParams: { userId: input.id },
|
|
622
|
+
reqHeaders: { Authorization: `Bearer ${token}` },
|
|
623
|
+
signal
|
|
624
|
+
})
|
|
625
|
+
} catch (err) {
|
|
626
|
+
if (isHttpResponseError(err) && err.status === 401) {
|
|
627
|
+
cache.invalidate('authToken')
|
|
628
|
+
// then retry, or rethrow to let the caller handle it
|
|
629
|
+
}
|
|
630
|
+
throw err
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
})
|
|
634
|
+
})
|
|
635
|
+
})
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
Cache API:
|
|
639
|
+
|
|
640
|
+
| Method | Description |
|
|
641
|
+
| --- | --- |
|
|
642
|
+
| `cache(key, fn, { ttlMs? })` | Get-or-compute. De-duplicates concurrent callers. Rejected promises are **not** cached. |
|
|
643
|
+
| `cache.get(key)` | Synchronous read; `undefined` if missing or expired. |
|
|
644
|
+
| `cache.set(key, value, { ttlMs? })` | Write a value directly. |
|
|
645
|
+
| `cache.invalidate(key)` | Drop an entry; next `cache(key, fn)` re-runs `fn`. |
|
|
646
|
+
| `cache.clear()` | Drop everything. |
|
|
647
|
+
|
|
648
|
+
The cache is also exposed on the client itself as `api.cache` for use outside
|
|
649
|
+
of action handlers (e.g. clearing auth on logout).
|
|
650
|
+
|
|
651
|
+
## Endpoint Configuration Reference
|
|
652
|
+
|
|
653
|
+
| Option | Required | Description |
|
|
654
|
+
| --- | --- | --- |
|
|
655
|
+
| `method` | Yes | HTTP method: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`. |
|
|
656
|
+
| `path` | Yes | URL path with `:param` placeholders for path params. Joined to the base URL with exactly one `/`. |
|
|
657
|
+
| `resSchema` | Yes | Zod schema to validate the response. |
|
|
658
|
+
| `reqBodySchema` | No | Zod schema for request body. |
|
|
659
|
+
| `reqParamsSchema` | No | Zod schema for path parameters. |
|
|
660
|
+
| `reqQuerySchema` | No | Zod schema for query parameters. |
|
|
661
|
+
| `reqHeadersSchema` | No | Zod schema for request-specific headers. |
|
|
662
|
+
| `reqBodyFormat` | No | `json` or `form-data`. Object and array bodies default to JSON when no `Content-Type` is set. |
|
|
663
|
+
| `baseApiUrl` | No | Key for a non-default base URL. |
|
|
664
|
+
| `defaultHeaders` | No | Endpoint-specific headers. |
|
|
665
|
+
| `resFormatter` | No | `(data, headers) => …` transforms the response before validation. `headers` is keyed by lowercase name. |
|
|
666
|
+
| `checkResponse` | No | Replaces the client's `checkResponse` for this endpoint; `false` skips it. |
|
|
667
|
+
| `reqDefaultQueryParams` | No | Default query parameters, overridden per key by `reqQuery`. |
|
|
668
|
+
| `doNotEncodeQueryParams` | No | Skip URL encoding for query params. |
|
|
669
|
+
|
|
670
|
+
## Common Patterns
|
|
671
|
+
|
|
672
|
+
### Response Formatter
|
|
673
|
+
|
|
674
|
+
Use `resFormatter` when the raw API response does not match the schema you want
|
|
675
|
+
callers to receive.
|
|
676
|
+
|
|
677
|
+
```ts
|
|
678
|
+
export const endpoint = defineEndpoint({
|
|
679
|
+
method: 'GET',
|
|
680
|
+
path: '/data',
|
|
681
|
+
resSchema: z.object({ items: z.array(z.string()) }),
|
|
682
|
+
resFormatter: (data) => {
|
|
683
|
+
const raw = data as { results: { name: string }[] }
|
|
684
|
+
return {
|
|
685
|
+
items: raw.results.map((result) => result.name)
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
})
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
### Result In Headers
|
|
692
|
+
|
|
693
|
+
Some calls return their result only in headers. `resFormatter` receives the
|
|
694
|
+
response headers (lowercase names) and an empty body as `''`; a missing header
|
|
695
|
+
fails `resSchema` as a `ResponseValidationError`. Cloudflare Stream's Tus
|
|
696
|
+
"create upload":
|
|
697
|
+
|
|
698
|
+
```ts
|
|
699
|
+
export const tusUploadPost = defineEndpoint({
|
|
700
|
+
method: 'POST',
|
|
701
|
+
path: '', // the base URL itself, e.g. .../accounts/:id/stream
|
|
702
|
+
reqDefaultQueryParams: { direct_user: 'true' },
|
|
703
|
+
defaultHeaders: { 'Tus-Resumable': '1.0.0' },
|
|
704
|
+
reqHeadersSchema: z.object({
|
|
705
|
+
'Upload-Length': z.string(),
|
|
706
|
+
'Upload-Metadata': z.string()
|
|
707
|
+
}),
|
|
708
|
+
resSchema: z.object({ tusEndpoint: z.string(), videoId: z.string() }),
|
|
709
|
+
resFormatter: (_body, headers) => ({
|
|
710
|
+
// `!` is safe: `resSchema` still rejects a missing header.
|
|
711
|
+
tusEndpoint: headers.location!,
|
|
712
|
+
videoId: headers['stream-media-id']!
|
|
713
|
+
}),
|
|
714
|
+
checkResponse: false // no `{ success, errors }` envelope to check
|
|
715
|
+
})
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
To read a header alongside the body (`etag`, rate limits), use the response's
|
|
719
|
+
`headers` instead.
|
|
720
|
+
|
|
721
|
+
### Optional And Nullable Fields
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
const Schema = z.object({
|
|
725
|
+
required_field: z.string(),
|
|
726
|
+
optional_field: z.string().optional(),
|
|
727
|
+
nullable_field: z.string().nullable(),
|
|
728
|
+
optional_nullable: z.string().nullish()
|
|
729
|
+
})
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
### Array Responses
|
|
733
|
+
|
|
734
|
+
```ts
|
|
735
|
+
const resSchema = z.array(
|
|
736
|
+
z.object({
|
|
737
|
+
id: z.number(),
|
|
738
|
+
name: z.string()
|
|
739
|
+
})
|
|
740
|
+
)
|
|
741
|
+
```
|