hanzio 1.2.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +786 -239
  2. package/dist/api-wrapper/builders.d.ts +4 -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 +39 -2
  7. package/dist/api-wrapper/index.d.ts +5 -0
  8. package/dist/api-wrapper/otel.d.ts +44 -0
  9. package/dist/api-wrapper/paginate.d.ts +12 -0
  10. package/dist/api-wrapper/redaction.d.ts +2 -0
  11. package/dist/api-wrapper/shared.d.ts +16 -2
  12. package/dist/api-wrapper/testing.d.ts +23 -0
  13. package/dist/api-wrapper/transport.d.ts +4 -5
  14. package/dist/api-wrapper/types.d.ts +103 -18
  15. package/dist/array/index.d.ts +50 -4
  16. package/dist/cache/createCache.d.ts +31 -0
  17. package/dist/cache/index.d.ts +19 -2
  18. package/dist/color/contrast.d.ts +40 -0
  19. package/dist/color/index.d.ts +2 -0
  20. package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
  21. package/dist/cool-console-log/index.d.ts +34 -5
  22. package/dist/crypto/encoding.d.ts +18 -0
  23. package/dist/crypto/hash.d.ts +33 -0
  24. package/dist/crypto/index.d.ts +4 -0
  25. package/dist/crypto/sealer.d.ts +25 -0
  26. package/dist/crypto/webhook.d.ts +20 -0
  27. package/dist/date/index.d.ts +46 -2
  28. package/dist/emitter/index.d.ts +21 -0
  29. package/dist/error/index.d.ts +12 -0
  30. package/dist/format/index.d.ts +102 -0
  31. package/dist/function/index.d.ts +60 -0
  32. package/dist/guard/index.d.ts +20 -0
  33. package/dist/i18n/index.d.ts +37 -0
  34. package/dist/index-0zvtcbzy.js +224 -0
  35. package/dist/index-8r2vdzjg.js +87 -0
  36. package/dist/index-hy7kbb99.js +157 -0
  37. package/dist/index-hyt6c56h.js +290 -0
  38. package/dist/{src/secrets/infisical.js → index-j1ybktbr.js} +500 -538
  39. package/dist/index-k9yx6bne.js +89 -0
  40. package/dist/index-kj1phgrx.js +169 -0
  41. package/dist/index-ktynw3vs.js +37 -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-w882zry3.js +29 -0
  46. package/dist/index.d.ts +7 -7
  47. package/dist/jwt/index.d.ts +50 -2
  48. package/dist/math/index.d.ts +2 -2
  49. package/dist/math/linearRegressionTrend.d.ts +14 -1
  50. package/dist/math/scale.d.ts +8 -0
  51. package/dist/object/index.d.ts +19 -0
  52. package/dist/p-queue/index.d.ts +1 -1
  53. package/dist/promise/index.d.ts +97 -2
  54. package/dist/secrets/core.d.ts +7 -0
  55. package/dist/secrets/index.d.ts +3 -1
  56. package/dist/secrets/public-config.d.ts +7 -0
  57. package/dist/secrets/vite.d.ts +1 -0
  58. package/dist/secrets/worker.d.ts +1 -0
  59. package/dist/sitemap/index.d.ts +48 -6
  60. package/dist/src/api-wrapper/index.js +60 -1130
  61. package/dist/src/api-wrapper/testing.js +20 -0
  62. package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
  63. package/dist/src/crypto/index.js +195 -0
  64. package/dist/src/format/index.js +211 -0
  65. package/dist/src/i18n/index.js +36 -0
  66. package/dist/src/index.js +739 -3145
  67. package/dist/src/jwt/index.js +138 -92
  68. package/dist/src/p-queue/index.js +9 -437
  69. package/dist/src/secrets/index.js +50 -1428
  70. package/dist/src/secrets/vite.js +10 -39
  71. package/dist/src/secrets/worker.js +14 -273
  72. package/dist/src/sitemap/index.js +183 -511
  73. package/dist/src/storage/index.js +119 -0
  74. package/dist/src/zod/index.js +340 -167
  75. package/dist/state/index.d.ts +21 -0
  76. package/dist/storage/index.d.ts +58 -0
  77. package/dist/string/index.d.ts +75 -2
  78. package/dist/typedswitch/index.d.ts +31 -24
  79. package/dist/types/index.d.ts +11 -0
  80. package/dist/url/index.d.ts +50 -1
  81. package/dist/zod/index.d.ts +32 -3
  82. package/dist/zod/introspect.d.ts +19 -0
  83. package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
  84. package/dist/zod/zodMinMaxFilter.d.ts +22 -3
  85. package/dist/zod/zodToTypeString.d.ts +8 -3
  86. package/package.json +78 -7
  87. package/src/api-wrapper/actions-impl.ts +11 -72
  88. package/src/api-wrapper/builders.ts +32 -25
  89. package/src/api-wrapper/create-client.ts +171 -41
  90. package/src/api-wrapper/describe.ts +157 -0
  91. package/src/api-wrapper/endpoint.ts +26 -0
  92. package/src/api-wrapper/error-reporting.ts +7 -1
  93. package/src/api-wrapper/errors.ts +107 -2
  94. package/src/api-wrapper/headers.ts +11 -6
  95. package/src/api-wrapper/index.ts +5 -0
  96. package/src/api-wrapper/otel.ts +139 -0
  97. package/src/api-wrapper/paginate.ts +28 -0
  98. package/src/api-wrapper/redaction.ts +42 -0
  99. package/src/api-wrapper/shared.ts +21 -2
  100. package/src/api-wrapper/testing.ts +48 -0
  101. package/src/api-wrapper/transport.ts +41 -43
  102. package/src/api-wrapper/types.ts +185 -18
  103. package/src/array/index.ts +195 -42
  104. package/src/cache/createCache.ts +157 -0
  105. package/src/cache/index.ts +132 -147
  106. package/src/color/contrast.ts +163 -0
  107. package/src/color/index.ts +22 -0
  108. package/src/{math → color}/metricsSeriesColors.ts +47 -49
  109. package/src/cool-console-log/index.ts +128 -41
  110. package/src/crypto/encoding.ts +83 -0
  111. package/src/crypto/hash.ts +170 -0
  112. package/src/crypto/index.ts +26 -0
  113. package/src/crypto/sealer.ts +168 -0
  114. package/src/crypto/webhook.ts +86 -0
  115. package/src/date/index.ts +109 -34
  116. package/src/emitter/index.ts +82 -0
  117. package/src/error/index.ts +63 -0
  118. package/src/format/index.ts +389 -0
  119. package/src/function/index.ts +201 -0
  120. package/src/guard/index.ts +80 -0
  121. package/src/i18n/index.ts +125 -0
  122. package/src/index.ts +11 -7
  123. package/src/jwt/index.ts +199 -125
  124. package/src/math/index.ts +5 -10
  125. package/src/math/linearRegressionTrend.ts +33 -8
  126. package/src/math/scale.ts +17 -0
  127. package/src/object/index.ts +99 -0
  128. package/src/p-queue/index.ts +2 -0
  129. package/src/promise/index.ts +304 -9
  130. package/src/secrets/core.ts +72 -16
  131. package/src/secrets/index.ts +16 -4
  132. package/src/secrets/public-config.ts +47 -0
  133. package/src/secrets/vite.ts +2 -0
  134. package/src/secrets/worker.ts +2 -0
  135. package/src/sitemap/index.ts +293 -130
  136. package/src/state/index.ts +133 -81
  137. package/src/storage/index.ts +187 -0
  138. package/src/string/index.ts +261 -23
  139. package/src/typedswitch/index.ts +58 -38
  140. package/src/types/index.ts +14 -0
  141. package/src/url/index.ts +146 -1
  142. package/src/zod/index.ts +105 -9
  143. package/src/zod/introspect.ts +103 -0
  144. package/src/zod/jsonToZodSchemaMapper.ts +44 -21
  145. package/src/zod/zodMinMaxFilter.ts +37 -13
  146. package/src/zod/zodToTypeString.ts +184 -181
  147. package/dist/api-wrapper/index.js +0 -250
  148. package/dist/gzip/index.d.ts +0 -2
  149. package/dist/gzip/index.js +0 -16
  150. package/dist/index.js +0 -1823
  151. package/dist/jwt/index.js +0 -19
  152. package/dist/sitemap/index.js +0 -552
  153. package/dist/src/gzip/index.js +0 -16
  154. package/dist/src/secrets/loaders.js +0 -1529
  155. 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: Record<string, string>
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 = replacePathParams(url, params, configError)
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 = `${baseUrl}${replacePathParams(path, params, configError)}`
58
+ finalUrl = joinUrlPath(
59
+ baseUrl,
60
+ replacePathParams(path, params, configError)
61
+ )
70
62
  }
71
63
 
72
- const queryString = formatQueryString(queryParams, doNotEncodeQueryParams)
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
- ): Record<string, string> {
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
- const merged = { ...defaultParams, ...parsedQuery }
116
-
117
- return Object.fromEntries(
118
- Object.entries(merged)
119
- .filter(([, value]) => value !== undefined)
120
- .map(([key, value]) => [key, String(value)])
121
- )
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+)/g, (_, key: string) => {
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 { ActionError, ApiError, ConfigError } from './errors'
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: Record<string, string>
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
- apiConfig.defaultHeaders ?? {},
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
- if (apiConfig.onRequest) {
181
- try {
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
- const config: RequestConfig = {
199
- method: endpoint.method,
200
- fullUrl,
201
- body,
202
- headers,
203
- timeoutMs: input.timeoutMs ?? apiConfig.timeoutMs,
204
- fetchFn: input.fetch ?? apiConfig.fetch ?? fetch,
205
- userSignal: input.signal
206
- }
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: apiConfig.retryDelayMs ?? 300,
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
- validateAndTransformResponse(endpoint, response, (over) =>
216
- ctx({ attempt, ...over })
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: inputForHandler,
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
- 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>
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
  }