UNPKG

@hey-api/client-fetch

Version:

🚀 Fetch API client for `@hey-api/openapi-ts` codegen.

407 lines (356 loc) • 9.66 kB
import type { QuerySerializer, QuerySerializerOptions, } from '@hey-api/client-core'; import { getAuthToken, jsonBodySerializer, serializeArrayParam, serializeObjectParam, serializePrimitiveParam, } from '@hey-api/client-core'; import type { Client, ClientOptions, Config, RequestOptions } from './types'; interface PathSerializer { path: Record<string, unknown>; url: string; } const PATH_PARAM_RE = /\{[^{}]+\}/g; type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; type MatrixStyle = 'label' | 'matrix' | 'simple'; type ArraySeparatorStyle = ArrayStyle | MatrixStyle; const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { let url = _url; const matches = _url.match(PATH_PARAM_RE); if (matches) { for (const match of matches) { let explode = false; let name = match.substring(1, match.length - 1); let style: ArraySeparatorStyle = 'simple'; if (name.endsWith('*')) { explode = true; name = name.substring(0, name.length - 1); } if (name.startsWith('.')) { name = name.substring(1); style = 'label'; } else if (name.startsWith(';')) { name = name.substring(1); style = 'matrix'; } const value = path[name]; if (value === undefined || value === null) { continue; } if (Array.isArray(value)) { url = url.replace( match, serializeArrayParam({ explode, name, style, value }), ); continue; } if (typeof value === 'object') { url = url.replace( match, serializeObjectParam({ explode, name, style, value: value as Record<string, unknown>, }), ); continue; } if (style === 'matrix') { url = url.replace( match, `;${serializePrimitiveParam({ name, value: value as string, })}`, ); continue; } const replaceValue = encodeURIComponent( style === 'label' ? `.${value as string}` : (value as string), ); url = url.replace(match, replaceValue); } } return url; }; export const createQuerySerializer = <T = unknown>({ allowReserved, array, object, }: QuerySerializerOptions = {}) => { const querySerializer = (queryParams: T) => { let 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)) { search = [ ...search, serializeArrayParam({ allowReserved, explode: true, name, style: 'form', value, ...array, }), ]; continue; } if (typeof value === 'object') { search = [ ...search, serializeObjectParam({ allowReserved, explode: true, name, style: 'deepObject', value: value as Record<string, unknown>, ...object, }), ]; continue; } search = [ ...search, serializePrimitiveParam({ allowReserved, name, value: value as string, }), ]; } } 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'; } }; export const setAuthParams = async ({ security, ...options }: Pick<Required<RequestOptions>, 'security'> & Pick<RequestOptions, 'auth' | 'query'> & { headers: Headers; }) => { for (const auth of security) { 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; } return; } }; export const buildUrl: Client['buildUrl'] = (options) => { const url = 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, }); return url; }; export const getUrl = ({ baseUrl, path, query, querySerializer, url: _url, }: { baseUrl?: string; path?: Record<string, unknown>; query?: Record<string, unknown>; querySerializer: QuerySerializer; url: string; }) => { const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; let url = (baseUrl ?? '') + pathUrl; if (path) { url = defaultPathSerializer({ path, url }); } let search = query ? querySerializer(query) : ''; if (search.startsWith('?')) { search = search.substring(1); } if (search) { url += `?${search}`; } return 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; }; export const mergeHeaders = ( ...headers: Array<Required<Config>['headers'] | undefined> ): Headers => { const mergedHeaders = new Headers(); for (const header of headers) { if (!header || typeof header !== 'object') { continue; } const iterator = header instanceof Headers ? header.entries() : 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; }; 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: Interceptor[]; constructor() { this._fns = []; } clear() { this._fns = []; } exists(fn: Interceptor) { return this._fns.indexOf(fn) !== -1; } eject(fn: Interceptor) { const index = this._fns.indexOf(fn); if (index !== -1) { this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)]; } } use(fn: Interceptor) { this._fns = [...this._fns, fn]; } } // `createInterceptors()` response, meant for external use as it does not // expose internals export interface Middleware<Req, Res, Err, Options> { error: Pick< Interceptors<ErrInterceptor<Err, Res, Req, Options>>, 'eject' | 'use' >; request: Pick<Interceptors<ReqInterceptor<Req, Options>>, 'eject' | 'use'>; response: Pick< Interceptors<ResInterceptor<Res, Req, Options>>, 'eject' | 'use' >; } // do not add `Middleware` as return type so we can use _fns internally export const createInterceptors = <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, parseAs: 'auto', querySerializer: defaultQuerySerializer, ...override, });