hanzio 1.0.10 → 1.2.0

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