hanzio 1.0.10 → 1.2.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/README.md +70 -4
- package/dist/api-wrapper/actions-impl.d.ts +4 -0
- package/dist/api-wrapper/builders.d.ts +23 -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 +64 -0
- package/dist/api-wrapper/headers.d.ts +10 -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 +55 -0
- package/dist/api-wrapper/types.d.ts +154 -0
- package/dist/api-wrapper/zod-issues.d.ts +17 -0
- package/dist/cache/index.d.ts +26 -0
- package/dist/secrets/core.d.ts +47 -0
- package/dist/secrets/deadline.d.ts +6 -0
- package/dist/secrets/environment.d.ts +9 -0
- package/dist/secrets/errors.d.ts +11 -0
- package/dist/secrets/index.d.ts +14 -23
- package/dist/secrets/infisical-cli.d.ts +6 -0
- package/dist/secrets/infisical-config.d.ts +17 -0
- package/dist/secrets/infisical-session.d.ts +4 -0
- package/dist/secrets/infisical.d.ts +14 -2
- package/dist/secrets/loaders.d.ts +9 -0
- package/dist/secrets/vite.d.ts +7 -3
- package/dist/secrets/worker-loader.d.ts +9 -0
- package/dist/secrets/worker.d.ts +3 -0
- package/dist/src/api-wrapper/index.js +1059 -177
- package/dist/src/index.js +2807 -1397
- package/dist/src/jwt/index.js +2 -2
- package/dist/src/p-queue/index.js +3 -3
- package/dist/src/secrets/index.js +1610 -407
- package/dist/src/secrets/infisical.js +1352 -214
- package/dist/src/secrets/loaders.js +1529 -0
- package/dist/src/secrets/vite.js +33 -11
- package/dist/src/secrets/worker.js +275 -0
- package/dist/src/sitemap/index.js +1 -1
- package/dist/src/zod/index.js +7 -7
- package/dist/typedswitch/index.d.ts +9 -5
- package/package.json +12 -3
- package/src/api-wrapper/actions-impl.ts +100 -0
- package/src/api-wrapper/builders.ts +200 -0
- package/src/api-wrapper/create-client.ts +393 -0
- package/src/api-wrapper/error-reporting.ts +31 -0
- package/src/api-wrapper/errors.ts +231 -0
- package/src/api-wrapper/headers.ts +79 -0
- package/src/api-wrapper/index.ts +5 -492
- package/src/api-wrapper/redaction.ts +62 -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 +350 -0
- package/src/api-wrapper/types.ts +259 -0
- package/src/api-wrapper/zod-issues.ts +157 -0
- package/src/cache/index.ts +228 -61
- package/src/secrets/core.ts +348 -0
- package/src/secrets/deadline.ts +69 -0
- package/src/secrets/environment.ts +69 -0
- package/src/secrets/errors.ts +29 -0
- package/src/secrets/index.ts +81 -140
- package/src/secrets/infisical-cli.ts +99 -0
- package/src/secrets/infisical-config.ts +70 -0
- package/src/secrets/infisical-session.ts +42 -0
- package/src/secrets/infisical.ts +219 -35
- package/src/secrets/loaders.ts +43 -0
- package/src/secrets/vite.ts +44 -15
- package/src/secrets/worker-loader.ts +56 -0
- package/src/secrets/worker.ts +4 -0
- package/src/typedswitch/index.ts +80 -69
|
@@ -0,0 +1,200 @@
|
|
|
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 type BuildUrlOptions = {
|
|
13
|
+
endpoint: ApiEndpoint
|
|
14
|
+
params: PathParams | undefined
|
|
15
|
+
queryParams: Record<string, string>
|
|
16
|
+
baseApiUrls: Record<string, BaseApiUrl>
|
|
17
|
+
defaultBaseApiUrl: string | undefined
|
|
18
|
+
url: string | undefined
|
|
19
|
+
doNotEncodeQueryParams?: boolean
|
|
20
|
+
configError: (msg: string) => ConfigError
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatQueryString(
|
|
24
|
+
queryParams: Record<string, string>,
|
|
25
|
+
doNotEncode?: boolean
|
|
26
|
+
): string {
|
|
27
|
+
if (Object.keys(queryParams).length === 0) return ''
|
|
28
|
+
return doNotEncode
|
|
29
|
+
? Object.entries(queryParams)
|
|
30
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
31
|
+
.join('&')
|
|
32
|
+
: new URLSearchParams(queryParams).toString()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildUrl(options: BuildUrlOptions) {
|
|
36
|
+
const {
|
|
37
|
+
endpoint,
|
|
38
|
+
params,
|
|
39
|
+
queryParams,
|
|
40
|
+
baseApiUrls,
|
|
41
|
+
defaultBaseApiUrl,
|
|
42
|
+
url,
|
|
43
|
+
doNotEncodeQueryParams,
|
|
44
|
+
configError
|
|
45
|
+
} = options
|
|
46
|
+
|
|
47
|
+
let finalUrl: string
|
|
48
|
+
if (url) {
|
|
49
|
+
finalUrl = replacePathParams(url, params, configError)
|
|
50
|
+
} else {
|
|
51
|
+
const defaultBaseUrl = defaultBaseApiUrl ?? Object.keys(baseApiUrls)[0]
|
|
52
|
+
if (!defaultBaseUrl) {
|
|
53
|
+
throw configError('At least one base API URL is required')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const baseUrlKey = endpoint.baseApiUrl ?? defaultBaseUrl
|
|
57
|
+
const configBaseUrl = baseApiUrls[baseUrlKey]
|
|
58
|
+
if (!configBaseUrl) {
|
|
59
|
+
throw configError(`Unknown base API URL: ${baseUrlKey}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const baseUrl =
|
|
63
|
+
typeof configBaseUrl === 'function' ? configBaseUrl() : configBaseUrl
|
|
64
|
+
const path =
|
|
65
|
+
typeof endpoint.path === 'function'
|
|
66
|
+
? endpoint.path(baseUrl)
|
|
67
|
+
: endpoint.path
|
|
68
|
+
// Substitute endpoint parameters without interpreting a port or IPv6 host as one.
|
|
69
|
+
finalUrl = `${baseUrl}${replacePathParams(path, params, configError)}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const queryString = formatQueryString(queryParams, doNotEncodeQueryParams)
|
|
73
|
+
return {
|
|
74
|
+
finalUrl,
|
|
75
|
+
fullUrl: queryString ? `${finalUrl}?${queryString}` : finalUrl
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildPathParams(
|
|
80
|
+
endpoint: ApiEndpoint,
|
|
81
|
+
params: PathParams | undefined,
|
|
82
|
+
ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
|
|
83
|
+
): PathParams | undefined {
|
|
84
|
+
if (!endpoint.reqParamsSchema) return params
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
return endpoint.reqParamsSchema.parse(params) as PathParams
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error instanceof ZodError) {
|
|
90
|
+
throw new RequestValidationError(error, 'params', params, ctx())
|
|
91
|
+
}
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function buildQueryParams(
|
|
97
|
+
endpoint: ApiEndpoint,
|
|
98
|
+
query: QueryParams | undefined,
|
|
99
|
+
ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
|
|
100
|
+
): Record<string, string> {
|
|
101
|
+
const defaultParams = endpoint.reqDefaultQueryParams ?? {}
|
|
102
|
+
let parsedQuery: QueryParams
|
|
103
|
+
if (endpoint.reqQuerySchema) {
|
|
104
|
+
try {
|
|
105
|
+
parsedQuery = endpoint.reqQuerySchema.parse(query) as QueryParams
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof ZodError) {
|
|
108
|
+
throw new RequestValidationError(error, 'query', query, ctx())
|
|
109
|
+
}
|
|
110
|
+
throw error
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
parsedQuery = query ?? {}
|
|
114
|
+
}
|
|
115
|
+
const merged = { ...defaultParams, ...parsedQuery }
|
|
116
|
+
|
|
117
|
+
return Object.fromEntries(
|
|
118
|
+
Object.entries(merged)
|
|
119
|
+
.filter(([, value]) => value !== undefined)
|
|
120
|
+
.map(([key, value]) => [key, String(value)])
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function buildRequestBody(
|
|
125
|
+
endpoint: ApiEndpoint,
|
|
126
|
+
body: unknown,
|
|
127
|
+
ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
|
|
128
|
+
): unknown {
|
|
129
|
+
if (!endpoint.reqBodySchema) return body
|
|
130
|
+
|
|
131
|
+
let parsedBody: Record<string, unknown>
|
|
132
|
+
try {
|
|
133
|
+
parsedBody = endpoint.reqBodySchema.parse(body) as Record<string, unknown>
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error instanceof ZodError) {
|
|
136
|
+
throw new RequestValidationError(error, 'body', body, ctx())
|
|
137
|
+
}
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (endpoint.reqBodyFormat !== 'form-data') {
|
|
142
|
+
return parsedBody
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const formData = new FormData()
|
|
146
|
+
for (const [key, value] of Object.entries(parsedBody)) {
|
|
147
|
+
if (value == null) continue
|
|
148
|
+
formData.set(key, value instanceof Blob ? value : String(value))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return formData
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function buildHeaders(
|
|
155
|
+
endpoint: ApiEndpoint,
|
|
156
|
+
headers: Record<string, string> | undefined,
|
|
157
|
+
defaultHeaders: Record<string, string>,
|
|
158
|
+
ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
|
|
159
|
+
): Record<string, string> {
|
|
160
|
+
const contentType =
|
|
161
|
+
endpoint.reqBodyFormat === 'json' ? 'application/json' : undefined
|
|
162
|
+
|
|
163
|
+
let parsedHeaders: Record<string, string>
|
|
164
|
+
if (endpoint.reqHeadersSchema) {
|
|
165
|
+
try {
|
|
166
|
+
parsedHeaders = endpoint.reqHeadersSchema.parse(headers) as Record<
|
|
167
|
+
string,
|
|
168
|
+
string
|
|
169
|
+
>
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error instanceof ZodError) {
|
|
172
|
+
throw new RequestValidationError(error, 'headers', headers, ctx())
|
|
173
|
+
}
|
|
174
|
+
throw error
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
parsedHeaders = headers ?? {}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return normalizeHeaders({
|
|
181
|
+
...defaultHeaders,
|
|
182
|
+
...(contentType && { 'Content-Type': contentType }),
|
|
183
|
+
...endpoint.defaultHeaders,
|
|
184
|
+
...parsedHeaders
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function replacePathParams(
|
|
189
|
+
path: string,
|
|
190
|
+
params: PathParams | undefined,
|
|
191
|
+
configError: (msg: string) => ConfigError
|
|
192
|
+
): string {
|
|
193
|
+
return path.replace(/:(\w+)/g, (_, key: string) => {
|
|
194
|
+
const value = params?.[key]
|
|
195
|
+
if (value === undefined) {
|
|
196
|
+
throw configError(`Missing required path parameter: ${key}`)
|
|
197
|
+
}
|
|
198
|
+
return encodeURIComponent(String(value))
|
|
199
|
+
})
|
|
200
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
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
|
+
buildPathParams,
|
|
19
|
+
buildQueryParams,
|
|
20
|
+
buildRequestBody,
|
|
21
|
+
buildUrl
|
|
22
|
+
} from './builders'
|
|
23
|
+
import { createActionCache, makeDefineAction } from './actions-impl'
|
|
24
|
+
import { reportApiError } from './error-reporting'
|
|
25
|
+
import { ActionError, ApiError, ConfigError } from './errors'
|
|
26
|
+
import { calculateSizeInMb } from './size'
|
|
27
|
+
import {
|
|
28
|
+
defaultShouldRetry,
|
|
29
|
+
makeRequestWithRetry,
|
|
30
|
+
type RequestConfig,
|
|
31
|
+
validateAndTransformResponse
|
|
32
|
+
} from './transport'
|
|
33
|
+
|
|
34
|
+
type AnyApiAction = ApiAction<unknown, unknown>
|
|
35
|
+
|
|
36
|
+
export interface CreateApiClientFn {
|
|
37
|
+
<T extends Record<string, ApiEndpoint>>(
|
|
38
|
+
apiConfig: ApiClientConfigNoActions<T>
|
|
39
|
+
): ApiClient<T, Record<string, never>>
|
|
40
|
+
<T extends Record<string, ApiEndpoint>, A extends Record<string, ApiAction>>(
|
|
41
|
+
apiConfig: ApiClientConfigWithActions<T, A>
|
|
42
|
+
): ApiClient<T, A>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isActionInvokeOptionsOnly(
|
|
46
|
+
value: unknown
|
|
47
|
+
): value is ActionInvokeOptions {
|
|
48
|
+
if (value === null || value === undefined) return false
|
|
49
|
+
if (typeof value !== 'object' || Array.isArray(value)) return false
|
|
50
|
+
return Object.keys(value as object).every(
|
|
51
|
+
(k) => k === 'signal' || k === 'meta'
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveActionInvocation(
|
|
56
|
+
action: AnyApiAction,
|
|
57
|
+
actionName: string,
|
|
58
|
+
first: unknown,
|
|
59
|
+
second?: ActionInvokeOptions
|
|
60
|
+
): { input: unknown; options: ActionInvokeOptions } {
|
|
61
|
+
if (!action.noRuntimeInput) {
|
|
62
|
+
return { input: first, options: second ?? {} }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (first === undefined || first === null) {
|
|
66
|
+
return { input: undefined, options: second ?? {} }
|
|
67
|
+
}
|
|
68
|
+
if (second !== undefined) {
|
|
69
|
+
return { input: undefined, options: second }
|
|
70
|
+
}
|
|
71
|
+
if (isActionInvokeOptionsOnly(first)) {
|
|
72
|
+
return { input: undefined, options: first }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw new ConfigError(
|
|
76
|
+
`Action "${actionName}" does not accept input; pass only optional { signal, meta }`,
|
|
77
|
+
{
|
|
78
|
+
endpoint: actionName,
|
|
79
|
+
method: 'GET',
|
|
80
|
+
url: '',
|
|
81
|
+
attempt: 0,
|
|
82
|
+
maxRetries: 0,
|
|
83
|
+
elapsedMs: 0
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createApiClientImpl<
|
|
89
|
+
T extends Record<string, ApiEndpoint>,
|
|
90
|
+
A extends Record<string, ApiAction> = Record<string, never>
|
|
91
|
+
>(apiConfig: ApiClientConfig<T, A>): ApiClient<T, A> {
|
|
92
|
+
let actions: Record<string, AnyApiAction> = {}
|
|
93
|
+
|
|
94
|
+
const cache = createActionCache()
|
|
95
|
+
|
|
96
|
+
const invokeEndpoint = async <K extends keyof T>(
|
|
97
|
+
endpointKey: K,
|
|
98
|
+
input: RequestInput<T[K]> = {} as RequestInput<T[K]>
|
|
99
|
+
): Promise<ApiWrapperResponse<z.infer<T[K]['resSchema']>>> => {
|
|
100
|
+
const endpoint = apiConfig.endpoints[endpointKey]
|
|
101
|
+
const rawInput = input as RequestInput<T[K]> & {
|
|
102
|
+
reqBody?: unknown
|
|
103
|
+
reqParams?: PathParams
|
|
104
|
+
reqQuery?: QueryParams
|
|
105
|
+
reqHeaders?: Record<string, string>
|
|
106
|
+
}
|
|
107
|
+
const endpointName = apiConfig.name
|
|
108
|
+
? `${apiConfig.name}.${String(endpointKey)}`
|
|
109
|
+
: String(endpointKey)
|
|
110
|
+
const startedAt = Date.now()
|
|
111
|
+
const maxRetries = input.retries ?? apiConfig.retries ?? 3
|
|
112
|
+
|
|
113
|
+
const baseContext: ApiErrorContext = {
|
|
114
|
+
endpoint: endpointName,
|
|
115
|
+
method: endpoint?.method ?? 'GET',
|
|
116
|
+
url: '',
|
|
117
|
+
attempt: 0,
|
|
118
|
+
maxRetries,
|
|
119
|
+
elapsedMs: 0,
|
|
120
|
+
meta: input.meta
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const elapsed = () => Date.now() - startedAt
|
|
124
|
+
const ctx = (over: Partial<ApiErrorContext> = {}): ApiErrorContext => ({
|
|
125
|
+
...baseContext,
|
|
126
|
+
elapsedMs: elapsed(),
|
|
127
|
+
...over
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
if (!endpoint) {
|
|
131
|
+
throw await reportApiError(
|
|
132
|
+
new ConfigError(`Unknown API endpoint: ${String(endpointKey)}`, ctx()),
|
|
133
|
+
apiConfig
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
baseContext.method = endpoint.method
|
|
138
|
+
|
|
139
|
+
let pathParams: PathParams | undefined
|
|
140
|
+
let queryParams: Record<string, string>
|
|
141
|
+
let body: unknown
|
|
142
|
+
let headers: Record<string, string>
|
|
143
|
+
try {
|
|
144
|
+
pathParams = buildPathParams(endpoint, rawInput.reqParams, ctx)
|
|
145
|
+
queryParams = buildQueryParams(endpoint, rawInput.reqQuery, ctx)
|
|
146
|
+
body = buildRequestBody(endpoint, rawInput.reqBody, ctx)
|
|
147
|
+
headers = buildHeaders(
|
|
148
|
+
endpoint,
|
|
149
|
+
rawInput.reqHeaders,
|
|
150
|
+
apiConfig.defaultHeaders ?? {},
|
|
151
|
+
ctx
|
|
152
|
+
)
|
|
153
|
+
} catch (e) {
|
|
154
|
+
if (e instanceof ApiError) throw await reportApiError(e, apiConfig)
|
|
155
|
+
throw e
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let fullUrl: string
|
|
159
|
+
try {
|
|
160
|
+
const built = buildUrl({
|
|
161
|
+
endpoint,
|
|
162
|
+
params: pathParams,
|
|
163
|
+
queryParams,
|
|
164
|
+
baseApiUrls: apiConfig.baseApiUrls,
|
|
165
|
+
defaultBaseApiUrl: apiConfig.defaultBaseApiUrl,
|
|
166
|
+
url: input.url,
|
|
167
|
+
doNotEncodeQueryParams: endpoint.doNotEncodeQueryParams,
|
|
168
|
+
configError: (msg) => new ConfigError(msg, ctx())
|
|
169
|
+
})
|
|
170
|
+
fullUrl = built.fullUrl
|
|
171
|
+
} catch (e) {
|
|
172
|
+
if (e instanceof ApiError) throw await reportApiError(e, apiConfig)
|
|
173
|
+
throw e
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
baseContext.url = fullUrl
|
|
177
|
+
baseContext.requestHeaders = headers
|
|
178
|
+
baseContext.requestBody = body
|
|
179
|
+
|
|
180
|
+
if (apiConfig.onRequest) {
|
|
181
|
+
try {
|
|
182
|
+
const result = await apiConfig.onRequest({
|
|
183
|
+
endpoint: endpointName,
|
|
184
|
+
method: endpoint.method,
|
|
185
|
+
url: fullUrl,
|
|
186
|
+
headers,
|
|
187
|
+
body,
|
|
188
|
+
meta: input.meta
|
|
189
|
+
})
|
|
190
|
+
if (result?.headers) headers = result.headers
|
|
191
|
+
if (result && 'body' in result) body = result.body
|
|
192
|
+
} catch (e) {
|
|
193
|
+
if (e instanceof ApiError) throw await reportApiError(e, apiConfig)
|
|
194
|
+
throw e
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const config: RequestConfig = {
|
|
199
|
+
method: endpoint.method,
|
|
200
|
+
fullUrl,
|
|
201
|
+
body,
|
|
202
|
+
headers,
|
|
203
|
+
timeoutMs: input.timeoutMs ?? apiConfig.timeoutMs,
|
|
204
|
+
fetchFn: input.fetch ?? apiConfig.fetch ?? fetch,
|
|
205
|
+
userSignal: input.signal
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const result = await makeRequestWithRetry({
|
|
210
|
+
config,
|
|
211
|
+
maxRetries,
|
|
212
|
+
retryDelay: apiConfig.retryDelayMs ?? 300,
|
|
213
|
+
maxRetryDelayMs: apiConfig.maxRetryDelayMs,
|
|
214
|
+
validateFn: (response, attempt) =>
|
|
215
|
+
validateAndTransformResponse(endpoint, response, (over) =>
|
|
216
|
+
ctx({ attempt, ...over })
|
|
217
|
+
),
|
|
218
|
+
shouldRetry: apiConfig.shouldRetry ?? defaultShouldRetry,
|
|
219
|
+
logger: apiConfig.logger,
|
|
220
|
+
ctx,
|
|
221
|
+
endpointName,
|
|
222
|
+
method: endpoint.method,
|
|
223
|
+
fullUrl,
|
|
224
|
+
meta: input.meta,
|
|
225
|
+
onResponse: apiConfig.onResponse,
|
|
226
|
+
onRetry: apiConfig.onRetry
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const responseTimeMs = elapsed()
|
|
230
|
+
const responseSizeMb =
|
|
231
|
+
result.validatedData.responseSizeBytes / (1024 * 1024)
|
|
232
|
+
const requestBodySizeMb = calculateSizeInMb(config.body)
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
data: result.validatedData.data as z.infer<T[K]['resSchema']>,
|
|
236
|
+
requestBodySizeMb,
|
|
237
|
+
responseSizeMb,
|
|
238
|
+
responseTimeMs,
|
|
239
|
+
httpStatus: result.httpStatus,
|
|
240
|
+
retryCount: result.retryCount
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (error instanceof ApiError)
|
|
244
|
+
throw await reportApiError(error, apiConfig)
|
|
245
|
+
throw error
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function invokeAction(
|
|
250
|
+
name: string,
|
|
251
|
+
rawInput: unknown,
|
|
252
|
+
options: ActionInvokeOptions = {}
|
|
253
|
+
): Promise<unknown> {
|
|
254
|
+
const action = actions[name]!
|
|
255
|
+
const inputForHandler = action.noRuntimeInput ? undefined : rawInput
|
|
256
|
+
const actionName = apiConfig.name ? `${apiConfig.name}.${name}` : name
|
|
257
|
+
const startedAt = Date.now()
|
|
258
|
+
const baseContext: ApiErrorContext = {
|
|
259
|
+
endpoint: actionName,
|
|
260
|
+
method: 'GET',
|
|
261
|
+
url: '',
|
|
262
|
+
attempt: 0,
|
|
263
|
+
maxRetries: 0,
|
|
264
|
+
elapsedMs: 0,
|
|
265
|
+
meta: options.meta,
|
|
266
|
+
requestBody: action.noRuntimeInput ? undefined : rawInput
|
|
267
|
+
}
|
|
268
|
+
const ctx = (over: Partial<ApiErrorContext> = {}): ApiErrorContext => ({
|
|
269
|
+
...baseContext,
|
|
270
|
+
elapsedMs: Date.now() - startedAt,
|
|
271
|
+
...over
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
apiConfig.logger?.debug?.('Action start', { action: actionName })
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const result = await action.handler({
|
|
278
|
+
input: inputForHandler,
|
|
279
|
+
api: proxy,
|
|
280
|
+
signal: options.signal,
|
|
281
|
+
meta: options.meta,
|
|
282
|
+
logger: apiConfig.logger,
|
|
283
|
+
cache
|
|
284
|
+
})
|
|
285
|
+
apiConfig.logger?.debug?.('Action end', {
|
|
286
|
+
action: actionName,
|
|
287
|
+
elapsedMs: Date.now() - startedAt
|
|
288
|
+
})
|
|
289
|
+
return result
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (error instanceof ApiError) {
|
|
292
|
+
throw await reportApiError(error, apiConfig)
|
|
293
|
+
}
|
|
294
|
+
const wrapped = new ActionError(
|
|
295
|
+
`Action ${actionName} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
296
|
+
ctx(),
|
|
297
|
+
error
|
|
298
|
+
)
|
|
299
|
+
throw await reportApiError(wrapped, apiConfig)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function dispatchAction(
|
|
304
|
+
name: string,
|
|
305
|
+
first?: unknown,
|
|
306
|
+
second?: ActionInvokeOptions
|
|
307
|
+
): Promise<unknown> {
|
|
308
|
+
const action = actions[name]!
|
|
309
|
+
const { input, options } = resolveActionInvocation(
|
|
310
|
+
action,
|
|
311
|
+
name,
|
|
312
|
+
first,
|
|
313
|
+
second
|
|
314
|
+
)
|
|
315
|
+
return invokeAction(name, input, options)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const request = (async (
|
|
319
|
+
key: string,
|
|
320
|
+
input?: unknown,
|
|
321
|
+
options?: ActionInvokeOptions
|
|
322
|
+
): Promise<unknown> => {
|
|
323
|
+
if (key in actions) {
|
|
324
|
+
return dispatchAction(key, input, options)
|
|
325
|
+
}
|
|
326
|
+
return invokeEndpoint(
|
|
327
|
+
key as keyof T,
|
|
328
|
+
(input ?? {}) as RequestInput<T[keyof T]>
|
|
329
|
+
)
|
|
330
|
+
}) as unknown as ApiClient<T, A>['request']
|
|
331
|
+
|
|
332
|
+
const client = { request, cache } as ApiClient<T, A>
|
|
333
|
+
const proxy: ApiClient<T, A> = new Proxy(client, {
|
|
334
|
+
get(target, prop, receiver) {
|
|
335
|
+
if (prop in target || typeof prop !== 'string') {
|
|
336
|
+
return Reflect.get(target, prop, receiver)
|
|
337
|
+
}
|
|
338
|
+
if (prop in apiConfig.endpoints) {
|
|
339
|
+
return (input?: RequestInput<T[keyof T]>) =>
|
|
340
|
+
invokeEndpoint(
|
|
341
|
+
prop as keyof T,
|
|
342
|
+
input ?? ({} as RequestInput<T[keyof T]>)
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
if (prop in actions) {
|
|
346
|
+
return (first?: unknown, second?: ActionInvokeOptions) =>
|
|
347
|
+
dispatchAction(prop, first, second)
|
|
348
|
+
}
|
|
349
|
+
return undefined
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
const rawActions = apiConfig.actions
|
|
354
|
+
if (typeof rawActions === 'function') {
|
|
355
|
+
actions = (
|
|
356
|
+
rawActions as (
|
|
357
|
+
helpers: ActionsFactoryHelpers<T, A>
|
|
358
|
+
) => Record<string, AnyApiAction>
|
|
359
|
+
)({
|
|
360
|
+
api: proxy,
|
|
361
|
+
defineAction: makeDefineAction<ApiClient<T, A>>()
|
|
362
|
+
}) as Record<string, AnyApiAction>
|
|
363
|
+
} else if (rawActions) {
|
|
364
|
+
actions = rawActions as Record<string, AnyApiAction>
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const collisions = Object.keys(actions).filter(
|
|
368
|
+
(key) => key in apiConfig.endpoints
|
|
369
|
+
)
|
|
370
|
+
if (collisions.length > 0) {
|
|
371
|
+
throw new ConfigError(
|
|
372
|
+
`Action name(s) collide with endpoint name(s): ${collisions.join(', ')}`,
|
|
373
|
+
{
|
|
374
|
+
endpoint: collisions[0]!,
|
|
375
|
+
method: 'GET',
|
|
376
|
+
url: '',
|
|
377
|
+
attempt: 0,
|
|
378
|
+
maxRetries: 0,
|
|
379
|
+
elapsedMs: 0
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return proxy
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export const createApiClient: CreateApiClientFn = ((apiConfig: unknown) =>
|
|
388
|
+
createApiClientImpl(
|
|
389
|
+
apiConfig as ApiClientConfig<
|
|
390
|
+
Record<string, ApiEndpoint>,
|
|
391
|
+
Record<string, ApiAction>
|
|
392
|
+
>
|
|
393
|
+
)) 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
|
+
}
|