From efc5e139f4087806e3d9d5fba5ba0551e8776755 Mon Sep 17 00:00:00 2001 From: Sylvain Perron Date: Wed, 22 Jul 2026 17:16:38 -0400 Subject: [PATCH 1/4] feat(opapi)!: generate transport-agnostic client code instead of axios The opapi client generator now emits a Client that takes any transport implementing a minimal generated HttpClient interface instead of an AxiosInstance. to-axios.ts becomes to-request.ts (toAxiosRequest -> toRequest, AxiosRequestConfig -> RequestConfig), and both the generated toApiError and the generated handler detect http errors structurally rather than via axios.isAxiosError, so axios-based transports keep working. Co-Authored-By: Claude Fable 5 --- opapi/CHANGELOG.md | 30 ++++++++++++ opapi/package.json | 2 +- opapi/src/generator.ts | 6 +-- opapi/src/generators/client-node.ts | 49 +++++++++++++------ opapi/src/handler-generator/export-handler.ts | 6 ++- 5 files changed, 72 insertions(+), 21 deletions(-) diff --git a/opapi/CHANGELOG.md b/opapi/CHANGELOG.md index b2cb0dbd5..83161b9d0 100644 --- a/opapi/CHANGELOG.md +++ b/opapi/CHANGELOG.md @@ -7,6 +7,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [2.0.0] - 2026-07-22 + +### Changed + +- **Breaking**: The `opapi` client generator no longer emits axios-based code. The generated `Client` now takes any transport implementing the generated `HttpClient` interface (`{ request: (config: RequestConfig) => Promise<{ data: T }> }`) instead of an `AxiosInstance`. +- **Breaking**: The generated `to-axios.ts` file is now `to-request.ts`, `toAxiosRequest` is now `toRequest` (returning a `RequestConfig` instead of an `AxiosRequestConfig`), and the `ClientProps.toAxiosRequest` override is now `ClientProps.toRequest`. +- **Breaking**: The generated handler no longer imports `isAxiosError` from axios; it detects http errors structurally (any `Error` with a `response` property). +- The generated `toApiError` detects http errors structurally (`err.response.data`) instead of using `axios.isAxiosError`, so it works with any transport including axios. + +#### Examples + +```ts +// Generated clients are now transport-agnostic. Bring any http client that +// implements the generated `HttpClient` interface — for example a fetch-based one: +import { Client } from './gen' + +const httpClient = { + request: async (config: { method: string; url: string; headers: Record; data?: any }) => { + const res = await fetch(`https://api.example.com${config.url}`, { + method: config.method, + headers: config.headers, + body: config.data ? JSON.stringify(config.data) : undefined, + }) + return { data: (await res.json()) as T } + }, +} + +const client = new Client(httpClient) +``` + ### Fixed - Fixed spelling mistakes in package files. diff --git a/opapi/package.json b/opapi/package.json index db3a51b09..1797dac64 100644 --- a/opapi/package.json +++ b/opapi/package.json @@ -1,6 +1,6 @@ { "name": "@bpinternal/opapi", - "version": "1.0.0", + "version": "2.0.0", "description": "Opapi is a highly opinionated library to generate server, client and documentation from OpenAPI specification using typescript.", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/opapi/src/generator.ts b/opapi/src/generator.ts index 839d6c342..f00026a4d 100644 --- a/opapi/src/generator.ts +++ b/opapi/src/generator.ts @@ -157,7 +157,7 @@ export const generateClientWithOpapi = async (state: State ?limit=10 */ -const GET_AXIOS_REQ_FUNCTION = ` -import { AxiosRequestConfig } from "axios" +const GET_REQUEST_FUNCTION = ` import qs from "qs" export type Primitive = string | number | boolean @@ -84,9 +84,16 @@ export type ParsedRequest = { body: AnyBodyParams } +export type RequestConfig = { + method: string + url: string + headers: Record + data?: any +} + const isDefined = (pair: [string, T | undefined]): pair is [string, T] => pair[1] !== undefined -export const toAxiosRequest = (req: ParsedRequest): AxiosRequestConfig => { +export const toRequest = (req: ParsedRequest): RequestConfig => { const { method, path, query, headers: headerParams, body } = req // prepare headers @@ -235,8 +242,8 @@ export const generateOperations = async (state: State, o } } -export const generateToAxios = async (toAxiosFile: string) => { - await writeTs(toAxiosFile, GET_AXIOS_REQ_FUNCTION) +export const generateToRequest = async (toRequestFile: string) => { + await writeTs(toRequestFile, GET_REQUEST_FUNCTION) } export const generateIndex = async (state: State, indexFile: string) => { @@ -244,9 +251,8 @@ export const generateIndex = async (state: State, indexF let indexCode = [ `${HEADER}`, - "import axios, { AxiosInstance } from 'axios'", "import { errorFrom } from './errors'", - "import { toAxiosRequest } from './to-axios'", + "import { toRequest, RequestConfig } from './to-request'", '', ].join('\n') @@ -263,9 +269,22 @@ export const generateIndex = async (state: State, indexF indexCode += `export const apiVersion = '${state.metadata.version}'\n\n` + indexCode += [ + // minimal transport interface the client depends on; any http client + // returning a response with a parsed json body under `data` works + 'export type HttpResponse = {', + ' data: T', + '}', + '', + 'export type HttpClient = {', + ' request: (config: RequestConfig) => Promise>', + '}', + ].join('\n') + indexCode += '\n\n' + indexCode += [ 'export type ClientProps = {', - ' toAxiosRequest: typeof toAxiosRequest', // allows to override the toAxiosRequest function + ' toRequest: typeof toRequest', // allows to override the toRequest function ' toApiError: typeof toApiError', // allows to override the toApiError function '}', ].join('\n') @@ -273,24 +292,24 @@ export const generateIndex = async (state: State, indexF indexCode += 'export class Client {\n\n' indexCode += - ' public constructor(private axiosInstance: AxiosInstance, private props: Partial = {}) {}\n\n' + ' public constructor(private httpClient: HttpClient, private props: Partial = {}) {}\n\n' for (const [name, operation] of Object.entries(operationsByName)) { const { inputName, resName } = Names.of(name) indexCode += [ ` public readonly ${name} = async (input: ${name}.${inputName}): Promise<${name}.${resName}> => {`, ` const { path, headers, query, body } = ${name}.parseReq(input)`, '', - ` const mapRequest = this.props.toAxiosRequest ?? toAxiosRequest`, + ` const mapRequest = this.props.toRequest ?? toRequest`, ` const mapErrorResponse = this.props.toApiError ?? toApiError`, '', - ` const axiosReq = mapRequest({`, + ` const httpReq = mapRequest({`, ` method: "${operation.method}",`, ' path,', ' headers: { ...headers },', ' query: { ...query },', ' body,', ' })', - ` return this.axiosInstance.request<${name}.${resName}>(axiosReq)`, + ` return this.httpClient.request<${name}.${resName}>(httpReq)`, ` .then((res) => res.data)`, ` .catch((e) => { throw mapErrorResponse(e) })`, ' }\n\n', diff --git a/opapi/src/handler-generator/export-handler.ts b/opapi/src/handler-generator/export-handler.ts index dbfc802c8..c7afd9191 100644 --- a/opapi/src/handler-generator/export-handler.ts +++ b/opapi/src/handler-generator/export-handler.ts @@ -1,7 +1,6 @@ import fs from 'fs/promises' const CONTENT = `import qs from 'qs' -import { isAxiosError } from 'axios' import { isApiError } from './errors' import * as types from './typings' @@ -101,8 +100,11 @@ export class Router { } } +type HttpErrorLike = Error & { response?: { data?: any; status?: number } } +const isHttpErrorLike = (thrown: unknown): thrown is HttpErrorLike => thrown instanceof Error && 'response' in thrown + const getErrorBody = (thrown: unknown) => { - if (isAxiosError(thrown)) { + if (isHttpErrorLike(thrown)) { const data = thrown.response?.data const statusCode = thrown.response?.status From 347b0ae6794b01eac4a4fe29facc92e01165d4d9 Mon Sep 17 00:00:00 2001 From: Sylvain Perron Date: Wed, 22 Jul 2026 17:18:34 -0400 Subject: [PATCH 2/4] style(opapi): prettier formatting Co-Authored-By: Claude Fable 5 --- opapi/src/generators/client-node.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/opapi/src/generators/client-node.ts b/opapi/src/generators/client-node.ts index 3d9d6c921..7e7ee0ab1 100644 --- a/opapi/src/generators/client-node.ts +++ b/opapi/src/generators/client-node.ts @@ -291,8 +291,7 @@ export const generateIndex = async (state: State, indexF indexCode += '\n\n' indexCode += 'export class Client {\n\n' - indexCode += - ' public constructor(private httpClient: HttpClient, private props: Partial = {}) {}\n\n' + indexCode += ' public constructor(private httpClient: HttpClient, private props: Partial = {}) {}\n\n' for (const [name, operation] of Object.entries(operationsByName)) { const { inputName, resName } = Names.of(name) indexCode += [ From e64ab68cb33e0e99fba7814ad2ab880e210d41a2 Mon Sep 17 00:00:00 2001 From: Sylvain Perron Date: Wed, 22 Jul 2026 17:22:28 -0400 Subject: [PATCH 3/4] fix(opapi): address review comments - import RequestConfig with an inline type modifier so generated code survives verbatimModuleSyntax consumers - document the HttpClient transport contract: implementations must reject on unsuccessful statuses with the error body under response.data (and fix the changelog example accordingly) - accept structural http errors in the generated handler even when the thrown value is not an Error instance (e.g. cross-realm errors) Co-Authored-By: Claude Fable 5 --- opapi/CHANGELOG.md | 11 +++++++++-- opapi/src/generators/client-node.ts | 10 +++++++--- opapi/src/handler-generator/export-handler.ts | 5 +++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/opapi/CHANGELOG.md b/opapi/CHANGELOG.md index 83161b9d0..1ee8b0e93 100644 --- a/opapi/CHANGELOG.md +++ b/opapi/CHANGELOG.md @@ -11,7 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Changed -- **Breaking**: The `opapi` client generator no longer emits axios-based code. The generated `Client` now takes any transport implementing the generated `HttpClient` interface (`{ request: (config: RequestConfig) => Promise<{ data: T }> }`) instead of an `AxiosInstance`. +- **Breaking**: The `opapi` client generator no longer emits axios-based code. The generated `Client` now takes any transport implementing the generated `HttpClient` interface (`{ request: (config: RequestConfig) => Promise<{ data: T }> }`) instead of an `AxiosInstance`. Like axios, the transport must reject on unsuccessful http statuses, exposing the parsed error body under `response.data` on the thrown error so the generated client can map it to an api error. - **Breaking**: The generated `to-axios.ts` file is now `to-request.ts`, `toAxiosRequest` is now `toRequest` (returning a `RequestConfig` instead of an `AxiosRequestConfig`), and the `ClientProps.toAxiosRequest` override is now `ClientProps.toRequest`. - **Breaking**: The generated handler no longer imports `isAxiosError` from axios; it detects http errors structurally (any `Error` with a `response` property). - The generated `toApiError` detects http errors structurally (`err.response.data`) instead of using `axios.isAxiosError`, so it works with any transport including axios. @@ -30,7 +30,14 @@ const httpClient = { headers: config.headers, body: config.data ? JSON.stringify(config.data) : undefined, }) - return { data: (await res.json()) as T } + const data = await res.json() + if (!res.ok) { + // the generated client maps rejections carrying `response.data` to api errors + throw Object.assign(new Error(`Request failed with status code ${res.status}`), { + response: { status: res.status, data }, + }) + } + return { data: data as T } }, } diff --git a/opapi/src/generators/client-node.ts b/opapi/src/generators/client-node.ts index 7e7ee0ab1..c7ebe1ca3 100644 --- a/opapi/src/generators/client-node.ts +++ b/opapi/src/generators/client-node.ts @@ -252,7 +252,7 @@ export const generateIndex = async (state: State, indexF let indexCode = [ `${HEADER}`, "import { errorFrom } from './errors'", - "import { toRequest, RequestConfig } from './to-request'", + "import { toRequest, type RequestConfig } from './to-request'", '', ].join('\n') @@ -270,12 +270,16 @@ export const generateIndex = async (state: State, indexF indexCode += `export const apiVersion = '${state.metadata.version}'\n\n` indexCode += [ - // minimal transport interface the client depends on; any http client - // returning a response with a parsed json body under `data` works 'export type HttpResponse = {', ' data: T', '}', '', + '/**', + ' * Minimal http transport the generated client depends on. Implementations', + ' * MUST reject (throw) on unsuccessful http statuses — exposing the parsed', + ' * error body under `response.data` on the thrown error so it can be mapped', + ' * to an api error — and resolve with the parsed response body under `data`.', + ' */', 'export type HttpClient = {', ' request: (config: RequestConfig) => Promise>', '}', diff --git a/opapi/src/handler-generator/export-handler.ts b/opapi/src/handler-generator/export-handler.ts index c7afd9191..1e929a460 100644 --- a/opapi/src/handler-generator/export-handler.ts +++ b/opapi/src/handler-generator/export-handler.ts @@ -100,8 +100,9 @@ export class Router { } } -type HttpErrorLike = Error & { response?: { data?: any; status?: number } } -const isHttpErrorLike = (thrown: unknown): thrown is HttpErrorLike => thrown instanceof Error && 'response' in thrown +type HttpErrorLike = { message?: string; response?: { data?: any; status?: number } } +const isHttpErrorLike = (thrown: unknown): thrown is HttpErrorLike => + typeof thrown === 'object' && thrown !== null && 'response' in thrown && (thrown as HttpErrorLike).response !== undefined const getErrorBody = (thrown: unknown) => { if (isHttpErrorLike(thrown)) { From a4a030893babb3ab3c95b6b21932ccdccb170975 Mon Sep 17 00:00:00 2001 From: Sylvain Perron Date: Wed, 22 Jul 2026 17:33:38 -0400 Subject: [PATCH 4/4] feat(opapi): drop node crypto import from generated errors.ts Generated errors.ts now uses globalThis.crypto (browsers, web workers, node >= 19, edge runtimes) with a Math.random fallback instead of importing the node crypto module, so generated clients have no node builtin dependencies. Co-Authored-By: Claude Fable 5 --- opapi/CHANGELOG.md | 1 + opapi/src/generators/errors.ts | 18 ++++++------------ 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/opapi/CHANGELOG.md b/opapi/CHANGELOG.md index 1ee8b0e93..a4b20ce78 100644 --- a/opapi/CHANGELOG.md +++ b/opapi/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Breaking**: The generated `to-axios.ts` file is now `to-request.ts`, `toAxiosRequest` is now `toRequest` (returning a `RequestConfig` instead of an `AxiosRequestConfig`), and the `ClientProps.toAxiosRequest` override is now `ClientProps.toRequest`. - **Breaking**: The generated handler no longer imports `isAxiosError` from axios; it detects http errors structurally (any `Error` with a `response` property). - The generated `toApiError` detects http errors structurally (`err.response.data`) instead of using `axios.isAxiosError`, so it works with any transport including axios. +- The generated `errors.ts` no longer imports the node `crypto` module: it uses `globalThis.crypto` when available (browsers, web workers, node >= 19, edge runtimes) and falls back to a `Math.random`-based polyfill otherwise, so generated clients have no node builtin dependencies. #### Examples diff --git a/opapi/src/generators/errors.ts b/opapi/src/generators/errors.ts index 749c5830d..bb76841b9 100644 --- a/opapi/src/generators/errors.ts +++ b/opapi/src/generators/errors.ts @@ -27,8 +27,6 @@ export function generateErrors(errors: ApiError[]) { const types = errors.map((error) => error.type) return ` -import crypto from 'crypto' - const codes = { ${Object.entries(codes) .map(([name, code]) => ` ${name}: ${code},`) @@ -41,19 +39,15 @@ declare const window: any type CryptoLib = { getRandomValues(array: Uint8Array): Uint8Array } const cryptoLibPolyfill: CryptoLib = { - // Fallback when crypto isn't available. + // Fallback in environments without a web crypto implementation. getRandomValues: (array: Uint8Array) => new Uint8Array(array.map(() => Math.floor(Math.random() * 256))), } -let cryptoLib: CryptoLib = - typeof window !== 'undefined' && typeof window.document !== 'undefined' - ? window.crypto // Note: On browsers we need to use window.crypto instead of the imported crypto module as the latter is externalized and doesn't have getRandomValues(). - : crypto - -if (!cryptoLib.getRandomValues) { - // Use a polyfill in older environments that have a crypto implementation missing getRandomValues() - cryptoLib = cryptoLibPolyfill -} +// globalThis.crypto covers browsers, web workers, node >= 19 and most edge runtimes +const cryptoLib: CryptoLib = + typeof globalThis.crypto !== 'undefined' && typeof globalThis.crypto.getRandomValues === 'function' + ? globalThis.crypto + : cryptoLibPolyfill abstract class BaseApiError extends Error { public readonly isApiError = true