hanzio 1.0.9 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-wrapper/actions-impl.d.ts +4 -0
- package/dist/api-wrapper/builders.d.ts +12 -0
- package/dist/api-wrapper/create-client.d.ts +6 -0
- package/dist/api-wrapper/error-reporting.d.ts +7 -0
- package/dist/api-wrapper/errors.d.ts +63 -0
- package/dist/api-wrapper/headers.d.ts +9 -0
- package/dist/api-wrapper/index.d.ts +5 -84
- package/dist/api-wrapper/redaction.d.ts +2 -0
- package/dist/api-wrapper/shared.d.ts +16 -0
- package/dist/api-wrapper/size.d.ts +1 -0
- package/dist/api-wrapper/test-helpers.d.ts +6 -0
- package/dist/api-wrapper/transport.d.ts +52 -0
- package/dist/api-wrapper/types.d.ts +153 -0
- package/dist/api-wrapper/zod-issues.d.ts +17 -0
- package/dist/secrets/index.d.ts +10 -8
- package/dist/secrets/infisical.d.ts +19 -2
- package/dist/secrets/loaders.d.ts +16 -0
- package/dist/src/api-wrapper/index.js +1018 -166
- package/dist/src/index.js +1155 -232
- package/dist/src/secrets/index.js +1138 -230
- package/dist/src/secrets/infisical.js +1028 -173
- package/dist/src/secrets/loaders.js +1236 -0
- package/package.json +2 -2
- package/src/api-wrapper/actions-impl.ts +100 -0
- package/src/api-wrapper/builders.ts +175 -0
- package/src/api-wrapper/create-client.ts +373 -0
- package/src/api-wrapper/error-reporting.ts +31 -0
- package/src/api-wrapper/errors.ts +221 -0
- package/src/api-wrapper/headers.ts +66 -0
- package/src/api-wrapper/index.ts +5 -492
- package/src/api-wrapper/redaction.ts +61 -0
- package/src/api-wrapper/shared.ts +25 -0
- package/src/api-wrapper/size.ts +19 -0
- package/src/api-wrapper/test-helpers.ts +25 -0
- package/src/api-wrapper/transport.ts +354 -0
- package/src/api-wrapper/types.ts +258 -0
- package/src/api-wrapper/zod-issues.ts +157 -0
- package/src/secrets/index.ts +125 -81
- package/src/secrets/infisical.ts +50 -14
- package/src/secrets/loaders.ts +67 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hanzio",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A lightweight set of utility functions for TypeScript",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/src/index.js",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"build": "bun run build:js && bun run build:types",
|
|
53
|
-
"build:js": "bun build ./src/index.ts ./src/zod/index.ts ./src/api-wrapper/index.ts ./src/jwt/index.ts ./src/sitemap/index.ts ./src/p-queue/index.ts ./src/secrets/index.ts ./src/secrets/infisical.ts ./src/secrets/vite.ts --outdir ./dist --target node --packages external",
|
|
53
|
+
"build:js": "bun build ./src/index.ts ./src/zod/index.ts ./src/api-wrapper/index.ts ./src/jwt/index.ts ./src/sitemap/index.ts ./src/p-queue/index.ts ./src/secrets/index.ts ./src/secrets/infisical.ts ./src/secrets/loaders.ts ./src/secrets/vite.ts --outdir ./dist --target node --packages external",
|
|
54
54
|
"build:types": "tsgo -p tsconfig.build.json",
|
|
55
55
|
"prepublishOnly": "bun run build",
|
|
56
56
|
"publish:patch": "npm version patch && npm publish",
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ActionCache, DefineAction } from './types'
|
|
2
|
+
|
|
3
|
+
type CacheEntry = {
|
|
4
|
+
value?: unknown
|
|
5
|
+
hasValue: boolean
|
|
6
|
+
expiresAt?: number
|
|
7
|
+
inflight?: Promise<unknown>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createActionCache(): ActionCache {
|
|
11
|
+
const store = new Map<string, CacheEntry>()
|
|
12
|
+
|
|
13
|
+
const isExpired = (entry: CacheEntry) =>
|
|
14
|
+
entry.expiresAt !== undefined && entry.expiresAt <= Date.now()
|
|
15
|
+
|
|
16
|
+
const cache = (async <T>(
|
|
17
|
+
key: string,
|
|
18
|
+
fn: () => Promise<T> | T,
|
|
19
|
+
options?: { ttlMs?: number }
|
|
20
|
+
): Promise<T> => {
|
|
21
|
+
const existing = store.get(key)
|
|
22
|
+
if (existing && !isExpired(existing)) {
|
|
23
|
+
if (existing.inflight) return existing.inflight as Promise<T>
|
|
24
|
+
if (existing.hasValue) return existing.value as T
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const promise = (async () => fn())()
|
|
28
|
+
const entry: CacheEntry = {
|
|
29
|
+
hasValue: false,
|
|
30
|
+
inflight: promise,
|
|
31
|
+
expiresAt:
|
|
32
|
+
options?.ttlMs !== undefined ? Date.now() + options.ttlMs : undefined
|
|
33
|
+
}
|
|
34
|
+
store.set(key, entry)
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const value = await promise
|
|
38
|
+
entry.value = value
|
|
39
|
+
entry.hasValue = true
|
|
40
|
+
entry.inflight = undefined
|
|
41
|
+
return value
|
|
42
|
+
} catch (error) {
|
|
43
|
+
store.delete(key)
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
}) as ActionCache
|
|
47
|
+
|
|
48
|
+
cache.get = <T>(key: string): T | undefined => {
|
|
49
|
+
const entry = store.get(key)
|
|
50
|
+
if (!entry || !entry.hasValue) return undefined
|
|
51
|
+
if (isExpired(entry)) {
|
|
52
|
+
store.delete(key)
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
return entry.value as T
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
cache.set = <T>(
|
|
59
|
+
key: string,
|
|
60
|
+
value: T,
|
|
61
|
+
options?: { ttlMs?: number }
|
|
62
|
+
): void => {
|
|
63
|
+
store.set(key, {
|
|
64
|
+
value,
|
|
65
|
+
hasValue: true,
|
|
66
|
+
expiresAt:
|
|
67
|
+
options?.ttlMs !== undefined ? Date.now() + options.ttlMs : undefined
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
cache.invalidate = (key: string): void => {
|
|
72
|
+
store.delete(key)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
cache.clear = (): void => {
|
|
76
|
+
store.clear()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return cache
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function makeDefineAction<TApi>(): DefineAction<TApi> {
|
|
83
|
+
function defineAction(...args: unknown[]): unknown {
|
|
84
|
+
if (args.length === 0) {
|
|
85
|
+
return (def: { handler: unknown }) => ({
|
|
86
|
+
...def,
|
|
87
|
+
noRuntimeInput: false as const
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
const def = args[0] as { handler: unknown }
|
|
91
|
+
return {
|
|
92
|
+
...def,
|
|
93
|
+
noRuntimeInput: true as const
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return defineAction as DefineAction<TApi>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// biome-ignore lint/suspicious/noExplicitAny: top-level form keeps ctx.api as any
|
|
100
|
+
export const defineAction: DefineAction<any> = makeDefineAction<any>()
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { ZodError } from 'zod'
|
|
2
|
+
import { type ConfigError, RequestValidationError } from './errors'
|
|
3
|
+
import { normalizeHeaders } from './headers'
|
|
4
|
+
import type {
|
|
5
|
+
ApiErrorContext,
|
|
6
|
+
BaseApiUrl,
|
|
7
|
+
PathParams,
|
|
8
|
+
QueryParams
|
|
9
|
+
} from './shared'
|
|
10
|
+
import type { ApiEndpoint } from './types'
|
|
11
|
+
|
|
12
|
+
export function buildUrl(
|
|
13
|
+
endpoint: ApiEndpoint,
|
|
14
|
+
params: PathParams | undefined,
|
|
15
|
+
reqQuery: QueryParams | undefined,
|
|
16
|
+
baseApiUrls: Record<string, BaseApiUrl>,
|
|
17
|
+
defaultBaseApiUrl: string | undefined,
|
|
18
|
+
url: string | undefined,
|
|
19
|
+
configError: (msg: string) => ConfigError
|
|
20
|
+
) {
|
|
21
|
+
if (url) {
|
|
22
|
+
const finalUrl = replacePathParams(url, params, configError)
|
|
23
|
+
const queryString = new URLSearchParams(
|
|
24
|
+
buildQueryParamsRaw(endpoint, reqQuery)
|
|
25
|
+
).toString()
|
|
26
|
+
return {
|
|
27
|
+
finalUrl,
|
|
28
|
+
fullUrl: queryString ? `${finalUrl}?${queryString}` : finalUrl
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const defaultBaseUrl = defaultBaseApiUrl ?? Object.keys(baseApiUrls)[0]
|
|
33
|
+
if (!defaultBaseUrl) {
|
|
34
|
+
throw configError('At least one base API URL is required')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const baseUrlKey = endpoint.baseApiUrl ?? defaultBaseUrl
|
|
38
|
+
const configBaseUrl = baseApiUrls[baseUrlKey]
|
|
39
|
+
if (!configBaseUrl) {
|
|
40
|
+
throw configError(`Unknown base API URL: ${baseUrlKey}`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const baseUrl =
|
|
44
|
+
typeof configBaseUrl === 'function' ? configBaseUrl() : configBaseUrl
|
|
45
|
+
const path =
|
|
46
|
+
typeof endpoint.path === 'function' ? endpoint.path(baseUrl) : endpoint.path
|
|
47
|
+
const finalUrl = replacePathParams(`${baseUrl}${path}`, params, configError)
|
|
48
|
+
const queryString = new URLSearchParams(
|
|
49
|
+
buildQueryParamsRaw(endpoint, reqQuery)
|
|
50
|
+
).toString()
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
finalUrl,
|
|
54
|
+
fullUrl: queryString ? `${finalUrl}?${queryString}` : finalUrl
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildQueryParamsRaw(
|
|
59
|
+
endpoint: ApiEndpoint,
|
|
60
|
+
query: QueryParams | undefined
|
|
61
|
+
): Record<string, string> {
|
|
62
|
+
const defaultParams = endpoint.reqDefaultQueryParams ?? {}
|
|
63
|
+
const merged = { ...defaultParams, ...(query ?? {}) }
|
|
64
|
+
return Object.fromEntries(
|
|
65
|
+
Object.entries(merged)
|
|
66
|
+
.filter(([, value]) => value !== undefined)
|
|
67
|
+
.map(([key, value]) => [key, String(value)])
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildQueryParams(
|
|
72
|
+
endpoint: ApiEndpoint,
|
|
73
|
+
query: QueryParams | undefined,
|
|
74
|
+
ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
|
|
75
|
+
): Record<string, string> {
|
|
76
|
+
const defaultParams = endpoint.reqDefaultQueryParams ?? {}
|
|
77
|
+
let parsedQuery: QueryParams
|
|
78
|
+
if (endpoint.reqQuerySchema) {
|
|
79
|
+
try {
|
|
80
|
+
parsedQuery = endpoint.reqQuerySchema.parse(query) as QueryParams
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof ZodError) {
|
|
83
|
+
throw new RequestValidationError(error, 'query', query, ctx())
|
|
84
|
+
}
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
parsedQuery = query ?? {}
|
|
89
|
+
}
|
|
90
|
+
const merged = { ...defaultParams, ...parsedQuery }
|
|
91
|
+
|
|
92
|
+
return Object.fromEntries(
|
|
93
|
+
Object.entries(merged)
|
|
94
|
+
.filter(([, value]) => value !== undefined)
|
|
95
|
+
.map(([key, value]) => [key, String(value)])
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildRequestBody(
|
|
100
|
+
endpoint: ApiEndpoint,
|
|
101
|
+
body: unknown,
|
|
102
|
+
ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
|
|
103
|
+
): unknown {
|
|
104
|
+
if (!endpoint.reqBodySchema) return body
|
|
105
|
+
|
|
106
|
+
let parsedBody: Record<string, unknown>
|
|
107
|
+
try {
|
|
108
|
+
parsedBody = endpoint.reqBodySchema.parse(body) as Record<string, unknown>
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error instanceof ZodError) {
|
|
111
|
+
throw new RequestValidationError(error, 'body', body, ctx())
|
|
112
|
+
}
|
|
113
|
+
throw error
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (endpoint.reqBodyFormat !== 'form-data') {
|
|
117
|
+
return parsedBody
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const formData = new FormData()
|
|
121
|
+
for (const [key, value] of Object.entries(parsedBody)) {
|
|
122
|
+
if (value == null) continue
|
|
123
|
+
formData.set(key, value instanceof Blob ? value : String(value))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return formData
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function buildHeaders(
|
|
130
|
+
endpoint: ApiEndpoint,
|
|
131
|
+
headers: Record<string, string> | undefined,
|
|
132
|
+
defaultHeaders: Record<string, string>,
|
|
133
|
+
ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
|
|
134
|
+
): Record<string, string> {
|
|
135
|
+
const contentType =
|
|
136
|
+
endpoint.reqBodyFormat === 'json' ? 'application/json' : undefined
|
|
137
|
+
|
|
138
|
+
let parsedHeaders: Record<string, string>
|
|
139
|
+
if (endpoint.reqHeadersSchema) {
|
|
140
|
+
try {
|
|
141
|
+
parsedHeaders = endpoint.reqHeadersSchema.parse(headers) as Record<
|
|
142
|
+
string,
|
|
143
|
+
string
|
|
144
|
+
>
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error instanceof ZodError) {
|
|
147
|
+
throw new RequestValidationError(error, 'headers', headers, ctx())
|
|
148
|
+
}
|
|
149
|
+
throw error
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
parsedHeaders = headers ?? {}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return normalizeHeaders({
|
|
156
|
+
...defaultHeaders,
|
|
157
|
+
...(contentType && { 'Content-Type': contentType }),
|
|
158
|
+
...endpoint.defaultHeaders,
|
|
159
|
+
...parsedHeaders
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function replacePathParams(
|
|
164
|
+
path: string,
|
|
165
|
+
params: PathParams | undefined,
|
|
166
|
+
configError: (msg: string) => ConfigError
|
|
167
|
+
): string {
|
|
168
|
+
return path.replace(/:(\w+)/g, (_, key: string) => {
|
|
169
|
+
const value = params?.[key]
|
|
170
|
+
if (value === undefined) {
|
|
171
|
+
throw configError(`Missing required path parameter: ${key}`)
|
|
172
|
+
}
|
|
173
|
+
return encodeURIComponent(String(value))
|
|
174
|
+
})
|
|
175
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import type { z } from 'zod'
|
|
2
|
+
import type {
|
|
3
|
+
ActionInvokeOptions,
|
|
4
|
+
ActionsFactoryHelpers,
|
|
5
|
+
ApiAction,
|
|
6
|
+
ApiClient,
|
|
7
|
+
ApiClientConfig,
|
|
8
|
+
ApiClientConfigNoActions,
|
|
9
|
+
ApiClientConfigWithActions,
|
|
10
|
+
ApiEndpoint,
|
|
11
|
+
ApiWrapperResponse,
|
|
12
|
+
RequestInput
|
|
13
|
+
} from './types'
|
|
14
|
+
import type { PathParams, QueryParams } from './shared'
|
|
15
|
+
import type { ApiErrorContext } from './shared'
|
|
16
|
+
import {
|
|
17
|
+
buildHeaders,
|
|
18
|
+
buildQueryParams,
|
|
19
|
+
buildRequestBody,
|
|
20
|
+
buildUrl
|
|
21
|
+
} from './builders'
|
|
22
|
+
import { createActionCache, makeDefineAction } from './actions-impl'
|
|
23
|
+
import { reportApiError } from './error-reporting'
|
|
24
|
+
import { ActionError, ApiError, ConfigError } from './errors'
|
|
25
|
+
import { calculateSizeInMb } from './size'
|
|
26
|
+
import {
|
|
27
|
+
defaultShouldRetry,
|
|
28
|
+
makeRequestWithRetry,
|
|
29
|
+
type RequestConfig,
|
|
30
|
+
validateAndTransformResponse
|
|
31
|
+
} from './transport'
|
|
32
|
+
|
|
33
|
+
type AnyApiAction = ApiAction<unknown, unknown>
|
|
34
|
+
|
|
35
|
+
export interface CreateApiClientFn {
|
|
36
|
+
<T extends Record<string, ApiEndpoint>>(
|
|
37
|
+
apiConfig: ApiClientConfigNoActions<T>
|
|
38
|
+
): ApiClient<T, Record<string, never>>
|
|
39
|
+
<T extends Record<string, ApiEndpoint>, A extends Record<string, ApiAction>>(
|
|
40
|
+
apiConfig: ApiClientConfigWithActions<T, A>
|
|
41
|
+
): ApiClient<T, A>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isActionInvokeOptionsOnly(
|
|
45
|
+
value: unknown
|
|
46
|
+
): value is ActionInvokeOptions {
|
|
47
|
+
if (value === null || value === undefined) return false
|
|
48
|
+
if (typeof value !== 'object' || Array.isArray(value)) return false
|
|
49
|
+
return Object.keys(value as object).every(
|
|
50
|
+
(k) => k === 'signal' || k === 'meta'
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createApiClientImpl<
|
|
55
|
+
T extends Record<string, ApiEndpoint>,
|
|
56
|
+
A extends Record<string, ApiAction> = Record<string, never>
|
|
57
|
+
>(apiConfig: ApiClientConfig<T, A>): ApiClient<T, A> {
|
|
58
|
+
let actions: Record<string, AnyApiAction> = {}
|
|
59
|
+
|
|
60
|
+
const cache = createActionCache()
|
|
61
|
+
|
|
62
|
+
const invokeEndpoint = async <K extends keyof T>(
|
|
63
|
+
endpointKey: K,
|
|
64
|
+
input: RequestInput<T[K]> = {} as RequestInput<T[K]>
|
|
65
|
+
): Promise<ApiWrapperResponse<z.infer<T[K]['resSchema']>>> => {
|
|
66
|
+
const endpoint = apiConfig.endpoints[endpointKey]
|
|
67
|
+
const rawInput = input as RequestInput<T[K]> & {
|
|
68
|
+
reqBody?: unknown
|
|
69
|
+
reqParams?: PathParams
|
|
70
|
+
reqQuery?: QueryParams
|
|
71
|
+
reqHeaders?: Record<string, string>
|
|
72
|
+
}
|
|
73
|
+
const endpointName = apiConfig.name
|
|
74
|
+
? `${apiConfig.name}.${String(endpointKey)}`
|
|
75
|
+
: String(endpointKey)
|
|
76
|
+
const startedAt = Date.now()
|
|
77
|
+
const maxRetries = input.retries ?? apiConfig.retries ?? 3
|
|
78
|
+
|
|
79
|
+
const baseContext: ApiErrorContext = {
|
|
80
|
+
endpoint: endpointName,
|
|
81
|
+
method: endpoint?.method ?? 'GET',
|
|
82
|
+
url: '',
|
|
83
|
+
attempt: 0,
|
|
84
|
+
maxRetries,
|
|
85
|
+
elapsedMs: 0,
|
|
86
|
+
meta: input.meta
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const elapsed = () => Date.now() - startedAt
|
|
90
|
+
const ctx = (over: Partial<ApiErrorContext> = {}): ApiErrorContext => ({
|
|
91
|
+
...baseContext,
|
|
92
|
+
elapsedMs: elapsed(),
|
|
93
|
+
...over
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (!endpoint) {
|
|
97
|
+
throw await reportApiError(
|
|
98
|
+
new ConfigError(`Unknown API endpoint: ${String(endpointKey)}`, ctx()),
|
|
99
|
+
apiConfig
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
baseContext.method = endpoint.method
|
|
104
|
+
|
|
105
|
+
let finalUrl: string
|
|
106
|
+
let fullUrl: string
|
|
107
|
+
try {
|
|
108
|
+
const built = buildUrl(
|
|
109
|
+
endpoint,
|
|
110
|
+
rawInput.reqParams,
|
|
111
|
+
rawInput.reqQuery,
|
|
112
|
+
apiConfig.baseApiUrls,
|
|
113
|
+
apiConfig.defaultBaseApiUrl,
|
|
114
|
+
input.url,
|
|
115
|
+
(msg) => new ConfigError(msg, ctx())
|
|
116
|
+
)
|
|
117
|
+
finalUrl = built.finalUrl
|
|
118
|
+
fullUrl = built.fullUrl
|
|
119
|
+
} catch (e) {
|
|
120
|
+
if (e instanceof ApiError) throw await reportApiError(e, apiConfig)
|
|
121
|
+
throw e
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
baseContext.url = fullUrl
|
|
125
|
+
|
|
126
|
+
let queryParams: Record<string, string>
|
|
127
|
+
let body: unknown
|
|
128
|
+
let headers: Record<string, string>
|
|
129
|
+
try {
|
|
130
|
+
queryParams = buildQueryParams(endpoint, rawInput.reqQuery, ctx)
|
|
131
|
+
body = buildRequestBody(endpoint, rawInput.reqBody, ctx)
|
|
132
|
+
headers = buildHeaders(
|
|
133
|
+
endpoint,
|
|
134
|
+
rawInput.reqHeaders,
|
|
135
|
+
apiConfig.defaultHeaders ?? {},
|
|
136
|
+
ctx
|
|
137
|
+
)
|
|
138
|
+
} catch (e) {
|
|
139
|
+
if (e instanceof ApiError) throw await reportApiError(e, apiConfig)
|
|
140
|
+
throw e
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
baseContext.requestHeaders = headers
|
|
144
|
+
baseContext.requestBody = body
|
|
145
|
+
|
|
146
|
+
if (apiConfig.onRequest) {
|
|
147
|
+
try {
|
|
148
|
+
const result = await apiConfig.onRequest({
|
|
149
|
+
endpoint: endpointName,
|
|
150
|
+
method: endpoint.method,
|
|
151
|
+
url: fullUrl,
|
|
152
|
+
headers,
|
|
153
|
+
body,
|
|
154
|
+
meta: input.meta
|
|
155
|
+
})
|
|
156
|
+
if (result?.headers) headers = result.headers
|
|
157
|
+
if (result && 'body' in result) body = result.body
|
|
158
|
+
} catch (e) {
|
|
159
|
+
if (e instanceof ApiError) throw await reportApiError(e, apiConfig)
|
|
160
|
+
throw e
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const config: RequestConfig = {
|
|
165
|
+
method: endpoint.method,
|
|
166
|
+
url: finalUrl,
|
|
167
|
+
queryParams,
|
|
168
|
+
body,
|
|
169
|
+
headers,
|
|
170
|
+
doNotEncodeQueryParams: endpoint.doNotEncodeQueryParams,
|
|
171
|
+
timeoutMs: input.timeoutMs ?? apiConfig.timeoutMs,
|
|
172
|
+
fetchFn: input.fetch ?? apiConfig.fetch ?? fetch,
|
|
173
|
+
userSignal: input.signal
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const result = await makeRequestWithRetry({
|
|
178
|
+
config,
|
|
179
|
+
maxRetries,
|
|
180
|
+
retryDelay: apiConfig.retryDelayMs ?? 300,
|
|
181
|
+
validateFn: (response) =>
|
|
182
|
+
validateAndTransformResponse(endpoint, response, ctx),
|
|
183
|
+
shouldRetry: apiConfig.shouldRetry ?? defaultShouldRetry,
|
|
184
|
+
logger: apiConfig.logger,
|
|
185
|
+
ctx,
|
|
186
|
+
endpointName,
|
|
187
|
+
method: endpoint.method,
|
|
188
|
+
fullUrl,
|
|
189
|
+
meta: input.meta,
|
|
190
|
+
onResponse: apiConfig.onResponse,
|
|
191
|
+
onRetry: apiConfig.onRetry
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const responseTimeMs = elapsed()
|
|
195
|
+
const responseSizeMb = calculateSizeInMb(result.validatedData)
|
|
196
|
+
const requestBodySizeMb = calculateSizeInMb(config.body)
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
data: result.validatedData as z.infer<T[K]['resSchema']>,
|
|
200
|
+
requestBodySizeMb,
|
|
201
|
+
responseSizeMb,
|
|
202
|
+
responseTimeMs,
|
|
203
|
+
httpStatus: result.httpStatus,
|
|
204
|
+
retryCount: result.retryCount
|
|
205
|
+
}
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error instanceof ApiError)
|
|
208
|
+
throw await reportApiError(error, apiConfig)
|
|
209
|
+
throw error
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function invokeAction(
|
|
214
|
+
name: string,
|
|
215
|
+
rawInput: unknown,
|
|
216
|
+
options: ActionInvokeOptions = {}
|
|
217
|
+
): Promise<unknown> {
|
|
218
|
+
const action = actions[name]!
|
|
219
|
+
const inputForHandler = action.noRuntimeInput ? undefined : rawInput
|
|
220
|
+
const actionName = apiConfig.name ? `${apiConfig.name}.${name}` : name
|
|
221
|
+
const startedAt = Date.now()
|
|
222
|
+
const baseContext: ApiErrorContext = {
|
|
223
|
+
endpoint: actionName,
|
|
224
|
+
method: 'GET',
|
|
225
|
+
url: '',
|
|
226
|
+
attempt: 0,
|
|
227
|
+
maxRetries: 0,
|
|
228
|
+
elapsedMs: 0,
|
|
229
|
+
meta: options.meta,
|
|
230
|
+
requestBody: action.noRuntimeInput ? undefined : rawInput
|
|
231
|
+
}
|
|
232
|
+
const ctx = (over: Partial<ApiErrorContext> = {}): ApiErrorContext => ({
|
|
233
|
+
...baseContext,
|
|
234
|
+
elapsedMs: Date.now() - startedAt,
|
|
235
|
+
...over
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
apiConfig.logger?.debug?.('Action start', { action: actionName })
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const result = await action.handler({
|
|
242
|
+
input: inputForHandler,
|
|
243
|
+
api: proxy,
|
|
244
|
+
signal: options.signal,
|
|
245
|
+
meta: options.meta,
|
|
246
|
+
logger: apiConfig.logger,
|
|
247
|
+
cache
|
|
248
|
+
})
|
|
249
|
+
apiConfig.logger?.debug?.('Action end', {
|
|
250
|
+
action: actionName,
|
|
251
|
+
elapsedMs: Date.now() - startedAt
|
|
252
|
+
})
|
|
253
|
+
return result
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (error instanceof ApiError) {
|
|
256
|
+
throw await reportApiError(error, apiConfig)
|
|
257
|
+
}
|
|
258
|
+
const wrapped = new ActionError(
|
|
259
|
+
`Action ${actionName} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
260
|
+
ctx(),
|
|
261
|
+
error
|
|
262
|
+
)
|
|
263
|
+
throw await reportApiError(wrapped, apiConfig)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const request = (async (
|
|
268
|
+
key: string,
|
|
269
|
+
input?: unknown,
|
|
270
|
+
options?: ActionInvokeOptions
|
|
271
|
+
): Promise<unknown> => {
|
|
272
|
+
if (key in actions) {
|
|
273
|
+
const action = actions[key]!
|
|
274
|
+
if (action.noRuntimeInput) {
|
|
275
|
+
if (input === undefined || input === null) {
|
|
276
|
+
return invokeAction(key, undefined, options ?? {})
|
|
277
|
+
}
|
|
278
|
+
if (options !== undefined) {
|
|
279
|
+
return invokeAction(key, undefined, options)
|
|
280
|
+
}
|
|
281
|
+
if (isActionInvokeOptionsOnly(input)) {
|
|
282
|
+
return invokeAction(key, undefined, input)
|
|
283
|
+
}
|
|
284
|
+
return invokeAction(key, undefined, {})
|
|
285
|
+
}
|
|
286
|
+
return invokeAction(key, input, options)
|
|
287
|
+
}
|
|
288
|
+
return invokeEndpoint(
|
|
289
|
+
key as keyof T,
|
|
290
|
+
(input ?? {}) as RequestInput<T[keyof T]>
|
|
291
|
+
)
|
|
292
|
+
}) as unknown as ApiClient<T, A>['request']
|
|
293
|
+
|
|
294
|
+
const client = { request, cache } as ApiClient<T, A>
|
|
295
|
+
const proxy: ApiClient<T, A> = new Proxy(client, {
|
|
296
|
+
get(target, prop, receiver) {
|
|
297
|
+
if (prop in target || typeof prop !== 'string') {
|
|
298
|
+
return Reflect.get(target, prop, receiver)
|
|
299
|
+
}
|
|
300
|
+
if (prop in apiConfig.endpoints) {
|
|
301
|
+
return (input?: RequestInput<T[keyof T]>) =>
|
|
302
|
+
invokeEndpoint(
|
|
303
|
+
prop as keyof T,
|
|
304
|
+
input ?? ({} as RequestInput<T[keyof T]>)
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
if (prop in actions) {
|
|
308
|
+
const action = actions[prop]!
|
|
309
|
+
if (action.noRuntimeInput) {
|
|
310
|
+
return (
|
|
311
|
+
first?: unknown,
|
|
312
|
+
second?: ActionInvokeOptions
|
|
313
|
+
): Promise<unknown> => {
|
|
314
|
+
if (first === undefined || first === null) {
|
|
315
|
+
return invokeAction(prop, undefined, second ?? {})
|
|
316
|
+
}
|
|
317
|
+
if (second !== undefined) {
|
|
318
|
+
return invokeAction(prop, undefined, second)
|
|
319
|
+
}
|
|
320
|
+
if (isActionInvokeOptionsOnly(first)) {
|
|
321
|
+
return invokeAction(prop, undefined, first)
|
|
322
|
+
}
|
|
323
|
+
return invokeAction(prop, undefined, {})
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return (input?: unknown, opt?: ActionInvokeOptions) =>
|
|
327
|
+
invokeAction(prop, input, opt)
|
|
328
|
+
}
|
|
329
|
+
return undefined
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
const rawActions = apiConfig.actions
|
|
334
|
+
if (typeof rawActions === 'function') {
|
|
335
|
+
actions = (
|
|
336
|
+
rawActions as (
|
|
337
|
+
helpers: ActionsFactoryHelpers<T, A>
|
|
338
|
+
) => Record<string, AnyApiAction>
|
|
339
|
+
)({
|
|
340
|
+
api: proxy,
|
|
341
|
+
defineAction: makeDefineAction<ApiClient<T, A>>()
|
|
342
|
+
}) as Record<string, AnyApiAction>
|
|
343
|
+
} else if (rawActions) {
|
|
344
|
+
actions = rawActions as Record<string, AnyApiAction>
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const collisions = Object.keys(actions).filter(
|
|
348
|
+
(key) => key in apiConfig.endpoints
|
|
349
|
+
)
|
|
350
|
+
if (collisions.length > 0) {
|
|
351
|
+
throw new ConfigError(
|
|
352
|
+
`Action name(s) collide with endpoint name(s): ${collisions.join(', ')}`,
|
|
353
|
+
{
|
|
354
|
+
endpoint: collisions[0]!,
|
|
355
|
+
method: 'GET',
|
|
356
|
+
url: '',
|
|
357
|
+
attempt: 0,
|
|
358
|
+
maxRetries: 0,
|
|
359
|
+
elapsedMs: 0
|
|
360
|
+
}
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return proxy
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export const createApiClient: CreateApiClientFn = ((apiConfig: unknown) =>
|
|
368
|
+
createApiClientImpl(
|
|
369
|
+
apiConfig as ApiClientConfig<
|
|
370
|
+
Record<string, ApiEndpoint>,
|
|
371
|
+
Record<string, ApiAction>
|
|
372
|
+
>
|
|
373
|
+
)) as CreateApiClientFn
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ApiError } from './errors'
|
|
2
|
+
import type { ApiClientConfig } from './types'
|
|
3
|
+
import type { ApiErrorContext } from './shared'
|
|
4
|
+
import { defaultRedactContext } from './redaction'
|
|
5
|
+
|
|
6
|
+
type ErrorHookConfig = Pick<
|
|
7
|
+
ApiClientConfig<never, never>,
|
|
8
|
+
'logger' | 'onError' | 'redact'
|
|
9
|
+
>
|
|
10
|
+
|
|
11
|
+
export function applyErrorContextRedaction(
|
|
12
|
+
ctx: ApiErrorContext,
|
|
13
|
+
config: Pick<ApiClientConfig<never, never>, 'redact'>
|
|
14
|
+
): ApiErrorContext {
|
|
15
|
+
const base = defaultRedactContext(ctx)
|
|
16
|
+
return config.redact ? config.redact(base) : base
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function reportApiError(
|
|
20
|
+
err: ApiError,
|
|
21
|
+
config: ErrorHookConfig
|
|
22
|
+
): Promise<ApiError> {
|
|
23
|
+
err.context = applyErrorContextRedaction(err.context, config)
|
|
24
|
+
try {
|
|
25
|
+
await config.onError?.(err)
|
|
26
|
+
} catch {
|
|
27
|
+
// ignore hook errors
|
|
28
|
+
}
|
|
29
|
+
config.logger?.error?.(err)
|
|
30
|
+
return err
|
|
31
|
+
}
|