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

fix(client-ofetch): add credentials property support to buildOfetchOptions

Co-authored-by: mrlubos <12529395+mrlubos@users.noreply.github.com>

+217 -77
+6 -7
examples/openapi-ts-ofetch/src/client/client.gen.ts
··· 1 1 // This file is auto-generated by @hey-api/openapi-ts 2 2 3 3 import { 4 - type ClientOptions as DefaultClientOptions, 4 + type ClientOptions, 5 5 type Config, 6 6 createClient, 7 7 createConfig, 8 8 } from './client'; 9 - import type { ClientOptions } from './types.gen'; 9 + import type { ClientOptions as ClientOptions2 } from './types.gen'; 10 10 11 11 /** 12 12 * The `createClientConfig()` function will be called on client initialization ··· 16 16 * `setConfig()`. This is useful for example if you're using Next.js 17 17 * to ensure your client always has the correct values. 18 18 */ 19 - export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> = 20 - ( 21 - override?: Config<DefaultClientOptions & T>, 22 - ) => Config<Required<DefaultClientOptions> & T>; 19 + export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = ( 20 + override?: Config<ClientOptions & T>, 21 + ) => Config<Required<ClientOptions> & T>; 23 22 24 23 export const client = createClient( 25 - createConfig<ClientOptions>({ 24 + createConfig<ClientOptions2>({ 26 25 baseUrl: 'https://petstore3.swagger.io/api/v3', 27 26 }), 28 27 );
+82 -44
examples/openapi-ts-ofetch/src/client/client/client.gen.ts
··· 28 28 } from './utils.gen'; 29 29 30 30 type ReqInit = Omit<RequestInit, 'body' | 'headers'> & { 31 - body?: any; 31 + body?: BodyInit | null | undefined; 32 32 headers: ReturnType<typeof mergeHeaders>; 33 33 }; 34 34 ··· 49 49 ResolvedRequestOptions 50 50 >(); 51 51 52 - const beforeRequest = async (options: RequestOptions) => { 52 + // precompute serialized / network body 53 + const resolveOptions = async (options: RequestOptions) => { 53 54 const opts = { 54 55 ..._config, 55 56 ...options, ··· 72 73 opts.serializedBody = opts.bodySerializer(opts.body); 73 74 } 74 75 75 - // remove Content-Type header if body is empty to avoid sending invalid requests 76 + // remove Content-Type if body is empty to avoid invalid requests 76 77 if (opts.body === undefined || opts.serializedBody === '') { 77 78 opts.headers.delete('Content-Type'); 78 79 } 79 80 80 - // Precompute network body for retries and consistent handling 81 + // if a raw body is provided (no serializer), adjust Content-Type only when it 82 + // equals the default JSON value to better match the concrete body type 83 + if ( 84 + opts.body !== undefined && 85 + opts.bodySerializer === null && 86 + (opts.headers.get('Content-Type') || '').toLowerCase() === 87 + 'application/json' 88 + ) { 89 + const b: unknown = opts.body; 90 + if (typeof FormData !== 'undefined' && b instanceof FormData) { 91 + // let the runtime set the multipart boundary 92 + opts.headers.delete('Content-Type'); 93 + } else if ( 94 + typeof URLSearchParams !== 'undefined' && 95 + b instanceof URLSearchParams 96 + ) { 97 + // standard urlencoded content type (+ charset) 98 + opts.headers.set( 99 + 'Content-Type', 100 + 'application/x-www-form-urlencoded;charset=UTF-8', 101 + ); 102 + } else if (typeof Blob !== 'undefined' && b instanceof Blob) { 103 + const t = b.type?.trim(); 104 + if (t) { 105 + opts.headers.set('Content-Type', t); 106 + } else { 107 + // unknown blob type: avoid sending a misleading JSON header 108 + opts.headers.delete('Content-Type'); 109 + } 110 + } 111 + } 112 + 113 + // precompute network body (stability for retries and interceptors) 81 114 const networkBody = getValidRequestBody(opts) as 82 115 | RequestInit['body'] 83 116 | null ··· 88 121 return { networkBody, opts, url }; 89 122 }; 90 123 124 + // apply request interceptors and mirror header/method/signal back to opts 125 + const applyRequestInterceptors = async ( 126 + request: Request, 127 + opts: ResolvedRequestOptions, 128 + ) => { 129 + for (const fn of interceptors.request.fns) { 130 + if (fn) { 131 + request = await fn(request, opts); 132 + } 133 + } 134 + // reflect interceptor changes into opts used by the network layer 135 + opts.headers = request.headers; 136 + opts.method = request.method as Uppercase<HttpMethod>; 137 + // ignore request.body changes to avoid turning serialized bodies into streams 138 + // body comes only from getValidRequestBody(options) 139 + // reflect signal if present 140 + opts.signal = (request as any).signal as AbortSignal | undefined; 141 + return request; 142 + }; 143 + 144 + // build ofetch options with stable retry logic based on body repeatability 145 + const buildNetworkOptions = ( 146 + opts: ResolvedRequestOptions, 147 + body: BodyInit | null | undefined, 148 + responseType: OfetchResponseType | undefined, 149 + ) => { 150 + const effectiveRetry = isRepeatableBody(body) 151 + ? (opts.retry as any) 152 + : (0 as any); 153 + return buildOfetchOptions(opts, body, responseType, effectiveRetry); 154 + }; 155 + 91 156 const request: Client['request'] = async (options) => { 92 157 const { 93 158 networkBody: initialNetworkBody, 94 159 opts, 95 160 url, 96 - } = await beforeRequest(options as any); 97 - // Compute response type mapping once 161 + } = await resolveOptions(options as any); 162 + // map parseAs -> ofetch responseType once per request 98 163 const ofetchResponseType: OfetchResponseType | undefined = 99 164 mapParseAsToResponseType(opts.parseAs, opts.responseType); 100 165 101 166 const $ofetch = opts.ofetch ?? ofetch; 102 167 103 - // Always create Request pre-network (align with client-fetch) 104 - let networkBody = initialNetworkBody; 168 + // create Request before network to run middleware consistently 169 + const networkBody = initialNetworkBody; 105 170 const requestInit: ReqInit = { 106 171 body: networkBody, 107 172 headers: opts.headers as Headers, ··· 111 176 }; 112 177 let request = new Request(url, requestInit); 113 178 114 - for (const fn of interceptors.request.fns) { 115 - if (fn) { 116 - request = await fn(request, opts); 117 - } 118 - } 119 - 120 - // Reflect any interceptor changes into opts used for network and downstream 121 - opts.headers = request.headers; 122 - opts.method = request.method as Uppercase<HttpMethod>; 123 - // Attempt to reflect possible signal/body changes (safely) 124 - 125 - const reqBody = (request as any).body as unknown; 126 - let effectiveRetry = opts.retry; 127 - if (reqBody !== undefined && reqBody !== null) { 128 - if (isRepeatableBody(reqBody)) { 129 - networkBody = reqBody as BodyInit; 130 - } else { 131 - networkBody = reqBody as BodyInit; 132 - // Disable retries for non-repeatable bodies 133 - effectiveRetry = 0 as any; 134 - } 135 - } 136 - 137 - opts.signal = (request as any).signal as AbortSignal | undefined; 179 + request = await applyRequestInterceptors(request, opts); 138 180 const finalUrl = request.url; 139 181 140 - // Build ofetch options and perform the request 141 - const responseOptions = buildOfetchOptions( 182 + // build ofetch options and perform the request (.raw keeps the Response) 183 + const responseOptions = buildNetworkOptions( 142 184 opts as ResolvedRequestOptions, 143 - networkBody ?? undefined, 144 - effectiveRetry as any, 185 + networkBody, 186 + ofetchResponseType, 145 187 ); 146 188 147 189 let response = await $ofetch.raw(finalUrl, responseOptions); ··· 167 209 } 168 210 } 169 211 170 - // Ensure error is never undefined after interceptors 212 + // ensure error is never undefined after interceptors 171 213 finalError = (finalError as any) || ({} as string); 172 214 173 215 if (opts.throwOnError) { ··· 183 225 184 226 const makeSseFn = 185 227 (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => { 186 - const { networkBody, opts, url } = await beforeRequest(options); 228 + const { networkBody, opts, url } = await resolveOptions(options); 187 229 const optsForSse: any = { ...opts }; 188 - delete optsForSse.body; 230 + delete optsForSse.body; // body is provided via serializedBody below 189 231 return createSseClient({ 190 232 ...optsForSse, 191 233 fetch: opts.fetch, ··· 193 235 method, 194 236 onRequest: async (url, init) => { 195 237 let request = new Request(url, init); 196 - for (const fn of interceptors.request.fns) { 197 - if (fn) { 198 - request = await fn(request, opts); 199 - } 200 - } 238 + request = await applyRequestInterceptors(request, opts); 201 239 return request; 202 240 }, 203 241 serializedBody: networkBody as BodyInit | null | undefined,
+38 -3
examples/openapi-ts-ofetch/src/client/client/types.gen.ts
··· 22 22 export interface Config<T extends ClientOptions = ClientOptions> 23 23 extends Omit<RequestInit, 'body' | 'headers' | 'method'>, 24 24 CoreConfig { 25 + /** 26 + * HTTP(S) agent configuration (Node.js only). Passed through to ofetch. 27 + */ 25 28 agent?: OfetchOptions['agent']; 26 29 /** 27 30 * Base URL for all requests made by this client. 28 31 */ 29 32 baseUrl?: T['baseUrl']; 30 - /** Node-only proxy/agent options */ 33 + /** 34 + * Node-only proxy/agent options. 35 + */ 31 36 dispatcher?: OfetchOptions['dispatcher']; 32 - /** Optional fetch instance used for SSE streaming */ 37 + /** 38 + * Fetch API implementation. Used for SSE streaming. You can use this option 39 + * to provide a custom fetch instance. 40 + * 41 + * @default globalThis.fetch 42 + */ 33 43 fetch?: typeof fetch; 44 + /** 45 + * Controls the native ofetch behaviour that throws `FetchError` when 46 + * `response.ok === false`. We default to suppressing it to match the fetch 47 + * client semantics and let `throwOnError` drive the outcome. 48 + */ 49 + ignoreResponseError?: OfetchOptions['ignoreResponseError']; 34 50 // No custom fetch option: provide custom instance via `ofetch` instead 35 51 /** 36 52 * Please don't use the Fetch client for Next.js applications. The `next` ··· 44 60 * be used for requests instead of the default `ofetch` export. 45 61 */ 46 62 ofetch?: typeof ofetch; 47 - /** ofetch interceptors and runtime options */ 63 + /** 64 + * ofetch hook called before a request is sent. 65 + */ 48 66 onRequest?: OfetchOptions['onRequest']; 67 + /** 68 + * ofetch hook called when a request fails before receiving a response 69 + * (e.g., network errors or aborted requests). 70 + */ 49 71 onRequestError?: OfetchOptions['onRequestError']; 72 + /** 73 + * ofetch hook called after a successful response is received and parsed. 74 + */ 50 75 onResponse?: OfetchOptions['onResponse']; 76 + /** 77 + * ofetch hook called when the response indicates an error (non-ok status) 78 + * or when response parsing fails. 79 + */ 51 80 onResponseError?: OfetchOptions['onResponseError']; 52 81 /** 53 82 * Return the response data parsed in a specified format. By default, `auto` ··· 82 111 * Automatically retry failed requests. 83 112 */ 84 113 retry?: OfetchOptions['retry']; 114 + /** 115 + * Delay (in ms) between retry attempts. 116 + */ 85 117 retryDelay?: OfetchOptions['retryDelay']; 118 + /** 119 + * HTTP status codes that should trigger a retry. 120 + */ 86 121 retryStatusCodes?: OfetchOptions['retryStatusCodes']; 87 122 /** 88 123 * Throw an error instead of returning it in the response?
+26 -15
examples/openapi-ts-ofetch/src/client/client/utils.gen.ts
··· 316 316 export const buildOfetchOptions = ( 317 317 opts: ResolvedRequestOptions, 318 318 body: BodyInit | null | undefined, 319 + responseType: OfetchResponseType | undefined, 319 320 retryOverride?: OfetchOptions['retry'], 320 - ): OfetchOptions => { 321 - const responseType = mapParseAsToResponseType( 322 - opts.parseAs, 323 - opts.responseType, 324 - ); 325 - return { 321 + ): OfetchOptions => 322 + ({ 326 323 agent: opts.agent as OfetchOptions['agent'], 327 - body: body as any, 324 + body, 325 + credentials: opts.credentials as OfetchOptions['credentials'], 328 326 dispatcher: opts.dispatcher as OfetchOptions['dispatcher'], 329 327 headers: opts.headers as Headers, 328 + ignoreResponseError: 329 + (opts.ignoreResponseError as OfetchOptions['ignoreResponseError']) ?? 330 + true, 330 331 method: opts.method, 331 332 onRequest: opts.onRequest as OfetchOptions['onRequest'], 332 333 onRequestError: opts.onRequestError as OfetchOptions['onRequestError'], 333 334 onResponse: opts.onResponse as OfetchOptions['onResponse'], 334 335 onResponseError: opts.onResponseError as OfetchOptions['onResponseError'], 335 336 parseResponse: opts.parseResponse as OfetchOptions['parseResponse'], 336 - query: undefined, // URL already includes query 337 + // URL already includes query 338 + query: undefined, 337 339 responseType, 338 - retry: (retryOverride ?? 339 - (opts.retry as OfetchOptions['retry'])) as OfetchOptions['retry'], 340 + retry: retryOverride ?? (opts.retry as OfetchOptions['retry']), 340 341 retryDelay: opts.retryDelay as OfetchOptions['retryDelay'], 341 342 retryStatusCodes: 342 343 opts.retryStatusCodes as OfetchOptions['retryStatusCodes'], 343 344 signal: opts.signal, 344 345 timeout: opts.timeout as number | undefined, 345 - } as OfetchOptions; 346 - }; 346 + }) as OfetchOptions; 347 347 348 348 /** 349 349 * Parse a successful response, handling empty bodies and stream cases. ··· 382 382 } 383 383 } 384 384 385 - // Prefer ofetch-populated data 385 + // Prefer ofetch-populated data unless we explicitly need raw `formData` 386 386 let data: unknown = (response as any)._data; 387 - if (typeof data === 'undefined') { 387 + if (inferredParseAs === 'formData' || typeof data === 'undefined') { 388 388 switch (inferredParseAs) { 389 389 case 'arrayBuffer': 390 390 case 'blob': 391 391 case 'formData': 392 - case 'json': 393 392 case 'text': 394 393 data = await (response as any)[inferredParseAs](); 395 394 break; 395 + case 'json': { 396 + // Some servers return 200 with no Content-Length and empty body. 397 + // response.json() would throw; detect empty via clone().text() first. 398 + const txt = await response.clone().text(); 399 + if (!txt) { 400 + data = {}; 401 + } else { 402 + data = await (response as any).json(); 403 + } 404 + break; 405 + } 396 406 case 'stream': 397 407 return response.body; 398 408 } ··· 526 536 ): Config<Omit<ClientOptions, keyof T> & T> => ({ 527 537 ...jsonBodySerializer, 528 538 headers: defaultHeaders, 539 + ignoreResponseError: true, 529 540 parseAs: 'auto', 530 541 querySerializer: defaultQuerySerializer, 531 542 ...override,
+1 -1
examples/openapi-ts-ofetch/src/client/index.ts
··· 1 1 // This file is auto-generated by @hey-api/openapi-ts 2 2 3 3 export * from './sdk.gen'; 4 - export * from './types.gen'; 4 + export type * from './types.gen';
+2 -2
examples/openapi-ts-ofetch/src/client/sdk.gen.ts
··· 1 1 // This file is auto-generated by @hey-api/openapi-ts 2 2 3 - import type { Client, Options as ClientOptions, TDataShape } from './client'; 3 + import type { Client, Options as Options2, TDataShape } from './client'; 4 4 import { client } from './client.gen'; 5 5 import type { 6 6 AddPetData, ··· 65 65 export type Options< 66 66 TData extends TDataShape = TDataShape, 67 67 ThrowOnError extends boolean = boolean, 68 - > = ClientOptions<TData, ThrowOnError> & { 68 + > = Options2<TData, ThrowOnError> & { 69 69 /** 70 70 * You can provide a client instance returned by `createClient()` instead of 71 71 * individual options. This might be also useful if you want to implement a
+4 -4
examples/openapi-ts-ofetch/src/client/types.gen.ts
··· 1 1 // This file is auto-generated by @hey-api/openapi-ts 2 2 3 + export type ClientOptions = { 4 + baseUrl: 'https://petstore3.swagger.io/api/v3' | (string & {}); 5 + }; 6 + 3 7 export type Order = { 4 8 complete?: boolean; 5 9 id?: number; ··· 693 697 */ 694 698 200: unknown; 695 699 }; 696 - 697 - export type ClientOptions = { 698 - baseUrl: 'https://petstore3.swagger.io/api/v3' | (string & {}); 699 - };
+57 -1
packages/openapi-ts/src/plugins/@hey-api/client-ofetch/__tests__/utils.test.ts
··· 1 1 import { describe, expect, it, vi } from 'vitest'; 2 2 3 3 import type { Auth } from '../../client-core/bundle/auth'; 4 - import { mergeHeaders, setAuthParams } from '../bundle/utils'; 4 + import type { ResolvedRequestOptions } from '../bundle/types'; 5 + import { 6 + buildOfetchOptions, 7 + mergeHeaders, 8 + setAuthParams, 9 + } from '../bundle/utils'; 5 10 6 11 describe('mergeHeaders', () => { 7 12 it('merges plain objects into Headers', () => { ··· 200 205 expect(Object.keys(query).length).toBe(0); 201 206 }); 202 207 }); 208 + 209 + describe('buildOfetchOptions', () => { 210 + it('passes through credentials property when provided', () => { 211 + const opts: ResolvedRequestOptions = { 212 + baseUrl: 'https://api.example.com', 213 + credentials: 'include' as const, 214 + headers: new Headers({ 'Content-Type': 'application/json' }), 215 + method: 'GET', 216 + url: '/test', 217 + }; 218 + 219 + const result = buildOfetchOptions(opts, null, undefined); 220 + 221 + expect(result.credentials).toBe('include'); 222 + }); 223 + 224 + it('passes through undefined credentials when not provided', () => { 225 + const opts: ResolvedRequestOptions = { 226 + baseUrl: 'https://api.example.com', 227 + headers: new Headers({ 'Content-Type': 'application/json' }), 228 + method: 'GET', 229 + url: '/test', 230 + }; 231 + 232 + const result = buildOfetchOptions(opts, null, undefined); 233 + 234 + expect(result.credentials).toBeUndefined(); 235 + }); 236 + 237 + it('passes through different credential values', () => { 238 + const testCases: Array<RequestCredentials> = [ 239 + 'omit', 240 + 'same-origin', 241 + 'include', 242 + ]; 243 + 244 + testCases.forEach((credentialValue) => { 245 + const opts: ResolvedRequestOptions = { 246 + baseUrl: 'https://api.example.com', 247 + credentials: credentialValue, 248 + headers: new Headers({ 'Content-Type': 'application/json' }), 249 + method: 'GET', 250 + url: '/test', 251 + }; 252 + 253 + const result = buildOfetchOptions(opts, null, undefined); 254 + 255 + expect(result.credentials).toBe(credentialValue); 256 + }); 257 + }); 258 + });
+1
packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/utils.ts
··· 320 320 ({ 321 321 agent: opts.agent as OfetchOptions['agent'], 322 322 body, 323 + credentials: opts.credentials as OfetchOptions['credentials'], 323 324 dispatcher: opts.dispatcher as OfetchOptions['dispatcher'], 324 325 headers: opts.headers as Headers, 325 326 ignoreResponseError: