hanzio 1.1.0 → 2.0.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 +832 -219
- package/dist/api-wrapper/builders.d.ts +15 -3
- package/dist/api-wrapper/create-client.d.ts +1 -1
- package/dist/api-wrapper/describe.d.ts +16 -0
- package/dist/api-wrapper/endpoint.d.ts +18 -0
- package/dist/api-wrapper/errors.d.ts +40 -2
- package/dist/api-wrapper/headers.d.ts +2 -1
- package/dist/api-wrapper/index.d.ts +5 -0
- package/dist/api-wrapper/otel.d.ts +44 -0
- package/dist/api-wrapper/paginate.d.ts +12 -0
- package/dist/api-wrapper/redaction.d.ts +2 -0
- package/dist/api-wrapper/shared.d.ts +16 -2
- package/dist/api-wrapper/testing.d.ts +23 -0
- package/dist/api-wrapper/transport.d.ts +12 -10
- package/dist/api-wrapper/types.d.ts +104 -18
- package/dist/array/index.d.ts +50 -4
- package/dist/cache/createCache.d.ts +31 -0
- package/dist/cache/index.d.ts +45 -2
- package/dist/color/contrast.d.ts +40 -0
- package/dist/color/index.d.ts +2 -0
- package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
- package/dist/cool-console-log/index.d.ts +34 -5
- package/dist/crypto/encoding.d.ts +18 -0
- package/dist/crypto/hash.d.ts +33 -0
- package/dist/crypto/index.d.ts +4 -0
- package/dist/crypto/sealer.d.ts +25 -0
- package/dist/crypto/webhook.d.ts +20 -0
- package/dist/date/index.d.ts +46 -2
- package/dist/emitter/index.d.ts +21 -0
- package/dist/error/index.d.ts +12 -0
- package/dist/format/index.d.ts +102 -0
- package/dist/function/index.d.ts +60 -0
- package/dist/guard/index.d.ts +20 -0
- package/dist/i18n/index.d.ts +37 -0
- package/dist/index-0zvtcbzy.js +224 -0
- package/dist/index-181e6vfw.js +29 -0
- package/dist/index-8r2vdzjg.js +87 -0
- package/dist/index-hy7kbb99.js +157 -0
- package/dist/{src/secrets/loaders.js → index-j1ybktbr.js} +608 -405
- package/dist/index-k9yx6bne.js +89 -0
- package/dist/index-kj1phgrx.js +169 -0
- package/dist/index-mnkcfyx8.js +78 -0
- package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
- package/dist/index-rp5pqghz.js +157 -0
- package/dist/index-srt06xd2.js +15 -0
- package/dist/index-wjyb9bsd.js +263 -0
- package/dist/index.d.ts +7 -7
- package/dist/jwt/index.d.ts +50 -2
- package/dist/math/index.d.ts +2 -2
- package/dist/math/linearRegressionTrend.d.ts +14 -1
- package/dist/math/scale.d.ts +8 -0
- package/dist/object/index.d.ts +19 -0
- package/dist/p-queue/index.d.ts +1 -1
- package/dist/promise/index.d.ts +97 -2
- 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 +13 -25
- 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 +8 -13
- package/dist/secrets/loaders.d.ts +5 -12
- 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/sitemap/index.d.ts +48 -6
- package/dist/src/api-wrapper/index.js +60 -1100
- package/dist/src/api-wrapper/testing.js +20 -0
- package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
- package/dist/src/crypto/index.js +195 -0
- package/dist/src/format/index.js +211 -0
- package/dist/src/i18n/index.js +36 -0
- package/dist/src/index.js +863 -2809
- package/dist/src/jwt/index.js +138 -92
- package/dist/src/p-queue/index.js +9 -437
- package/dist/src/secrets/index.js +261 -1377
- package/dist/src/secrets/vite.js +6 -17
- package/dist/src/secrets/worker.js +12 -0
- package/dist/src/sitemap/index.js +183 -511
- package/dist/src/storage/index.js +119 -0
- package/dist/src/zod/index.js +347 -174
- package/dist/state/index.d.ts +21 -0
- package/dist/storage/index.d.ts +58 -0
- package/dist/string/index.d.ts +75 -2
- package/dist/typedswitch/index.d.ts +37 -26
- package/dist/types/index.d.ts +11 -0
- package/dist/url/index.d.ts +50 -1
- package/dist/zod/index.d.ts +32 -3
- package/dist/zod/introspect.d.ts +19 -0
- package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
- package/dist/zod/zodMinMaxFilter.d.ts +22 -3
- package/dist/zod/zodToTypeString.d.ts +8 -3
- package/package.json +87 -7
- package/src/api-wrapper/actions-impl.ts +11 -72
- package/src/api-wrapper/builders.ts +84 -52
- package/src/api-wrapper/create-client.ts +246 -96
- package/src/api-wrapper/describe.ts +157 -0
- package/src/api-wrapper/endpoint.ts +26 -0
- package/src/api-wrapper/error-reporting.ts +7 -1
- package/src/api-wrapper/errors.ts +117 -2
- package/src/api-wrapper/headers.ts +19 -1
- package/src/api-wrapper/index.ts +5 -0
- package/src/api-wrapper/otel.ts +139 -0
- package/src/api-wrapper/paginate.ts +28 -0
- package/src/api-wrapper/redaction.ts +43 -0
- package/src/api-wrapper/shared.ts +21 -2
- package/src/api-wrapper/testing.ts +48 -0
- package/src/api-wrapper/transport.ts +68 -74
- package/src/api-wrapper/types.ts +186 -18
- package/src/array/index.ts +195 -42
- package/src/cache/createCache.ts +157 -0
- package/src/cache/index.ts +235 -83
- package/src/color/contrast.ts +163 -0
- package/src/color/index.ts +22 -0
- package/src/{math → color}/metricsSeriesColors.ts +47 -49
- package/src/cool-console-log/index.ts +128 -41
- package/src/crypto/encoding.ts +83 -0
- package/src/crypto/hash.ts +170 -0
- package/src/crypto/index.ts +26 -0
- package/src/crypto/sealer.ts +168 -0
- package/src/crypto/webhook.ts +86 -0
- package/src/date/index.ts +109 -34
- package/src/emitter/index.ts +82 -0
- package/src/error/index.ts +63 -0
- package/src/format/index.ts +389 -0
- package/src/function/index.ts +201 -0
- package/src/guard/index.ts +80 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +11 -7
- package/src/jwt/index.ts +199 -125
- package/src/math/index.ts +5 -10
- package/src/math/linearRegressionTrend.ts +33 -8
- package/src/math/scale.ts +17 -0
- package/src/object/index.ts +99 -0
- package/src/p-queue/index.ts +2 -0
- package/src/promise/index.ts +304 -9
- 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 +79 -210
- 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 +214 -66
- package/src/secrets/loaders.ts +24 -48
- package/src/secrets/vite.ts +44 -15
- package/src/secrets/worker-loader.ts +56 -0
- package/src/secrets/worker.ts +4 -0
- package/src/sitemap/index.ts +293 -130
- package/src/state/index.ts +133 -81
- package/src/storage/index.ts +187 -0
- package/src/string/index.ts +261 -23
- package/src/typedswitch/index.ts +134 -103
- package/src/types/index.ts +14 -0
- package/src/url/index.ts +146 -1
- package/src/zod/index.ts +105 -9
- package/src/zod/introspect.ts +103 -0
- package/src/zod/jsonToZodSchemaMapper.ts +44 -21
- package/src/zod/zodMinMaxFilter.ts +37 -13
- package/src/zod/zodToTypeString.ts +184 -181
- package/dist/api-wrapper/index.js +0 -250
- package/dist/gzip/index.d.ts +0 -2
- package/dist/gzip/index.js +0 -16
- package/dist/index.js +0 -1823
- package/dist/jwt/index.js +0 -19
- package/dist/sitemap/index.js +0 -552
- package/dist/src/gzip/index.js +0 -16
- package/dist/src/secrets/infisical.js +0 -1194
- package/dist/zod/index.js +0 -210
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
ApiClientConfigNoActions,
|
|
9
9
|
ApiClientConfigWithActions,
|
|
10
10
|
ApiEndpoint,
|
|
11
|
+
ApiMiddlewareContext,
|
|
11
12
|
ApiWrapperResponse,
|
|
12
13
|
RequestInput
|
|
13
14
|
} from './types'
|
|
@@ -15,13 +16,21 @@ import type { PathParams, QueryParams } from './shared'
|
|
|
15
16
|
import type { ApiErrorContext } from './shared'
|
|
16
17
|
import {
|
|
17
18
|
buildHeaders,
|
|
19
|
+
buildPathParams,
|
|
18
20
|
buildQueryParams,
|
|
19
21
|
buildRequestBody,
|
|
20
22
|
buildUrl
|
|
21
23
|
} from './builders'
|
|
24
|
+
import { err, exponentialBackoff, ok } from '../promise'
|
|
22
25
|
import { createActionCache, makeDefineAction } from './actions-impl'
|
|
23
26
|
import { reportApiError } from './error-reporting'
|
|
24
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
ActionError,
|
|
29
|
+
ApiError,
|
|
30
|
+
ApiResponseError,
|
|
31
|
+
ConfigError,
|
|
32
|
+
RequestValidationError
|
|
33
|
+
} from './errors'
|
|
25
34
|
import { calculateSizeInMb } from './size'
|
|
26
35
|
import {
|
|
27
36
|
defaultShouldRetry,
|
|
@@ -32,13 +41,15 @@ import {
|
|
|
32
41
|
|
|
33
42
|
type AnyApiAction = ApiAction<unknown, unknown>
|
|
34
43
|
|
|
44
|
+
// The actions overload comes first: TypeScript fixes a context-sensitive
|
|
45
|
+
// `actions` factory's parameter types on the first overload it tries.
|
|
35
46
|
export interface CreateApiClientFn {
|
|
36
|
-
<T extends Record<string, ApiEndpoint>>(
|
|
37
|
-
apiConfig: ApiClientConfigNoActions<T>
|
|
38
|
-
): ApiClient<T, Record<string, never>>
|
|
39
47
|
<T extends Record<string, ApiEndpoint>, A extends Record<string, ApiAction>>(
|
|
40
48
|
apiConfig: ApiClientConfigWithActions<T, A>
|
|
41
49
|
): ApiClient<T, A>
|
|
50
|
+
<T extends Record<string, ApiEndpoint>>(
|
|
51
|
+
apiConfig: ApiClientConfigNoActions<T>
|
|
52
|
+
): ApiClient<T, Record<string, never>>
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
function isActionInvokeOptionsOnly(
|
|
@@ -51,6 +62,39 @@ function isActionInvokeOptionsOnly(
|
|
|
51
62
|
)
|
|
52
63
|
}
|
|
53
64
|
|
|
65
|
+
function resolveActionInvocation(
|
|
66
|
+
action: AnyApiAction,
|
|
67
|
+
actionName: string,
|
|
68
|
+
first: unknown,
|
|
69
|
+
second?: ActionInvokeOptions
|
|
70
|
+
): { input: unknown; options: ActionInvokeOptions } {
|
|
71
|
+
if (!action.noRuntimeInput) {
|
|
72
|
+
return { input: first, options: second ?? {} }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (first === undefined || first === null) {
|
|
76
|
+
return { input: undefined, options: second ?? {} }
|
|
77
|
+
}
|
|
78
|
+
if (second !== undefined) {
|
|
79
|
+
return { input: undefined, options: second }
|
|
80
|
+
}
|
|
81
|
+
if (isActionInvokeOptionsOnly(first)) {
|
|
82
|
+
return { input: undefined, options: first }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new ConfigError(
|
|
86
|
+
`Action "${actionName}" does not accept input; pass only optional { signal, meta }`,
|
|
87
|
+
{
|
|
88
|
+
endpoint: actionName,
|
|
89
|
+
method: 'GET',
|
|
90
|
+
url: '',
|
|
91
|
+
attempt: 0,
|
|
92
|
+
maxRetries: 0,
|
|
93
|
+
elapsedMs: 0
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
54
98
|
function createApiClientImpl<
|
|
55
99
|
T extends Record<string, ApiEndpoint>,
|
|
56
100
|
A extends Record<string, ApiAction> = Record<string, never>
|
|
@@ -101,38 +145,25 @@ function createApiClientImpl<
|
|
|
101
145
|
}
|
|
102
146
|
|
|
103
147
|
baseContext.method = endpoint.method
|
|
148
|
+
// Until the full URL is built, errors (e.g. input validation) show the path template.
|
|
149
|
+
if (typeof endpoint.path === 'string') baseContext.url = endpoint.path
|
|
104
150
|
|
|
105
|
-
let
|
|
106
|
-
let
|
|
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>
|
|
151
|
+
let pathParams: PathParams | undefined
|
|
152
|
+
let queryParams: QueryParams
|
|
127
153
|
let body: unknown
|
|
128
154
|
let headers: Record<string, string>
|
|
129
155
|
try {
|
|
156
|
+
pathParams = buildPathParams(endpoint, rawInput.reqParams, ctx)
|
|
130
157
|
queryParams = buildQueryParams(endpoint, rawInput.reqQuery, ctx)
|
|
131
158
|
body = buildRequestBody(endpoint, rawInput.reqBody, ctx)
|
|
159
|
+
const defaultHeaders =
|
|
160
|
+
typeof apiConfig.defaultHeaders === 'function'
|
|
161
|
+
? await apiConfig.defaultHeaders()
|
|
162
|
+
: apiConfig.defaultHeaders
|
|
132
163
|
headers = buildHeaders(
|
|
133
164
|
endpoint,
|
|
134
165
|
rawInput.reqHeaders,
|
|
135
|
-
|
|
166
|
+
defaultHeaders ?? {},
|
|
136
167
|
ctx
|
|
137
168
|
)
|
|
138
169
|
} catch (e) {
|
|
@@ -140,11 +171,32 @@ function createApiClientImpl<
|
|
|
140
171
|
throw e
|
|
141
172
|
}
|
|
142
173
|
|
|
174
|
+
let fullUrl: string
|
|
175
|
+
try {
|
|
176
|
+
const built = buildUrl({
|
|
177
|
+
endpoint,
|
|
178
|
+
params: pathParams,
|
|
179
|
+
queryParams,
|
|
180
|
+
baseApiUrls: apiConfig.baseApiUrls,
|
|
181
|
+
defaultBaseApiUrl: apiConfig.defaultBaseApiUrl,
|
|
182
|
+
url: input.url,
|
|
183
|
+
doNotEncodeQueryParams: endpoint.doNotEncodeQueryParams,
|
|
184
|
+
configError: (msg) => new ConfigError(msg, ctx())
|
|
185
|
+
})
|
|
186
|
+
fullUrl = built.fullUrl
|
|
187
|
+
} catch (e) {
|
|
188
|
+
if (e instanceof ApiError) throw await reportApiError(e, apiConfig)
|
|
189
|
+
throw e
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
baseContext.url = fullUrl
|
|
143
193
|
baseContext.requestHeaders = headers
|
|
144
194
|
baseContext.requestBody = body
|
|
145
195
|
|
|
146
|
-
|
|
147
|
-
|
|
196
|
+
const execute = async (): Promise<
|
|
197
|
+
ApiWrapperResponse<z.infer<T[K]['resSchema']>>
|
|
198
|
+
> => {
|
|
199
|
+
if (apiConfig.onRequest) {
|
|
148
200
|
const result = await apiConfig.onRequest({
|
|
149
201
|
endpoint: endpointName,
|
|
150
202
|
method: endpoint.method,
|
|
@@ -155,31 +207,38 @@ function createApiClientImpl<
|
|
|
155
207
|
})
|
|
156
208
|
if (result?.headers) headers = result.headers
|
|
157
209
|
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
210
|
}
|
|
162
|
-
}
|
|
163
211
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
userSignal: input.signal
|
|
174
|
-
}
|
|
212
|
+
const config: RequestConfig = {
|
|
213
|
+
method: endpoint.method,
|
|
214
|
+
fullUrl,
|
|
215
|
+
body,
|
|
216
|
+
headers,
|
|
217
|
+
timeoutMs: input.timeoutMs ?? apiConfig.timeoutMs,
|
|
218
|
+
fetchFn: input.fetch ?? apiConfig.fetch ?? fetch,
|
|
219
|
+
userSignal: input.signal
|
|
220
|
+
}
|
|
175
221
|
|
|
176
|
-
try {
|
|
177
222
|
const result = await makeRequestWithRetry({
|
|
178
223
|
config,
|
|
179
224
|
maxRetries,
|
|
180
|
-
retryDelay:
|
|
181
|
-
|
|
182
|
-
|
|
225
|
+
retryDelay:
|
|
226
|
+
apiConfig.retryDelayMs ??
|
|
227
|
+
((attempt) =>
|
|
228
|
+
exponentialBackoff(attempt + 1, {
|
|
229
|
+
baseMs: 300,
|
|
230
|
+
maxMs: apiConfig.maxRetryDelayMs
|
|
231
|
+
})),
|
|
232
|
+
maxRetryDelayMs: apiConfig.maxRetryDelayMs,
|
|
233
|
+
validateFn: async (response, attempt) => {
|
|
234
|
+
const validated = await validateAndTransformResponse(
|
|
235
|
+
endpoint,
|
|
236
|
+
response,
|
|
237
|
+
(over) => ctx({ attempt, ...over })
|
|
238
|
+
)
|
|
239
|
+
await runCheckResponse(validated.data, response.status, attempt)
|
|
240
|
+
return validated
|
|
241
|
+
},
|
|
183
242
|
shouldRetry: apiConfig.shouldRetry ?? defaultShouldRetry,
|
|
184
243
|
logger: apiConfig.logger,
|
|
185
244
|
ctx,
|
|
@@ -191,23 +250,76 @@ function createApiClientImpl<
|
|
|
191
250
|
onRetry: apiConfig.onRetry
|
|
192
251
|
})
|
|
193
252
|
|
|
194
|
-
const responseTimeMs = elapsed()
|
|
195
|
-
const responseSizeMb = calculateSizeInMb(result.validatedData)
|
|
196
|
-
const requestBodySizeMb = calculateSizeInMb(config.body)
|
|
197
|
-
|
|
198
253
|
return {
|
|
199
|
-
data: result.validatedData as z.infer<T[K]['resSchema']>,
|
|
200
|
-
requestBodySizeMb,
|
|
201
|
-
responseSizeMb,
|
|
202
|
-
responseTimeMs,
|
|
254
|
+
data: result.validatedData.data as z.infer<T[K]['resSchema']>,
|
|
255
|
+
requestBodySizeMb: calculateSizeInMb(config.body),
|
|
256
|
+
responseSizeMb: result.validatedData.responseSizeBytes / (1024 * 1024),
|
|
257
|
+
responseTimeMs: elapsed(),
|
|
203
258
|
httpStatus: result.httpStatus,
|
|
204
259
|
retryCount: result.retryCount
|
|
205
260
|
}
|
|
206
|
-
} catch (error) {
|
|
207
|
-
if (error instanceof ApiError)
|
|
208
|
-
throw await reportApiError(error, apiConfig)
|
|
209
|
-
throw error
|
|
210
261
|
}
|
|
262
|
+
|
|
263
|
+
const runCheckResponse = async (
|
|
264
|
+
data: unknown,
|
|
265
|
+
httpStatus: number,
|
|
266
|
+
attempt: number
|
|
267
|
+
) => {
|
|
268
|
+
if (!apiConfig.checkResponse) return
|
|
269
|
+
try {
|
|
270
|
+
await apiConfig.checkResponse(data, {
|
|
271
|
+
endpoint: endpointName,
|
|
272
|
+
method: endpoint.method,
|
|
273
|
+
url: fullUrl,
|
|
274
|
+
httpStatus,
|
|
275
|
+
attempt,
|
|
276
|
+
meta: input.meta
|
|
277
|
+
})
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error instanceof ApiError) throw error
|
|
280
|
+
throw new ApiResponseError(
|
|
281
|
+
error instanceof Error ? error.message : String(error),
|
|
282
|
+
data,
|
|
283
|
+
ctx({ attempt }),
|
|
284
|
+
error
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Innermost step: errors are redacted and passed to onError before any
|
|
290
|
+
// middleware sees them.
|
|
291
|
+
const run = async () => {
|
|
292
|
+
try {
|
|
293
|
+
return await execute()
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (error instanceof ApiError) {
|
|
296
|
+
throw await reportApiError(error, apiConfig)
|
|
297
|
+
}
|
|
298
|
+
throw error
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const middleware = apiConfig.use ?? []
|
|
303
|
+
if (middleware.length === 0) return run()
|
|
304
|
+
|
|
305
|
+
const middlewareContext: ApiMiddlewareContext = {
|
|
306
|
+
client: apiConfig.name,
|
|
307
|
+
endpoint: String(endpointKey),
|
|
308
|
+
method: endpoint.method,
|
|
309
|
+
url: fullUrl,
|
|
310
|
+
headers,
|
|
311
|
+
meta: input.meta
|
|
312
|
+
}
|
|
313
|
+
const dispatch = (
|
|
314
|
+
index: number
|
|
315
|
+
): Promise<ApiWrapperResponse<z.infer<T[K]['resSchema']>>> => {
|
|
316
|
+
const current = middleware[index]
|
|
317
|
+
if (!current) return run()
|
|
318
|
+
return current(middlewareContext, () => dispatch(index + 1)) as Promise<
|
|
319
|
+
ApiWrapperResponse<z.infer<T[K]['resSchema']>>
|
|
320
|
+
>
|
|
321
|
+
}
|
|
322
|
+
return dispatch(0)
|
|
211
323
|
}
|
|
212
324
|
|
|
213
325
|
async function invokeAction(
|
|
@@ -237,9 +349,21 @@ function createApiClientImpl<
|
|
|
237
349
|
|
|
238
350
|
apiConfig.logger?.debug?.('Action start', { action: actionName })
|
|
239
351
|
|
|
352
|
+
let parsedInput = inputForHandler
|
|
353
|
+
if (action.input) {
|
|
354
|
+
const parsed = await action.input.safeParseAsync(rawInput)
|
|
355
|
+
if (!parsed.success) {
|
|
356
|
+
throw await reportApiError(
|
|
357
|
+
new RequestValidationError(parsed.error, 'input', rawInput, ctx()),
|
|
358
|
+
apiConfig
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
parsedInput = parsed.data
|
|
362
|
+
}
|
|
363
|
+
|
|
240
364
|
try {
|
|
241
365
|
const result = await action.handler({
|
|
242
|
-
input:
|
|
366
|
+
input: parsedInput,
|
|
243
367
|
api: proxy,
|
|
244
368
|
signal: options.signal,
|
|
245
369
|
meta: options.meta,
|
|
@@ -264,26 +388,28 @@ function createApiClientImpl<
|
|
|
264
388
|
}
|
|
265
389
|
}
|
|
266
390
|
|
|
391
|
+
async function dispatchAction(
|
|
392
|
+
name: string,
|
|
393
|
+
first?: unknown,
|
|
394
|
+
second?: ActionInvokeOptions
|
|
395
|
+
): Promise<unknown> {
|
|
396
|
+
const action = actions[name]!
|
|
397
|
+
const { input, options } = resolveActionInvocation(
|
|
398
|
+
action,
|
|
399
|
+
name,
|
|
400
|
+
first,
|
|
401
|
+
second
|
|
402
|
+
)
|
|
403
|
+
return invokeAction(name, input, options)
|
|
404
|
+
}
|
|
405
|
+
|
|
267
406
|
const request = (async (
|
|
268
407
|
key: string,
|
|
269
408
|
input?: unknown,
|
|
270
409
|
options?: ActionInvokeOptions
|
|
271
410
|
): Promise<unknown> => {
|
|
272
411
|
if (key in actions) {
|
|
273
|
-
|
|
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)
|
|
412
|
+
return dispatchAction(key, input, options)
|
|
287
413
|
}
|
|
288
414
|
return invokeEndpoint(
|
|
289
415
|
key as keyof T,
|
|
@@ -291,7 +417,31 @@ function createApiClientImpl<
|
|
|
291
417
|
)
|
|
292
418
|
}) as unknown as ApiClient<T, A>['request']
|
|
293
419
|
|
|
294
|
-
|
|
420
|
+
// Resolves to `Result` instead of throwing ApiErrors; other errors still throw.
|
|
421
|
+
const safe = new Proxy(
|
|
422
|
+
{},
|
|
423
|
+
{
|
|
424
|
+
get(_target, prop) {
|
|
425
|
+
if (typeof prop !== 'string') return undefined
|
|
426
|
+
if (!(prop in apiConfig.endpoints) && !(prop in actions)) {
|
|
427
|
+
return undefined
|
|
428
|
+
}
|
|
429
|
+
return async (...args: unknown[]) => {
|
|
430
|
+
try {
|
|
431
|
+
const method = Reflect.get(proxy, prop) as (
|
|
432
|
+
...a: unknown[]
|
|
433
|
+
) => Promise<unknown>
|
|
434
|
+
return ok(await method(...args))
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (error instanceof ApiError) return err(error)
|
|
437
|
+
throw error
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
const client = { request, cache, safe } as unknown as ApiClient<T, A>
|
|
295
445
|
const proxy: ApiClient<T, A> = new Proxy(client, {
|
|
296
446
|
get(target, prop, receiver) {
|
|
297
447
|
if (prop in target || typeof prop !== 'string') {
|
|
@@ -305,26 +455,8 @@ function createApiClientImpl<
|
|
|
305
455
|
)
|
|
306
456
|
}
|
|
307
457
|
if (prop in actions) {
|
|
308
|
-
|
|
309
|
-
|
|
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)
|
|
458
|
+
return (first?: unknown, second?: ActionInvokeOptions) =>
|
|
459
|
+
dispatchAction(prop, first, second)
|
|
328
460
|
}
|
|
329
461
|
return undefined
|
|
330
462
|
}
|
|
@@ -344,6 +476,24 @@ function createApiClientImpl<
|
|
|
344
476
|
actions = rawActions as Record<string, AnyApiAction>
|
|
345
477
|
}
|
|
346
478
|
|
|
479
|
+
const reserved = [
|
|
480
|
+
...Object.keys(apiConfig.endpoints),
|
|
481
|
+
...Object.keys(actions)
|
|
482
|
+
].filter((key) => key in client)
|
|
483
|
+
if (reserved.length > 0) {
|
|
484
|
+
throw new ConfigError(
|
|
485
|
+
`Endpoint/action name(s) are reserved by the client: ${reserved.join(', ')}`,
|
|
486
|
+
{
|
|
487
|
+
endpoint: reserved[0]!,
|
|
488
|
+
method: 'GET',
|
|
489
|
+
url: '',
|
|
490
|
+
attempt: 0,
|
|
491
|
+
maxRetries: 0,
|
|
492
|
+
elapsedMs: 0
|
|
493
|
+
}
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
|
|
347
497
|
const collisions = Object.keys(actions).filter(
|
|
348
498
|
(key) => key in apiConfig.endpoints
|
|
349
499
|
)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActionError,
|
|
3
|
+
type ApiError,
|
|
4
|
+
type ApiErrorCategory,
|
|
5
|
+
ApiResponseError,
|
|
6
|
+
HttpResponseError,
|
|
7
|
+
RequestTimeoutError,
|
|
8
|
+
RequestValidationError,
|
|
9
|
+
ResponseValidationError
|
|
10
|
+
} from './errors'
|
|
11
|
+
import { previewValue, renderIssueSummary } from './zod-issues'
|
|
12
|
+
|
|
13
|
+
const BODY_PREVIEW = 500
|
|
14
|
+
|
|
15
|
+
const schemaByTarget = {
|
|
16
|
+
body: 'reqBodySchema',
|
|
17
|
+
query: 'reqQuerySchema',
|
|
18
|
+
params: 'reqParamsSchema',
|
|
19
|
+
headers: 'reqHeadersSchema',
|
|
20
|
+
input: 'the action input schema'
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
function headline(error: ApiError): string {
|
|
24
|
+
if (error instanceof HttpResponseError) {
|
|
25
|
+
const status = `HTTP ${error.status}`
|
|
26
|
+
if (error.status >= 500) {
|
|
27
|
+
return `server error (${status}): the API failed while handling the request`
|
|
28
|
+
}
|
|
29
|
+
if (error.status === 401 || error.status === 403) {
|
|
30
|
+
return `request rejected (${status}): check credentials and permissions`
|
|
31
|
+
}
|
|
32
|
+
if (error.status === 404) {
|
|
33
|
+
return `request rejected (${status}): check the path and the IDs in it`
|
|
34
|
+
}
|
|
35
|
+
if (error.status === 429) return `rate limited (${status})`
|
|
36
|
+
return `request rejected (${status}): check params, query and body`
|
|
37
|
+
}
|
|
38
|
+
if (error instanceof RequestValidationError) {
|
|
39
|
+
return `input validation failed: the caller's ${error.target} doesn't match ${schemaByTarget[error.target]} (nothing was sent)`
|
|
40
|
+
}
|
|
41
|
+
if (error instanceof ResponseValidationError) {
|
|
42
|
+
return error.invalidJson
|
|
43
|
+
? 'output validation failed: the response body is not valid JSON'
|
|
44
|
+
: "output validation failed: the response doesn't match resSchema"
|
|
45
|
+
}
|
|
46
|
+
if (error instanceof ApiResponseError) {
|
|
47
|
+
return `response rejected by checkResponse: ${error.message}`
|
|
48
|
+
}
|
|
49
|
+
if (error instanceof RequestTimeoutError) {
|
|
50
|
+
return `timed out after ${error.timeoutMs}ms`
|
|
51
|
+
}
|
|
52
|
+
const labels: Partial<Record<ApiErrorCategory, string>> = {
|
|
53
|
+
network: `network error: couldn't reach the API (${error.message})`,
|
|
54
|
+
aborted: 'aborted by the caller',
|
|
55
|
+
config: `configuration error: ${error.message}`,
|
|
56
|
+
action: `action failed: ${error.message}`
|
|
57
|
+
}
|
|
58
|
+
return labels[error.category] ?? error.message
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function details(error: ApiError): string[] {
|
|
62
|
+
if (error instanceof HttpResponseError) {
|
|
63
|
+
const body =
|
|
64
|
+
error.bodyJson === undefined
|
|
65
|
+
? error.body
|
|
66
|
+
: previewValue(error.bodyJson, BODY_PREVIEW)
|
|
67
|
+
return body
|
|
68
|
+
? [
|
|
69
|
+
` response: ${body.length > BODY_PREVIEW ? `${body.slice(0, BODY_PREVIEW)}…` : body}`
|
|
70
|
+
]
|
|
71
|
+
: []
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
error instanceof ResponseValidationError ||
|
|
75
|
+
error instanceof RequestValidationError
|
|
76
|
+
) {
|
|
77
|
+
const lines = [renderIssueSummary(error.issues)]
|
|
78
|
+
if (error instanceof ResponseValidationError && !error.invalidJson) {
|
|
79
|
+
lines.push(
|
|
80
|
+
' fix: update resSchema (or resFormatter), or mark changed fields optional'
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
return lines
|
|
84
|
+
}
|
|
85
|
+
if (error instanceof ApiResponseError) {
|
|
86
|
+
return [` response: ${previewValue(error.data, BODY_PREVIEW)}`]
|
|
87
|
+
}
|
|
88
|
+
if (error instanceof ActionError && error.cause instanceof Error) {
|
|
89
|
+
return error.cause.stack ? [` cause: ${error.cause.stack}`] : []
|
|
90
|
+
}
|
|
91
|
+
return []
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A multi-line, human-readable summary of an `ApiError` that says up front
|
|
96
|
+
* whether the server, the caller's input, or the response shape is at fault.
|
|
97
|
+
* The client logs this via `logger.error` for every failed request.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ✗ github.usersGet failed: output validation failed: the response doesn't match resSchema
|
|
101
|
+
* GET https://api.github.com/users/1 · attempt 1/4 · 212ms
|
|
102
|
+
* [1] id: Expected number, received string
|
|
103
|
+
* value: "oops"
|
|
104
|
+
* fix: update resSchema (or resFormatter), or mark changed fields optional
|
|
105
|
+
*/
|
|
106
|
+
export function describeApiError(error: ApiError): string {
|
|
107
|
+
const { context } = error
|
|
108
|
+
const sent =
|
|
109
|
+
error.category !== 'input_validation' && error.category !== 'config'
|
|
110
|
+
const meta = [
|
|
111
|
+
context.url ? `${context.method} ${context.url}` : context.method,
|
|
112
|
+
...(sent
|
|
113
|
+
? [`attempt ${context.attempt + 1}/${context.maxRetries + 1}`]
|
|
114
|
+
: []),
|
|
115
|
+
`${context.elapsedMs}ms`
|
|
116
|
+
]
|
|
117
|
+
if (error instanceof HttpResponseError && error.requestId) {
|
|
118
|
+
meta.push(`request id ${error.requestId}`)
|
|
119
|
+
}
|
|
120
|
+
return [
|
|
121
|
+
`✗ ${context.endpoint} failed: ${headline(error)}`,
|
|
122
|
+
` ${meta.join(' · ')}`,
|
|
123
|
+
...details(error)
|
|
124
|
+
].join('\n')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Flat, primitive attributes for structured loggers and log processors. */
|
|
128
|
+
export function apiErrorLogAttributes(
|
|
129
|
+
error: ApiError
|
|
130
|
+
): Record<string, string | number | boolean> {
|
|
131
|
+
const { context } = error
|
|
132
|
+
const attributes: Record<string, string | number | boolean> = {
|
|
133
|
+
errorName: error.name,
|
|
134
|
+
category: error.category,
|
|
135
|
+
endpoint: context.endpoint,
|
|
136
|
+
method: context.method,
|
|
137
|
+
url: context.url,
|
|
138
|
+
attempt: context.attempt,
|
|
139
|
+
maxRetries: context.maxRetries,
|
|
140
|
+
elapsedMs: context.elapsedMs
|
|
141
|
+
}
|
|
142
|
+
if (error instanceof HttpResponseError) {
|
|
143
|
+
attributes.status = error.status
|
|
144
|
+
if (error.requestId) attributes.requestId = error.requestId
|
|
145
|
+
}
|
|
146
|
+
if (
|
|
147
|
+
error instanceof ResponseValidationError ||
|
|
148
|
+
error instanceof RequestValidationError
|
|
149
|
+
) {
|
|
150
|
+
attributes.issueCount = error.issues.length
|
|
151
|
+
attributes.issues = error.validationIssues
|
|
152
|
+
}
|
|
153
|
+
if (error instanceof RequestValidationError) attributes.target = error.target
|
|
154
|
+
if (error instanceof RequestTimeoutError)
|
|
155
|
+
attributes.timeoutMs = error.timeoutMs
|
|
156
|
+
return attributes
|
|
157
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { z } from 'zod'
|
|
2
|
+
import type { ApiEndpoint } from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Defines an endpoint with full inference, as an alternative to
|
|
6
|
+
* `satisfies ApiEndpoint`: `resFormatter` must return what `resSchema`
|
|
7
|
+
* accepts, and a literal `path` types its `:params` (making `reqParams`
|
|
8
|
+
* required when there's no `reqParamsSchema`).
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* export const usersGet = defineEndpoint({
|
|
12
|
+
* method: 'GET',
|
|
13
|
+
* path: '/users/:userId',
|
|
14
|
+
* resSchema: User
|
|
15
|
+
* })
|
|
16
|
+
*/
|
|
17
|
+
export function defineEndpoint<const TEndpoint extends ApiEndpoint>(
|
|
18
|
+
endpoint: TEndpoint & {
|
|
19
|
+
resFormatter?: (
|
|
20
|
+
data: unknown,
|
|
21
|
+
headers: Record<string, string>
|
|
22
|
+
) => z.input<TEndpoint['resSchema']>
|
|
23
|
+
}
|
|
24
|
+
): TEndpoint {
|
|
25
|
+
return endpoint
|
|
26
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ApiError } from './errors'
|
|
2
2
|
import type { ApiClientConfig } from './types'
|
|
3
3
|
import type { ApiErrorContext } from './shared'
|
|
4
|
+
import { apiErrorLogAttributes, describeApiError } from './describe'
|
|
4
5
|
import { defaultRedactContext } from './redaction'
|
|
5
6
|
|
|
6
7
|
type ErrorHookConfig = Pick<
|
|
@@ -20,12 +21,17 @@ export async function reportApiError(
|
|
|
20
21
|
err: ApiError,
|
|
21
22
|
config: ErrorHookConfig
|
|
22
23
|
): Promise<ApiError> {
|
|
24
|
+
const rawUrl = err.context.url
|
|
23
25
|
err.context = applyErrorContextRedaction(err.context, config)
|
|
26
|
+
// Messages were built from the raw URL; keep redacted query values out of them.
|
|
27
|
+
if (rawUrl && rawUrl !== err.context.url) {
|
|
28
|
+
err.message = err.message.split(rawUrl).join(err.context.url)
|
|
29
|
+
}
|
|
24
30
|
try {
|
|
25
31
|
await config.onError?.(err)
|
|
26
32
|
} catch {
|
|
27
33
|
// ignore hook errors
|
|
28
34
|
}
|
|
29
|
-
config.logger?.error?.(err)
|
|
35
|
+
config.logger?.error?.(describeApiError(err), apiErrorLogAttributes(err))
|
|
30
36
|
return err
|
|
31
37
|
}
|