diff --git a/examples/generate-vercel-client/api-client/client.ts b/examples/generate-vercel-client/api-client/client.ts new file mode 100644 index 00000000..c0c5e5aa --- /dev/null +++ b/examples/generate-vercel-client/api-client/client.ts @@ -0,0 +1,224 @@ +import fetch, { FetchError } from 'node-fetch' +import * as https from 'node:https' +import * as k8s from '@kubernetes/client-node' + +function removeNullableProperties>( + obj: T +): T { + Object.keys(obj).forEach( + (key) => (obj[key] === undefined || obj[key] === null) && delete obj[key] + ) + return obj +} + +/** + * Exponential backoff based on the attempt number. + * + * @remarks + * 1. 600ms * random(0.4, 1.4) + * 2. 1200ms * random(0.4, 1.4) + * 3. 2400ms * random(0.4, 1.4) + * 4. 4800ms * random(0.4, 1.4) + * 5. 9600ms * random(0.4, 1.4) + * + * @param attempt - Current attempt + * @param maxRetries - Maximum number of retries + */ +async function defaultBackoff(attempt: number, maxRetries: number) { + const attempts = Math.min(attempt, maxRetries) + + const timeout = ~~((Math.random() + 0.4) * (300 << attempts)) + await new Promise((resolve) => + setTimeout((res: any) => resolve(res), timeout) + ) +} + +const isPlainObject = (value: any) => value?.constructor === Object + +type QueryArgsSpec = { + path: string + method?: 'GET' | 'DELETE' | 'PATCH' | 'POST' | 'PUT' | 'OPTIONS' | 'HEAD' + body?: any + contentType?: string + params?: any + headers?: Record | undefined +} + +type MaybePromise = T | Promise + +type InterceptorArgs = { + args: QueryArgsSpec + opts: https.RequestOptions +} +type Interceptor = ( + args: InterceptorArgs, + options: Options +) => MaybePromise + +const interceptors: Interceptor[] = [] + +type RetryConditionFunction = (extraArgs: { + res?: Response + error: unknown + args: QueryArgsSpec + attempt: number + options: RetryOptions +}) => boolean | Promise + +type RetryOptions = { + retryCondition?: RetryConditionFunction | undefined + maxRetries?: number | undefined +} + +type HttpHeaderOptions = { + headers?: Record | undefined +} + +export type Options = RetryOptions & HttpHeaderOptions + +export async function apiClient( + args: QueryArgsSpec, + extraOptions?: Options +): Promise { + const maxRetries = extraOptions?.maxRetries ?? 3 + + const defaultRetryCondition: RetryConditionFunction = ({ ...obj }) => { + const { res, attempt, error } = obj + if (attempt > maxRetries) { + return false + } + + if (error instanceof FetchError) { + return true + } + if (res && res.status >= 500) { + return true + } + return false + } + + const options = { + maxRetries, + backoff: defaultBackoff, + retryCondition: defaultRetryCondition, + ...extraOptions, + } + + let { path, method, params, body, contentType } = { ...args } + + let httpsOptions: https.RequestOptions = { + path, + headers: removeNullableProperties({ + ...args.headers, + ...options.headers, + }), + } + if (method) { + httpsOptions.method = method + } + + for (const interceptor of interceptors) { + httpsOptions = await interceptor( + { + args, + opts: httpsOptions, + }, + options + ) + } + + if ( + !httpsOptions.agent && + (httpsOptions.ca || httpsOptions.cert || httpsOptions.key) + ) { + const agent = new https.Agent( + removeNullableProperties({ + ca: httpsOptions.ca, + cert: httpsOptions.cert, + key: httpsOptions.key, + port: httpsOptions.port ? Number(httpsOptions.port) : undefined, + }) + ) + httpsOptions.agent = agent + } + + if (!httpsOptions.protocol) { + httpsOptions.protocol = 'http:' + } + const host = httpsOptions.host || httpsOptions.hostname + let baseUrl = `${httpsOptions.protocol}//${host}` + const searchParams = toSearchParams(params) + if (searchParams.size) { + baseUrl += (baseUrl.includes('?') ? '&' : '?') + toSearchParams(params) + } + const url = new URL(baseUrl) + if (httpsOptions.port) { + url.port = httpsOptions.port.toString() + } + if (httpsOptions.path) { + url.pathname = httpsOptions.path + } + let isJson = false + if (isPlainObject(body) || Array.isArray(body)) { + isJson = true + body = JSON.stringify(body) + } + const headers: Record = { + ...(httpsOptions.headers as any), + } + if (contentType) { + headers['Content-Type'] = contentType + } else if (!httpsOptions.headers?.['Content-Type'] && isJson) { + headers['Content-Type'] = 'application/json' + } + + let retry = 0 + while (true) { + try { + const res = await fetch(url, { + headers, + protocol: httpsOptions.protocol || undefined, + method, + agent: httpsOptions.agent, + body, + }) + + const isSuccess = res.status >= 200 && res.status < 300 + const contentType = res.headers.get('content-type') + const isJsonResponse = contentType?.includes('application/json') ?? false + + if (isSuccess && isJsonResponse) { + return (await res.json()) as Response + } + + // helpful message for debugging + const text = await res.text() + if (res.status === 404 && text.includes('404 page not found')) { + console.info( + `Did you forget to install your Custom Resources Definitions? path: ${httpsOptions.path}` + ) + } + throw new Error(text) + } catch (e: any) { + retry++ + + if ( + !(await options.retryCondition({ + res: e?.value?.res, + error: e, + args, + attempt: retry, + options: options, + })) + ) { + throw e + } + + await options.backoff(retry, options.maxRetries) + } + } +} + +const toSearchParams = (params: Record) => { + return new URLSearchParams(removeNullableProperties(params)) +} diff --git a/examples/generate-vercel-client/justfile b/examples/generate-vercel-client/justfile new file mode 100644 index 00000000..74ba1e85 --- /dev/null +++ b/examples/generate-vercel-client/justfile @@ -0,0 +1,9 @@ +update-swagger: + #!/bin/sh + # Swaggerが壊れています. 一時的に手動でパッチしています. + # https://github.com/orgs/vercel/discussions/4813#discussioncomment-7539757 + wget -O swagger.json https://openapi.vercel.sh/ + +generate-code: + #!/bin/sh + npx kubernetes-typescript-client-codegen-openapi ./vercel.config.ts diff --git a/examples/generate-vercel-client/vercel.config.ts b/examples/generate-vercel-client/vercel.config.ts new file mode 100644 index 00000000..f9f48f54 --- /dev/null +++ b/examples/generate-vercel-client/vercel.config.ts @@ -0,0 +1,10 @@ +import type { ConfigFile } from 'kubernetes-typescript-client-codegen-openapi' + +const config: ConfigFile = { + schemaFile: './swagger.json', + apiFile: './api-client/client.ts', + outputFile: './generated/vercel.ts', + strict: false, +} + +export default config