fork of hey-api/openapi-ts because I need some additional things

feat(ofetch): init core client func & config

authored by

Dmitriy Brolnickij and committed by
Lubos
7023a42c b59afd5e

+1200
+25
packages/openapi-ts/src/plugins/@hey-api/client-ofetch/api.ts
··· 1 + import type { ICodegenSymbolSelector } from '@hey-api/codegen-core'; 2 + 3 + import type { Plugin } from '../../types'; 4 + 5 + type SelectorType = 'client'; 6 + 7 + export type IApi = { 8 + /** 9 + * @param type Selector type. 10 + * @param value Depends on `type`: 11 + * - `client`: never 12 + * @returns Selector array 13 + */ 14 + getSelector: (type: SelectorType, value?: string) => ICodegenSymbolSelector; 15 + }; 16 + 17 + export class Api implements IApi { 18 + constructor(public meta: Plugin.Name<'@hey-api/client-ofetch'>) {} 19 + 20 + getSelector( 21 + ...args: ReadonlyArray<string | undefined> 22 + ): ICodegenSymbolSelector { 23 + return [this.meta.name, ...(args as ICodegenSymbolSelector)]; 24 + } 25 + }
+273
packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/client.ts
··· 1 + import { ofetch, type ResponseType as OfetchResponseType } from 'ofetch'; 2 + 3 + import { createSseClient } from '../../client-core/bundle/serverSentEvents'; 4 + import type { HttpMethod } from '../../client-core/bundle/types'; 5 + import { getValidRequestBody } from '../../client-core/bundle/utils'; 6 + import type { 7 + Client, 8 + Config, 9 + RequestOptions, 10 + ResolvedRequestOptions, 11 + } from './types'; 12 + import { 13 + buildOfetchOptions, 14 + buildUrl, 15 + createConfig, 16 + createInterceptors, 17 + isRepeatableBody, 18 + mapParseAsToResponseType, 19 + mergeConfigs, 20 + mergeHeaders, 21 + parseError, 22 + parseSuccess, 23 + setAuthParams, 24 + wrapDataReturn, 25 + wrapErrorReturn, 26 + } from './utils'; 27 + 28 + type ReqInit = Omit<RequestInit, 'body' | 'headers'> & { 29 + body?: BodyInit | null | undefined; 30 + headers: ReturnType<typeof mergeHeaders>; 31 + }; 32 + 33 + export const createClient = (config: Config = {}): Client => { 34 + let _config = mergeConfigs(createConfig(), config); 35 + 36 + const getConfig = (): Config => ({ ..._config }); 37 + 38 + const setConfig = (config: Config): Config => { 39 + _config = mergeConfigs(_config, config); 40 + return getConfig(); 41 + }; 42 + 43 + const interceptors = createInterceptors< 44 + Request, 45 + Response, 46 + unknown, 47 + ResolvedRequestOptions 48 + >(); 49 + 50 + // Resolve final options, serialized body, network body and URL 51 + const resolveOptions = async (options: RequestOptions) => { 52 + const opts = { 53 + ..._config, 54 + ...options, 55 + headers: mergeHeaders(_config.headers, options.headers), 56 + serializedBody: undefined, 57 + }; 58 + 59 + if (opts.security) { 60 + await setAuthParams({ 61 + ...opts, 62 + security: opts.security, 63 + }); 64 + } 65 + 66 + if (opts.requestValidator) { 67 + await opts.requestValidator(opts); 68 + } 69 + 70 + if (opts.body !== undefined && opts.bodySerializer) { 71 + opts.serializedBody = opts.bodySerializer(opts.body); 72 + } 73 + 74 + // remove Content-Type header if body is empty to avoid sending invalid requests 75 + if (opts.body === undefined || opts.serializedBody === '') { 76 + opts.headers.delete('Content-Type'); 77 + } 78 + 79 + // If user provides a raw body (no serializer), adjust Content-Type sensibly. 80 + // Avoid overriding explicit user-defined headers; only correct the default JSON header. 81 + if ( 82 + opts.body !== undefined && 83 + opts.bodySerializer === null && 84 + (opts.headers.get('Content-Type') || '').toLowerCase() === 85 + 'application/json' 86 + ) { 87 + const b: unknown = opts.body; 88 + if (typeof FormData !== 'undefined' && b instanceof FormData) { 89 + // Let the runtime set proper boundary 90 + opts.headers.delete('Content-Type'); 91 + } else if ( 92 + typeof URLSearchParams !== 'undefined' && 93 + b instanceof URLSearchParams 94 + ) { 95 + // Set standard urlencoded content type with charset 96 + opts.headers.set( 97 + 'Content-Type', 98 + 'application/x-www-form-urlencoded;charset=UTF-8', 99 + ); 100 + } else if (typeof Blob !== 'undefined' && b instanceof Blob) { 101 + const t = b.type?.trim(); 102 + if (t) { 103 + opts.headers.set('Content-Type', t); 104 + } else { 105 + // No known type for the blob: avoid sending misleading JSON header 106 + opts.headers.delete('Content-Type'); 107 + } 108 + } 109 + } 110 + 111 + // Precompute network body for retries and consistent handling 112 + const networkBody = getValidRequestBody(opts) as 113 + | RequestInit['body'] 114 + | null 115 + | undefined; 116 + 117 + const url = buildUrl(opts); 118 + 119 + return { networkBody, opts, url }; 120 + }; 121 + 122 + // Apply request interceptors to a Request and reflect header/method/signal 123 + const applyRequestInterceptors = async ( 124 + request: Request, 125 + opts: ResolvedRequestOptions, 126 + ) => { 127 + for (const fn of interceptors.request.fns) { 128 + if (fn) { 129 + request = await fn(request, opts); 130 + } 131 + } 132 + // Reflect any interceptor changes into opts used for network and downstream 133 + opts.headers = request.headers; 134 + opts.method = request.method as Uppercase<HttpMethod>; 135 + // Note: we intentionally ignore request.body changes from interceptors to 136 + // avoid turning serialized bodies into streams. Body is sourced solely 137 + // from getValidRequestBody(options) for consistency. 138 + // Attempt to reflect possible signal changes 139 + opts.signal = (request as any).signal as AbortSignal | undefined; 140 + return request; 141 + }; 142 + 143 + // Build ofetch options with stable retry logic based on body repeatability 144 + const buildNetworkOptions = ( 145 + opts: ResolvedRequestOptions, 146 + body: BodyInit | null | undefined, 147 + responseType: OfetchResponseType | undefined, 148 + ) => { 149 + const effectiveRetry = isRepeatableBody(body) 150 + ? (opts.retry as any) 151 + : (0 as any); 152 + return buildOfetchOptions(opts, body, responseType, effectiveRetry); 153 + }; 154 + 155 + const request: Client['request'] = async (options) => { 156 + const { 157 + networkBody: initialNetworkBody, 158 + opts, 159 + url, 160 + } = await resolveOptions(options as any); 161 + // Compute response type mapping once 162 + const ofetchResponseType: OfetchResponseType | undefined = 163 + mapParseAsToResponseType(opts.parseAs, opts.responseType); 164 + 165 + const $ofetch = opts.ofetch ?? ofetch; 166 + 167 + // Always create Request pre-network (align with client-fetch) 168 + const networkBody = initialNetworkBody; 169 + const requestInit: ReqInit = { 170 + body: networkBody, 171 + headers: opts.headers as Headers, 172 + method: opts.method, 173 + redirect: 'follow', 174 + signal: opts.signal, 175 + }; 176 + let request = new Request(url, requestInit); 177 + 178 + request = await applyRequestInterceptors(request, opts); 179 + const finalUrl = request.url; 180 + 181 + // Build ofetch options and perform the request 182 + const responseOptions = buildNetworkOptions( 183 + opts as ResolvedRequestOptions, 184 + networkBody, 185 + ofetchResponseType, 186 + ); 187 + 188 + let response = await $ofetch.raw(finalUrl, responseOptions); 189 + 190 + for (const fn of interceptors.response.fns) { 191 + if (fn) { 192 + response = await fn(response, request, opts); 193 + } 194 + } 195 + 196 + const result = { request, response }; 197 + 198 + if (response.ok) { 199 + const data = await parseSuccess(response, opts, ofetchResponseType); 200 + return wrapDataReturn(data, result, opts.responseStyle); 201 + } 202 + 203 + let finalError = await parseError(response); 204 + 205 + for (const fn of interceptors.error.fns) { 206 + if (fn) { 207 + finalError = await fn(finalError, response, request, opts); 208 + } 209 + } 210 + 211 + // Ensure error is never undefined after interceptors 212 + finalError = (finalError as any) || ({} as string); 213 + 214 + if (opts.throwOnError) { 215 + throw finalError; 216 + } 217 + 218 + return wrapErrorReturn(finalError, result, opts.responseStyle) as any; 219 + }; 220 + 221 + const makeMethodFn = 222 + (method: Uppercase<HttpMethod>) => (options: RequestOptions) => 223 + request({ ...options, method } as any); 224 + 225 + const makeSseFn = 226 + (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => { 227 + const { networkBody, opts, url } = await resolveOptions(options); 228 + const optsForSse: any = { ...opts }; 229 + delete optsForSse.body; 230 + return createSseClient({ 231 + ...optsForSse, 232 + fetch: opts.fetch, 233 + headers: opts.headers as Headers, 234 + method, 235 + onRequest: async (url, init) => { 236 + let request = new Request(url, init); 237 + request = await applyRequestInterceptors(request, opts); 238 + return request; 239 + }, 240 + serializedBody: networkBody as BodyInit | null | undefined, 241 + signal: opts.signal, 242 + url, 243 + }); 244 + }; 245 + 246 + return { 247 + buildUrl, 248 + connect: makeMethodFn('CONNECT'), 249 + delete: makeMethodFn('DELETE'), 250 + get: makeMethodFn('GET'), 251 + getConfig, 252 + head: makeMethodFn('HEAD'), 253 + interceptors, 254 + options: makeMethodFn('OPTIONS'), 255 + patch: makeMethodFn('PATCH'), 256 + post: makeMethodFn('POST'), 257 + put: makeMethodFn('PUT'), 258 + request, 259 + setConfig, 260 + sse: { 261 + connect: makeSseFn('CONNECT'), 262 + delete: makeSseFn('DELETE'), 263 + get: makeSseFn('GET'), 264 + head: makeSseFn('HEAD'), 265 + options: makeSseFn('OPTIONS'), 266 + patch: makeSseFn('PATCH'), 267 + post: makeSseFn('POST'), 268 + put: makeSseFn('PUT'), 269 + trace: makeSseFn('TRACE'), 270 + }, 271 + trace: makeMethodFn('TRACE'), 272 + } as Client; 273 + };
+23
packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/index.ts
··· 1 + export type { Auth } from '../../client-core/bundle/auth'; 2 + export type { QuerySerializerOptions } from '../../client-core/bundle/bodySerializer'; 3 + export { 4 + formDataBodySerializer, 5 + jsonBodySerializer, 6 + urlSearchParamsBodySerializer, 7 + } from '../../client-core/bundle/bodySerializer'; 8 + export { buildClientParams } from '../../client-core/bundle/params'; 9 + export { createClient } from './client'; 10 + export type { 11 + Client, 12 + ClientOptions, 13 + Config, 14 + CreateClientConfig, 15 + Options, 16 + OptionsLegacyParser, 17 + RequestOptions, 18 + RequestResult, 19 + ResolvedRequestOptions, 20 + ResponseStyle, 21 + TDataShape, 22 + } from './types'; 23 + export { createConfig, mergeHeaders } from './utils';
+298
packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/types.ts
··· 1 + import type { 2 + FetchOptions as OfetchOptions, 3 + ResponseType as OfetchResponseType, 4 + } from 'ofetch'; 5 + import type { ofetch } from 'ofetch'; 6 + 7 + import type { Auth } from '../../client-core/bundle/auth'; 8 + import type { 9 + ServerSentEventsOptions, 10 + ServerSentEventsResult, 11 + } from '../../client-core/bundle/serverSentEvents'; 12 + import type { 13 + Client as CoreClient, 14 + Config as CoreConfig, 15 + } from '../../client-core/bundle/types'; 16 + import type { Middleware } from './utils'; 17 + 18 + export type ResponseStyle = 'data' | 'fields'; 19 + 20 + export interface Config<T extends ClientOptions = ClientOptions> 21 + extends Omit<RequestInit, 'body' | 'headers' | 'method'>, 22 + CoreConfig { 23 + agent?: OfetchOptions['agent']; 24 + /** 25 + * Base URL for all requests made by this client. 26 + */ 27 + baseUrl?: T['baseUrl']; 28 + /** Node-only proxy/agent options */ 29 + dispatcher?: OfetchOptions['dispatcher']; 30 + /** Optional fetch instance used for SSE streaming */ 31 + fetch?: typeof fetch; 32 + // No custom fetch option: provide custom instance via `ofetch` instead 33 + /** 34 + * Please don't use the Fetch client for Next.js applications. The `next` 35 + * options won't have any effect. 36 + * 37 + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. 38 + */ 39 + next?: never; 40 + /** 41 + * Custom ofetch instance created via `ofetch.create()`. If provided, it will 42 + * be used for requests instead of the default `ofetch` export. 43 + */ 44 + ofetch?: typeof ofetch; 45 + /** ofetch interceptors and runtime options */ 46 + onRequest?: OfetchOptions['onRequest']; 47 + onRequestError?: OfetchOptions['onRequestError']; 48 + onResponse?: OfetchOptions['onResponse']; 49 + onResponseError?: OfetchOptions['onResponseError']; 50 + /** 51 + * Return the response data parsed in a specified format. By default, `auto` 52 + * will infer the appropriate method from the `Content-Type` response header. 53 + * You can override this behavior with any of the {@link Body} methods. 54 + * Select `stream` if you don't want to parse response data at all. 55 + * 56 + * @default 'auto' 57 + */ 58 + parseAs?: 59 + | 'arrayBuffer' 60 + | 'auto' 61 + | 'blob' 62 + | 'formData' 63 + | 'json' 64 + | 'stream' 65 + | 'text'; 66 + /** Custom response parser (ofetch). */ 67 + parseResponse?: OfetchOptions['parseResponse']; 68 + /** 69 + * Should we return only data or multiple fields (data, error, response, etc.)? 70 + * 71 + * @default 'fields' 72 + */ 73 + responseStyle?: ResponseStyle; 74 + /** 75 + * ofetch responseType override. If provided, it will be passed directly to 76 + * ofetch and take precedence over `parseAs`. 77 + */ 78 + responseType?: OfetchResponseType; 79 + /** 80 + * Automatically retry failed requests. 81 + */ 82 + retry?: OfetchOptions['retry']; 83 + retryDelay?: OfetchOptions['retryDelay']; 84 + retryStatusCodes?: OfetchOptions['retryStatusCodes']; 85 + /** 86 + * Throw an error instead of returning it in the response? 87 + * 88 + * @default false 89 + */ 90 + throwOnError?: T['throwOnError']; 91 + /** 92 + * Abort the request after the given milliseconds. 93 + */ 94 + timeout?: number; 95 + } 96 + 97 + export interface RequestOptions< 98 + TData = unknown, 99 + TResponseStyle extends ResponseStyle = 'fields', 100 + ThrowOnError extends boolean = boolean, 101 + Url extends string = string, 102 + > extends Config<{ 103 + responseStyle: TResponseStyle; 104 + throwOnError: ThrowOnError; 105 + }>, 106 + Pick< 107 + ServerSentEventsOptions<TData>, 108 + | 'onSseError' 109 + | 'onSseEvent' 110 + | 'sseDefaultRetryDelay' 111 + | 'sseMaxRetryAttempts' 112 + | 'sseMaxRetryDelay' 113 + > { 114 + /** 115 + * Any body that you want to add to your request. 116 + * 117 + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} 118 + */ 119 + body?: unknown; 120 + path?: Record<string, unknown>; 121 + query?: Record<string, unknown>; 122 + /** 123 + * Security mechanism(s) to use for the request. 124 + */ 125 + security?: ReadonlyArray<Auth>; 126 + url: Url; 127 + } 128 + 129 + export interface ResolvedRequestOptions< 130 + TResponseStyle extends ResponseStyle = 'fields', 131 + ThrowOnError extends boolean = boolean, 132 + Url extends string = string, 133 + > extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> { 134 + serializedBody?: string; 135 + } 136 + 137 + export type RequestResult< 138 + TData = unknown, 139 + TError = unknown, 140 + ThrowOnError extends boolean = boolean, 141 + TResponseStyle extends ResponseStyle = 'fields', 142 + > = ThrowOnError extends true 143 + ? Promise< 144 + TResponseStyle extends 'data' 145 + ? TData extends Record<string, unknown> 146 + ? TData[keyof TData] 147 + : TData 148 + : { 149 + data: TData extends Record<string, unknown> 150 + ? TData[keyof TData] 151 + : TData; 152 + request: Request; 153 + response: Response; 154 + } 155 + > 156 + : Promise< 157 + TResponseStyle extends 'data' 158 + ? 159 + | (TData extends Record<string, unknown> 160 + ? TData[keyof TData] 161 + : TData) 162 + | undefined 163 + : ( 164 + | { 165 + data: TData extends Record<string, unknown> 166 + ? TData[keyof TData] 167 + : TData; 168 + error: undefined; 169 + } 170 + | { 171 + data: undefined; 172 + error: TError extends Record<string, unknown> 173 + ? TError[keyof TError] 174 + : TError; 175 + } 176 + ) & { 177 + request: Request; 178 + response: Response; 179 + } 180 + >; 181 + 182 + export interface ClientOptions { 183 + baseUrl?: string; 184 + responseStyle?: ResponseStyle; 185 + throwOnError?: boolean; 186 + } 187 + 188 + type MethodFn = < 189 + TData = unknown, 190 + TError = unknown, 191 + ThrowOnError extends boolean = false, 192 + TResponseStyle extends ResponseStyle = 'fields', 193 + >( 194 + options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>, 195 + ) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>; 196 + 197 + type SseFn = < 198 + TData = unknown, 199 + TError = unknown, 200 + ThrowOnError extends boolean = false, 201 + TResponseStyle extends ResponseStyle = 'fields', 202 + >( 203 + options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>, 204 + ) => Promise<ServerSentEventsResult<TData, TError>>; 205 + 206 + type RequestFn = < 207 + TData = unknown, 208 + TError = unknown, 209 + ThrowOnError extends boolean = false, 210 + TResponseStyle extends ResponseStyle = 'fields', 211 + >( 212 + options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> & 213 + Pick< 214 + Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, 215 + 'method' 216 + >, 217 + ) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>; 218 + 219 + type BuildUrlFn = < 220 + TData extends { 221 + body?: unknown; 222 + path?: Record<string, unknown>; 223 + query?: Record<string, unknown>; 224 + url: string; 225 + }, 226 + >( 227 + options: Pick<TData, 'url'> & Options<TData>, 228 + ) => string; 229 + 230 + export type Client = CoreClient< 231 + RequestFn, 232 + Config, 233 + MethodFn, 234 + BuildUrlFn, 235 + SseFn 236 + > & { 237 + interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>; 238 + }; 239 + 240 + /** 241 + * The `createClientConfig()` function will be called on client initialization 242 + * and the returned object will become the client's initial configuration. 243 + * 244 + * You may want to initialize your client this way instead of calling 245 + * `setConfig()`. This is useful for example if you're using Next.js 246 + * to ensure your client always has the correct values. 247 + */ 248 + export type CreateClientConfig<T extends ClientOptions = ClientOptions> = ( 249 + override?: Config<ClientOptions & T>, 250 + ) => Config<Required<ClientOptions> & T>; 251 + 252 + export interface TDataShape { 253 + body?: unknown; 254 + headers?: unknown; 255 + path?: unknown; 256 + query?: unknown; 257 + url: string; 258 + } 259 + 260 + type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>; 261 + 262 + export type Options< 263 + TData extends TDataShape = TDataShape, 264 + ThrowOnError extends boolean = boolean, 265 + TResponse = unknown, 266 + TResponseStyle extends ResponseStyle = 'fields', 267 + > = OmitKeys< 268 + RequestOptions<TResponse, TResponseStyle, ThrowOnError>, 269 + 'body' | 'path' | 'query' | 'url' 270 + > & 271 + Omit<TData, 'url'>; 272 + 273 + export type OptionsLegacyParser< 274 + TData = unknown, 275 + ThrowOnError extends boolean = boolean, 276 + TResponseStyle extends ResponseStyle = 'fields', 277 + > = TData extends { body?: any } 278 + ? TData extends { headers?: any } 279 + ? OmitKeys< 280 + RequestOptions<unknown, TResponseStyle, ThrowOnError>, 281 + 'body' | 'headers' | 'url' 282 + > & 283 + TData 284 + : OmitKeys< 285 + RequestOptions<unknown, TResponseStyle, ThrowOnError>, 286 + 'body' | 'url' 287 + > & 288 + TData & 289 + Pick<RequestOptions<unknown, TResponseStyle, ThrowOnError>, 'headers'> 290 + : TData extends { headers?: any } 291 + ? OmitKeys< 292 + RequestOptions<unknown, TResponseStyle, ThrowOnError>, 293 + 'headers' | 'url' 294 + > & 295 + TData & 296 + Pick<RequestOptions<unknown, TResponseStyle, ThrowOnError>, 'body'> 297 + : OmitKeys<RequestOptions<unknown, TResponseStyle, ThrowOnError>, 'url'> & 298 + TData;
+536
packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/utils.ts
··· 1 + import type { 2 + FetchOptions as OfetchOptions, 3 + ResponseType as OfetchResponseType, 4 + } from 'ofetch'; 5 + 6 + import { getAuthToken } from '../../client-core/bundle/auth'; 7 + import type { QuerySerializerOptions } from '../../client-core/bundle/bodySerializer'; 8 + import { jsonBodySerializer } from '../../client-core/bundle/bodySerializer'; 9 + import { 10 + serializeArrayParam, 11 + serializeObjectParam, 12 + serializePrimitiveParam, 13 + } from '../../client-core/bundle/pathSerializer'; 14 + import { getUrl } from '../../client-core/bundle/utils'; 15 + import type { 16 + Client, 17 + ClientOptions, 18 + Config, 19 + RequestOptions, 20 + ResolvedRequestOptions, 21 + ResponseStyle, 22 + } from './types'; 23 + 24 + export const createQuerySerializer = <T = unknown>({ 25 + allowReserved, 26 + array, 27 + object, 28 + }: QuerySerializerOptions = {}) => { 29 + const querySerializer = (queryParams: T) => { 30 + const search: string[] = []; 31 + if (queryParams && typeof queryParams === 'object') { 32 + for (const name in queryParams) { 33 + const value = queryParams[name]; 34 + 35 + if (value === undefined || value === null) { 36 + continue; 37 + } 38 + 39 + if (Array.isArray(value)) { 40 + const serializedArray = serializeArrayParam({ 41 + allowReserved, 42 + explode: true, 43 + name, 44 + style: 'form', 45 + value, 46 + ...array, 47 + }); 48 + if (serializedArray) search.push(serializedArray); 49 + } else if (typeof value === 'object') { 50 + const serializedObject = serializeObjectParam({ 51 + allowReserved, 52 + explode: true, 53 + name, 54 + style: 'deepObject', 55 + value: value as Record<string, unknown>, 56 + ...object, 57 + }); 58 + if (serializedObject) search.push(serializedObject); 59 + } else { 60 + const serializedPrimitive = serializePrimitiveParam({ 61 + allowReserved, 62 + name, 63 + value: value as string, 64 + }); 65 + if (serializedPrimitive) search.push(serializedPrimitive); 66 + } 67 + } 68 + } 69 + return search.join('&'); 70 + }; 71 + return querySerializer; 72 + }; 73 + 74 + /** 75 + * Infers parseAs value from provided Content-Type header. 76 + */ 77 + export const getParseAs = ( 78 + contentType: string | null, 79 + ): Exclude<Config['parseAs'], 'auto'> => { 80 + if (!contentType) { 81 + // If no Content-Type header is provided, the best we can do is return the raw response body, 82 + // which is effectively the same as the 'stream' option. 83 + return 'stream'; 84 + } 85 + 86 + const cleanContent = contentType.split(';')[0]?.trim(); 87 + 88 + if (!cleanContent) { 89 + return; 90 + } 91 + 92 + if ( 93 + cleanContent.startsWith('application/json') || 94 + cleanContent.endsWith('+json') 95 + ) { 96 + return 'json'; 97 + } 98 + 99 + if (cleanContent === 'multipart/form-data') { 100 + return 'formData'; 101 + } 102 + 103 + if ( 104 + ['application/', 'audio/', 'image/', 'video/'].some((type) => 105 + cleanContent.startsWith(type), 106 + ) 107 + ) { 108 + return 'blob'; 109 + } 110 + 111 + if (cleanContent.startsWith('text/')) { 112 + return 'text'; 113 + } 114 + 115 + return; 116 + }; 117 + 118 + /** 119 + * Map our parseAs value to ofetch responseType when not explicitly provided. 120 + */ 121 + export const mapParseAsToResponseType = ( 122 + parseAs: Config['parseAs'] | undefined, 123 + explicit?: OfetchResponseType, 124 + ): OfetchResponseType | undefined => { 125 + if (explicit) return explicit; 126 + switch (parseAs) { 127 + case 'arrayBuffer': 128 + case 'blob': 129 + case 'json': 130 + case 'text': 131 + case 'stream': 132 + return parseAs; 133 + case 'formData': 134 + case 'auto': 135 + default: 136 + return undefined; // let ofetch auto-detect 137 + } 138 + }; 139 + 140 + const checkForExistence = ( 141 + options: Pick<RequestOptions, 'auth' | 'query'> & { 142 + headers: Headers; 143 + }, 144 + name?: string, 145 + ): boolean => { 146 + if (!name) { 147 + return false; 148 + } 149 + if ( 150 + options.headers.has(name) || 151 + options.query?.[name] || 152 + options.headers.get('Cookie')?.includes(`${name}=`) 153 + ) { 154 + return true; 155 + } 156 + return false; 157 + }; 158 + 159 + export const setAuthParams = async ({ 160 + security, 161 + ...options 162 + }: Pick<Required<RequestOptions>, 'security'> & 163 + Pick<RequestOptions, 'auth' | 'query'> & { 164 + headers: Headers; 165 + }) => { 166 + for (const auth of security) { 167 + if (checkForExistence(options, auth.name)) { 168 + continue; 169 + } 170 + 171 + const token = await getAuthToken(auth, options.auth); 172 + 173 + if (!token) { 174 + continue; 175 + } 176 + 177 + const name = auth.name ?? 'Authorization'; 178 + 179 + switch (auth.in) { 180 + case 'query': 181 + if (!options.query) { 182 + options.query = {}; 183 + } 184 + options.query[name] = token; 185 + break; 186 + case 'cookie': 187 + options.headers.append('Cookie', `${name}=${token}`); 188 + break; 189 + case 'header': 190 + default: 191 + options.headers.set(name, token); 192 + break; 193 + } 194 + } 195 + }; 196 + 197 + export const buildUrl: Client['buildUrl'] = (options) => 198 + getUrl({ 199 + baseUrl: options.baseUrl as string, 200 + path: options.path, 201 + query: options.query, 202 + querySerializer: 203 + typeof options.querySerializer === 'function' 204 + ? options.querySerializer 205 + : createQuerySerializer(options.querySerializer), 206 + url: options.url, 207 + }); 208 + 209 + export const mergeConfigs = (a: Config, b: Config): Config => { 210 + const config = { ...a, ...b }; 211 + if (config.baseUrl?.endsWith('/')) { 212 + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); 213 + } 214 + config.headers = mergeHeaders(a.headers, b.headers); 215 + return config; 216 + }; 217 + 218 + const headersEntries = (headers: Headers): Array<[string, string]> => { 219 + const entries: Array<[string, string]> = []; 220 + headers.forEach((value, key) => { 221 + entries.push([key, value]); 222 + }); 223 + return entries; 224 + }; 225 + 226 + export const mergeHeaders = ( 227 + ...headers: Array<Required<Config>['headers'] | undefined> 228 + ): Headers => { 229 + const mergedHeaders = new Headers(); 230 + for (const header of headers) { 231 + if (!header) { 232 + continue; 233 + } 234 + 235 + const iterator = 236 + header instanceof Headers 237 + ? headersEntries(header) 238 + : Object.entries(header); 239 + 240 + for (const [key, value] of iterator) { 241 + if (value === null) { 242 + mergedHeaders.delete(key); 243 + } else if (Array.isArray(value)) { 244 + for (const v of value) { 245 + mergedHeaders.append(key, v as string); 246 + } 247 + } else if (value !== undefined) { 248 + // assume object headers are meant to be JSON stringified, i.e. their 249 + // content value in OpenAPI specification is 'application/json' 250 + mergedHeaders.set( 251 + key, 252 + typeof value === 'object' ? JSON.stringify(value) : (value as string), 253 + ); 254 + } 255 + } 256 + } 257 + return mergedHeaders; 258 + }; 259 + 260 + /** 261 + * Heuristic to detect whether a request body can be safely retried. 262 + */ 263 + export const isRepeatableBody = (body: unknown): boolean => { 264 + if (body == null) return true; // undefined/null treated as no-body 265 + if (typeof body === 'string') return true; 266 + if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) 267 + return true; 268 + if (typeof Uint8Array !== 'undefined' && body instanceof Uint8Array) 269 + return true; 270 + if (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) 271 + return true; 272 + if (typeof Blob !== 'undefined' && body instanceof Blob) return true; 273 + if (typeof FormData !== 'undefined' && body instanceof FormData) return true; 274 + // Streams are not repeatable 275 + if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) 276 + return false; 277 + // Default: assume non-repeatable for unknown structured bodies 278 + return false; 279 + }; 280 + 281 + /** 282 + * Small helper to unify data vs fields return style. 283 + */ 284 + export const wrapDataReturn = <T>( 285 + data: T, 286 + result: { request: Request; response: Response }, 287 + responseStyle: ResponseStyle | undefined, 288 + ): 289 + | T 290 + | ((T extends Record<string, unknown> ? { data: T } : { data: T }) & 291 + typeof result) => 292 + (responseStyle ?? 'fields') === 'data' 293 + ? (data as any) 294 + : ({ data, ...result } as any); 295 + 296 + /** 297 + * Small helper to unify error vs fields return style. 298 + */ 299 + export const wrapErrorReturn = <E>( 300 + error: E, 301 + result: { request: Request; response: Response }, 302 + responseStyle: ResponseStyle | undefined, 303 + ): 304 + | undefined 305 + | ((E extends Record<string, unknown> ? { error: E } : { error: E }) & 306 + typeof result) => 307 + (responseStyle ?? 'fields') === 'data' 308 + ? undefined 309 + : ({ error, ...result } as any); 310 + 311 + /** 312 + * Build options for $ofetch.raw from our resolved opts and body. 313 + */ 314 + export const buildOfetchOptions = ( 315 + opts: ResolvedRequestOptions, 316 + body: BodyInit | null | undefined, 317 + responseType: OfetchResponseType | undefined, 318 + retryOverride?: OfetchOptions['retry'], 319 + ): OfetchOptions => 320 + ({ 321 + agent: opts.agent as OfetchOptions['agent'], 322 + body, 323 + dispatcher: opts.dispatcher as OfetchOptions['dispatcher'], 324 + headers: opts.headers as Headers, 325 + method: opts.method, 326 + onRequest: opts.onRequest as OfetchOptions['onRequest'], 327 + onRequestError: opts.onRequestError as OfetchOptions['onRequestError'], 328 + onResponse: opts.onResponse as OfetchOptions['onResponse'], 329 + onResponseError: opts.onResponseError as OfetchOptions['onResponseError'], 330 + parseResponse: opts.parseResponse as OfetchOptions['parseResponse'], 331 + // URL already includes query 332 + query: undefined, 333 + responseType, 334 + retry: retryOverride ?? (opts.retry as OfetchOptions['retry']), 335 + retryDelay: opts.retryDelay as OfetchOptions['retryDelay'], 336 + retryStatusCodes: 337 + opts.retryStatusCodes as OfetchOptions['retryStatusCodes'], 338 + signal: opts.signal, 339 + timeout: opts.timeout as number | undefined, 340 + }) as OfetchOptions; 341 + 342 + /** 343 + * Parse a successful response, handling empty bodies and stream cases. 344 + */ 345 + export const parseSuccess = async ( 346 + response: Response, 347 + opts: ResolvedRequestOptions, 348 + ofetchResponseType?: OfetchResponseType, 349 + ): Promise<unknown> => { 350 + // Stream requested: return stream body 351 + if (ofetchResponseType === 'stream') { 352 + return response.body; 353 + } 354 + 355 + const inferredParseAs = 356 + (opts.parseAs === 'auto' 357 + ? getParseAs(response.headers.get('Content-Type')) 358 + : opts.parseAs) ?? 'json'; 359 + 360 + // Handle empty responses 361 + if ( 362 + response.status === 204 || 363 + response.headers.get('Content-Length') === '0' 364 + ) { 365 + switch (inferredParseAs) { 366 + case 'arrayBuffer': 367 + case 'blob': 368 + case 'text': 369 + return await (response as any)[inferredParseAs](); 370 + case 'formData': 371 + return new FormData(); 372 + case 'stream': 373 + return response.body; 374 + default: 375 + return {}; 376 + } 377 + } 378 + 379 + // Prefer ofetch-populated data 380 + let data: unknown = (response as any)._data; 381 + if (typeof data === 'undefined') { 382 + switch (inferredParseAs) { 383 + case 'arrayBuffer': 384 + case 'blob': 385 + case 'formData': 386 + case 'text': 387 + data = await (response as any)[inferredParseAs](); 388 + break; 389 + case 'json': { 390 + // Some servers return 200 with no Content-Length and empty body. 391 + // response.json() would throw; detect empty via clone().text() first. 392 + const txt = await response.clone().text(); 393 + if (!txt) { 394 + data = {}; 395 + } else { 396 + data = await (response as any).json(); 397 + } 398 + break; 399 + } 400 + case 'stream': 401 + return response.body; 402 + } 403 + } 404 + 405 + if (inferredParseAs === 'json') { 406 + if (opts.responseValidator) { 407 + await opts.responseValidator(data); 408 + } 409 + if (opts.responseTransformer) { 410 + data = await opts.responseTransformer(data); 411 + } 412 + } 413 + 414 + return data; 415 + }; 416 + 417 + /** 418 + * Parse an error response payload. 419 + */ 420 + export const parseError = async (response: Response): Promise<unknown> => { 421 + let error: unknown = (response as any)._data; 422 + if (typeof error === 'undefined') { 423 + const textError = await response.text(); 424 + try { 425 + error = JSON.parse(textError); 426 + } catch { 427 + error = textError; 428 + } 429 + } 430 + return error ?? ({} as string); 431 + }; 432 + 433 + type ErrInterceptor<Err, Res, Req, Options> = ( 434 + error: Err, 435 + response: Res, 436 + request: Req, 437 + options: Options, 438 + ) => Err | Promise<Err>; 439 + 440 + type ReqInterceptor<Req, Options> = ( 441 + request: Req, 442 + options: Options, 443 + ) => Req | Promise<Req>; 444 + 445 + type ResInterceptor<Res, Req, Options> = ( 446 + response: Res, 447 + request: Req, 448 + options: Options, 449 + ) => Res | Promise<Res>; 450 + 451 + class Interceptors<Interceptor> { 452 + fns: Array<Interceptor | null> = []; 453 + 454 + clear(): void { 455 + this.fns = []; 456 + } 457 + 458 + eject(id: number | Interceptor): void { 459 + const index = this.getInterceptorIndex(id); 460 + if (this.fns[index]) { 461 + this.fns[index] = null; 462 + } 463 + } 464 + 465 + exists(id: number | Interceptor): boolean { 466 + const index = this.getInterceptorIndex(id); 467 + return Boolean(this.fns[index]); 468 + } 469 + 470 + getInterceptorIndex(id: number | Interceptor): number { 471 + if (typeof id === 'number') { 472 + return this.fns[id] ? id : -1; 473 + } 474 + return this.fns.indexOf(id); 475 + } 476 + 477 + update( 478 + id: number | Interceptor, 479 + fn: Interceptor, 480 + ): number | Interceptor | false { 481 + const index = this.getInterceptorIndex(id); 482 + if (this.fns[index]) { 483 + this.fns[index] = fn; 484 + return id; 485 + } 486 + return false; 487 + } 488 + 489 + use(fn: Interceptor): number { 490 + this.fns.push(fn); 491 + return this.fns.length - 1; 492 + } 493 + } 494 + 495 + export interface Middleware<Req, Res, Err, Options> { 496 + error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>; 497 + request: Interceptors<ReqInterceptor<Req, Options>>; 498 + response: Interceptors<ResInterceptor<Res, Req, Options>>; 499 + } 500 + 501 + export const createInterceptors = <Req, Res, Err, Options>(): Middleware< 502 + Req, 503 + Res, 504 + Err, 505 + Options 506 + > => ({ 507 + error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(), 508 + request: new Interceptors<ReqInterceptor<Req, Options>>(), 509 + response: new Interceptors<ResInterceptor<Res, Req, Options>>(), 510 + }); 511 + 512 + const defaultQuerySerializer = createQuerySerializer({ 513 + allowReserved: false, 514 + array: { 515 + explode: true, 516 + style: 'form', 517 + }, 518 + object: { 519 + explode: true, 520 + style: 'deepObject', 521 + }, 522 + }); 523 + 524 + const defaultHeaders = { 525 + 'Content-Type': 'application/json', 526 + }; 527 + 528 + export const createConfig = <T extends ClientOptions = ClientOptions>( 529 + override: Config<Omit<ClientOptions, keyof T> & T> = {}, 530 + ): Config<Omit<ClientOptions, keyof T> & T> => ({ 531 + ...jsonBodySerializer, 532 + headers: defaultHeaders, 533 + parseAs: 'auto', 534 + querySerializer: defaultQuerySerializer, 535 + ...override, 536 + });
+23
packages/openapi-ts/src/plugins/@hey-api/client-ofetch/config.ts
··· 1 + import { definePluginConfig } from '../../shared/utils/config'; 2 + import { clientDefaultConfig, clientDefaultMeta } from '../client-core/config'; 3 + import { clientPluginHandler } from '../client-core/plugin'; 4 + import { Api } from './api'; 5 + import type { HeyApiClientOfetchPlugin } from './types'; 6 + 7 + export const defaultConfig: HeyApiClientOfetchPlugin['Config'] = { 8 + ...clientDefaultMeta, 9 + api: new Api({ 10 + name: '@hey-api/client-ofetch', 11 + }), 12 + config: { 13 + ...clientDefaultConfig, 14 + throwOnError: false, 15 + }, 16 + handler: clientPluginHandler, 17 + name: '@hey-api/client-ofetch', 18 + }; 19 + 20 + /** 21 + * Type helper for `@hey-api/client-ofetch` plugin, returns {@link Plugin.Config} object 22 + */ 23 + export const defineConfig = definePluginConfig(defaultConfig);
+3
packages/openapi-ts/src/plugins/@hey-api/client-ofetch/index.ts
··· 1 + export type { Client as OfetchClient } from './bundle/types'; 2 + export { defaultConfig, defineConfig } from './config'; 3 + export type { HeyApiClientOfetchPlugin } from './types';
+19
packages/openapi-ts/src/plugins/@hey-api/client-ofetch/types.d.ts
··· 1 + import type { DefinePlugin, Plugin } from '../../types'; 2 + import type { Client } from '../client-core/types'; 3 + import type { IApi } from './api'; 4 + 5 + export type UserConfig = Plugin.Name<'@hey-api/client-ofetch'> & 6 + Client.Config & { 7 + /** 8 + * Throw an error instead of returning it in the response? 9 + * 10 + * @default false 11 + */ 12 + throwOnError?: boolean; 13 + }; 14 + 15 + export type HeyApiClientOfetchPlugin = DefinePlugin< 16 + UserConfig, 17 + UserConfig, 18 + IApi 19 + >;