hanzio 1.2.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 +786 -239
- package/dist/api-wrapper/builders.d.ts +4 -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 +39 -2
- 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 +4 -5
- package/dist/api-wrapper/types.d.ts +103 -18
- package/dist/array/index.d.ts +50 -4
- package/dist/cache/createCache.d.ts +31 -0
- package/dist/cache/index.d.ts +19 -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/infisical.js → index-j1ybktbr.js} +500 -538
- 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/sitemap/index.d.ts +48 -6
- package/dist/src/api-wrapper/index.js +60 -1130
- 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 +739 -3145
- package/dist/src/jwt/index.js +138 -92
- package/dist/src/p-queue/index.js +9 -437
- package/dist/src/secrets/index.js +44 -1428
- package/dist/src/secrets/vite.js +6 -39
- package/dist/src/secrets/worker.js +10 -273
- package/dist/src/sitemap/index.js +183 -511
- package/dist/src/storage/index.js +119 -0
- package/dist/src/zod/index.js +340 -167
- 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 +31 -24
- 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 +78 -7
- package/src/api-wrapper/actions-impl.ts +11 -72
- package/src/api-wrapper/builders.ts +32 -25
- package/src/api-wrapper/create-client.ts +171 -41
- 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 +107 -2
- package/src/api-wrapper/headers.ts +11 -6
- 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 +42 -0
- package/src/api-wrapper/shared.ts +21 -2
- package/src/api-wrapper/testing.ts +48 -0
- package/src/api-wrapper/transport.ts +41 -43
- package/src/api-wrapper/types.ts +185 -18
- package/src/array/index.ts +195 -42
- package/src/cache/createCache.ts +157 -0
- package/src/cache/index.ts +132 -147
- 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/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 +58 -38
- 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/loaders.js +0 -1529
- package/dist/zod/index.js +0 -210
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ZodError } from 'zod'
|
|
2
|
+
import { buildQueryString } from '../url'
|
|
2
3
|
import { type ConfigError, RequestValidationError } from './errors'
|
|
3
4
|
import { normalizeHeaders } from './headers'
|
|
4
5
|
import type {
|
|
@@ -12,7 +13,7 @@ import type { ApiEndpoint } from './types'
|
|
|
12
13
|
export type BuildUrlOptions = {
|
|
13
14
|
endpoint: ApiEndpoint
|
|
14
15
|
params: PathParams | undefined
|
|
15
|
-
queryParams:
|
|
16
|
+
queryParams: QueryParams
|
|
16
17
|
baseApiUrls: Record<string, BaseApiUrl>
|
|
17
18
|
defaultBaseApiUrl: string | undefined
|
|
18
19
|
url: string | undefined
|
|
@@ -20,18 +21,6 @@ export type BuildUrlOptions = {
|
|
|
20
21
|
configError: (msg: string) => ConfigError
|
|
21
22
|
}
|
|
22
23
|
|
|
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
24
|
export function buildUrl(options: BuildUrlOptions) {
|
|
36
25
|
const {
|
|
37
26
|
endpoint,
|
|
@@ -46,7 +35,7 @@ export function buildUrl(options: BuildUrlOptions) {
|
|
|
46
35
|
|
|
47
36
|
let finalUrl: string
|
|
48
37
|
if (url) {
|
|
49
|
-
finalUrl =
|
|
38
|
+
finalUrl = replaceUrlPathParams(url, params, configError)
|
|
50
39
|
} else {
|
|
51
40
|
const defaultBaseUrl = defaultBaseApiUrl ?? Object.keys(baseApiUrls)[0]
|
|
52
41
|
if (!defaultBaseUrl) {
|
|
@@ -66,10 +55,15 @@ export function buildUrl(options: BuildUrlOptions) {
|
|
|
66
55
|
? endpoint.path(baseUrl)
|
|
67
56
|
: endpoint.path
|
|
68
57
|
// Substitute endpoint parameters without interpreting a port or IPv6 host as one.
|
|
69
|
-
finalUrl =
|
|
58
|
+
finalUrl = joinUrlPath(
|
|
59
|
+
baseUrl,
|
|
60
|
+
replacePathParams(path, params, configError)
|
|
61
|
+
)
|
|
70
62
|
}
|
|
71
63
|
|
|
72
|
-
const queryString =
|
|
64
|
+
const queryString = buildQueryString(queryParams, {
|
|
65
|
+
encode: !doNotEncodeQueryParams
|
|
66
|
+
})
|
|
73
67
|
return {
|
|
74
68
|
finalUrl,
|
|
75
69
|
fullUrl: queryString ? `${finalUrl}?${queryString}` : finalUrl
|
|
@@ -97,7 +91,7 @@ export function buildQueryParams(
|
|
|
97
91
|
endpoint: ApiEndpoint,
|
|
98
92
|
query: QueryParams | undefined,
|
|
99
93
|
ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
|
|
100
|
-
):
|
|
94
|
+
): QueryParams {
|
|
101
95
|
const defaultParams = endpoint.reqDefaultQueryParams ?? {}
|
|
102
96
|
let parsedQuery: QueryParams
|
|
103
97
|
if (endpoint.reqQuerySchema) {
|
|
@@ -112,13 +106,7 @@ export function buildQueryParams(
|
|
|
112
106
|
} else {
|
|
113
107
|
parsedQuery = query ?? {}
|
|
114
108
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return Object.fromEntries(
|
|
118
|
-
Object.entries(merged)
|
|
119
|
-
.filter(([, value]) => value !== undefined)
|
|
120
|
-
.map(([key, value]) => [key, String(value)])
|
|
121
|
-
)
|
|
109
|
+
return { ...defaultParams, ...parsedQuery }
|
|
122
110
|
}
|
|
123
111
|
|
|
124
112
|
export function buildRequestBody(
|
|
@@ -185,12 +173,31 @@ export function buildHeaders(
|
|
|
185
173
|
})
|
|
186
174
|
}
|
|
187
175
|
|
|
176
|
+
/** Joins with exactly one `/`; empty paths and query-only paths are appended as-is. */
|
|
177
|
+
export function joinUrlPath(base: string, path: string): string {
|
|
178
|
+
if (!path || path.startsWith('?')) return `${base}${path}`
|
|
179
|
+
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Substitutes `:params` in the path of a full URL only, so ports are left alone. */
|
|
183
|
+
function replaceUrlPathParams(
|
|
184
|
+
url: string,
|
|
185
|
+
params: PathParams | undefined,
|
|
186
|
+
configError: (msg: string) => ConfigError
|
|
187
|
+
): string {
|
|
188
|
+
const origin = url.match(/^[a-z][a-z\d+.-]*:\/\/[^/?#]*/i)?.[0] ?? ''
|
|
189
|
+
const rest = url.slice(origin.length)
|
|
190
|
+
const suffixIndex = rest.search(/[?#]/)
|
|
191
|
+
const path = suffixIndex === -1 ? rest : rest.slice(0, suffixIndex)
|
|
192
|
+
return `${origin}${replacePathParams(path, params, configError)}${rest.slice(path.length)}`
|
|
193
|
+
}
|
|
194
|
+
|
|
188
195
|
export function replacePathParams(
|
|
189
196
|
path: string,
|
|
190
197
|
params: PathParams | undefined,
|
|
191
198
|
configError: (msg: string) => ConfigError
|
|
192
199
|
): string {
|
|
193
|
-
return path.replace(/:(\w
|
|
200
|
+
return path.replace(/:([A-Za-z_]\w*)/g, (_, key: string) => {
|
|
194
201
|
const value = params?.[key]
|
|
195
202
|
if (value === undefined) {
|
|
196
203
|
throw configError(`Missing required path parameter: ${key}`)
|
|
@@ -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'
|
|
@@ -20,9 +21,16 @@ import {
|
|
|
20
21
|
buildRequestBody,
|
|
21
22
|
buildUrl
|
|
22
23
|
} from './builders'
|
|
24
|
+
import { err, exponentialBackoff, ok } from '../promise'
|
|
23
25
|
import { createActionCache, makeDefineAction } from './actions-impl'
|
|
24
26
|
import { reportApiError } from './error-reporting'
|
|
25
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
ActionError,
|
|
29
|
+
ApiError,
|
|
30
|
+
ApiResponseError,
|
|
31
|
+
ConfigError,
|
|
32
|
+
RequestValidationError
|
|
33
|
+
} from './errors'
|
|
26
34
|
import { calculateSizeInMb } from './size'
|
|
27
35
|
import {
|
|
28
36
|
defaultShouldRetry,
|
|
@@ -33,13 +41,15 @@ import {
|
|
|
33
41
|
|
|
34
42
|
type AnyApiAction = ApiAction<unknown, unknown>
|
|
35
43
|
|
|
44
|
+
// The actions overload comes first: TypeScript fixes a context-sensitive
|
|
45
|
+
// `actions` factory's parameter types on the first overload it tries.
|
|
36
46
|
export interface CreateApiClientFn {
|
|
37
|
-
<T extends Record<string, ApiEndpoint>>(
|
|
38
|
-
apiConfig: ApiClientConfigNoActions<T>
|
|
39
|
-
): ApiClient<T, Record<string, never>>
|
|
40
47
|
<T extends Record<string, ApiEndpoint>, A extends Record<string, ApiAction>>(
|
|
41
48
|
apiConfig: ApiClientConfigWithActions<T, A>
|
|
42
49
|
): ApiClient<T, A>
|
|
50
|
+
<T extends Record<string, ApiEndpoint>>(
|
|
51
|
+
apiConfig: ApiClientConfigNoActions<T>
|
|
52
|
+
): ApiClient<T, Record<string, never>>
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
function isActionInvokeOptionsOnly(
|
|
@@ -135,19 +145,25 @@ function createApiClientImpl<
|
|
|
135
145
|
}
|
|
136
146
|
|
|
137
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
|
|
138
150
|
|
|
139
151
|
let pathParams: PathParams | undefined
|
|
140
|
-
let queryParams:
|
|
152
|
+
let queryParams: QueryParams
|
|
141
153
|
let body: unknown
|
|
142
154
|
let headers: Record<string, string>
|
|
143
155
|
try {
|
|
144
156
|
pathParams = buildPathParams(endpoint, rawInput.reqParams, ctx)
|
|
145
157
|
queryParams = buildQueryParams(endpoint, rawInput.reqQuery, ctx)
|
|
146
158
|
body = buildRequestBody(endpoint, rawInput.reqBody, ctx)
|
|
159
|
+
const defaultHeaders =
|
|
160
|
+
typeof apiConfig.defaultHeaders === 'function'
|
|
161
|
+
? await apiConfig.defaultHeaders()
|
|
162
|
+
: apiConfig.defaultHeaders
|
|
147
163
|
headers = buildHeaders(
|
|
148
164
|
endpoint,
|
|
149
165
|
rawInput.reqHeaders,
|
|
150
|
-
|
|
166
|
+
defaultHeaders ?? {},
|
|
151
167
|
ctx
|
|
152
168
|
)
|
|
153
169
|
} catch (e) {
|
|
@@ -177,8 +193,10 @@ function createApiClientImpl<
|
|
|
177
193
|
baseContext.requestHeaders = headers
|
|
178
194
|
baseContext.requestBody = body
|
|
179
195
|
|
|
180
|
-
|
|
181
|
-
|
|
196
|
+
const execute = async (): Promise<
|
|
197
|
+
ApiWrapperResponse<z.infer<T[K]['resSchema']>>
|
|
198
|
+
> => {
|
|
199
|
+
if (apiConfig.onRequest) {
|
|
182
200
|
const result = await apiConfig.onRequest({
|
|
183
201
|
endpoint: endpointName,
|
|
184
202
|
method: endpoint.method,
|
|
@@ -189,32 +207,38 @@ function createApiClientImpl<
|
|
|
189
207
|
})
|
|
190
208
|
if (result?.headers) headers = result.headers
|
|
191
209
|
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
210
|
}
|
|
196
|
-
}
|
|
197
211
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
+
}
|
|
207
221
|
|
|
208
|
-
try {
|
|
209
222
|
const result = await makeRequestWithRetry({
|
|
210
223
|
config,
|
|
211
224
|
maxRetries,
|
|
212
|
-
retryDelay:
|
|
225
|
+
retryDelay:
|
|
226
|
+
apiConfig.retryDelayMs ??
|
|
227
|
+
((attempt) =>
|
|
228
|
+
exponentialBackoff(attempt + 1, {
|
|
229
|
+
baseMs: 300,
|
|
230
|
+
maxMs: apiConfig.maxRetryDelayMs
|
|
231
|
+
})),
|
|
213
232
|
maxRetryDelayMs: apiConfig.maxRetryDelayMs,
|
|
214
|
-
validateFn: (response, attempt) =>
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
+
},
|
|
218
242
|
shouldRetry: apiConfig.shouldRetry ?? defaultShouldRetry,
|
|
219
243
|
logger: apiConfig.logger,
|
|
220
244
|
ctx,
|
|
@@ -226,24 +250,76 @@ function createApiClientImpl<
|
|
|
226
250
|
onRetry: apiConfig.onRetry
|
|
227
251
|
})
|
|
228
252
|
|
|
229
|
-
const responseTimeMs = elapsed()
|
|
230
|
-
const responseSizeMb =
|
|
231
|
-
result.validatedData.responseSizeBytes / (1024 * 1024)
|
|
232
|
-
const requestBodySizeMb = calculateSizeInMb(config.body)
|
|
233
|
-
|
|
234
253
|
return {
|
|
235
254
|
data: result.validatedData.data as z.infer<T[K]['resSchema']>,
|
|
236
|
-
requestBodySizeMb,
|
|
237
|
-
responseSizeMb,
|
|
238
|
-
responseTimeMs,
|
|
255
|
+
requestBodySizeMb: calculateSizeInMb(config.body),
|
|
256
|
+
responseSizeMb: result.validatedData.responseSizeBytes / (1024 * 1024),
|
|
257
|
+
responseTimeMs: elapsed(),
|
|
239
258
|
httpStatus: result.httpStatus,
|
|
240
259
|
retryCount: result.retryCount
|
|
241
260
|
}
|
|
242
|
-
} catch (error) {
|
|
243
|
-
if (error instanceof ApiError)
|
|
244
|
-
throw await reportApiError(error, apiConfig)
|
|
245
|
-
throw error
|
|
246
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)
|
|
247
323
|
}
|
|
248
324
|
|
|
249
325
|
async function invokeAction(
|
|
@@ -273,9 +349,21 @@ function createApiClientImpl<
|
|
|
273
349
|
|
|
274
350
|
apiConfig.logger?.debug?.('Action start', { action: actionName })
|
|
275
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
|
+
|
|
276
364
|
try {
|
|
277
365
|
const result = await action.handler({
|
|
278
|
-
input:
|
|
366
|
+
input: parsedInput,
|
|
279
367
|
api: proxy,
|
|
280
368
|
signal: options.signal,
|
|
281
369
|
meta: options.meta,
|
|
@@ -329,7 +417,31 @@ function createApiClientImpl<
|
|
|
329
417
|
)
|
|
330
418
|
}) as unknown as ApiClient<T, A>['request']
|
|
331
419
|
|
|
332
|
-
|
|
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>
|
|
333
445
|
const proxy: ApiClient<T, A> = new Proxy(client, {
|
|
334
446
|
get(target, prop, receiver) {
|
|
335
447
|
if (prop in target || typeof prop !== 'string') {
|
|
@@ -364,6 +476,24 @@ function createApiClientImpl<
|
|
|
364
476
|
actions = rawActions as Record<string, AnyApiAction>
|
|
365
477
|
}
|
|
366
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
|
+
|
|
367
497
|
const collisions = Object.keys(actions).filter(
|
|
368
498
|
(key) => key in apiConfig.endpoints
|
|
369
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
|
}
|