diff --git a/opapi/CHANGELOG.md b/opapi/CHANGELOG.md index b2cb0dbd5..a4b20ce78 100644 --- a/opapi/CHANGELOG.md +++ b/opapi/CHANGELOG.md @@ -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: (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 (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, + }) + 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. 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, type RequestConfig } from './to-request'", '', ].join('\n') @@ -263,34 +269,50 @@ export const generateIndex = async (state: State, indexF indexCode += `export const apiVersion = '${state.metadata.version}'\n\n` + indexCode += [ + '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>', + '}', + ].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 = {}) {}\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 += [ ` 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/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 diff --git a/opapi/src/handler-generator/export-handler.ts b/opapi/src/handler-generator/export-handler.ts index dbfc802c8..1e929a460 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,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