Last active
January 15, 2025 05:31
-
-
Save mary-ext/f71b0d3b10fa046e906b0a86c6451178 to your computer and use it in GitHub Desktop.
TypeScript OpenAPI client
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// #region Utility types | |
type RequiredKeysOf<TType extends object> = TType extends any | |
? Exclude< | |
{ [Key in keyof TType]: TType extends Record<Key, TType[Key]> ? Key : never }[keyof TType], | |
undefined | |
> | |
: never; | |
type HasRequiredKeys<TType extends object> = RequiredKeysOf<TType> extends never ? false : true; | |
type Dig<TObject, TPattern> = TObject[keyof TObject & TPattern]; | |
type GetValueWithDefault<TObject, TPattern, TDefault> = TObject extends any | |
? Dig<TObject, TPattern> extends never | |
? TDefault | |
: Dig<TObject, TPattern> | |
: never; | |
// eslint-disable-next-line @typescript-eslint/no-unused-vars | |
const tNever = Symbol(); | |
type Never = typeof tNever; | |
// #endregion | |
// #region Middleware runner | |
type Middleware<TParams extends any[], TReturn> = ( | |
...params: [...TParams, next: (...params: TParams) => TReturn] | |
) => TReturn; | |
const createAsyncMiddlewareRunner = <TParams extends any[], TReturn>( | |
middlewares: [...Middleware<TParams, Promise<TReturn>>[], Middleware<TParams, Promise<TReturn>>], | |
) => { | |
// prettier-ignore | |
return middlewares.reduceRight<(...params: TParams) => Promise<TReturn>>( | |
(next, run) => (...args) => run(...args, next), | |
() => Promise.reject(new Error(`middleware chain exhausted`)), | |
); | |
}; | |
export type FetchMiddleware = Middleware<[request: Request], Promise<Response>>; | |
// #endregion | |
// #region Request | |
export type HttpMethod = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch' | 'trace'; | |
type BodyType<T = unknown> = { | |
json: T; | |
text: string; | |
blob: Blob; | |
bytes: Uint8Array; | |
stream: ReadableStream<Uint8Array>; | |
}; | |
export type ParseAs = keyof BodyType; | |
export type ParamsOptions<T> = T extends { parameters: any } | |
? HasRequiredKeys<Dig<T['parameters'], string>> extends true | |
? { params: T['parameters'] } | |
: { params?: T['parameters'] } | |
: T extends Never | |
? { | |
params?: { | |
query?: Record<string, unknown>; | |
path?: Record<string, unknown>; | |
}; | |
} | |
: {}; | |
export type BodyOptions<T> = T extends { requestBody: { content: infer C } } | |
? C extends { 'application/json': infer B extends Record<string, unknown> } | |
? { body: B } | |
: C extends { 'multipart/form-data': Record<string, unknown> } | |
? { body: FormData } | |
: {} | |
: T extends Never | |
? { body?: BodyInit } | |
: {}; | |
export type RequestOptions<T> = ParamsOptions<T> & BodyOptions<T> & { parseAs?: ParseAs }; | |
export type FetchOptions<T> = Omit<RequestInit, 'method' | 'body'> & RequestOptions<T>; | |
// #endregion | |
// #region Response | |
// prettier-ignore | |
type OkStatus = 200 | 201 | 202 | 203 | 204 | 206 | 207 | "2XX"; | |
// prettier-ignore | |
type ErrorStatus = 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511 | "5XX" | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 429 | 431 | 444 | 450 | 451 | 497 | 498 | 499 | "4XX" | "default"; | |
type MediaType = `${string}/${string}`; | |
type ResponseContent<T> = T extends { content: any } ? T['content'] : unknown; | |
type ResponseObjectMap<T> = T extends { responses: any } ? T['responses'] : unknown; | |
export type ParseAsResponse<T, O> = O extends { parseAs: ParseAs } ? BodyType<T>[O['parseAs']] : T; | |
type SuccessResponse<T> = ResponseContent<Dig<T, OkStatus>>; | |
type ErrorResponse<T> = ResponseContent<Dig<T, ErrorStatus>>; | |
export type FetchResponse<TSchema, TInit> = | |
| { | |
response: Response; | |
data: ParseAsResponse< | |
GetValueWithDefault<SuccessResponse<ResponseObjectMap<TSchema>>, MediaType, Record<string, never>>, | |
TInit | |
>; | |
error?: never; | |
} | |
| { | |
response: Response; | |
error: ParseAsResponse< | |
GetValueWithDefault<ErrorResponse<ResponseObjectMap<TSchema>>, MediaType, Record<string, never>>, | |
TInit | |
>; | |
data?: never; | |
}; | |
// #endregion | |
// #region OpenAPI paths | |
export type PathsWithMethod<TPath, TMethod extends HttpMethod> = { | |
[Pathname in keyof TPath]: TPath[Pathname] extends { [K in TMethod]: unknown } ? Pathname : never; | |
}[keyof TPath]; | |
type PathMethods = Partial<Record<HttpMethod, {}>>; | |
type Paths = Record<string, PathMethods>; | |
// #endregion | |
// #region Client | |
type ClientMethod<TPaths extends Paths, TMethod extends HttpMethod> = < | |
TPath extends PathsWithMethod<TPaths, TMethod>, | |
TInit extends FetchOptions<TPaths[TPath][TMethod]>, | |
>( | |
path: TPath, | |
...rest: HasRequiredKeys<TInit> extends true ? [init: TInit] : [init?: TInit] | |
) => Promise<FetchResponse<TPaths[TPath][TMethod], TInit>>; | |
interface Client<TPaths extends {}> { | |
get: ClientMethod<TPaths, 'get'>; | |
put: ClientMethod<TPaths, 'put'>; | |
post: ClientMethod<TPaths, 'post'>; | |
delete: ClientMethod<TPaths, 'delete'>; | |
options: ClientMethod<TPaths, 'options'>; | |
head: ClientMethod<TPaths, 'head'>; | |
patch: ClientMethod<TPaths, 'patch'>; | |
trace: ClientMethod<TPaths, 'trace'>; | |
} | |
const globalFetch = fetch; | |
export interface ClientOptions { | |
base?: string; | |
middlewares?: FetchMiddleware[]; | |
fetch?: typeof globalFetch; | |
} | |
const isBodyInit = (body: any): boolean => { | |
return ( | |
body instanceof Blob || | |
body instanceof ReadableStream || | |
body instanceof URLSearchParams || | |
body instanceof FormData || | |
body instanceof ArrayBuffer || | |
ArrayBuffer.isView(body) | |
); | |
}; | |
export const createClient = <TPaths extends {}>({ | |
base: baseUrl = '', | |
middlewares = [], | |
fetch = globalFetch, | |
}: ClientOptions = {}): Client<TPaths> => { | |
const run = createAsyncMiddlewareRunner<[request: Request], Response>([ | |
...middlewares, | |
(request) => fetch(request), | |
]); | |
const createHttpMethod = (method: HttpMethod) => { | |
return async ( | |
path: string, | |
{ body, params, headers, parseAs = 'json', ...init }: FetchOptions<Never> = {}, | |
) => { | |
headers = new Headers(headers); | |
if (!headers.has('accept')) { | |
headers.set('accept', 'application/json'); | |
} | |
if (typeof body === 'object' && !isBodyInit(body)) { | |
body = JSON.stringify(body); | |
if (!headers.has('content-type')) { | |
headers.set('content-type', 'application/json'); | |
} | |
} | |
const url = baseUrl + serializePathParams(path, params?.path) + serializeQueryParams(params?.query); | |
const request = new Request(url, { ...init, method, body, headers }); | |
const response = await run(request); | |
const isOkResponse = response.ok; | |
const isEmptyResponse = response.status === 204 || response.headers.get('content-length') === '0'; | |
if (isEmptyResponse) { | |
return isOkResponse ? { response, data: {} } : { response, error: {} }; | |
} | |
if (response.ok) { | |
// if "stream", skip parsing entirely | |
if (parseAs === 'stream') { | |
return { response, data: response.body }; | |
} | |
return { response, data: await response[parseAs]() }; | |
} | |
let error = await response.text(); | |
try { | |
error = JSON.parse(error); | |
} catch { | |
// noop | |
} | |
return { response, error }; | |
}; | |
}; | |
return { | |
get: createHttpMethod('get'), | |
put: createHttpMethod('put'), | |
post: createHttpMethod('post'), | |
delete: createHttpMethod('delete'), | |
options: createHttpMethod('options'), | |
head: createHttpMethod('head'), | |
patch: createHttpMethod('patch'), | |
trace: createHttpMethod('trace'), | |
} as any; | |
}; | |
// #endregion | |
// #region Request serializers | |
const serializePathParams = (path: string, params: Record<string, unknown> = {}): string => { | |
return path.replace(/\{([^}]+)\}/g, (_match, key) => { | |
return '' + params[key]; | |
}); | |
}; | |
const serializeQueryParams = (params: Record<string, unknown> = {}): string => { | |
let searchParams: URLSearchParams | undefined; | |
for (const key in params) { | |
const value = params[key]; | |
if (value === undefined) { | |
continue; | |
} | |
// lazily initialize search params | |
searchParams ??= new URLSearchParams(); | |
if (Array.isArray(value)) { | |
if (Array.isArray(value)) { | |
for (let idx = 0, len = value.length; idx < len; idx++) { | |
const val = value[idx]; | |
searchParams.append(key, '' + val); | |
} | |
} else { | |
searchParams.set(key, '' + value); | |
} | |
} | |
} | |
return searchParams ? `?${searchParams.toString()}` : ''; | |
}; | |
// #endregion | |
type Promisable<T> = T | Promise<T>; | |
type OkResponse<T> = | |
| { response: Response; data: T | undefined; error?: never } | |
| { response: Response; error: { detail: string }; data?: never }; | |
export function ok<T>(value: Promise<OkResponse<T>>): Promise<T>; | |
export function ok<T>(value: OkResponse<T>): T; | |
export function ok(value: Promisable<OkResponse<any>>): any { | |
if (value instanceof Promise) { | |
return value.then(ok); | |
} | |
if (value.error) { | |
throw new Error(value.error.detail); | |
} | |
return value.data; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment