/** * A minimal, dependency-free typed HTTP client driven by an * `openapi-typescript`-generated `paths` type. Turnover can't infer client types * through decorators, so the typed client is codegen-based: * * 1. Extract the spec: `Bun.write("openapi.json", JSON.stringify(app.openapi({ toJsonSchema })))` * 2. Generate types: `bunx openapi-typescript openapi.json -o api.d.ts` * 3. `const api = createClient({ baseUrl })` — fully typed calls. */ type Any = any /** Extract the `application/json` type from an OpenAPI content object. */ type JsonContent = T extends { content: { 'application/json': infer C } } ? C : never /** The operation object for a path + method, if present. */ type Operation< Paths, P extends keyof Paths, M extends string, > = M extends keyof Paths[P] ? Paths[P][M] : never /** Paths in `Paths` that define the given method. */ type PathsWith = { [P in keyof Paths]: M extends keyof Paths[P] ? P : never }[keyof Paths] /** The success (200/201) JSON response type of an operation. */ type ResponseOf = Op extends { responses: infer R } ? R extends { 200: infer Res } ? JsonContent : R extends { 201: infer Res } ? JsonContent : unknown : unknown /** Per-call options derived from an operation's parameters and request body. */ type RequestOptions = (Op extends { parameters: infer P } ? { params: P } : { params?: never }) & (Op extends { requestBody: infer B } ? { body: JsonContent } : { body?: never }) & { headers?: Record } /** Whether an operation requires options (has path params or a body). */ type NeedsOptions = Op extends { parameters: { path: unknown } } ? true : Op extends { requestBody: unknown } ? true : false /** The result of a request: `data` on success, `error` on a non-2xx. */ export interface ClientResult { /** Set only on a 2xx response: the JSON-parsed body, or the raw text if the response isn't `application/json`. Mutually exclusive with `error`. */ data?: T /** Set only on a non-2xx response: the JSON-parsed body, or raw text for non-JSON. The client resolves (never throws) on HTTP errors. */ error?: unknown /** The raw `Response`; its body has already been consumed to produce `data`/`error`. */ response: Response } type ClientMethod =

>( path: P, ...args: NeedsOptions> extends true ? [options: RequestOptions>] : [options?: RequestOptions>] ) => Promise>>> /** A typed client over an `openapi-typescript` `paths` type. */ export interface Client { /** Send a typed `GET` request. */ get: ClientMethod /** Send a typed `POST` request. */ post: ClientMethod /** Send a typed `PUT` request. */ put: ClientMethod /** Send a typed `PATCH` request. */ patch: ClientMethod /** Send a typed `DELETE` request. */ delete: ClientMethod } /** Configuration for {@link createClient}. */ export interface ClientConfig { /** Base URL prepended to every request path (a trailing slash is trimmed). */ baseUrl: string /** Headers merged into every request; a call's own `headers` override these on conflict. A JSON `content-type` is added automatically when a body is sent. */ headers?: Record /** Transport override (default: the global `fetch`); pass `(req) => app.handle(req)` to drive an in-memory turnover app with no socket. */ fetch?: (request: Request) => Promise } /** * Create a typed client for an API described by an `openapi-typescript` `paths` type. * * Path params go under `options.params.path` (substituted into `{name}` * placeholders), query params under `options.params.query`, and the JSON body * under `options.body`. A non-2xx response resolves normally with `error` set * (and `data` undefined) — this client never throws on HTTP status; only a * transport/`fetch` failure rejects. * * @typeParam Paths - the generated `openapi-typescript` `paths` type for the API * @param config - base URL, default headers, and an optional `fetch` override * @returns a {@link Client} with typed `get`/`post`/`put`/`patch`/`delete` methods */ export function createClient(config: ClientConfig): Client { const base = config.baseUrl.replace(/\/$/, '') const request = async ( method: string, path: string, options?: { params?: Any body?: unknown headers?: Record }, ): Promise> => { let url = base + path const pathParams = options?.params?.path as | Record | undefined if (pathParams) { for (const [key, val] of Object.entries(pathParams)) { url = url.replace(`{${key}}`, encodeURIComponent(String(val))) } } const query = options?.params?.query as Record | undefined if (query) { const parts: string[] = [] for (const [key, val] of Object.entries(query)) { if (val !== undefined && val !== null) { parts.push( `${encodeURIComponent(key)}=${encodeURIComponent(String(val))}`, ) } } if (parts.length > 0) url += `?${parts.join('&')}` } const headers: Record = { ...config.headers, ...options?.headers, } const init: RequestInit = { method: method.toUpperCase(), headers } if (options?.body !== undefined) { init.body = JSON.stringify(options.body) headers['content-type'] = 'application/json' } const req = new Request(url, init) const response = config.fetch ? await config.fetch(req) : await fetch(req) const isJson = (response.headers.get('content-type') ?? '').includes( 'application/json', ) const payload = isJson ? await response.json() : await response.text() return response.ok ? { data: payload, response } : { error: payload, response } } return { get: (path, options) => request('get', path as string, options as Any), post: (path, options) => request('post', path as string, options as Any), put: (path, options) => request('put', path as string, options as Any), patch: (path, options) => request('patch', path as string, options as Any), delete: (path, options) => request('delete', path as string, options as Any), } as Client }