diff --git a/.changeset/slick-dancers-think.md b/.changeset/slick-dancers-think.md new file mode 100644 index 000000000..ae20ca202 --- /dev/null +++ b/.changeset/slick-dancers-think.md @@ -0,0 +1,5 @@ +--- +'@hey-api/openapi-ts': patch +--- + +fix(client-axios): revert return error when axios request fails diff --git a/examples/openapi-ts-axios/src/client/client.gen.ts b/examples/openapi-ts-axios/src/client/client.gen.ts index 102ab4bfb..c8bfd460b 100644 --- a/examples/openapi-ts-axios/src/client/client.gen.ts +++ b/examples/openapi-ts-axios/src/client/client.gen.ts @@ -1,12 +1,12 @@ // This file is auto-generated by @hey-api/openapi-ts import { - type ClientOptions as DefaultClientOptions, + type ClientOptions, type Config, createClient, createConfig, } from './client'; -import type { ClientOptions } from './types.gen'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; /** * The `createClientConfig()` function will be called on client initialization @@ -16,13 +16,12 @@ import type { ClientOptions } from './types.gen'; * `setConfig()`. This is useful for example if you're using Next.js * to ensure your client always has the correct values. */ -export type CreateClientConfig = - ( - override?: Config, - ) => Config & T>; +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; export const client = createClient( - createConfig({ + createConfig({ baseURL: 'https://petstore3.swagger.io/api/v3', }), ); diff --git a/examples/openapi-ts-axios/src/client/client/client.gen.ts b/examples/openapi-ts-axios/src/client/client/client.gen.ts index b2f7b118f..f81a9e786 100644 --- a/examples/openapi-ts-axios/src/client/client/client.gen.ts +++ b/examples/openapi-ts-axios/src/client/client/client.gen.ts @@ -3,7 +3,10 @@ import type { AxiosError, AxiosInstance, RawAxiosRequestHeaders } from 'axios'; import axios from 'axios'; -import type { Client, Config } from './types.gen'; +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { Client, Config, RequestOptions } from './types.gen'; import { buildUrl, createConfig, @@ -38,8 +41,7 @@ export const createClient = (config: Config = {}): Client => { return getConfig(); }; - // @ts-expect-error - const request: Client['request'] = async (options) => { + const beforeRequest = async (options: RequestOptions) => { const opts = { ..._config, ...options, @@ -58,12 +60,19 @@ export const createClient = (config: Config = {}): Client => { await opts.requestValidator(opts); } - if (opts.body && opts.bodySerializer) { + if (opts.body !== undefined && opts.bodySerializer) { opts.body = opts.bodySerializer(opts.body); } const url = buildUrl(opts); + return { opts, url }; + }; + + // @ts-expect-error + const request: Client['request'] = async (options) => { + // @ts-expect-error + const { opts, url } = await beforeRequest(options); try { // assign Axios here for consistency with fetch const _axios = opts.axios!; @@ -71,8 +80,8 @@ export const createClient = (config: Config = {}): Client => { const { auth, ...optsWithoutAuth } = opts; const response = await _axios({ ...optsWithoutAuth, - baseURL: opts.baseURL as string, - data: opts.body, + baseURL: '', // the baseURL is already included in `url` + data: getValidRequestBody(opts), headers: opts.headers as RawAxiosRequestHeaders, // let `paramsSerializer()` handle query params if it exists params: opts.paramsSerializer ? opts.query : undefined, @@ -106,18 +115,49 @@ export const createClient = (config: Config = {}): Client => { } }; + const makeMethodFn = + (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = + (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + headers: opts.headers as Record, + method, + // @ts-expect-error + signal: opts.signal, + url, + }); + }; + return { buildUrl, - delete: (options) => request({ ...options, method: 'DELETE' }), - get: (options) => request({ ...options, method: 'GET' }), + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), getConfig, - head: (options) => request({ ...options, method: 'HEAD' }), + head: makeMethodFn('HEAD'), instance, - options: (options) => request({ ...options, method: 'OPTIONS' }), - patch: (options) => request({ ...options, method: 'PATCH' }), - post: (options) => request({ ...options, method: 'POST' }), - put: (options) => request({ ...options, method: 'PUT' }), + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), request, setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), } as Client; }; diff --git a/examples/openapi-ts-axios/src/client/client/index.ts b/examples/openapi-ts-axios/src/client/client/index.ts index 8ddc04f42..cff1d39c9 100644 --- a/examples/openapi-ts-axios/src/client/client/index.ts +++ b/examples/openapi-ts-axios/src/client/client/index.ts @@ -8,6 +8,7 @@ export { urlSearchParamsBodySerializer, } from '../core/bodySerializer.gen'; export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; export { createClient } from './client.gen'; export type { Client, diff --git a/examples/openapi-ts-axios/src/client/client/types.gen.ts b/examples/openapi-ts-axios/src/client/client/types.gen.ts index b28841acc..d59239b9a 100644 --- a/examples/openapi-ts-axios/src/client/client/types.gen.ts +++ b/examples/openapi-ts-axios/src/client/client/types.gen.ts @@ -10,6 +10,10 @@ import type { } from 'axios'; import type { Auth } from '../core/auth.gen'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen'; import type { Client as CoreClient, Config as CoreConfig, @@ -56,11 +60,20 @@ export interface Config } export interface RequestOptions< + TData = unknown, ThrowOnError extends boolean = boolean, Url extends string = string, > extends Config<{ - throwOnError: ThrowOnError; - }> { + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { /** * Any body that you want to add to your request. * @@ -76,6 +89,11 @@ export interface RequestOptions< url: Url; } +export interface ClientOptions { + baseURL?: string; + throwOnError?: boolean; +} + export type RequestResult< TData = unknown, TError = unknown, @@ -100,26 +118,29 @@ export type RequestResult< }) >; -export interface ClientOptions { - baseURL?: string; - throwOnError?: boolean; -} - type MethodFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, >( - options: Omit, 'method'>, + options: Omit, 'method'>, ) => RequestResult; +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, +>( + options: Omit, 'method'>, +) => Promise>; + type RequestFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, >( - options: Omit, 'method'> & - Pick>, 'method'>, + options: Omit, 'method'> & + Pick>, 'method'>, ) => RequestResult; type BuildUrlFn = < @@ -130,10 +151,16 @@ type BuildUrlFn = < url: string; }, >( - options: Pick & Omit, 'axios'>, + options: Pick & Options, ) => string; -export type Client = CoreClient & { +export type Client = CoreClient< + RequestFn, + Config, + MethodFn, + BuildUrlFn, + SseFn +> & { instance: AxiosInstance; }; @@ -162,7 +189,11 @@ type OmitKeys = Pick>; export type Options< TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, -> = OmitKeys, 'body' | 'path' | 'query' | 'url'> & + TResponse = unknown, +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & Omit; export type OptionsLegacyParser< @@ -170,12 +201,16 @@ export type OptionsLegacyParser< ThrowOnError extends boolean = boolean, > = TData extends { body?: any } ? TData extends { headers?: any } - ? OmitKeys, 'body' | 'headers' | 'url'> & TData - : OmitKeys, 'body' | 'url'> & + ? OmitKeys< + RequestOptions, + 'body' | 'headers' | 'url' + > & + TData + : OmitKeys, 'body' | 'url'> & TData & - Pick, 'headers'> + Pick, 'headers'> : TData extends { headers?: any } - ? OmitKeys, 'headers' | 'url'> & + ? OmitKeys, 'headers' | 'url'> & TData & - Pick, 'body'> - : OmitKeys, 'url'> & TData; + Pick, 'body'> + : OmitKeys, 'url'> & TData; diff --git a/examples/openapi-ts-axios/src/client/client/utils.gen.ts b/examples/openapi-ts-axios/src/client/client/utils.gen.ts index 8f20fa853..c87309246 100644 --- a/examples/openapi-ts-axios/src/client/client/utils.gen.ts +++ b/examples/openapi-ts-axios/src/client/client/utils.gen.ts @@ -1,16 +1,13 @@ // This file is auto-generated by @hey-api/openapi-ts import { getAuthToken } from '../core/auth.gen'; -import type { - QuerySerializer, - QuerySerializerOptions, -} from '../core/bodySerializer.gen'; -import type { ArraySeparatorStyle } from '../core/pathSerializer.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam, } from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; import type { Client, ClientOptions, @@ -18,83 +15,6 @@ import type { RequestOptions, } from './types.gen'; -interface PathSerializer { - path: Record; - url: string; -} - -const PATH_PARAM_RE = /\{[^{}]+\}/g; - -const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { - let url = _url; - const matches = _url.match(PATH_PARAM_RE); - if (matches) { - for (const match of matches) { - let explode = false; - let name = match.substring(1, match.length - 1); - let style: ArraySeparatorStyle = 'simple'; - - if (name.endsWith('*')) { - explode = true; - name = name.substring(0, name.length - 1); - } - - if (name.startsWith('.')) { - name = name.substring(1); - style = 'label'; - } else if (name.startsWith(';')) { - name = name.substring(1); - style = 'matrix'; - } - - const value = path[name]; - - if (value === undefined || value === null) { - continue; - } - - if (Array.isArray(value)) { - url = url.replace( - match, - serializeArrayParam({ explode, name, style, value }), - ); - continue; - } - - if (typeof value === 'object') { - url = url.replace( - match, - serializeObjectParam({ - explode, - name, - style, - value: value as Record, - valueOnly: true, - }), - ); - continue; - } - - if (style === 'matrix') { - url = url.replace( - match, - `;${serializePrimitiveParam({ - name, - value: value as string, - })}`, - ); - continue; - } - - const replaceValue = encodeURIComponent( - style === 'label' ? `.${value as string}` : (value as string), - ); - url = url.replace(match, replaceValue); - } - } - return url; -}; - export const createQuerySerializer = ({ allowReserved, array, @@ -211,7 +131,15 @@ export const setAuthParams = async ({ }; export const buildUrl: Client['buildUrl'] = (options) => { - const url = getUrl({ + const instanceBaseUrl = options.axios?.defaults?.baseURL; + + const baseUrl = + !!options.baseURL && typeof options.baseURL === 'string' + ? options.baseURL + : instanceBaseUrl; + + return getUrl({ + baseUrl: baseUrl as string, path: options.path, // let `paramsSerializer()` handle query params if it exists query: !options.paramsSerializer ? options.query : undefined, @@ -221,33 +149,6 @@ export const buildUrl: Client['buildUrl'] = (options) => { : createQuerySerializer(options.querySerializer), url: options.url, }); - return url; -}; - -export const getUrl = ({ - path, - query, - querySerializer, - url: _url, -}: { - path?: Record; - query?: Record; - querySerializer: QuerySerializer; - url: string; -}) => { - const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; - let url = pathUrl; - if (path) { - url = defaultPathSerializer({ path, url }); - } - let search = query ? querySerializer(query) : ''; - if (search.startsWith('?')) { - search = search.substring(1); - } - if (search) { - url += `?${search}`; - } - return url; }; export const mergeConfigs = (a: Config, b: Config): Config => { diff --git a/examples/openapi-ts-axios/src/client/core/queryKeySerializer.gen.ts b/examples/openapi-ts-axios/src/client/core/queryKeySerializer.gen.ts new file mode 100644 index 000000000..d3bb68396 --- /dev/null +++ b/examples/openapi-ts-axios/src/client/core/queryKeySerializer.gen.ts @@ -0,0 +1,136 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown) => { + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => + a.localeCompare(b), + ); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = ( + value: unknown, +): JsonValue | undefined => { + if (value === null) { + return null; + } + + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; + } + + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if ( + typeof URLSearchParams !== 'undefined' && + value instanceof URLSearchParams + ) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/examples/openapi-ts-axios/src/client/core/serverSentEvents.gen.ts b/examples/openapi-ts-axios/src/client/core/serverSentEvents.gen.ts new file mode 100644 index 000000000..f8fd78e28 --- /dev/null +++ b/examples/openapi-ts-axios/src/client/core/serverSentEvents.gen.ts @@ -0,0 +1,264 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +export type ServerSentEventsOptions = Omit< + RequestInit, + 'method' +> & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult< + TData = unknown, + TReturn = void, + TNext = unknown, +> = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export const createSseClient = ({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult => { + let lastEventId: string | undefined; + + const sleep = + sseSleepFn ?? + ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) + throw new Error( + `SSE failed: ${response.status} ${response.statusText}`, + ); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt( + line.replace(/^retry:\s*/, ''), + 10, + ); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if ( + sseMaxRetryAttempts !== undefined && + attempt >= sseMaxRetryAttempts + ) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min( + retryDelay * 2 ** (attempt - 1), + sseMaxRetryDelay ?? 30000, + ); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +}; diff --git a/examples/openapi-ts-axios/src/client/core/types.gen.ts b/examples/openapi-ts-axios/src/client/core/types.gen.ts index 5bfae35c0..643c070c9 100644 --- a/examples/openapi-ts-axios/src/client/core/types.gen.ts +++ b/examples/openapi-ts-axios/src/client/core/types.gen.ts @@ -7,29 +7,36 @@ import type { QuerySerializerOptions, } from './bodySerializer.gen'; -export interface Client< +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never, -> { + SseFn = never, +> = { /** * Returns the final request URL. */ buildUrl: BuildUrlFn; - connect: MethodFn; - delete: MethodFn; - get: MethodFn; getConfig: () => Config; - head: MethodFn; - options: MethodFn; - patch: MethodFn; - post: MethodFn; - put: MethodFn; request: RequestFn; setConfig: (config: Config) => Config; - trace: MethodFn; -} +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] + ? { sse?: never } + : { sse: { [K in HttpMethod]: SseFn } }); export interface Config { /** @@ -65,16 +72,7 @@ export interface Config { * * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} */ - method?: - | 'CONNECT' - | 'DELETE' - | 'GET' - | 'HEAD' - | 'OPTIONS' - | 'PATCH' - | 'POST' - | 'PUT' - | 'TRACE'; + method?: Uppercase; /** * A function for serializing request query parameters. By default, arrays * will be exploded in form style, objects will be exploded in deepObject diff --git a/examples/openapi-ts-axios/src/client/core/utils.gen.ts b/examples/openapi-ts-axios/src/client/core/utils.gen.ts new file mode 100644 index 000000000..0b5389d08 --- /dev/null +++ b/examples/openapi-ts-axios/src/client/core/utils.gen.ts @@ -0,0 +1,143 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen'; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace( + match, + serializeArrayParam({ explode, name, style, value }), + ); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}) { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e. client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/examples/openapi-ts-axios/src/client/index.ts b/examples/openapi-ts-axios/src/client/index.ts index 688e3c912..57ed02bf5 100644 --- a/examples/openapi-ts-axios/src/client/index.ts +++ b/examples/openapi-ts-axios/src/client/index.ts @@ -1,3 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts + export * from './sdk.gen'; -export * from './types.gen'; +export type * from './types.gen'; diff --git a/examples/openapi-ts-axios/src/client/sdk.gen.ts b/examples/openapi-ts-axios/src/client/sdk.gen.ts index 59074a4f4..bb7cee4ba 100644 --- a/examples/openapi-ts-axios/src/client/sdk.gen.ts +++ b/examples/openapi-ts-axios/src/client/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Client, Options as ClientOptions, TDataShape } from './client'; -import { client as _heyApiClient } from './client.gen'; +import type { Client, Options as Options2, TDataShape } from './client'; +import { client } from './client.gen'; import type { AddPetData, AddPetErrors, @@ -65,7 +65,7 @@ import type { export type Options< TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, -> = ClientOptions & { +> = Options2 & { /** * You can provide a client instance returned by `createClient()` instead of * individual options. This might be also useful if you want to implement a @@ -81,16 +81,13 @@ export type Options< /** * Add a new pet to the store. + * * Add a new pet to the store. */ export const addPet = ( options: Options, ) => - (options.client ?? _heyApiClient).post< - AddPetResponses, - AddPetErrors, - ThrowOnError - >({ + (options.client ?? client).post({ responseType: 'json', security: [ { @@ -108,12 +105,13 @@ export const addPet = ( /** * Update an existing pet. + * * Update an existing pet by Id. */ export const updatePet = ( options: Options, ) => - (options.client ?? _heyApiClient).put< + (options.client ?? client).put< UpdatePetResponses, UpdatePetErrors, ThrowOnError @@ -135,12 +133,13 @@ export const updatePet = ( /** * Finds Pets by status. + * * Multiple status values can be provided with comma separated strings. */ export const findPetsByStatus = ( options: Options, ) => - (options.client ?? _heyApiClient).get< + (options.client ?? client).get< FindPetsByStatusResponses, FindPetsByStatusErrors, ThrowOnError @@ -158,12 +157,13 @@ export const findPetsByStatus = ( /** * Finds Pets by tags. + * * Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. */ export const findPetsByTags = ( options: Options, ) => - (options.client ?? _heyApiClient).get< + (options.client ?? client).get< FindPetsByTagsResponses, FindPetsByTagsErrors, ThrowOnError @@ -181,12 +181,13 @@ export const findPetsByTags = ( /** * Deletes a pet. + * * Delete a pet. */ export const deletePet = ( options: Options, ) => - (options.client ?? _heyApiClient).delete< + (options.client ?? client).delete< DeletePetResponses, DeletePetErrors, ThrowOnError @@ -203,12 +204,13 @@ export const deletePet = ( /** * Find pet by ID. + * * Returns a single pet. */ export const getPetById = ( options: Options, ) => - (options.client ?? _heyApiClient).get< + (options.client ?? client).get< GetPetByIdResponses, GetPetByIdErrors, ThrowOnError @@ -230,12 +232,13 @@ export const getPetById = ( /** * Updates a pet in the store with form data. + * * Updates a pet resource based on the form data. */ export const updatePetWithForm = ( options: Options, ) => - (options.client ?? _heyApiClient).post< + (options.client ?? client).post< UpdatePetWithFormResponses, UpdatePetWithFormErrors, ThrowOnError @@ -253,12 +256,13 @@ export const updatePetWithForm = ( /** * Uploads an image. + * * Upload image of the pet. */ export const uploadFile = ( options: Options, ) => - (options.client ?? _heyApiClient).post< + (options.client ?? client).post< UploadFileResponses, UploadFileErrors, ThrowOnError @@ -281,12 +285,13 @@ export const uploadFile = ( /** * Returns pet inventories by status. + * * Returns a map of status codes to quantities. */ export const getInventory = ( options?: Options, ) => - (options?.client ?? _heyApiClient).get< + (options?.client ?? client).get< GetInventoryResponses, GetInventoryErrors, ThrowOnError @@ -304,12 +309,13 @@ export const getInventory = ( /** * Place an order for a pet. + * * Place a new order in the store. */ export const placeOrder = ( options?: Options, ) => - (options?.client ?? _heyApiClient).post< + (options?.client ?? client).post< PlaceOrderResponses, PlaceOrderErrors, ThrowOnError @@ -325,12 +331,13 @@ export const placeOrder = ( /** * Delete purchase order by identifier. + * * For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors. */ export const deleteOrder = ( options: Options, ) => - (options.client ?? _heyApiClient).delete< + (options.client ?? client).delete< DeleteOrderResponses, DeleteOrderErrors, ThrowOnError @@ -341,12 +348,13 @@ export const deleteOrder = ( /** * Find purchase order by ID. + * * For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions. */ export const getOrderById = ( options: Options, ) => - (options.client ?? _heyApiClient).get< + (options.client ?? client).get< GetOrderByIdResponses, GetOrderByIdErrors, ThrowOnError @@ -358,12 +366,13 @@ export const getOrderById = ( /** * Create user. + * * This can only be done by the logged in user. */ export const createUser = ( options?: Options, ) => - (options?.client ?? _heyApiClient).post< + (options?.client ?? client).post< CreateUserResponses, CreateUserErrors, ThrowOnError @@ -379,12 +388,13 @@ export const createUser = ( /** * Creates list of users with given input array. + * * Creates list of users with given input array. */ export const createUsersWithListInput = ( options?: Options, ) => - (options?.client ?? _heyApiClient).post< + (options?.client ?? client).post< CreateUsersWithListInputResponses, CreateUsersWithListInputErrors, ThrowOnError @@ -400,12 +410,13 @@ export const createUsersWithListInput = ( /** * Logs user into the system. + * * Log into the system. */ export const loginUser = ( options?: Options, ) => - (options?.client ?? _heyApiClient).get< + (options?.client ?? client).get< LoginUserResponses, LoginUserErrors, ThrowOnError @@ -417,12 +428,13 @@ export const loginUser = ( /** * Logs out current logged in user session. + * * Log user out of the system. */ export const logoutUser = ( options?: Options, ) => - (options?.client ?? _heyApiClient).get< + (options?.client ?? client).get< LogoutUserResponses, LogoutUserErrors, ThrowOnError @@ -433,12 +445,13 @@ export const logoutUser = ( /** * Delete user resource. + * * This can only be done by the logged in user. */ export const deleteUser = ( options: Options, ) => - (options.client ?? _heyApiClient).delete< + (options.client ?? client).delete< DeleteUserResponses, DeleteUserErrors, ThrowOnError @@ -449,12 +462,13 @@ export const deleteUser = ( /** * Get user by user name. + * * Get user detail based on username. */ export const getUserByName = ( options: Options, ) => - (options.client ?? _heyApiClient).get< + (options.client ?? client).get< GetUserByNameResponses, GetUserByNameErrors, ThrowOnError @@ -466,12 +480,13 @@ export const getUserByName = ( /** * Update user resource. + * * This can only be done by the logged in user. */ export const updateUser = ( options: Options, ) => - (options.client ?? _heyApiClient).put< + (options.client ?? client).put< UpdateUserResponses, UpdateUserErrors, ThrowOnError diff --git a/examples/openapi-ts-axios/src/client/types.gen.ts b/examples/openapi-ts-axios/src/client/types.gen.ts index 257d5446a..48da19d87 100644 --- a/examples/openapi-ts-axios/src/client/types.gen.ts +++ b/examples/openapi-ts-axios/src/client/types.gen.ts @@ -1,5 +1,9 @@ // This file is auto-generated by @hey-api/openapi-ts +export type ClientOptions = { + baseURL: 'https://petstore3.swagger.io/api/v3' | (string & {}); +}; + export type Order = { complete?: boolean; id?: number; @@ -693,7 +697,3 @@ export type UpdateUserResponses = { */ 200: unknown; }; - -export type ClientOptions = { - baseURL: 'https://petstore3.swagger.io/api/v3' | (string & {}); -};