Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions opapi/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,44 @@ 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: <T>(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.
- 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

```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 <T>(config: { method: string; url: string; headers: Record<string, string>; 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,
})
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 }
},
}

const client = new Client(httpClient)
```

### Fixed

- Fixed spelling mistakes in package files.
Expand Down
2 changes: 1 addition & 1 deletion opapi/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 3 additions & 3 deletions opapi/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export const generateClientWithOpapi = async (state: State<string, string, strin
const errorsFile = pathlib.join(dir, 'errors.ts')
const indexFile = pathlib.join(dir, 'index.ts')
const operationsDir = pathlib.join(dir, 'operations')
const toAxiosFile = pathlib.join(dir, 'to-axios.ts')
const toRequestFile = pathlib.join(dir, 'to-request.ts')
fslib.mkdirSync(operationsDir, { recursive: true })

log.info('Generating models')
Expand All @@ -170,8 +170,8 @@ export const generateClientWithOpapi = async (state: State<string, string, strin
const errorsFileContent = generateErrors(state.errors ?? [])
await fslib.promises.writeFile(errorsFile, errorsFileContent)

log.info('Generating to-axios file')
await clientNode.generateToAxios(toAxiosFile)
log.info('Generating to-request file')
await clientNode.generateToRequest(toRequestFile)

log.info('Generating index file')
await clientNode.generateIndex(state, indexFile)
Expand Down
54 changes: 38 additions & 16 deletions opapi/src/generators/client-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@ const HEADER = `// this file was automatically generated, do not edit
/* eslint-disable */
`

const GET_ERROR_FUNCTION = `// maps axios error to api error type
const GET_ERROR_FUNCTION = `// maps http error to api error type
function toApiError(err: unknown): Error {
if (axios.isAxiosError(err) && err.response?.data) {
return errorFrom(err.response.data)
const data = (err as { response?: { data?: unknown } } | null)?.response?.data
if (data) {
return errorFrom(data)
}
return errorFrom(err)
}
Expand All @@ -65,8 +66,7 @@ function toApiError(err: unknown): Error {
* { limit: 10 } -> ?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
Expand All @@ -84,9 +84,16 @@ export type ParsedRequest = {
body: AnyBodyParams
}

export type RequestConfig = {
method: string
url: string
headers: Record<string, string>
data?: any
}

const isDefined = <T>(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
Expand Down Expand Up @@ -235,18 +242,17 @@ export const generateOperations = async (state: State<string, string, string>, 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<string, string, string>, indexFile: string) => {
const operationsByName = _.mapKeys(state.operations, (v) => v.name)

let indexCode = [
`${HEADER}`,
"import axios, { AxiosInstance } from 'axios'",
"import { errorFrom } from './errors'",
"import { toAxiosRequest } from './to-axios'",
"import { toRequest, type RequestConfig } from './to-request'",
'',
].join('\n')

Expand All @@ -263,34 +269,50 @@ export const generateIndex = async (state: State<string, string, string>, indexF

indexCode += `export const apiVersion = '${state.metadata.version}'\n\n`

indexCode += [
'export type HttpResponse<T> = {',
' 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: <T>(config: RequestConfig) => Promise<HttpResponse<T>>',
Comment thread
slvnperron marked this conversation as resolved.
'}',
].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')
indexCode += '\n\n'

indexCode += 'export class Client {\n\n'
indexCode +=
' public constructor(private axiosInstance: AxiosInstance, private props: Partial<ClientProps> = {}) {}\n\n'
indexCode += ' public constructor(private httpClient: HttpClient, private props: Partial<ClientProps> = {}) {}\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',
Expand Down
18 changes: 6 additions & 12 deletions opapi/src/generators/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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},`)
Expand All @@ -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<Code extends ErrorCode, Type extends string, Description extends string> extends Error {
public readonly isApiError = true
Expand Down
7 changes: 5 additions & 2 deletions opapi/src/handler-generator/export-handler.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -101,8 +100,12 @@ export class Router {
}
}

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 (isAxiosError(thrown)) {
if (isHttpErrorLike(thrown)) {
const data = thrown.response?.data
const statusCode = thrown.response?.status

Expand Down
Loading