diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/api.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/api.ts new file mode 100644 index 000000000..a54a8ec1b --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/api.ts @@ -0,0 +1,25 @@ +import type { ICodegenSymbolSelector } from '@hey-api/codegen-core'; + +import type { Plugin } from '../../types'; + +type SelectorType = 'client'; + +export type IApi = { + /** + * @param type Selector type. + * @param value Depends on `type`: + * - `client`: never + * @returns Selector array + */ + getSelector: (type: SelectorType, value?: string) => ICodegenSymbolSelector; +}; + +export class Api implements IApi { + constructor(public meta: Plugin.Name<'@hey-api/client-ofetch'>) {} + + getSelector( + ...args: ReadonlyArray + ): ICodegenSymbolSelector { + return [this.meta.name, ...(args as ICodegenSymbolSelector)]; + } +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/client.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/client.ts new file mode 100644 index 000000000..29d6467d0 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/client.ts @@ -0,0 +1,273 @@ +import { ofetch, type ResponseType as OfetchResponseType } from 'ofetch'; + +import { createSseClient } from '../../client-core/bundle/serverSentEvents'; +import type { HttpMethod } from '../../client-core/bundle/types'; +import { getValidRequestBody } from '../../client-core/bundle/utils'; +import type { + Client, + Config, + RequestOptions, + ResolvedRequestOptions, +} from './types'; +import { + buildOfetchOptions, + buildUrl, + createConfig, + createInterceptors, + isRepeatableBody, + mapParseAsToResponseType, + mergeConfigs, + mergeHeaders, + parseError, + parseSuccess, + setAuthParams, + wrapDataReturn, + wrapErrorReturn, +} from './utils'; + +type ReqInit = Omit & { + body?: BodyInit | null | undefined; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors< + Request, + Response, + unknown, + ResolvedRequestOptions + >(); + + // Resolve final options, serialized body, network body and URL + const resolveOptions = async (options: RequestOptions) => { + const opts = { + ..._config, + ...options, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined, + }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body); + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + // If user provides a raw body (no serializer), adjust Content-Type sensibly. + // Avoid overriding explicit user-defined headers; only correct the default JSON header. + if ( + opts.body !== undefined && + opts.bodySerializer === null && + (opts.headers.get('Content-Type') || '').toLowerCase() === + 'application/json' + ) { + const b: unknown = opts.body; + if (typeof FormData !== 'undefined' && b instanceof FormData) { + // Let the runtime set proper boundary + opts.headers.delete('Content-Type'); + } else if ( + typeof URLSearchParams !== 'undefined' && + b instanceof URLSearchParams + ) { + // Set standard urlencoded content type with charset + opts.headers.set( + 'Content-Type', + 'application/x-www-form-urlencoded;charset=UTF-8', + ); + } else if (typeof Blob !== 'undefined' && b instanceof Blob) { + const t = b.type?.trim(); + if (t) { + opts.headers.set('Content-Type', t); + } else { + // No known type for the blob: avoid sending misleading JSON header + opts.headers.delete('Content-Type'); + } + } + } + + // Precompute network body for retries and consistent handling + const networkBody = getValidRequestBody(opts) as + | RequestInit['body'] + | null + | undefined; + + const url = buildUrl(opts); + + return { networkBody, opts, url }; + }; + + // Apply request interceptors to a Request and reflect header/method/signal + const applyRequestInterceptors = async ( + request: Request, + opts: ResolvedRequestOptions, + ) => { + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + // Reflect any interceptor changes into opts used for network and downstream + opts.headers = request.headers; + opts.method = request.method as Uppercase; + // Note: we intentionally ignore request.body changes from interceptors to + // avoid turning serialized bodies into streams. Body is sourced solely + // from getValidRequestBody(options) for consistency. + // Attempt to reflect possible signal changes + opts.signal = (request as any).signal as AbortSignal | undefined; + return request; + }; + + // Build ofetch options with stable retry logic based on body repeatability + const buildNetworkOptions = ( + opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, + responseType: OfetchResponseType | undefined, + ) => { + const effectiveRetry = isRepeatableBody(body) + ? (opts.retry as any) + : (0 as any); + return buildOfetchOptions(opts, body, responseType, effectiveRetry); + }; + + const request: Client['request'] = async (options) => { + const { + networkBody: initialNetworkBody, + opts, + url, + } = await resolveOptions(options as any); + // Compute response type mapping once + const ofetchResponseType: OfetchResponseType | undefined = + mapParseAsToResponseType(opts.parseAs, opts.responseType); + + const $ofetch = opts.ofetch ?? ofetch; + + // Always create Request pre-network (align with client-fetch) + const networkBody = initialNetworkBody; + const requestInit: ReqInit = { + body: networkBody, + headers: opts.headers as Headers, + method: opts.method, + redirect: 'follow', + signal: opts.signal, + }; + let request = new Request(url, requestInit); + + request = await applyRequestInterceptors(request, opts); + const finalUrl = request.url; + + // Build ofetch options and perform the request + const responseOptions = buildNetworkOptions( + opts as ResolvedRequestOptions, + networkBody, + ofetchResponseType, + ); + + let response = await $ofetch.raw(finalUrl, responseOptions); + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { request, response }; + + if (response.ok) { + const data = await parseSuccess(response, opts, ofetchResponseType); + return wrapDataReturn(data, result, opts.responseStyle); + } + + let finalError = await parseError(response); + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = await fn(finalError, response, request, opts); + } + } + + // Ensure error is never undefined after interceptors + finalError = (finalError as any) || ({} as string); + + if (opts.throwOnError) { + throw finalError; + } + + return wrapErrorReturn(finalError, result, opts.responseStyle) as any; + }; + + const makeMethodFn = + (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method } as any); + + const makeSseFn = + (method: Uppercase) => async (options: RequestOptions) => { + const { networkBody, opts, url } = await resolveOptions(options); + const optsForSse: any = { ...opts }; + delete optsForSse.body; + return createSseClient({ + ...optsForSse, + fetch: opts.fetch, + headers: opts.headers as Headers, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + request = await applyRequestInterceptors(request, opts); + return request; + }, + serializedBody: networkBody as BodyInit | null | undefined, + signal: opts.signal, + url, + }); + }; + + return { + buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + 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/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/index.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/index.ts new file mode 100644 index 000000000..a237c773b --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/index.ts @@ -0,0 +1,23 @@ +export type { Auth } from '../../client-core/bundle/auth'; +export type { QuerySerializerOptions } from '../../client-core/bundle/bodySerializer'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../../client-core/bundle/bodySerializer'; +export { buildClientParams } from '../../client-core/bundle/params'; +export { createClient } from './client'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + OptionsLegacyParser, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types'; +export { createConfig, mergeHeaders } from './utils'; diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/types.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/types.ts new file mode 100644 index 000000000..b6e236789 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/types.ts @@ -0,0 +1,298 @@ +import type { + FetchOptions as OfetchOptions, + ResponseType as OfetchResponseType, +} from 'ofetch'; +import type { ofetch } from 'ofetch'; + +import type { Auth } from '../../client-core/bundle/auth'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../../client-core/bundle/serverSentEvents'; +import type { + Client as CoreClient, + Config as CoreConfig, +} from '../../client-core/bundle/types'; +import type { Middleware } from './utils'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config + extends Omit, + CoreConfig { + agent?: OfetchOptions['agent']; + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** Node-only proxy/agent options */ + dispatcher?: OfetchOptions['dispatcher']; + /** Optional fetch instance used for SSE streaming */ + fetch?: typeof fetch; + // No custom fetch option: provide custom instance via `ofetch` instead + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Custom ofetch instance created via `ofetch.create()`. If provided, it will + * be used for requests instead of the default `ofetch` export. + */ + ofetch?: typeof ofetch; + /** ofetch interceptors and runtime options */ + onRequest?: OfetchOptions['onRequest']; + onRequestError?: OfetchOptions['onRequestError']; + onResponse?: OfetchOptions['onResponse']; + onResponseError?: OfetchOptions['onResponseError']; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: + | 'arrayBuffer' + | 'auto' + | 'blob' + | 'formData' + | 'json' + | 'stream' + | 'text'; + /** Custom response parser (ofetch). */ + parseResponse?: OfetchOptions['parseResponse']; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * ofetch responseType override. If provided, it will be passed directly to + * ofetch and take precedence over `parseAs`. + */ + responseType?: OfetchResponseType; + /** + * Automatically retry failed requests. + */ + retry?: OfetchOptions['retry']; + retryDelay?: OfetchOptions['retryDelay']; + retryStatusCodes?: OfetchOptions['retryStatusCodes']; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; + /** + * Abort the request after the given milliseconds. + */ + timeout?: number; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record + ? TData[keyof TData] + : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? + | (TData extends Record + ? TData[keyof TData] + : TData) + | undefined + : ( + | { + data: TData extends Record + ? TData[keyof TData] + : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record + ? TError[keyof TError] + : TError; + } + ) & { + request: Request; + response: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => RequestResult; + +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'> & + Pick< + Required>, + 'method' + >, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: Pick & Options, +) => string; + +export type Client = CoreClient< + RequestFn, + Config, + MethodFn, + BuildUrlFn, + SseFn +> & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `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 interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + Omit; + +export type OptionsLegacyParser< + TData = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = TData extends { body?: any } + ? TData extends { headers?: any } + ? OmitKeys< + RequestOptions, + 'body' | 'headers' | 'url' + > & + TData + : OmitKeys< + RequestOptions, + 'body' | 'url' + > & + TData & + Pick, 'headers'> + : TData extends { headers?: any } + ? OmitKeys< + RequestOptions, + 'headers' | 'url' + > & + TData & + Pick, 'body'> + : OmitKeys, 'url'> & + TData; diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/utils.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/utils.ts new file mode 100644 index 000000000..2e7db688d --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/utils.ts @@ -0,0 +1,536 @@ +import type { + FetchOptions as OfetchOptions, + ResponseType as OfetchResponseType, +} from 'ofetch'; + +import { getAuthToken } from '../../client-core/bundle/auth'; +import type { QuerySerializerOptions } from '../../client-core/bundle/bodySerializer'; +import { jsonBodySerializer } from '../../client-core/bundle/bodySerializer'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../../client-core/bundle/pathSerializer'; +import { getUrl } from '../../client-core/bundle/utils'; +import type { + Client, + ClientOptions, + Config, + RequestOptions, + ResolvedRequestOptions, + ResponseStyle, +} from './types'; + +export const createQuerySerializer = ({ + allowReserved, + array, + object, +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved, + explode: true, + name, + style: 'form', + value, + ...array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = ( + contentType: string | null, +): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if ( + cleanContent.startsWith('application/json') || + cleanContent.endsWith('+json') + ) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => + cleanContent.startsWith(type), + ) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +/** + * Map our parseAs value to ofetch responseType when not explicitly provided. + */ +export const mapParseAsToResponseType = ( + parseAs: Config['parseAs'] | undefined, + explicit?: OfetchResponseType, +): OfetchResponseType | undefined => { + if (explicit) return explicit; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'json': + case 'text': + case 'stream': + return parseAs; + case 'formData': + case 'auto': + default: + return undefined; // let ofetch auto-detect + } +}; + +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, 'security'> & + Pick & { + headers: Headers; + }) => { + for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +}; + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = + header instanceof Headers + ? headersEntries(header) + : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +/** + * Heuristic to detect whether a request body can be safely retried. + */ +export const isRepeatableBody = (body: unknown): boolean => { + if (body == null) return true; // undefined/null treated as no-body + if (typeof body === 'string') return true; + if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) + return true; + if (typeof Uint8Array !== 'undefined' && body instanceof Uint8Array) + return true; + if (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) + return true; + if (typeof Blob !== 'undefined' && body instanceof Blob) return true; + if (typeof FormData !== 'undefined' && body instanceof FormData) return true; + // Streams are not repeatable + if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) + return false; + // Default: assume non-repeatable for unknown structured bodies + return false; +}; + +/** + * Small helper to unify data vs fields return style. + */ +export const wrapDataReturn = ( + data: T, + result: { request: Request; response: Response }, + responseStyle: ResponseStyle | undefined, +): + | T + | ((T extends Record ? { data: T } : { data: T }) & + typeof result) => + (responseStyle ?? 'fields') === 'data' + ? (data as any) + : ({ data, ...result } as any); + +/** + * Small helper to unify error vs fields return style. + */ +export const wrapErrorReturn = ( + error: E, + result: { request: Request; response: Response }, + responseStyle: ResponseStyle | undefined, +): + | undefined + | ((E extends Record ? { error: E } : { error: E }) & + typeof result) => + (responseStyle ?? 'fields') === 'data' + ? undefined + : ({ error, ...result } as any); + +/** + * Build options for $ofetch.raw from our resolved opts and body. + */ +export const buildOfetchOptions = ( + opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, + responseType: OfetchResponseType | undefined, + retryOverride?: OfetchOptions['retry'], +): OfetchOptions => + ({ + agent: opts.agent as OfetchOptions['agent'], + body, + dispatcher: opts.dispatcher as OfetchOptions['dispatcher'], + headers: opts.headers as Headers, + method: opts.method, + onRequest: opts.onRequest as OfetchOptions['onRequest'], + onRequestError: opts.onRequestError as OfetchOptions['onRequestError'], + onResponse: opts.onResponse as OfetchOptions['onResponse'], + onResponseError: opts.onResponseError as OfetchOptions['onResponseError'], + parseResponse: opts.parseResponse as OfetchOptions['parseResponse'], + // URL already includes query + query: undefined, + responseType, + retry: retryOverride ?? (opts.retry as OfetchOptions['retry']), + retryDelay: opts.retryDelay as OfetchOptions['retryDelay'], + retryStatusCodes: + opts.retryStatusCodes as OfetchOptions['retryStatusCodes'], + signal: opts.signal, + timeout: opts.timeout as number | undefined, + }) as OfetchOptions; + +/** + * Parse a successful response, handling empty bodies and stream cases. + */ +export const parseSuccess = async ( + response: Response, + opts: ResolvedRequestOptions, + ofetchResponseType?: OfetchResponseType, +): Promise => { + // Stream requested: return stream body + if (ofetchResponseType === 'stream') { + return response.body; + } + + const inferredParseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + // Handle empty responses + if ( + response.status === 204 || + response.headers.get('Content-Length') === '0' + ) { + switch (inferredParseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + return await (response as any)[inferredParseAs](); + case 'formData': + return new FormData(); + case 'stream': + return response.body; + default: + return {}; + } + } + + // Prefer ofetch-populated data + let data: unknown = (response as any)._data; + if (typeof data === 'undefined') { + switch (inferredParseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'text': + data = await (response as any)[inferredParseAs](); + break; + case 'json': { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; detect empty via clone().text() first. + const txt = await response.clone().text(); + if (!txt) { + data = {}; + } else { + data = await (response as any).json(); + } + break; + } + case 'stream': + return response.body; + } + } + + if (inferredParseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return data; +}; + +/** + * Parse an error response payload. + */ +export const parseError = async (response: Response): Promise => { + let error: unknown = (response as any)._data; + if (typeof error === 'undefined') { + const textError = await response.text(); + try { + error = JSON.parse(textError); + } catch { + error = textError; + } + } + return error ?? ({} as string); +}; + +type ErrInterceptor = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise; + +type ReqInterceptor = ( + request: Req, + options: Options, +) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update( + id: number | Interceptor, + fn: Interceptor, + ): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/config.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/config.ts new file mode 100644 index 000000000..6cfaa83bf --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/config.ts @@ -0,0 +1,23 @@ +import { definePluginConfig } from '../../shared/utils/config'; +import { clientDefaultConfig, clientDefaultMeta } from '../client-core/config'; +import { clientPluginHandler } from '../client-core/plugin'; +import { Api } from './api'; +import type { HeyApiClientOfetchPlugin } from './types'; + +export const defaultConfig: HeyApiClientOfetchPlugin['Config'] = { + ...clientDefaultMeta, + api: new Api({ + name: '@hey-api/client-ofetch', + }), + config: { + ...clientDefaultConfig, + throwOnError: false, + }, + handler: clientPluginHandler, + name: '@hey-api/client-ofetch', +}; + +/** + * Type helper for `@hey-api/client-ofetch` plugin, returns {@link Plugin.Config} object + */ +export const defineConfig = definePluginConfig(defaultConfig); diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/index.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/index.ts new file mode 100644 index 000000000..bc402f684 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/index.ts @@ -0,0 +1,3 @@ +export type { Client as OfetchClient } from './bundle/types'; +export { defaultConfig, defineConfig } from './config'; +export type { HeyApiClientOfetchPlugin } from './types'; diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/types.d.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/types.d.ts new file mode 100644 index 000000000..633236a4e --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/types.d.ts @@ -0,0 +1,19 @@ +import type { DefinePlugin, Plugin } from '../../types'; +import type { Client } from '../client-core/types'; +import type { IApi } from './api'; + +export type UserConfig = Plugin.Name<'@hey-api/client-ofetch'> & + Client.Config & { + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: boolean; + }; + +export type HeyApiClientOfetchPlugin = DefinePlugin< + UserConfig, + UserConfig, + IApi +>;