@hey-api/openapi-ts 0.83.1 → 0.84.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,540 @@
1
+ import type {
2
+ FetchOptions as OfetchOptions,
3
+ ResponseType as OfetchResponseType,
4
+ } from 'ofetch';
5
+
6
+ import { getAuthToken } from '../core/auth';
7
+ import type { QuerySerializerOptions } from '../core/bodySerializer';
8
+ import { jsonBodySerializer } from '../core/bodySerializer';
9
+ import {
10
+ serializeArrayParam,
11
+ serializeObjectParam,
12
+ serializePrimitiveParam,
13
+ } from '../core/pathSerializer';
14
+ import { getUrl } from '../core/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
+ ignoreResponseError:
326
+ (opts.ignoreResponseError as OfetchOptions['ignoreResponseError']) ??
327
+ true,
328
+ method: opts.method,
329
+ onRequest: opts.onRequest as OfetchOptions['onRequest'],
330
+ onRequestError: opts.onRequestError as OfetchOptions['onRequestError'],
331
+ onResponse: opts.onResponse as OfetchOptions['onResponse'],
332
+ onResponseError: opts.onResponseError as OfetchOptions['onResponseError'],
333
+ parseResponse: opts.parseResponse as OfetchOptions['parseResponse'],
334
+ // URL already includes query
335
+ query: undefined,
336
+ responseType,
337
+ retry: retryOverride ?? (opts.retry as OfetchOptions['retry']),
338
+ retryDelay: opts.retryDelay as OfetchOptions['retryDelay'],
339
+ retryStatusCodes:
340
+ opts.retryStatusCodes as OfetchOptions['retryStatusCodes'],
341
+ signal: opts.signal,
342
+ timeout: opts.timeout as number | undefined,
343
+ }) as OfetchOptions;
344
+
345
+ /**
346
+ * Parse a successful response, handling empty bodies and stream cases.
347
+ */
348
+ export const parseSuccess = async (
349
+ response: Response,
350
+ opts: ResolvedRequestOptions,
351
+ ofetchResponseType?: OfetchResponseType,
352
+ ): Promise<unknown> => {
353
+ // Stream requested: return stream body
354
+ if (ofetchResponseType === 'stream') {
355
+ return response.body;
356
+ }
357
+
358
+ const inferredParseAs =
359
+ (opts.parseAs === 'auto'
360
+ ? getParseAs(response.headers.get('Content-Type'))
361
+ : opts.parseAs) ?? 'json';
362
+
363
+ // Handle empty responses
364
+ if (
365
+ response.status === 204 ||
366
+ response.headers.get('Content-Length') === '0'
367
+ ) {
368
+ switch (inferredParseAs) {
369
+ case 'arrayBuffer':
370
+ case 'blob':
371
+ case 'text':
372
+ return await (response as any)[inferredParseAs]();
373
+ case 'formData':
374
+ return new FormData();
375
+ case 'stream':
376
+ return response.body;
377
+ default:
378
+ return {};
379
+ }
380
+ }
381
+
382
+ // Prefer ofetch-populated data unless we explicitly need raw `formData`
383
+ let data: unknown = (response as any)._data;
384
+ if (inferredParseAs === 'formData' || typeof data === 'undefined') {
385
+ switch (inferredParseAs) {
386
+ case 'arrayBuffer':
387
+ case 'blob':
388
+ case 'formData':
389
+ case 'text':
390
+ data = await (response as any)[inferredParseAs]();
391
+ break;
392
+ case 'json': {
393
+ // Some servers return 200 with no Content-Length and empty body.
394
+ // response.json() would throw; detect empty via clone().text() first.
395
+ const txt = await response.clone().text();
396
+ if (!txt) {
397
+ data = {};
398
+ } else {
399
+ data = await (response as any).json();
400
+ }
401
+ break;
402
+ }
403
+ case 'stream':
404
+ return response.body;
405
+ }
406
+ }
407
+
408
+ if (inferredParseAs === 'json') {
409
+ if (opts.responseValidator) {
410
+ await opts.responseValidator(data);
411
+ }
412
+ if (opts.responseTransformer) {
413
+ data = await opts.responseTransformer(data);
414
+ }
415
+ }
416
+
417
+ return data;
418
+ };
419
+
420
+ /**
421
+ * Parse an error response payload.
422
+ */
423
+ export const parseError = async (response: Response): Promise<unknown> => {
424
+ let error: unknown = (response as any)._data;
425
+ if (typeof error === 'undefined') {
426
+ const textError = await response.text();
427
+ try {
428
+ error = JSON.parse(textError);
429
+ } catch {
430
+ error = textError;
431
+ }
432
+ }
433
+ return error ?? ({} as string);
434
+ };
435
+
436
+ type ErrInterceptor<Err, Res, Req, Options> = (
437
+ error: Err,
438
+ response: Res,
439
+ request: Req,
440
+ options: Options,
441
+ ) => Err | Promise<Err>;
442
+
443
+ type ReqInterceptor<Req, Options> = (
444
+ request: Req,
445
+ options: Options,
446
+ ) => Req | Promise<Req>;
447
+
448
+ type ResInterceptor<Res, Req, Options> = (
449
+ response: Res,
450
+ request: Req,
451
+ options: Options,
452
+ ) => Res | Promise<Res>;
453
+
454
+ class Interceptors<Interceptor> {
455
+ fns: Array<Interceptor | null> = [];
456
+
457
+ clear(): void {
458
+ this.fns = [];
459
+ }
460
+
461
+ eject(id: number | Interceptor): void {
462
+ const index = this.getInterceptorIndex(id);
463
+ if (this.fns[index]) {
464
+ this.fns[index] = null;
465
+ }
466
+ }
467
+
468
+ exists(id: number | Interceptor): boolean {
469
+ const index = this.getInterceptorIndex(id);
470
+ return Boolean(this.fns[index]);
471
+ }
472
+
473
+ getInterceptorIndex(id: number | Interceptor): number {
474
+ if (typeof id === 'number') {
475
+ return this.fns[id] ? id : -1;
476
+ }
477
+ return this.fns.indexOf(id);
478
+ }
479
+
480
+ update(
481
+ id: number | Interceptor,
482
+ fn: Interceptor,
483
+ ): number | Interceptor | false {
484
+ const index = this.getInterceptorIndex(id);
485
+ if (this.fns[index]) {
486
+ this.fns[index] = fn;
487
+ return id;
488
+ }
489
+ return false;
490
+ }
491
+
492
+ use(fn: Interceptor): number {
493
+ this.fns.push(fn);
494
+ return this.fns.length - 1;
495
+ }
496
+ }
497
+
498
+ export interface Middleware<Req, Res, Err, Options> {
499
+ error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
500
+ request: Interceptors<ReqInterceptor<Req, Options>>;
501
+ response: Interceptors<ResInterceptor<Res, Req, Options>>;
502
+ }
503
+
504
+ export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
505
+ Req,
506
+ Res,
507
+ Err,
508
+ Options
509
+ > => ({
510
+ error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
511
+ request: new Interceptors<ReqInterceptor<Req, Options>>(),
512
+ response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
513
+ });
514
+
515
+ const defaultQuerySerializer = createQuerySerializer({
516
+ allowReserved: false,
517
+ array: {
518
+ explode: true,
519
+ style: 'form',
520
+ },
521
+ object: {
522
+ explode: true,
523
+ style: 'deepObject',
524
+ },
525
+ });
526
+
527
+ const defaultHeaders = {
528
+ 'Content-Type': 'application/json',
529
+ };
530
+
531
+ export const createConfig = <T extends ClientOptions = ClientOptions>(
532
+ override: Config<Omit<ClientOptions, keyof T> & T> = {},
533
+ ): Config<Omit<ClientOptions, keyof T> & T> => ({
534
+ ...jsonBodySerializer,
535
+ headers: defaultHeaders,
536
+ ignoreResponseError: true,
537
+ parseAs: 'auto',
538
+ querySerializer: defaultQuerySerializer,
539
+ ...override,
540
+ });