@hey-api/openapi-ts
Version:
🌀 OpenAPI to TypeScript code generator. Generate API clients, SDKs, validators, and more.
286 lines (249 loc) • 8.83 kB
text/typescript
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,
};
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
});
}
if (opts.requestValidator) {
await opts.requestValidator(opts);
}
if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body);
}
// 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 as any).signal as AbortSignal | undefined;
// 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 as any)
: (0 as any);
return buildOfetchOptions(opts, body, responseType, effectiveRetry);
};
const request: Client['request'] = async (options) => {
const {
networkBody: initialNetworkBody,
opts,
url,
} = await resolveOptions(options as any);
// 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 as Headers,
method: opts.method,
redirect: 'follow',
signal: opts.signal,
};
let 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 as ResolvedRequestOptions,
networkBody,
ofetchResponseType,
);
let 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);
}
let finalError = await parseError(response);
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = await fn(finalError, response, request, opts);
}
}
// ensure error is never undefined after interceptors
finalError = (finalError as any) || ({} as string);
if (opts.throwOnError) {
throw finalError;
}
return wrapErrorReturn(finalError, result, opts.responseStyle) as any;
};
const makeMethodFn =
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
request({ ...options, method } as any);
const makeSseFn =
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { networkBody, opts, url } = await resolveOptions(options);
const optsForSse: any = { ...opts };
delete optsForSse.body; // body is provided via serializedBody below
return createSseClient({
...optsForSse,
fetch: opts.fetch,
headers: opts.headers as Headers,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
request = await applyRequestInterceptors(request, opts, networkBody);
return request;
},
serializedBody: networkBody as BodyInit | null | undefined,
signal: opts.signal,
url,
});
};
return {
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;
};