UNPKG

@hey-api/openapi-ts

Version:

🚀 The OpenAPI to TypeScript codegen. Generate clients, SDKs, validators, and more.

542 lines (490 loc) • 14.3 kB
import type { FetchOptions as OfetchOptions, ResponseType as OfetchResponseType, } from 'ofetch'; import { getAuthToken } from '../core/auth'; import type { QuerySerializerOptions } from '../core/bodySerializer'; import { jsonBodySerializer } from '../core/bodySerializer'; import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam, } from '../core/pathSerializer'; import { getUrl } from '../core/utils'; import type { Client, ClientOptions, Config, RequestOptions, ResolvedRequestOptions, ResponseStyle, } from './types'; export const createQuerySerializer = <T = unknown>({ 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<string, unknown>, ...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<Config['parseAs'], 'auto'> => { 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<RequestOptions, 'auth' | 'query'> & { 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<Required<RequestOptions>, 'security'> & Pick<RequestOptions, 'auth' | 'query'> & { 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<Required<Config>['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 = <T>( data: T, result: { request: Request; response: Response }, responseStyle: ResponseStyle | undefined, ): | T | ((T extends Record<string, unknown> ? { 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 = <E>( error: E, result: { request: Request; response: Response }, responseStyle: ResponseStyle | undefined, ): | undefined | ((E extends Record<string, unknown> ? { 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, credentials: opts.credentials as OfetchOptions['credentials'], dispatcher: opts.dispatcher as OfetchOptions['dispatcher'], headers: opts.headers as Headers, ignoreResponseError: (opts.ignoreResponseError as OfetchOptions['ignoreResponseError']) ?? true, 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<unknown> => { // 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 unless we explicitly need raw `formData` let data: unknown = (response as any)._data; if (inferredParseAs === 'formData' || 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<unknown> => { 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<Err, Res, Req, Options> = ( error: Err, response: Res, request: Req, options: Options, ) => Err | Promise<Err>; type ReqInterceptor<Req, Options> = ( request: Req, options: Options, ) => Req | Promise<Req>; type ResInterceptor<Res, Req, Options> = ( response: Res, request: Req, options: Options, ) => Res | Promise<Res>; class Interceptors<Interceptor> { fns: Array<Interceptor | null> = []; 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<Req, Res, Err, Options> { error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>; request: Interceptors<ReqInterceptor<Req, Options>>; response: Interceptors<ResInterceptor<Res, Req, Options>>; } export const createInterceptors = <Req, Res, Err, Options>(): Middleware< Req, Res, Err, Options > => ({ error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(), request: new Interceptors<ReqInterceptor<Req, Options>>(), response: new Interceptors<ResInterceptor<Res, Req, Options>>(), }); const defaultQuerySerializer = createQuerySerializer({ allowReserved: false, array: { explode: true, style: 'form', }, object: { explode: true, style: 'deepObject', }, }); const defaultHeaders = { 'Content-Type': 'application/json', }; export const createConfig = <T extends ClientOptions = ClientOptions>( override: Config<Omit<ClientOptions, keyof T> & T> = {}, ): Config<Omit<ClientOptions, keyof T> & T> => ({ ...jsonBodySerializer, headers: defaultHeaders, ignoreResponseError: true, parseAs: 'auto', querySerializer: defaultQuerySerializer, ...override, });