@zodapi/client
v0.3.0
Published
Typed fetch/axios client over zodapi route contracts with optional zod validation
Downloads
227
Readme
@zodapi/client
Typed HTTP client over zodapi route contracts:
path- or alias-addressed calls, optional zod validation at runtime, and zodios-style error guards.
Works over fetch by default, axios via @zodapi/client/axios, or any custom adapter.
import { ValidationApiError, createClient, matchErrorByStatus } from '@zodapi/client'
import { z } from 'zod'
import { createUser, routes } from './contract.js'
const client = createClient(routes, { baseUrl: 'http://localhost:3000' })
const user = await client.get('/users/{id}', { params: { id: '1' } }) // by path
const same = await client.getUser({ params: { id: '1' } }) // by alias
try {
await client.createUser({ body: newUser })
} catch (err) {
if (err instanceof ValidationApiError) {
z.flattenError(err.error).fieldErrors // a real ZodError, revived from the server's issues
} else if (matchErrorByStatus(createUser, err, 409)) {
err.data.error.existingId // fully typed, runtime-checked with zod
}
}Argument objects (params, query, body, headers, signal) are typed from the route's
request schemas; return types are the union of the route's declared 2xx json bodies.
Options
createClient(routes, {
baseUrl: 'http://localhost:3000',
validate: 'response', // 'none' | 'request' | 'response' | 'both' (default 'response')
encodeRequests: false, // body IO (default false): wire-form (z.input) body; true = decoded (z.output), z.encode'd
fullResponse: false, // resolve with { data, status, headers } instead of the bare body
headers: () => ({ authorization: `Bearer ${token}` }), // static object or (async) function
adapter: fetchAdapter(), // transport seam
decoders: decodersFor(problemFlavor), // error decoders (default: zodapi's own problem+json 400)
onError: ({ error, attempt }) => {}, // error hook; return 'retry' to re-run the request
})- Validation default is
'response'(zodios behaviour): 2xx bodies are parsed with the contract schema; error-response bodies stay raw and are checked by the error guards instead. Override per call with{ validate: ... }. - Errors throw. Non-2xx responses run through the error decoders first: server-side validation
failures throw
ValidationApiError(itserroris a realz.ZodErrorrevived from the server's issues, so client- and server-side failures share one handling path), other recognised problem+json responses throwProblemApiError. Otherwise declared statuses throwApiError(narrow withisErrorFromRoute,isErrorFromAlias,matchErrorByStatus,isValidationError— all re-exported from@zodapi/core); undeclared statuses throwUnexpectedResponseApiError; client-side validation failures throwRequestValidationError/ResponseValidationError. - Non-zodapi backends that speak RFC 9457 (ASP.NET, Spring, ...) opt in with
decoders: [...decodersFor('problem-details', { keyCasing: 'camel' })]— an ASP.NETValidationProblemDetailserrorsmap is converted to zod issues (keys camelCased and split into paths;$.items[0].qtybecomes['items', 0, 'qty']). Contracts generated by@zodapi/codegenexport a detectedproblemFlavorto feeddecodersFor. Passdecoders: []to disable decoding entirely. - Query arrays are serialised with the
[]key suffix (tags[]=a&tags[]=b), matching the normalisationcreateApp()from@zodapi/honoapplies at the edge. - Params, query, and headers are always decoded values. The transport turns them into strings
regardless, so you pass natural types — a plain
numberfor a coerced param,truefor az.stringbool(), aDatefor a codec — typedz.outputwith keys the input side lets you omit (.default()ed or.optional()) staying optional. Codec-bearing values are alwaysz.encoded to their wire form (per key, so a codec can sit next to aqueryArray(), whichz.encodealone would reject as a one-way transform). - Bodies are wire-form by default;
encodeRequests: trueflips them to decoded. With it on (client-level or per call) you pass schema-output values —Dateobjects where the contract uses date codecs — typedz.output, and the clientz.encodes them; encoding is a serialization concern independent ofvalidate, thoughz.encodevalidates as it encodes, so an invalid value throwsRequestValidationErroreven with request validation off. - Codecs (e.g. the date codecs
@zodapi/codegenemits with itsdatesoptions) only decode when response validation runs, so the client fails fast — before sending — when a 2xx response schema with a codec would be skipped (validatemust be'response'or'both'). In the default body mode, validated codec-bearing bodies are re-encoded to their wire form (a date-only codec staysYYYY-MM-DDinstead of beingJSON.stringify'd as a full datetime). - Raw response access goes through
fullResponse(client-level default, or per call in either direction): the call resolves with{ data, status, headers }—datavalidated/decoded exactly as without the envelope,headersthe rawHeaders— for pagination headers, tests, and the like. Non-2xx responses already carrystatus/headers/dataon the thrownApiError. - Retries go through
onError(client-level, or per call to replace it): the hook receives{ error, route, alias, attempt }before an error is thrown and may return'retry'(sync or async) to re-run the request. Theheadersfunction is re-evaluated on every attempt, so an expired-token flow is just: on a 401, refresh the token, return'retry'. Transport errors,ApiErrorand subclasses, andResponseValidationErrorreach the hook; client-side request validation/encoding failures don't (the same input would fail again). There's no built-in attempt cap — bound retries withattempt.
Axios
import axios from 'axios'
import { axiosAdapter } from '@zodapi/client/axios'
const client = createClient(routes, { baseUrl, adapter: axiosAdapter(axios.create()) })Status handling stays with the zodapi client (validateStatus is disabled), so declared error
responses throw ApiError exactly as with the fetch adapter. axios is an optional peer dependency.
A custom transport is just an Adapter: (request: AdapterRequest) => Promise<AdapterResponse>.
Install
pnpm add @zodapi/client zodContracts come from @zodapi/hono route definitions shared out of a TypeScript backend, or from
@zodapi/codegen for backends that only publish an OpenAPI 3.1 document.
