UNPKG

@hey-api/openapi-ts

Version:

🌀 OpenAPI to TypeScript codegen. Production-grade SDKs, Zod schemas, TanStack Query hooks, and 20+ plugins. Used by Vercel, OpenCode, and PayPal.

269 lines (229 loc) • 9.06 kB
import { ofetch, type ResponseType as OfetchResponseType } from 'ofetch'; import { createSseClient } from '../core/serverSentEvents'; import type { HttpMethod } from '../core/types'; import { getValidRequestBody } from '../core/utils'; import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types'; import { buildOfetchOptions, buildUrl, createConfig, createInterceptors, isRepeatableBody, mapParseAsToResponseType, mergeConfigs, mergeHeaders, parseError, parseSuccess, setAuthParams, wrapDataReturn, wrapErrorReturn, } from './utils'; type ReqInit = Omit<RequestInit, 'body' | 'headers'> & { body?: BodyInit | null | undefined; headers: ReturnType<typeof mergeHeaders>; }; export const createClient = (config: Config = {}): Client => { let _config = mergeConfigs(createConfig(), config); const getConfig = (): Config => ({ ..._config }); const setConfig = (config: Config): Config => { _config = mergeConfigs(_config, config); return getConfig(); }; const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>(); // precompute serialized / network body const resolveOptions = async (options: RequestOptions) => { const opts = { ..._config, ...options, headers: mergeHeaders(_config.headers, options.headers), serializedBody: undefined as string | undefined, }; if (opts.security) { await setAuthParams(opts); } if (opts.requestValidator) { await opts.requestValidator(opts); } if (opts.body !== undefined && opts.bodySerializer) { opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; } // remove Content-Type if body is empty to avoid invalid requests if (opts.body === undefined || opts.serializedBody === '') { opts.headers.delete('Content-Type'); } // if a raw body is provided (no serializer), adjust Content-Type only when it // equals the default JSON value to better match the concrete body type if ( opts.body !== undefined && opts.bodySerializer === null && (opts.headers.get('Content-Type') || '').toLowerCase() === 'application/json' ) { const b: unknown = opts.body; if (typeof FormData !== 'undefined' && b instanceof FormData) { // let the runtime set the multipart boundary opts.headers.delete('Content-Type'); } else if (typeof URLSearchParams !== 'undefined' && b instanceof URLSearchParams) { // standard urlencoded content type (+ charset) opts.headers.set('Content-Type', 'application/x-www-form-urlencoded;charset=UTF-8'); } else if (typeof Blob !== 'undefined' && b instanceof Blob) { const t = b.type?.trim(); if (t) { opts.headers.set('Content-Type', t); } else { // unknown blob type: avoid sending a misleading JSON header opts.headers.delete('Content-Type'); } } } // precompute network body (stability for retries and interceptors) const networkBody = getValidRequestBody(opts) as RequestInit['body'] | null | undefined; const url = buildUrl(opts); return { networkBody, opts, url }; }; // apply request interceptors and mirror header/method/signal back to opts const applyRequestInterceptors = async ( request: Request, opts: ResolvedRequestOptions, body: BodyInit | null | undefined, ) => { for (const fn of interceptors.request.fns) { if (fn) { request = await fn(request, opts); } } // reflect interceptor changes into opts used by the network layer opts.headers = request.headers; opts.method = request.method as Uppercase<HttpMethod>; // ignore request.body changes to avoid turning serialized bodies into streams // body comes only from getValidRequestBody(options) // reflect signal if present opts.signal = request.signal; // When body is FormData, remove Content-Type header to avoid boundary mismatch. // Note: We already delete Content-Type in resolveOptions for FormData, but the // Request constructor (line 175) re-adds it with an auto-generated boundary. // Since we pass the original FormData (not the Request's body) to ofetch, and // ofetch will generate its own boundary, we must remove the Request's Content-Type // to let ofetch set the correct one. Otherwise the boundary in the header won't // match the boundary in the actual multipart body sent by ofetch. if (typeof FormData !== 'undefined' && body instanceof FormData) { opts.headers.delete('Content-Type'); } return request; }; // build ofetch options with stable retry logic based on body repeatability const buildNetworkOptions = ( opts: ResolvedRequestOptions, body: BodyInit | null | undefined, responseType: OfetchResponseType | undefined, ) => { const effectiveRetry = isRepeatableBody(body) ? opts.retry : 0; return buildOfetchOptions(opts, body, responseType, effectiveRetry); }; const request: Client['request'] = async (options) => { const throwOnError = options.throwOnError ?? _config.throwOnError; const responseStyle = options.responseStyle ?? _config.responseStyle; let request: Request | undefined; let response: Awaited<ReturnType<typeof ofetch.raw>> | undefined; try { const { networkBody: initialNetworkBody, opts, url, } = await resolveOptions(options as RequestOptions); // map parseAs -> ofetch responseType once per request const ofetchResponseType: OfetchResponseType | undefined = mapParseAsToResponseType( opts.parseAs, opts.responseType, ); const $ofetch = opts.ofetch ?? ofetch; // create Request before network to run middleware consistently const networkBody = initialNetworkBody; const requestInit: ReqInit = { body: networkBody, headers: opts.headers, method: opts.method, redirect: 'follow', signal: opts.signal, }; request = new Request(url, requestInit); request = await applyRequestInterceptors(request, opts, networkBody); const finalUrl = request.url; // build ofetch options and perform the request (.raw keeps the Response) const responseOptions = buildNetworkOptions(opts, networkBody, ofetchResponseType); response = await $ofetch.raw(finalUrl, responseOptions); for (const fn of interceptors.response.fns) { if (fn) { response = await fn(response, request, opts); } } const result = { request, response }; if (response.ok) { const data = await parseSuccess(response, opts, ofetchResponseType); return wrapDataReturn(data, result, opts.responseStyle); } throw await parseError(response); } catch (error) { let finalError = error; for (const fn of interceptors.error.fns) { if (fn) { finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); } } // ensure error is never undefined after interceptors finalError = finalError || ({} as string); if (throwOnError) { throw finalError; } return wrapErrorReturn(finalError, { request, response }, responseStyle) as any; } }; const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => request({ ...options, method }); const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => { const { networkBody, opts, url } = await resolveOptions(options); const optsForSse = { ...opts }; delete optsForSse.body; // body is provided via serializedBody below return createSseClient({ ...(optsForSse as Omit<typeof opts, 'body'>), fetch: opts.fetch, headers: opts.headers, method, onRequest: async (url, init) => { let request = new Request(url, init); request = await applyRequestInterceptors(request, opts, networkBody); return request; }, serializedBody: networkBody, signal: opts.signal, url, }); }; const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); return { buildUrl: _buildUrl, connect: makeMethodFn('CONNECT'), delete: makeMethodFn('DELETE'), get: makeMethodFn('GET'), getConfig, head: makeMethodFn('HEAD'), interceptors, options: makeMethodFn('OPTIONS'), patch: makeMethodFn('PATCH'), post: makeMethodFn('POST'), put: makeMethodFn('PUT'), request, setConfig, sse: { connect: makeSseFn('CONNECT'), delete: makeSseFn('DELETE'), get: makeSseFn('GET'), head: makeSseFn('HEAD'), options: makeSseFn('OPTIONS'), patch: makeSseFn('PATCH'), post: makeSseFn('POST'), put: makeSseFn('PUT'), trace: makeSseFn('TRACE'), }, trace: makeMethodFn('TRACE'), } as Client; };