Skip to content

Commit c4fc4aa

Browse files
authored
Merge pull request #2710 from hey-api/copilot/fix-a01977c0-ddcf-4172-81f2-12f0dff81913
2 parents 44ccd36 + ba7e6dc commit c4fc4aa

File tree

20 files changed

+232
-77
lines changed

20 files changed

+232
-77
lines changed

.changeset/breezy-dodos-jog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hey-api/openapi-ts": patch
3+
---
4+
5+
fix(client-ofetch): add missing credentials property support
Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
// This file is auto-generated by @hey-api/openapi-ts
22

33
import {
4-
type ClientOptions as DefaultClientOptions,
4+
type ClientOptions,
55
type Config,
66
createClient,
77
createConfig,
88
} from './client';
9-
import type { ClientOptions } from './types.gen';
9+
import type { ClientOptions as ClientOptions2 } from './types.gen';
1010

1111
/**
1212
* The `createClientConfig()` function will be called on client initialization
@@ -16,13 +16,12 @@ import type { ClientOptions } from './types.gen';
1616
* `setConfig()`. This is useful for example if you're using Next.js
1717
* to ensure your client always has the correct values.
1818
*/
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>;
2322

2423
export const client = createClient(
25-
createConfig<ClientOptions>({
24+
createConfig<ClientOptions2>({
2625
baseUrl: 'https://petstore3.swagger.io/api/v3',
2726
}),
2827
);

examples/openapi-ts-ofetch/src/client/client/client.gen.ts

Lines changed: 82 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
} from './utils.gen';
2929

3030
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
31-
body?: any;
31+
body?: BodyInit | null | undefined;
3232
headers: ReturnType<typeof mergeHeaders>;
3333
};
3434

