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.
@@ -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
+ ```