-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
8c3f226
commit 65ac8ec
Showing
3 changed files
with
165 additions
and
128 deletions.
There are no files selected for viewing
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,14 +1,30 @@ | ||
import fetch, { FetchError } from 'node-fetch' | ||
import * as https from 'node:https' | ||
import * as k8s from '@kubernetes/client-node' | ||
|
||
function removeNullableProperties<T extends Record<string, unknown>>( | ||
obj: T | ||
): T { | ||
Object.keys(obj).forEach( | ||
(key) => (obj[key] === undefined || obj[key] === null) && delete obj[key] | ||
) | ||
return obj | ||
import * as k8s from "@kubernetes/client-node"; | ||
import NodeCache from "node-cache"; | ||
import fetch, { FetchError } from "node-fetch"; | ||
import fs from "node:fs"; | ||
import * as https from "node:https"; | ||
|
||
const fileCache = new NodeCache({ stdTTL: 3600, checkperiod: 120 }); | ||
|
||
async function readFile(filePath: string) { | ||
if (!fileCache.has(filePath)) { | ||
fileCache.set(filePath, await fs.promises.readFile(filePath, "utf8")); | ||
} | ||
|
||
return fileCache.get<string>(filePath); | ||
} | ||
|
||
type RemoveUndefined<T> = { | ||
[K in keyof T]: Exclude<T[K], undefined | null>; | ||
}; | ||
|
||
export function removeNullableProperties< | ||
T extends Record<string, unknown | undefined> | undefined, | ||
>(object: T): RemoveUndefined<T> { | ||
if (!object) return object as RemoveUndefined<T>; | ||
for (const key of Object.keys(object)) | ||
(object[key] === undefined || object[key] === null) && delete object[key]; | ||
return object as RemoveUndefined<T>; | ||
} | ||
|
||
/** | ||
|
@@ -25,123 +41,129 @@ function removeNullableProperties<T extends Record<string, unknown>>( | |
* @param maxRetries - Maximum number of retries | ||
*/ | ||
async function defaultBackoff(attempt: number, maxRetries: number) { | ||
const attempts = Math.min(attempt, maxRetries) | ||
const attempts = Math.min(attempt, maxRetries); | ||
|
||
const timeout = ~~((Math.random() + 0.4) * (300 << attempts)) | ||
const timeout = Math.trunc((Math.random() + 0.4) * (300 << attempts)); | ||
await new Promise((resolve) => | ||
setTimeout((res: any) => resolve(res), timeout) | ||
) | ||
setTimeout((response: any) => resolve(response), timeout), | ||
); | ||
} | ||
|
||
const isPlainObject = (value: any) => value?.constructor === Object | ||
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 | ||
} | ||
type QueryArgumentsSpec = { | ||
path: string | undefined; | ||
method?: | ||
| "GET" | ||
| "DELETE" | ||
| "PATCH" | ||
| "POST" | ||
| "PUT" | ||
| "OPTIONS" | ||
| "HEAD" | ||
| undefined; | ||
body?: any | undefined; | ||
contentType?: string | undefined; | ||
params?: any | undefined; | ||
}; | ||
|
||
type MaybePromise<T> = T | Promise<T> | ||
type MaybePromise<T> = T | Promise<T>; | ||
|
||
type InterceptorArgs = { | ||
args: QueryArgsSpec | ||
opts: https.RequestOptions | ||
} | ||
type InterceptorArguments = { | ||
args: QueryArgumentsSpec; | ||
opts: https.RequestOptions; | ||
}; | ||
type Interceptor = ( | ||
args: InterceptorArgs, | ||
options: Options | ||
) => MaybePromise<https.RequestOptions> | ||
arguments_: InterceptorArguments, | ||
options: Options, | ||
) => MaybePromise<https.RequestOptions>; | ||
|
||
const interceptors: Interceptor[] = [ | ||
async function injectKubernetesParameters({ opts }) { | ||
const kc = new k8s.KubeConfig() | ||
kc.loadFromDefault() | ||
const nextOpts: https.RequestOptions = { ...opts } | ||
// @kubernetes/[email protected]用 | ||
// await kc.applytoHTTPSOptions(nextOpts) | ||
// @kubernetes/[email protected]用 | ||
await kc.applyToHTTPSOptions(nextOpts) | ||
const kc = new k8s.KubeConfig(); | ||
kc.loadFromDefault(); | ||
const nextOptions: https.RequestOptions = { ...opts }; | ||
await kc.applyToHTTPSOptions(nextOptions) | ||
|
||
const cluster = kc.getCurrentCluster() | ||
|
||
if (cluster?.server) { | ||
const url = new URL(cluster.server) | ||
nextOpts.host = url.hostname | ||
nextOpts.protocol = url.protocol | ||
nextOpts.port = url.port | ||
const url = new URL(cluster.server); | ||
nextOptions.host = url.hostname; | ||
nextOptions.protocol = url.protocol; | ||
nextOptions.port = url.port; | ||
} | ||
return nextOpts | ||
return nextOptions; | ||
}, | ||
] | ||
]; | ||
|
||
type RetryConditionFunction = (extraArgs: { | ||
res?: Response | ||
error: unknown | ||
args: QueryArgsSpec | ||
attempt: number | ||
options: RetryOptions | ||
}) => boolean | Promise<boolean> | ||
type RetryConditionFunction = (extraArguments: { | ||
res?: Response; | ||
error: unknown; | ||
args: QueryArgumentsSpec; | ||
attempt: number; | ||
options: RetryOptions; | ||
}) => boolean | Promise<boolean>; | ||
|
||
type RetryOptions = { | ||
retryCondition?: RetryConditionFunction | undefined | ||
maxRetries?: number | undefined | ||
} | ||
retryCondition?: RetryConditionFunction; | ||
maxRetries?: number; | ||
}; | ||
|
||
type HttpHeaderOptions = { | ||
headers?: Record<string, string> | undefined | ||
} | ||
headers?: Record<string, string> | undefined; | ||
}; | ||
|
||
export type Options = RetryOptions & HttpHeaderOptions | ||
export type Options = RetryOptions & HttpHeaderOptions; | ||
|
||
export async function apiClient<Response>( | ||
args: QueryArgsSpec, | ||
extraOptions?: Options | ||
arguments_: QueryArgumentsSpec, | ||
extraOptions?: Options, | ||
): Promise<Response> { | ||
const maxRetries = extraOptions?.maxRetries ?? 3 | ||
const maxRetries = extraOptions?.maxRetries ?? 3; | ||
|
||
const defaultRetryCondition: RetryConditionFunction = ({ ...obj }) => { | ||
const { res, attempt, error } = obj | ||
const defaultRetryCondition: RetryConditionFunction = ({ ...object }) => { | ||
const { res, attempt, error } = object; | ||
if (attempt > maxRetries) { | ||
return false | ||
return false; | ||
} | ||
|
||
if (error instanceof FetchError) { | ||
return true | ||
return true; | ||
} | ||
if (res && res.status >= 500) { | ||
return true | ||
return true; | ||
} | ||
return false | ||
} | ||
return false; | ||
}; | ||
|
||
const options = { | ||
maxRetries, | ||
backoff: defaultBackoff, | ||
retryCondition: defaultRetryCondition, | ||
...extraOptions, | ||
} | ||
...removeNullableProperties(extraOptions), | ||
}; | ||
|
||
let { path, method, params, body, contentType } = { ...args } | ||
let { path, method, params, body, contentType } = { ...arguments_ }; | ||
|
||
let httpsOptions: https.RequestOptions = { | ||
path, | ||
headers: { | ||
...options.headers, | ||
}, | ||
} | ||
}; | ||
if (method) { | ||
httpsOptions.method = method | ||
httpsOptions.method = method; | ||
} | ||
|
||
for (const interceptor of interceptors) { | ||
httpsOptions = await interceptor( | ||
{ | ||
args, | ||
args: arguments_, | ||
opts: httpsOptions, | ||
}, | ||
options | ||
) | ||
options, | ||
); | ||
} | ||
|
||
if ( | ||
|
@@ -154,88 +176,91 @@ export async function apiClient<Response>( | |
cert: httpsOptions.cert, | ||
key: httpsOptions.key, | ||
port: httpsOptions.port ? Number(httpsOptions.port) : undefined, | ||
}) | ||
) | ||
httpsOptions.agent = agent | ||
}), | ||
); | ||
httpsOptions.agent = agent; | ||
} | ||
|
||
if (!httpsOptions.protocol) { | ||
httpsOptions.protocol = 'http:' | ||
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 host = httpsOptions.host || httpsOptions.hostname; | ||
let baseUrl = `${httpsOptions.protocol}//${host}`; | ||
const searchParameters = toSearchParameters(params); | ||
if (searchParameters.size > 0) { | ||
baseUrl += (baseUrl.includes("?") ? "&" : "?") + toSearchParameters(params); | ||
} | ||
const url = new URL(baseUrl) | ||
const url = new URL(baseUrl); | ||
if (httpsOptions.port) { | ||
url.port = httpsOptions.port.toString() | ||
url.port = httpsOptions.port.toString(); | ||
} | ||
if (httpsOptions.path) { | ||
url.pathname = httpsOptions.path | ||
url.pathname = httpsOptions.path; | ||
} | ||
let isJson = false | ||
let isJson = false; | ||
if (isPlainObject(body) || Array.isArray(body)) { | ||
isJson = true | ||
body = JSON.stringify(body) | ||
isJson = true; | ||
body = JSON.stringify(body); | ||
} | ||
const headers: Record<string, string> = { | ||
...(httpsOptions.headers as any), | ||
} | ||
}; | ||
if (contentType) { | ||
headers['Content-Type'] = contentType | ||
} else if (!httpsOptions.headers?.['Content-Type'] && isJson) { | ||
headers['Content-Type'] = 'application/json' | ||
headers["Content-Type"] = contentType; | ||
} else if (!httpsOptions.headers?.["Content-Type"] && isJson) { | ||
headers["Content-Type"] = "application/json"; | ||
} | ||
|
||
let retry = 0 | ||
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 | ||
const response = await fetch( | ||
url, | ||
removeNullableProperties({ | ||
headers, | ||
protocol: httpsOptions.protocol || undefined, | ||
method, | ||
agent: httpsOptions.agent, | ||
body, | ||
}), | ||
); | ||
|
||
const isSuccess = response.status >= 200 && response.status < 300; | ||
const contentType = response.headers.get("content-type"); | ||
const isJsonResponse = contentType?.includes("application/json") ?? false; | ||
|
||
if (isSuccess && isJsonResponse) { | ||
return (await res.json()) as Response | ||
return (await response.json()) as Response; | ||
} | ||
|
||
// helpful message for debugging | ||
const text = await res.text() | ||
if (res.status === 404 && text.includes('404 page not found')) { | ||
const text = await response.text(); | ||
if (response.status === 404 && text.includes("404 page not found")) { | ||
console.info( | ||
`Did you forget to install your Custom Resources Definitions? path: ${httpsOptions.path}` | ||
) | ||
`Did you forget to install your Custom Resources Definitions? path: ${httpsOptions.path}`, | ||
); | ||
} | ||
throw new Error(text) | ||
} catch (e: any) { | ||
retry++ | ||
throw new Error(text); | ||
} catch (error: any) { | ||
retry++; | ||
|
||
if ( | ||
!(await options.retryCondition({ | ||
res: e?.value?.res, | ||
error: e, | ||
args, | ||
res: error?.value?.res, | ||
error: error, | ||
args: arguments_, | ||
attempt: retry, | ||
options: options, | ||
})) | ||
) { | ||
throw e | ||
throw error; | ||
} | ||
|
||
await options.backoff(retry, options.maxRetries) | ||
await options.backoff(retry, options.maxRetries); | ||
} | ||
} | ||
} | ||
|
||
const toSearchParams = (params: Record<string, string>) => { | ||
return new URLSearchParams(removeNullableProperties(params)) | ||
} | ||
const toSearchParameters = (parameters: Record<string, string>) => { | ||
return new URLSearchParams(removeNullableProperties(parameters)); | ||
}; |
This file contains 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
Oops, something went wrong.