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