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.
Files changed (172) hide show
  1. package/README.md +832 -219
  2. package/dist/api-wrapper/builders.d.ts +15 -3
  3. package/dist/api-wrapper/create-client.d.ts +1 -1
  4. package/dist/api-wrapper/describe.d.ts +16 -0
  5. package/dist/api-wrapper/endpoint.d.ts +18 -0
  6. package/dist/api-wrapper/errors.d.ts +40 -2
  7. package/dist/api-wrapper/headers.d.ts +2 -1
  8. package/dist/api-wrapper/index.d.ts +5 -0
  9. package/dist/api-wrapper/otel.d.ts +44 -0
  10. package/dist/api-wrapper/paginate.d.ts +12 -0
  11. package/dist/api-wrapper/redaction.d.ts +2 -0
  12. package/dist/api-wrapper/shared.d.ts +16 -2
  13. package/dist/api-wrapper/testing.d.ts +23 -0
  14. package/dist/api-wrapper/transport.d.ts +12 -10
  15. package/dist/api-wrapper/types.d.ts +104 -18
  16. package/dist/array/index.d.ts +50 -4
  17. package/dist/cache/createCache.d.ts +31 -0
  18. package/dist/cache/index.d.ts +45 -2
  19. package/dist/color/contrast.d.ts +40 -0
  20. package/dist/color/index.d.ts +2 -0
  21. package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
  22. package/dist/cool-console-log/index.d.ts +34 -5
  23. package/dist/crypto/encoding.d.ts +18 -0
  24. package/dist/crypto/hash.d.ts +33 -0
  25. package/dist/crypto/index.d.ts +4 -0
  26. package/dist/crypto/sealer.d.ts +25 -0
  27. package/dist/crypto/webhook.d.ts +20 -0
  28. package/dist/date/index.d.ts +46 -2
  29. package/dist/emitter/index.d.ts +21 -0
  30. package/dist/error/index.d.ts +12 -0
  31. package/dist/format/index.d.ts +102 -0
  32. package/dist/function/index.d.ts +60 -0
  33. package/dist/guard/index.d.ts +20 -0
  34. package/dist/i18n/index.d.ts +37 -0
  35. package/dist/index-0zvtcbzy.js +224 -0
  36. package/dist/index-181e6vfw.js +29 -0
  37. package/dist/index-8r2vdzjg.js +87 -0
  38. package/dist/index-hy7kbb99.js +157 -0
  39. package/dist/{src/secrets/loaders.js → index-j1ybktbr.js} +608 -405
  40. package/dist/index-k9yx6bne.js +89 -0
  41. package/dist/index-kj1phgrx.js +169 -0
  42. package/dist/index-mnkcfyx8.js +78 -0
  43. package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
  44. package/dist/index-rp5pqghz.js +157 -0
  45. package/dist/index-srt06xd2.js +15 -0
  46. package/dist/index-wjyb9bsd.js +263 -0
  47. package/dist/index.d.ts +7 -7
  48. package/dist/jwt/index.d.ts +50 -2
  49. package/dist/math/index.d.ts +2 -2
  50. package/dist/math/linearRegressionTrend.d.ts +14 -1
  51. package/dist/math/scale.d.ts +8 -0
  52. package/dist/object/index.d.ts +19 -0
  53. package/dist/p-queue/index.d.ts +1 -1
  54. package/dist/promise/index.d.ts +97 -2
  55. package/dist/secrets/core.d.ts +47 -0
  56. package/dist/secrets/deadline.d.ts +6 -0
  57. package/dist/secrets/environment.d.ts +9 -0
  58. package/dist/secrets/errors.d.ts +11 -0
  59. package/dist/secrets/index.d.ts +13 -25
  60. package/dist/secrets/infisical-cli.d.ts +6 -0
  61. package/dist/secrets/infisical-config.d.ts +17 -0
  62. package/dist/secrets/infisical-session.d.ts +4 -0
  63. package/dist/secrets/infisical.d.ts +8 -13
  64. package/dist/secrets/loaders.d.ts +5 -12
  65. package/dist/secrets/vite.d.ts +7 -3
  66. package/dist/secrets/worker-loader.d.ts +9 -0
  67. package/dist/secrets/worker.d.ts +3 -0
  68. package/dist/sitemap/index.d.ts +48 -6
  69. package/dist/src/api-wrapper/index.js +60 -1100
  70. package/dist/src/api-wrapper/testing.js +20 -0
  71. package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
  72. package/dist/src/crypto/index.js +195 -0
  73. package/dist/src/format/index.js +211 -0
  74. package/dist/src/i18n/index.js +36 -0
  75. package/dist/src/index.js +863 -2809
  76. package/dist/src/jwt/index.js +138 -92
  77. package/dist/src/p-queue/index.js +9 -437
  78. package/dist/src/secrets/index.js +261 -1377
  79. package/dist/src/secrets/vite.js +6 -17
  80. package/dist/src/secrets/worker.js +12 -0
  81. package/dist/src/sitemap/index.js +183 -511
  82. package/dist/src/storage/index.js +119 -0
  83. package/dist/src/zod/index.js +347 -174
  84. package/dist/state/index.d.ts +21 -0
  85. package/dist/storage/index.d.ts +58 -0
  86. package/dist/string/index.d.ts +75 -2
  87. package/dist/typedswitch/index.d.ts +37 -26
  88. package/dist/types/index.d.ts +11 -0
  89. package/dist/url/index.d.ts +50 -1
  90. package/dist/zod/index.d.ts +32 -3
  91. package/dist/zod/introspect.d.ts +19 -0
  92. package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
  93. package/dist/zod/zodMinMaxFilter.d.ts +22 -3
  94. package/dist/zod/zodToTypeString.d.ts +8 -3
  95. package/package.json +87 -7
  96. package/src/api-wrapper/actions-impl.ts +11 -72
  97. package/src/api-wrapper/builders.ts +84 -52
  98. package/src/api-wrapper/create-client.ts +246 -96
  99. package/src/api-wrapper/describe.ts +157 -0
  100. package/src/api-wrapper/endpoint.ts +26 -0
  101. package/src/api-wrapper/error-reporting.ts +7 -1
  102. package/src/api-wrapper/errors.ts +117 -2
  103. package/src/api-wrapper/headers.ts +19 -1
  104. package/src/api-wrapper/index.ts +5 -0
  105. package/src/api-wrapper/otel.ts +139 -0
  106. package/src/api-wrapper/paginate.ts +28 -0
  107. package/src/api-wrapper/redaction.ts +43 -0
  108. package/src/api-wrapper/shared.ts +21 -2
  109. package/src/api-wrapper/testing.ts +48 -0
  110. package/src/api-wrapper/transport.ts +68 -74
  111. package/src/api-wrapper/types.ts +186 -18
  112. package/src/array/index.ts +195 -42
  113. package/src/cache/createCache.ts +157 -0
  114. package/src/cache/index.ts +235 -83
  115. package/src/color/contrast.ts +163 -0
  116. package/src/color/index.ts +22 -0
  117. package/src/{math → color}/metricsSeriesColors.ts +47 -49
  118. package/src/cool-console-log/index.ts +128 -41
  119. package/src/crypto/encoding.ts +83 -0
  120. package/src/crypto/hash.ts +170 -0
  121. package/src/crypto/index.ts +26 -0
  122. package/src/crypto/sealer.ts +168 -0
  123. package/src/crypto/webhook.ts +86 -0
  124. package/src/date/index.ts +109 -34
  125. package/src/emitter/index.ts +82 -0
  126. package/src/error/index.ts +63 -0
  127. package/src/format/index.ts +389 -0
  128. package/src/function/index.ts +201 -0
  129. package/src/guard/index.ts +80 -0
  130. package/src/i18n/index.ts +125 -0
  131. package/src/index.ts +11 -7
  132. package/src/jwt/index.ts +199 -125
  133. package/src/math/index.ts +5 -10
  134. package/src/math/linearRegressionTrend.ts +33 -8
  135. package/src/math/scale.ts +17 -0
  136. package/src/object/index.ts +99 -0
  137. package/src/p-queue/index.ts +2 -0
  138. package/src/promise/index.ts +304 -9
  139. package/src/secrets/core.ts +348 -0
  140. package/src/secrets/deadline.ts +69 -0
  141. package/src/secrets/environment.ts +69 -0
  142. package/src/secrets/errors.ts +29 -0
  143. package/src/secrets/index.ts +79 -210
  144. package/src/secrets/infisical-cli.ts +99 -0
  145. package/src/secrets/infisical-config.ts +70 -0
  146. package/src/secrets/infisical-session.ts +42 -0
  147. package/src/secrets/infisical.ts +214 -66
  148. package/src/secrets/loaders.ts +24 -48
  149. package/src/secrets/vite.ts +44 -15
  150. package/src/secrets/worker-loader.ts +56 -0
  151. package/src/secrets/worker.ts +4 -0
  152. package/src/sitemap/index.ts +293 -130
  153. package/src/state/index.ts +133 -81
  154. package/src/storage/index.ts +187 -0
  155. package/src/string/index.ts +261 -23
  156. package/src/typedswitch/index.ts +134 -103
  157. package/src/types/index.ts +14 -0
  158. package/src/url/index.ts +146 -1
  159. package/src/zod/index.ts +105 -9
  160. package/src/zod/introspect.ts +103 -0
  161. package/src/zod/jsonToZodSchemaMapper.ts +44 -21
  162. package/src/zod/zodMinMaxFilter.ts +37 -13
  163. package/src/zod/zodToTypeString.ts +184 -181
  164. package/dist/api-wrapper/index.js +0 -250
  165. package/dist/gzip/index.d.ts +0 -2
  166. package/dist/gzip/index.js +0 -16
  167. package/dist/index.js +0 -1823
  168. package/dist/jwt/index.js +0 -19
  169. package/dist/sitemap/index.js +0 -552
  170. package/dist/src/gzip/index.js +0 -16
  171. package/dist/src/secrets/infisical.js +0 -1194
  172. 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 { ActionError, ApiError, ConfigError } from './errors'
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 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>
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
- apiConfig.defaultHeaders ?? {},
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
- if (apiConfig.onRequest) {
147
- try {
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
- 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
- }
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: apiConfig.retryDelayMs ?? 300,
181
- validateFn: (response) =>
182
- validateAndTransformResponse(endpoint, response, ctx),
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: inputForHandler,
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
- 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)
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
- const client = { request, cache } as ApiClient<T, A>
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
- 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)
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
  }