import axios from 'axios' import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios' import { applyHeaderStyles, defaultBodySerializer, defaultPathSerializer, defaultQuerySerializer, isDefaultJsonBody, serializeCookies } from './serializers' import type { HeadersInit, PathParamStyle, PathSerializer, Serializers, Styles } from './serializers' import { type StandardSchemaValidator, validateStandardSchema } from './standardSchema' /** * HTTP status codes treated as a success, everything else is an error. */ export type SuccessStatusCode = '200' | '201' | '202' | '203' | '204' | '205' | '206' | '207' | '208' | '226' /** * The success members of a per-status responses record. */ export type SuccessOf = TResponses[Extract] /** * The error members of a per-status responses record, every documented status that is not a 2xx. */ export type ErrorOf = TResponses[Exclude] /** * Converts a response record's string status key to its numeric literal, leaving non-numeric keys like `default` as `number`. */ export type ToStatusNumber = TStatus extends `${infer TNumber extends number}` ? TNumber : number /** * The plain body of a per-status response, unwrapping the `{ contentType; data }` union so an error result keeps the bare body union on `error`. */ export type DataOf = T extends { contentType: string; data: infer TData } ? TData : T /** * The success variant for a single status, flattened so the negotiated `contentType` sits next to `data` and `switch (result.contentType)` narrows it. */ export type SuccessVariant = TEntry extends { contentType: string; data: unknown } ? TEntry extends { contentType: infer TContentType; data: infer TData } ? { status: ToStatusNumber; data: TData; error: undefined; contentType: TContentType; request: TRequest; response: TResponse } : never : { status: ToStatusNumber; data: TEntry; error: undefined; contentType: string | undefined; request: TRequest; response: TResponse } /** * One result variant for a single documented status, keyed by the numeric `status` so a `switch (result.status)` narrows `data` or `error`. */ export type ResultByStatus = TStatus extends SuccessStatusCode ? SuccessVariant : { status: ToStatusNumber data: undefined error: DataOf contentType: string | undefined request: TRequest response: TResponse } /** * The union of every documented status' result variant. */ export type ResultUnion = { [TStatus in keyof TResponses]: ResultByStatus }[keyof TResponses] /** * The union of just the success (2xx) status variants, selected by status code so an untyped error payload can never widen `data`. */ export type SuccessResultUnion = { [TStatus in Extract]: ResultByStatus }[Extract] /** * The shape every generated function returns, discriminated by the top-level `status`, narrowing to the 2xx variants under `throwOnError` and to every documented status without it. */ export type RequestResult = ThrowOnError extends true ? [SuccessResultUnion] extends [never] ? { status: number data: SuccessOf error: undefined contentType: string | undefined request: TRequest response: TResponse } : SuccessResultUnion : [ResultUnion] extends [never] ? { status: number; data: undefined; error: undefined; contentType: string | undefined; request: TRequest; response: TResponse } : ResultUnion /** * The data-shaped keys of the grouped options object, which `Options` re-adds typed per operation. */ export type DataShape = { body?: unknown; cookies?: unknown; headers?: unknown; path?: unknown; query?: unknown } export type ResponseType = 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream' | 'formdata' /** * Turns a raw response body into a parsed value, registered per media type as a codec's `deserialize` to handle formats the runtime does not decode itself. */ export type Deserializer = (raw: unknown, contentType: string) => T | Promise /** * Serializes a request body for a single media type, registered per content type as a codec's `serialize` to encode formats the default serializer does not handle. */ export type ContentBodySerializer = (body: unknown, contentType?: string) => unknown /** * A per-content-type codec registered on `codecs`, keyed by content type. `serialize` encodes the * request body for that media type and `deserialize` decodes the response body. Either half is * optional, so a codec can handle one direction. */ export type Codec = { serialize?: ContentBodySerializer deserialize?: Deserializer } /** * The per-call content type selection, where a bare string sets the request content type and the object form also sets the response format sent as `Accept`. */ export type ContentType = string | { request?: string; response?: string } /** * A Standard Schema validator (zod, valibot, arktype) that parses a value before it is sent or after * it is received. `runValidator` runs it through `validateStandardSchema`. Wired through the per-call * `validator.request` / `validator.response` / `validator.error` hooks (`error` runs on the error body when a * non-2xx call does not throw). */ export type Validator = StandardSchemaValidator /** * A resolved security scheme carried on each generated call's `security` array and passed to the `auth` resolver. */ export type Auth = { type: 'http' | 'apiKey' | 'oauth2' | 'openIdConnect' scheme?: 'bearer' | 'basic' name?: string in?: 'header' | 'query' | 'cookie' } /** * The raw token a consumer returns for a scheme (or `user:password` for basic), or `undefined` to skip it. */ export type AuthToken = string | undefined /** * Resolves the token for a security scheme, either a static token or a callback called per scheme until one returns a token. */ export type AuthResolver = AuthToken | ((auth: Auth) => AuthToken | Promise) /** * Extra axios config the runtime spreads onto every request, an escape hatch for per-call fields it does not set itself such as `timeout`, `proxy`, and the progress callbacks. */ export type AxiosOptions = AxiosRequestConfig /** * The request a generated function hands to the runtime, with `body` / `headers` / `path` / `query` from the grouped options. */ export type RequestConfig = { baseURL?: string url?: string method?: 'GET' | 'PUT' | 'PATCH' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' path?: Record query?: unknown params?: unknown cookies?: Record body?: TBody headers?: HeadersInit styles?: Styles signal?: AbortSignal options?: AxiosOptions contentType?: ContentType responseType?: ResponseType throwOnError?: boolean validateStatus?: (status: number) => boolean client?: ClientInstance transport?: AxiosInstance serializer?: Serializers codecs?: Record validator?: { request?: Validator; response?: Validator; error?: Validator } security?: Array auth?: AuthResolver } /** * The grouped options object passed to every generated function: the request config minus the * data-shaped keys and the literal `url`, plus the per-operation `Request`. */ export type Options = Omit< RequestConfig, keyof DataShape | 'url' > & TData & { client?: ClientInstance throwOnError?: ThrowOnError } /** * Client-level configuration shared by every call an instance makes, overridden by the per-call `RequestConfig`. */ export type ClientConfig = { baseURL?: string headers?: HeadersInit options?: AxiosOptions throwOnError?: boolean validateStatus?: (status: number) => boolean transport?: AxiosInstance serializer?: Serializers codecs?: Record auth?: AuthResolver } /** * The result a resolved call produces before it is cast to `RequestResult` by the generated wrapper. */ export type CallResult = { status: number data: unknown error: unknown contentType: string | undefined request: TRequest response: TResponse } export type InterceptorFn = (value: T) => T | Promise /** * A single interceptor channel with a transport-agnostic `use` / `eject` / `update` API, backed by axios's native interceptor managers. */ export type InterceptorChannel = { use: (fn: InterceptorFn) => number eject: (id: number) => void update: (id: number, fn: InterceptorFn) => void } /** * The three interceptor channels every client instance exposes, wrapping axios's native managers with `error` mapped onto the response rejection handler. */ export type Interceptors = { request: InterceptorChannel response: InterceptorChannel error: InterceptorChannel } /** * A client instance: the callable send plus configuration, interceptors, and an isolated * `createClient` factory. */ export type ClientInstance = { (config: RequestConfig): Promise> getConfig: () => ClientConfig setConfig: (config: ClientConfig) => ClientConfig getUrl: (config: RequestConfig) => string interceptors: Interceptors createClient: (config?: ClientConfig) => ClientInstance } /** * Thrown for a non-2xx response, so a resolved call always means success. */ export class ResponseError extends Error { data: TError status: number statusText: string contentType: string | undefined request: TRequest response: TResponse constructor(config: { data: TError; status: number; statusText: string; contentType?: string; request: TRequest; response: TResponse }) { super(`Request failed with status ${config.status}${config.statusText ? ` ${config.statusText}` : ''}`) this.name = 'ResponseError' this.data = config.data this.status = config.status this.statusText = config.statusText this.contentType = config.contentType this.request = config.request this.response = config.response } } export type ResponseErrorConfig = ResponseError function serializeHeaders(headers: HeadersInit | undefined): Record { if (!headers) return {} const entries = Array.isArray(headers) ? headers : Object.entries(headers) const result: Record = {} for (const [key, value] of entries) { if (value === undefined || value === null) continue result[key] = typeof value === 'string' ? value : typeof value === 'object' ? JSON.stringify(value) : String(value) } return result } function mergeHeaders(...sources: Array): Record { return Object.assign({}, ...sources.map(serializeHeaders)) } function getHeader(headers: Record, name: string): string | undefined { const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase()) return key ? headers[key] : undefined } function hasHeader(headers: Record, name: string): boolean { return Object.keys(headers).some((k) => k.toLowerCase() === name.toLowerCase()) } /** * Joins the URL parts, interpolates URL-encoded `{param}` segments, and appends the serialized query, backing `getUrl`. */ function serializeUrl({ parts, pathParams, search, pathSerializer = defaultPathSerializer, pathStyles, }: { parts: Array pathParams: Record search: string pathSerializer?: PathSerializer pathStyles?: Record }): string { const path = parts .filter(Boolean) .join('') .replace(/\{([^{}]+)\}/g, (_, key: string) => pathSerializer({ name: key, value: pathParams[key], options: pathStyles?.[key] })) return path + (search ? `?${search}` : '') } /** * Wraps an axios interceptor registration behind the shared `use` / `eject` / `update` API, mapping a stable external id onto axios's own so `update` can swap a handler in place. */ function createInterceptorChannel(register: (fn: InterceptorFn) => number, ejectNative: (id: number) => void): InterceptorChannel { const ids = new Map() let counter = 0 return { use(fn) { const id = ++counter ids.set(id, register(fn)) return id }, eject(id) { const nativeId = ids.get(id) if (nativeId === undefined) return ejectNative(nativeId) ids.delete(id) }, update(id, fn) { const nativeId = ids.get(id) if (nativeId !== undefined) ejectNative(nativeId) ids.set(id, register(fn)) }, } } /** * Walks the per-operation security in order and places the first resolved token on the request, mutating `headers` / `query` in place. */ export async function resolveAuth(params: { security: Array | undefined auth: AuthResolver | undefined headers: Record query: Record }): Promise { const { security, auth, headers, query } = params if (!security?.length || auth === undefined) return for (const scheme of security) { const token = typeof auth === 'function' ? await auth(scheme) : auth if (token === undefined) continue if (scheme.type === 'apiKey') { const name = scheme.name ?? 'Authorization' if (scheme.in === 'query') { if (query[name] === undefined) query[name] = token } else if (scheme.in === 'cookie') { headers.Cookie = [headers.Cookie, `${name}=${token}`].filter(Boolean).join('; ') } else if (!hasHeader(headers, name)) { headers[name] = token } } else if (!hasHeader(headers, 'Authorization')) { headers.Authorization = scheme.scheme === 'basic' ? `Basic ${btoa(token)}` : `Bearer ${token}` } return } } async function runValidator(validator: Validator | undefined, value: T): Promise { if (!validator) return value return validateStandardSchema(validator, value) } /** * The base media type of a `Content-Type` value, lowercased and stripped of any `; charset=...` parameters. */ function baseContentType(value: string | null | undefined): string | undefined { if (!value) return undefined return value.split(';')[0]!.trim().toLowerCase() || undefined } /** * Reads the negotiated response content type from the response headers as a base media type. */ function getResponseContentType(headers: Record | undefined): string | undefined { if (!headers) return undefined const value = headers['content-type'] ?? headers['Content-Type'] return baseContentType(typeof value === 'string' ? value : undefined) } /** * Normalizes the `contentType` option to its `{ request, response }` form, treating a bare string as the request content type. */ function resolveContentType(contentType: ContentType | undefined): { request?: string; response?: string } { if (typeof contentType === 'string') return { request: contentType } return contentType ?? {} } /** * The per-concern serializers for a call, the per-call serializer winning over the client's and * falling back to the defaults. */ function resolveSerializers({ config, requestConfig }: { config: { serializer?: Serializers }; requestConfig: { serializer?: Serializers } }) { return { querySerializer: requestConfig.serializer?.query ?? config.serializer?.query ?? defaultQuerySerializer, bodySerializer: requestConfig.serializer?.body ?? config.serializer?.body ?? defaultBodySerializer, pathSerializer: requestConfig.serializer?.path ?? config.serializer?.path ?? defaultPathSerializer, } } /** * Resolves everything a call needs before it touches axios: merged headers with the negotiated * content type, auth on headers or query, serialized cookies, the validated and serialized body, * and the final axios request config with `throwOnError` riding `validateStatus`. */ async function resolveRequest({ config, requestConfig, }: { config: ClientConfig requestConfig: RequestConfig }): Promise<{ axiosConfig: AxiosRequestConfig; codecs: Record; throwOnError: boolean }> { const { querySerializer, bodySerializer, pathSerializer } = resolveSerializers({ config, requestConfig }) const codecs = { ...config.codecs, ...requestConfig.codecs } const headers = mergeHeaders(config.headers, applyHeaderStyles(requestConfig.headers, requestConfig.styles?.header)) const { request: requestContentTypeOption, response: responseContentType } = resolveContentType(requestConfig.contentType) const requestContentType = requestContentTypeOption ?? getHeader(headers, 'content-type') if (responseContentType && !hasHeader(headers, 'accept')) { headers['Accept'] = responseContentType } const query: Record = { ...((requestConfig.query ?? requestConfig.params) as Record | undefined) } await resolveAuth({ security: requestConfig.security, auth: requestConfig.auth ?? config.auth, headers, query, }) if (requestConfig.cookies) { const cookie = serializeCookies(requestConfig.cookies, requestConfig.styles?.cookie) if (cookie) headers.Cookie = [headers.Cookie, cookie].filter(Boolean).join('; ') } const validatedBody = await runValidator(requestConfig.validator?.request, requestConfig.body) const requestContentTypeBase = baseContentType(requestContentType) const contentCodec = requestContentTypeBase ? codecs[requestContentTypeBase] : undefined const usesDefaultBodySerializer = !contentCodec?.serialize && bodySerializer === defaultBodySerializer const body = contentCodec?.serialize ? contentCodec.serialize(validatedBody, requestContentType) : bodySerializer({ body: validatedBody, contentType: requestContentType, encoding: requestConfig.styles?.body }) // A FormData body must keep its Content-Type unset so axios appends the multipart boundary. if (body instanceof FormData) { for (const key of Object.keys(headers)) { if (key.toLowerCase() === 'content-type') delete headers[key] } } else if (requestContentTypeOption) { headers['Content-Type'] = requestContentTypeOption } else if (usesDefaultBodySerializer && isDefaultJsonBody(validatedBody) && !hasHeader(headers, 'content-type')) { headers['Content-Type'] = 'application/json' } const pathParams = requestConfig.path ?? {} const url = (requestConfig.url ?? '').replace(/\{([^{}]+)\}/g, (_, key: string) => pathSerializer({ name: key, value: pathParams[key], options: requestConfig.styles?.path?.[key] }), ) const throwOnError = requestConfig.throwOnError ?? config.throwOnError ?? true const validateStatus = requestConfig.validateStatus ?? config.validateStatus ?? (throwOnError ? (status: number) => status >= 200 && status < 300 : () => true) const options = config.options || requestConfig.options ? { ...config.options, ...requestConfig.options } : undefined const axiosConfig: AxiosRequestConfig = { ...options, // timeout, proxy, maxRedirects, decompress, onUploadProgress, … url, baseURL: requestConfig.baseURL ?? config.baseURL, method: requestConfig.method ?? 'GET', headers, params: query, paramsSerializer: (params) => querySerializer(params as Record, requestConfig.styles?.query), data: body, transformRequest: (data) => data, signal: requestConfig.signal, responseType: requestConfig.responseType, validateStatus, } // Only the fetch adapter exposes a streaming `response.data` (a ReadableStream) in the browser. // The default XHR adapter buffers the whole body. Default streams to it, but respect an explicit adapter. if (requestConfig.responseType === 'stream' && !axiosConfig.adapter) { axiosConfig.adapter = 'fetch' } return { axiosConfig, codecs, throwOnError } } /** * Turns an axios response into the call result: decodes the body through the matching codec and * validates the success or error body. A thrown axios error never reaches this, so `error` here is * always the body of a non-2xx response that `validateStatus` let through. */ async function settleResponse({ response, codecs, validator, }: { response: AxiosResponse codecs: Record validator: { response?: Validator; error?: Validator } | undefined }): Promise> { const isSuccess = response.status >= 200 && response.status < 300 const contentType = getResponseContentType(response.headers as Record) let decoded: unknown = response.data if (contentType) { const codec = codecs[contentType] if (codec?.deserialize) decoded = await codec.deserialize(response.data, contentType) } const data = isSuccess ? await runValidator(validator?.response, decoded) : undefined const error = isSuccess ? undefined : await runValidator(validator?.error, decoded) return { status: response.status, data, error, contentType, request: response.config as TRequest, response: response as TResponse, } } /** * Builds the shared client core bound to an axios instance (defaulting to `axios.create()`), with `throwOnError` riding axios's `validateStatus`. */ export function createClientCore(options: ClientConfig = {}): ClientInstance { let config: ClientConfig = { ...options } const instance = config.transport ?? axios.create() const requestManager = instance.interceptors.request const responseManager = instance.interceptors.response const interceptors: Interceptors = { request: createInterceptorChannel( (fn) => requestManager.use(fn), (id) => requestManager.eject(id), ), response: createInterceptorChannel( (fn) => responseManager.use(fn), (id) => responseManager.eject(id), ), error: createInterceptorChannel( (fn) => responseManager.use(undefined, async (error: unknown) => { await fn(error as AxiosError) return Promise.reject(error) }), (id) => responseManager.eject(id), ), } const client = (async (requestConfig: RequestConfig): Promise> => { const activeInstance = requestConfig.transport ?? config.transport ?? instance const { axiosConfig, codecs, throwOnError } = await resolveRequest({ config, requestConfig }) try { const response = await activeInstance.request(axiosConfig) return await settleResponse({ response, codecs, validator: requestConfig.validator }) } catch (error) { const axiosError = error as AxiosError if (throwOnError && axiosError.response) { throw new ResponseError({ data: axiosError.response.data, status: axiosError.response.status, statusText: axiosError.response.statusText, contentType: getResponseContentType(axiosError.response.headers as Record), request: axiosError.config as TRequest, response: axiosError.response as TResponse, }) } throw error } }) as ClientInstance client.getConfig = () => config client.setConfig = (next) => { config = { ...config, ...next, headers: { ...serializeHeaders(config.headers), ...serializeHeaders(next.headers) } } return config } client.getUrl = (requestConfig) => { const { querySerializer, pathSerializer } = resolveSerializers({ config, requestConfig }) const query: Record = { ...((requestConfig.query ?? requestConfig.params) as Record | undefined) } return serializeUrl({ parts: [requestConfig.baseURL ?? config.baseURL, requestConfig.url], pathParams: requestConfig.path ?? {}, search: querySerializer(query, requestConfig.styles?.query), pathSerializer, pathStyles: requestConfig.styles?.path, }) } client.interceptors = interceptors client.createClient = (next) => createClientCore({ ...config, ...next }) return client } /** * One decoded Server-Sent Event, with `data` parsed as JSON when valid and kept as the raw string otherwise. */ export type ServerSentEvent = { data: TData event?: string id?: string retry?: number } async function* readBytes(stream: ReadableStream | AsyncIterable): AsyncGenerator { if (!('getReader' in stream)) { yield* stream return } const reader = stream.getReader() try { while (true) { const { done, value } = await reader.read() if (done) return yield value } } finally { await reader.cancel().catch(() => {}) } } function parseEvent(raw: string): ServerSentEvent | undefined { const data: Array = [] const event: ServerSentEvent = { data: undefined as TData } let seen = false for (const line of raw.split('\n')) { if (!line || line.startsWith(':')) continue seen = true const index = line.indexOf(':') const field = index === -1 ? line : line.slice(0, index) const value = index === -1 ? '' : line.slice(index + 1).replace(/^ /, '') if (field === 'data') data.push(value) else if (field === 'event') event.event = value else if (field === 'id') event.id = value else if (field === 'retry' && Number.isFinite(Number(value))) event.retry = Number(value) } if (!seen) return undefined if (data.length) { const joined = data.join('\n') try { event.data = JSON.parse(joined) as TData } catch { event.data = joined as TData } } return event } /** * Parses a `text/event-stream` body into typed Server-Sent Events, consumed with `for await` and stopped early by breaking the loop. */ export async function* parseEventStream( stream: ReadableStream | AsyncIterable, ): AsyncGenerator> { const decoder = new TextDecoder() const normalize = (text: string) => text.replace(/\r\n|\r/g, '\n') let buffer = '' for await (const chunk of readBytes(stream)) { const blocks = normalize(buffer + decoder.decode(chunk, { stream: true })).split('\n\n') buffer = blocks.pop() ?? '' for (const block of blocks) { const event = parseEvent(block) if (event) yield event } } const event = parseEvent(normalize(buffer + decoder.decode())) if (event) yield event } /** * The resolved shape returned by a generated `text/event-stream` operation: the typed event * `stream` plus the native `response`. */ export type EventStreamResult = { stream: AsyncGenerator> response: TResponse } /** * Wraps a transport result whose `data` is a streaming body into an `EventStreamResult`, exposing * the parsed events as a typed async iterator. Generated SSE operations call this. */ export async function toEventStream(result: Promise<{ data: unknown; response: AxiosResponse }>): Promise> { const { data, response } = await result return { response, stream: parseEventStream(data as ReadableStream | AsyncIterable), } } export const client = createClientCore() export const createClient = (config?: Parameters[0]) => client.createClient(config)