@@ -49,7 +49,8 @@ export const createClient = (config: Config = {}): Client => {
4949
ResolvedRequestOptions
5050
>();
5151

52-
const beforeRequest = async (options: RequestOptions) => {
52+
// precompute serialized / network body
53+
const resolveOptions = async (options: RequestOptions) => {
5354
const opts = {
5455
..._config,
5556
...options,
@@ -72,12 +73,44 @@ export const createClient = (config: Config = {}): Client => {
7273
opts.serializedBody = opts.bodySerializer(opts.body);
7374
}
7475

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
7677
if (opts.body === undefined || opts.serializedBody === '') {
7778
opts.headers.delete('Content-Type');
7879
}
7980

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)
81114
const networkBody = getValidRequestBody(opts) as
82115
| RequestInit['body']
83116
| null
@@ -88,20 +121,52 @@ export const createClient = (config: Config = {}): Client => {
88121
return { networkBody, opts, url };
89122
};
90123

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+
91156
const request: Client['request'] = async (options) => {
92157
const {
93158
networkBody: initialNetworkBody,
94159
opts,
95160
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
98163
const ofetchResponseType: OfetchResponseType | undefined =
99164
mapParseAsToResponseType(opts.parseAs, opts.responseType);
100165

101166
const $ofetch = opts.ofetch ?? ofetch;
102167

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;
105170
const requestInit: ReqInit = {
106171
body: networkBody,
107172
headers: opts.headers as Headers,
@@ -111,37 +176,14 @@ export const createClient = (config: Config = {}): Client => {
111176
};
112177
let request = new Request(url, requestInit);
113178

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);
138180
const finalUrl = request.url;
139181

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(
142184
opts as ResolvedRequestOptions,
143-
networkBody ?? undefined,
144-
effectiveRetry as any,
185+
networkBody,
186+
ofetchResponseType,
145187
);
146188

147189
let response = await $ofetch.raw(finalUrl, responseOptions);
@@ -167,7 +209,7 @@ export const createClient = (config: Config = {}): Client => {
167209
}
168210
}
169211

170-
// Ensure error is never undefined after interceptors
212+
// ensure error is never undefined after interceptors
171213
finalError = (finalError as any) || ({} as string);
172214

173215
if (opts.throwOnError) {
@@ -183,21 +225,17 @@ export const createClient = (config: Config = {}): Client => {
183225

184226
const makeSseFn =
185227
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
186-
const { networkBody, opts, url } = await beforeRequest(options);
228+
const { networkBody, opts, url } = await resolveOptions(options);
187229
const optsForSse: any = { ...opts };
188-
delete optsForSse.body;
230+
delete optsForSse.body; // body is provided via serializedBody below
189231
return createSseClient({
190232
...optsForSse,
191233
fetch: opts.fetch,
192234
headers: opts.headers as Headers,
193235
method,
194236
onRequest: async (url, init) => {
195237
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);
201239
return request;
202240
},
203241
serializedBody: networkBody as BodyInit | null | undefined,

examples/openapi-ts-ofetch/src/client/client/types.gen.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,31 @@ export type ResponseStyle = 'data' | 'fields';
2222
export interface Config<T extends ClientOptions = ClientOptions>
2323
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
2424
CoreConfig {
25+
/**
26+
* HTTP(S) agent configuration (Node.js only). Passed through to ofetch.
27+
*/
2528
agent?: OfetchOptions['agent'];
2629
/**
2730
* Base URL for all requests made by this client.
2831
*/
2932
baseUrl?: T['baseUrl'];
30-
/** Node-only proxy/agent options */
33+
/**
34+
* Node-only proxy/agent options.
35+
*/
3136
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+
*/
3343
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'];
3450
// No custom fetch option: provide custom instance via `ofetch` instead
3551
/**
3652
* Please don't use the Fetch client for Next.js applications. The `next`
@@ -44,10 +60,23 @@ export interface Config<T extends ClientOptions = ClientOptions>
4460
* be used for requests instead of the default `ofetch` export.
4561
*/
4662
ofetch?: typeof ofetch;
47-
/** ofetch interceptors and runtime options */
63+
/**
64+
* ofetch hook called before a request is sent.
65+
*/
4866
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+
*/
4971
onRequestError?: OfetchOptions['onRequestError'];
72+
/**
73+
* ofetch hook called after a successful response is received and parsed.
74+
*/
5075
onResponse?: OfetchOptions['onResponse'];
76+
/**
77+
* ofetch hook called when the response indicates an error (non-ok status)
78+
* or when response parsing fails.
79+
*/
5180
onResponseError?: OfetchOptions['onResponseError'];
5281
/**
5382
* Return the response data parsed in a specified format. By default, `auto`
@@ -82,7 +111,13 @@ export interface Config<T extends ClientOptions = ClientOptions>
82111
* Automatically retry failed requests.
83112
*/
84113
retry?: OfetchOptions['retry'];
114+
/**
115+
* Delay (in ms) between retry attempts.
116+
*/
85117
retryDelay?: OfetchOptions['retryDelay'];
118+
/**
119+
* HTTP status codes that should trigger a retry.
120+
*/
86121
retryStatusCodes?: OfetchOptions['retryStatusCodes'];
87122
/**
88123
* Throw an error instead of returning it in the response?

examples/openapi-ts-ofetch/src/client/client/utils.gen.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -316,34 +316,34 @@ export const wrapErrorReturn = <E>(
316316
export const buildOfetchOptions = (
317317
opts: ResolvedRequestOptions,
318318
body: BodyInit | null | undefined,
319+
responseType: OfetchResponseType | undefined,
319320
retryOverride?: OfetchOptions['retry'],
320-
): OfetchOptions => {
321-
const responseType = mapParseAsToResponseType(
322-
opts.parseAs,
323-
opts.responseType,
324-
);
325-
return {
321+
): OfetchOptions =>
322+
({
326323
agent: opts.agent as OfetchOptions['agent'],
327-
body: body as any,
324+
body,
325+
credentials: opts.credentials as OfetchOptions['credentials'],
328326
dispatcher: opts.dispatcher as OfetchOptions['dispatcher'],
329327
headers: opts.headers as Headers,
328+
ignoreResponseError:
329+
(opts.ignoreResponseError as OfetchOptions['ignoreResponseError']) ??
330+
true,
330331
method: opts.method,
331332
onRequest: opts.onRequest as OfetchOptions['onRequest'],
332333
onRequestError: opts.onRequestError as OfetchOptions['onRequestError'],
333334
onResponse: opts.onResponse as OfetchOptions['onResponse'],
334335
onResponseError: opts.onResponseError as OfetchOptions['onResponseError'],
335336
parseResponse: opts.parseResponse as OfetchOptions['parseResponse'],
336-
query: undefined, // URL already includes query
337+
// URL already includes query
338+
query: undefined,
337339
responseType,
338-
retry: (retryOverride ??
339-
(opts.retry as OfetchOptions['retry'])) as OfetchOptions['retry'],
340+
retry: retryOverride ?? (opts.retry as OfetchOptions['retry']),
340341
retryDelay: opts.retryDelay as OfetchOptions['retryDelay'],
341342
retryStatusCodes:
342343
opts.retryStatusCodes as OfetchOptions['retryStatusCodes'],
343344
signal: opts.signal,
344345
timeout: opts.timeout as number | undefined,
345-
} as OfetchOptions;
346-
};
346+
}) as OfetchOptions;
347347

348348
/**
349349
* Parse a successful response, handling empty bodies and stream cases.
@@ -382,17 +382,27 @@ export const parseSuccess = async (
382382
}
383383
}
384384

385-
// Prefer ofetch-populated data
385+
// Prefer ofetch-populated data unless we explicitly need raw `formData`
386386
let data: unknown = (response as any)._data;
387-
if (typeof data === 'undefined') {
387+
if (inferredParseAs === 'formData' || typeof data === 'undefined') {
388388
switch (inferredParseAs) {
389389
case 'arrayBuffer':
390390
case 'blob':
391391
case 'formData':
392-
case 'json':
393392
case 'text':
394393
data = await (response as any)[inferredParseAs]();
395394
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+
}
396406
case 'stream':
397407
return response.body;
398408
}
@@ -526,6 +536,7 @@ export const createConfig = <T extends ClientOptions = ClientOptions>(
526536
): Config<Omit<ClientOptions, keyof T> & T> => ({
527537
...jsonBodySerializer,
528538
headers: defaultHeaders,
539+
ignoreResponseError: true,
529540
parseAs: 'auto',
530541
querySerializer: defaultQuerySerializer,
531542
...override,

0 commit comments

Comments
 (0)