hanzio 1.0.9 → 1.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 (40) hide show
  1. package/dist/api-wrapper/actions-impl.d.ts +4 -0
  2. package/dist/api-wrapper/builders.d.ts +12 -0
  3. package/dist/api-wrapper/create-client.d.ts +6 -0
  4. package/dist/api-wrapper/error-reporting.d.ts +7 -0
  5. package/dist/api-wrapper/errors.d.ts +63 -0
  6. package/dist/api-wrapper/headers.d.ts +9 -0
  7. package/dist/api-wrapper/index.d.ts +5 -84
  8. package/dist/api-wrapper/redaction.d.ts +2 -0
  9. package/dist/api-wrapper/shared.d.ts +16 -0
  10. package/dist/api-wrapper/size.d.ts +1 -0
  11. package/dist/api-wrapper/test-helpers.d.ts +6 -0
  12. package/dist/api-wrapper/transport.d.ts +52 -0
  13. package/dist/api-wrapper/types.d.ts +153 -0
  14. package/dist/api-wrapper/zod-issues.d.ts +17 -0
  15. package/dist/secrets/index.d.ts +10 -8
  16. package/dist/secrets/infisical.d.ts +19 -2
  17. package/dist/secrets/loaders.d.ts +16 -0
  18. package/dist/src/api-wrapper/index.js +1018 -166
  19. package/dist/src/index.js +1155 -232
  20. package/dist/src/secrets/index.js +1138 -230
  21. package/dist/src/secrets/infisical.js +1028 -173
  22. package/dist/src/secrets/loaders.js +1236 -0
  23. package/package.json +2 -2
  24. package/src/api-wrapper/actions-impl.ts +100 -0
  25. package/src/api-wrapper/builders.ts +175 -0
  26. package/src/api-wrapper/create-client.ts +373 -0
  27. package/src/api-wrapper/error-reporting.ts +31 -0
  28. package/src/api-wrapper/errors.ts +221 -0
  29. package/src/api-wrapper/headers.ts +66 -0
  30. package/src/api-wrapper/index.ts +5 -492
  31. package/src/api-wrapper/redaction.ts +61 -0
  32. package/src/api-wrapper/shared.ts +25 -0
  33. package/src/api-wrapper/size.ts +19 -0
  34. package/src/api-wrapper/test-helpers.ts +25 -0
  35. package/src/api-wrapper/transport.ts +354 -0
  36. package/src/api-wrapper/types.ts +258 -0
  37. package/src/api-wrapper/zod-issues.ts +157 -0
  38. package/src/secrets/index.ts +125 -81
  39. package/src/secrets/infisical.ts +50 -14
  40. package/src/secrets/loaders.ts +67 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanzio",
3
- "version": "1.0.9",
3
+ "version": "1.1.0",
4
4
  "description": "A lightweight set of utility functions for TypeScript",
5
5
  "type": "module",
6
6
  "module": "./dist/src/index.js",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "scripts": {
52
52
  "build": "bun run build:js && bun run build:types",
53
- "build:js": "bun build ./src/index.ts ./src/zod/index.ts ./src/api-wrapper/index.ts ./src/jwt/index.ts ./src/sitemap/index.ts ./src/p-queue/index.ts ./src/secrets/index.ts ./src/secrets/infisical.ts ./src/secrets/vite.ts --outdir ./dist --target node --packages external",
53
+ "build:js": "bun build ./src/index.ts ./src/zod/index.ts ./src/api-wrapper/index.ts ./src/jwt/index.ts ./src/sitemap/index.ts ./src/p-queue/index.ts ./src/secrets/index.ts ./src/secrets/infisical.ts ./src/secrets/loaders.ts ./src/secrets/vite.ts --outdir ./dist --target node --packages external",
54
54
  "build:types": "tsgo -p tsconfig.build.json",
55
55
  "prepublishOnly": "bun run build",
56
56
  "publish:patch": "npm version patch && npm publish",
@@ -0,0 +1,100 @@
1
+ import type { ActionCache, DefineAction } from './types'
2
+
3
+ type CacheEntry = {
4
+ value?: unknown
5
+ hasValue: boolean
6
+ expiresAt?: number
7
+ inflight?: Promise<unknown>
8
+ }
9
+
10
+ export function createActionCache(): ActionCache {
11
+ const store = new Map<string, CacheEntry>()
12
+
13
+ const isExpired = (entry: CacheEntry) =>
14
+ entry.expiresAt !== undefined && entry.expiresAt <= Date.now()
15
+
16
+ const cache = (async <T>(
17
+ key: string,
18
+ fn: () => Promise<T> | T,
19
+ options?: { ttlMs?: number }
20
+ ): Promise<T> => {
21
+ const existing = store.get(key)
22
+ if (existing && !isExpired(existing)) {
23
+ if (existing.inflight) return existing.inflight as Promise<T>
24
+ if (existing.hasValue) return existing.value as T
25
+ }
26
+
27
+ const promise = (async () => fn())()
28
+ const entry: CacheEntry = {
29
+ hasValue: false,
30
+ inflight: promise,
31
+ expiresAt:
32
+ options?.ttlMs !== undefined ? Date.now() + options.ttlMs : undefined
33
+ }
34
+ store.set(key, entry)
35
+
36
+ try {
37
+ const value = await promise
38
+ entry.value = value
39
+ entry.hasValue = true
40
+ entry.inflight = undefined
41
+ return value
42
+ } catch (error) {
43
+ store.delete(key)
44
+ throw error
45
+ }
46
+ }) as ActionCache
47
+
48
+ cache.get = <T>(key: string): T | undefined => {
49
+ const entry = store.get(key)
50
+ if (!entry || !entry.hasValue) return undefined
51
+ if (isExpired(entry)) {
52
+ store.delete(key)
53
+ return undefined
54
+ }
55
+ return entry.value as T
56
+ }
57
+
58
+ cache.set = <T>(
59
+ key: string,
60
+ value: T,
61
+ options?: { ttlMs?: number }
62
+ ): void => {
63
+ store.set(key, {
64
+ value,
65
+ hasValue: true,
66
+ expiresAt:
67
+ options?.ttlMs !== undefined ? Date.now() + options.ttlMs : undefined
68
+ })
69
+ }
70
+
71
+ cache.invalidate = (key: string): void => {
72
+ store.delete(key)
73
+ }
74
+
75
+ cache.clear = (): void => {
76
+ store.clear()
77
+ }
78
+
79
+ return cache
80
+ }
81
+
82
+ export function makeDefineAction<TApi>(): DefineAction<TApi> {
83
+ function defineAction(...args: unknown[]): unknown {
84
+ if (args.length === 0) {
85
+ return (def: { handler: unknown }) => ({
86
+ ...def,
87
+ noRuntimeInput: false as const
88
+ })
89
+ }
90
+ const def = args[0] as { handler: unknown }
91
+ return {
92
+ ...def,
93
+ noRuntimeInput: true as const
94
+ }
95
+ }
96
+ return defineAction as DefineAction<TApi>
97
+ }
98
+
99
+ // biome-ignore lint/suspicious/noExplicitAny: top-level form keeps ctx.api as any
100
+ export const defineAction: DefineAction<any> = makeDefineAction<any>()
@@ -0,0 +1,175 @@
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 function buildUrl(
13
+ endpoint: ApiEndpoint,
14
+ params: PathParams | undefined,
15
+ reqQuery: QueryParams | undefined,
16
+ baseApiUrls: Record<string, BaseApiUrl>,
17
+ defaultBaseApiUrl: string | undefined,
18
+ url: string | undefined,
19
+ configError: (msg: string) => ConfigError
20
+ ) {
21
+ if (url) {
22
+ const finalUrl = replacePathParams(url, params, configError)
23
+ const queryString = new URLSearchParams(
24
+ buildQueryParamsRaw(endpoint, reqQuery)
25
+ ).toString()
26
+ return {
27
+ finalUrl,
28
+ fullUrl: queryString ? `${finalUrl}?${queryString}` : finalUrl
29
+ }
30
+ }
31
+
32
+ const defaultBaseUrl = defaultBaseApiUrl ?? Object.keys(baseApiUrls)[0]
33
+ if (!defaultBaseUrl) {
34
+ throw configError('At least one base API URL is required')
35
+ }
36
+
37
+ const baseUrlKey = endpoint.baseApiUrl ?? defaultBaseUrl
38
+ const configBaseUrl = baseApiUrls[baseUrlKey]
39
+ if (!configBaseUrl) {
40
+ throw configError(`Unknown base API URL: ${baseUrlKey}`)
41
+ }
42
+
43
+ const baseUrl =
44
+ typeof configBaseUrl === 'function' ? configBaseUrl() : configBaseUrl
45
+ const path =
46
+ typeof endpoint.path === 'function' ? endpoint.path(baseUrl) : endpoint.path
47
+ const finalUrl = replacePathParams(`${baseUrl}${path}`, params, configError)
48
+ const queryString = new URLSearchParams(
49
+ buildQueryParamsRaw(endpoint, reqQuery)
50
+ ).toString()
51
+
52
+ return {
53
+ finalUrl,
54
+ fullUrl: queryString ? `${finalUrl}?${queryString}` : finalUrl
55
+ }
56
+ }
57
+
58
+ export function buildQueryParamsRaw(
59
+ endpoint: ApiEndpoint,
60
+ query: QueryParams | undefined
61
+ ): Record<string, string> {
62
+ const defaultParams = endpoint.reqDefaultQueryParams ?? {}
63
+ const merged = { ...defaultParams, ...(query ?? {}) }
64
+ return Object.fromEntries(
65
+ Object.entries(merged)
66
+ .filter(([, value]) => value !== undefined)
67
+ .map(([key, value]) => [key, String(value)])
68
+ )
69
+ }
70
+
71
+ export function buildQueryParams(
72
+ endpoint: ApiEndpoint,
73
+ query: QueryParams | undefined,
74
+ ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
75
+ ): Record<string, string> {
76
+ const defaultParams = endpoint.reqDefaultQueryParams ?? {}
77
+ let parsedQuery: QueryParams
78
+ if (endpoint.reqQuerySchema) {
79
+ try {
80
+ parsedQuery = endpoint.reqQuerySchema.parse(query) as QueryParams
81
+ } catch (error) {
82
+ if (error instanceof ZodError) {
83
+ throw new RequestValidationError(error, 'query', query, ctx())
84
+ }
85
+ throw error
86
+ }
87
+ } else {
88
+ parsedQuery = query ?? {}
89
+ }
90
+ const merged = { ...defaultParams, ...parsedQuery }
91
+
92
+ return Object.fromEntries(
93
+ Object.entries(merged)
94
+ .filter(([, value]) => value !== undefined)
95
+ .map(([key, value]) => [key, String(value)])
96
+ )
97
+ }
98
+
99
+ export function buildRequestBody(
100
+ endpoint: ApiEndpoint,
101
+ body: unknown,
102
+ ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
103
+ ): unknown {
104
+ if (!endpoint.reqBodySchema) return body
105
+
106
+ let parsedBody: Record<string, unknown>
107
+ try {
108
+ parsedBody = endpoint.reqBodySchema.parse(body) as Record<string, unknown>
109
+ } catch (error) {
110
+ if (error instanceof ZodError) {
111
+ throw new RequestValidationError(error, 'body', body, ctx())
112
+ }
113
+ throw error
114
+ }
115
+
116
+ if (endpoint.reqBodyFormat !== 'form-data') {
117
+ return parsedBody
118
+ }
119
+
120
+ const formData = new FormData()
121
+ for (const [key, value] of Object.entries(parsedBody)) {
122
+ if (value == null) continue
123
+ formData.set(key, value instanceof Blob ? value : String(value))
124
+ }
125
+
126
+ return formData
127
+ }
128
+
129
+ export function buildHeaders(
130
+ endpoint: ApiEndpoint,
131
+ headers: Record<string, string> | undefined,
132
+ defaultHeaders: Record<string, string>,
133
+ ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
134
+ ): Record<string, string> {
135
+ const contentType =
136
+ endpoint.reqBodyFormat === 'json' ? 'application/json' : undefined
137
+
138
+ let parsedHeaders: Record<string, string>
139
+ if (endpoint.reqHeadersSchema) {
140
+ try {
141
+ parsedHeaders = endpoint.reqHeadersSchema.parse(headers) as Record<
142
+ string,
143
+ string
144
+ >
145
+ } catch (error) {
146
+ if (error instanceof ZodError) {
147
+ throw new RequestValidationError(error, 'headers', headers, ctx())
148
+ }
149
+ throw error
150
+ }
151
+ } else {
152
+ parsedHeaders = headers ?? {}
153
+ }
154
+
155
+ return normalizeHeaders({
156
+ ...defaultHeaders,
157
+ ...(contentType && { 'Content-Type': contentType }),
158
+ ...endpoint.defaultHeaders,
159
+ ...parsedHeaders
160
+ })
161
+ }
162
+
163
+ export function replacePathParams(
164
+ path: string,
165
+ params: PathParams | undefined,
166
+ configError: (msg: string) => ConfigError
167
+ ): string {
168
+ return path.replace(/:(\w+)/g, (_, key: string) => {
169
+ const value = params?.[key]
170
+ if (value === undefined) {
171
+ throw configError(`Missing required path parameter: ${key}`)
172
+ }
173
+ return encodeURIComponent(String(value))
174
+ })
175
+ }
@@ -0,0 +1,373 @@
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
+ buildQueryParams,
19
+ buildRequestBody,
20
+ buildUrl
21
+ } from './builders'
22
+ import { createActionCache, makeDefineAction } from './actions-impl'
23
+ import { reportApiError } from './error-reporting'
24
+ import { ActionError, ApiError, ConfigError } from './errors'
25
+ import { calculateSizeInMb } from './size'
26
+ import {
27
+ defaultShouldRetry,
28
+ makeRequestWithRetry,
29
+ type RequestConfig,
30
+ validateAndTransformResponse
31
+ } from './transport'
32
+
33
+ type AnyApiAction = ApiAction<unknown, unknown>
34
+
35
+ export interface CreateApiClientFn {
36
+ <T extends Record<string, ApiEndpoint>>(
37
+ apiConfig: ApiClientConfigNoActions<T>
38
+ ): ApiClient<T, Record<string, never>>
39
+ <T extends Record<string, ApiEndpoint>, A extends Record<string, ApiAction>>(
40
+ apiConfig: ApiClientConfigWithActions<T, A>
41
+ ): ApiClient<T, A>
42
+ }
43
+
44
+ function isActionInvokeOptionsOnly(
45
+ value: unknown
46
+ ): value is ActionInvokeOptions {
47
+ if (value === null || value === undefined) return false
48
+ if (typeof value !== 'object' || Array.isArray(value)) return false
49
+ return Object.keys(value as object).every(
50
+ (k) => k === 'signal' || k === 'meta'
51
+ )
52
+ }
53
+
54
+ function createApiClientImpl<
55
+ T extends Record<string, ApiEndpoint>,
56
+ A extends Record<string, ApiAction> = Record<string, never>
57
+ >(apiConfig: ApiClientConfig<T, A>): ApiClient<T, A> {
58
+ let actions: Record<string, AnyApiAction> = {}
59
+
60
+ const cache = createActionCache()
61
+
62
+ const invokeEndpoint = async <K extends keyof T>(
63
+ endpointKey: K,
64
+ input: RequestInput<T[K]> = {} as RequestInput<T[K]>
65
+ ): Promise<ApiWrapperResponse<z.infer<T[K]['resSchema']>>> => {
66
+ const endpoint = apiConfig.endpoints[endpointKey]
67
+ const rawInput = input as RequestInput<T[K]> & {
68
+ reqBody?: unknown
69
+ reqParams?: PathParams
70
+ reqQuery?: QueryParams
71
+ reqHeaders?: Record<string, string>
72
+ }
73
+ const endpointName = apiConfig.name
74
+ ? `${apiConfig.name}.${String(endpointKey)}`
75
+ : String(endpointKey)
76
+ const startedAt = Date.now()
77
+ const maxRetries = input.retries ?? apiConfig.retries ?? 3
78
+
79
+ const baseContext: ApiErrorContext = {
80
+ endpoint: endpointName,
81
+ method: endpoint?.method ?? 'GET',
82
+ url: '',
83
+ attempt: 0,
84
+ maxRetries,
85
+ elapsedMs: 0,
86
+ meta: input.meta
87
+ }
88
+
89
+ const elapsed = () => Date.now() - startedAt
90
+ const ctx = (over: Partial<ApiErrorContext> = {}): ApiErrorContext => ({
91
+ ...baseContext,
92
+ elapsedMs: elapsed(),
93
+ ...over
94
+ })
95
+
96
+ if (!endpoint) {
97
+ throw await reportApiError(
98
+ new ConfigError(`Unknown API endpoint: ${String(endpointKey)}`, ctx()),
99
+ apiConfig
100
+ )
101
+ }
102
+
103
+ baseContext.method = endpoint.method
104
+
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>
127
+ let body: unknown
128
+ let headers: Record<string, string>
129
+ try {
130
+ queryParams = buildQueryParams(endpoint, rawInput.reqQuery, ctx)
131
+ body = buildRequestBody(endpoint, rawInput.reqBody, ctx)
132
+ headers = buildHeaders(
133
+ endpoint,
134
+ rawInput.reqHeaders,
135
+ apiConfig.defaultHeaders ?? {},
136
+ ctx
137
+ )
138
+ } catch (e) {
139
+ if (e instanceof ApiError) throw await reportApiError(e, apiConfig)
140
+ throw e
141
+ }
142
+
143
+ baseContext.requestHeaders = headers
144
+ baseContext.requestBody = body
145
+
146
+ if (apiConfig.onRequest) {
147
+ try {
148
+ const result = await apiConfig.onRequest({
149
+ endpoint: endpointName,
150
+ method: endpoint.method,
151
+ url: fullUrl,
152
+ headers,
153
+ body,
154
+ meta: input.meta
155
+ })
156
+ if (result?.headers) headers = result.headers
157
+ 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
+ }
162
+ }
163
+
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
+ }
175
+
176
+ try {
177
+ const result = await makeRequestWithRetry({
178
+ config,
179
+ maxRetries,
180
+ retryDelay: apiConfig.retryDelayMs ?? 300,
181
+ validateFn: (response) =>
182
+ validateAndTransformResponse(endpoint, response, ctx),
183
+ shouldRetry: apiConfig.shouldRetry ?? defaultShouldRetry,
184
+ logger: apiConfig.logger,
185
+ ctx,
186
+ endpointName,
187
+ method: endpoint.method,
188
+ fullUrl,
189
+ meta: input.meta,
190
+ onResponse: apiConfig.onResponse,
191
+ onRetry: apiConfig.onRetry
192
+ })
193
+
194
+ const responseTimeMs = elapsed()
195
+ const responseSizeMb = calculateSizeInMb(result.validatedData)
196
+ const requestBodySizeMb = calculateSizeInMb(config.body)
197
+
198
+ return {
199
+ data: result.validatedData as z.infer<T[K]['resSchema']>,
200
+ requestBodySizeMb,
201
+ responseSizeMb,
202
+ responseTimeMs,
203
+ httpStatus: result.httpStatus,
204
+ retryCount: result.retryCount
205
+ }
206
+ } catch (error) {
207
+ if (error instanceof ApiError)
208
+ throw await reportApiError(error, apiConfig)
209
+ throw error
210
+ }
211
+ }
212
+
213
+ async function invokeAction(
214
+ name: string,
215
+ rawInput: unknown,
216
+ options: ActionInvokeOptions = {}
217
+ ): Promise<unknown> {
218
+ const action = actions[name]!
219
+ const inputForHandler = action.noRuntimeInput ? undefined : rawInput
220
+ const actionName = apiConfig.name ? `${apiConfig.name}.${name}` : name
221
+ const startedAt = Date.now()
222
+ const baseContext: ApiErrorContext = {
223
+ endpoint: actionName,
224
+ method: 'GET',
225
+ url: '',
226
+ attempt: 0,
227
+ maxRetries: 0,
228
+ elapsedMs: 0,
229
+ meta: options.meta,
230
+ requestBody: action.noRuntimeInput ? undefined : rawInput
231
+ }
232
+ const ctx = (over: Partial<ApiErrorContext> = {}): ApiErrorContext => ({
233
+ ...baseContext,
234
+ elapsedMs: Date.now() - startedAt,
235
+ ...over
236
+ })
237
+
238
+ apiConfig.logger?.debug?.('Action start', { action: actionName })
239
+
240
+ try {
241
+ const result = await action.handler({
242
+ input: inputForHandler,
243
+ api: proxy,
244
+ signal: options.signal,
245
+ meta: options.meta,
246
+ logger: apiConfig.logger,
247
+ cache
248
+ })
249
+ apiConfig.logger?.debug?.('Action end', {
250
+ action: actionName,
251
+ elapsedMs: Date.now() - startedAt
252
+ })
253
+ return result
254
+ } catch (error) {
255
+ if (error instanceof ApiError) {
256
+ throw await reportApiError(error, apiConfig)
257
+ }
258
+ const wrapped = new ActionError(
259
+ `Action ${actionName} failed: ${error instanceof Error ? error.message : String(error)}`,
260
+ ctx(),
261
+ error
262
+ )
263
+ throw await reportApiError(wrapped, apiConfig)
264
+ }
265
+ }
266
+
267
+ const request = (async (
268
+ key: string,
269
+ input?: unknown,
270
+ options?: ActionInvokeOptions
271
+ ): Promise<unknown> => {
272
+ 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)
287
+ }
288
+ return invokeEndpoint(
289
+ key as keyof T,
290
+ (input ?? {}) as RequestInput<T[keyof T]>
291
+ )
292
+ }) as unknown as ApiClient<T, A>['request']
293
+
294
+ const client = { request, cache } as ApiClient<T, A>
295
+ const proxy: ApiClient<T, A> = new Proxy(client, {
296
+ get(target, prop, receiver) {
297
+ if (prop in target || typeof prop !== 'string') {
298
+ return Reflect.get(target, prop, receiver)
299
+ }
300
+ if (prop in apiConfig.endpoints) {
301
+ return (input?: RequestInput<T[keyof T]>) =>
302
+ invokeEndpoint(
303
+ prop as keyof T,
304
+ input ?? ({} as RequestInput<T[keyof T]>)
305
+ )
306
+ }
307
+ 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)
328
+ }
329
+ return undefined
330
+ }
331
+ })
332
+
333
+ const rawActions = apiConfig.actions
334
+ if (typeof rawActions === 'function') {
335
+ actions = (
336
+ rawActions as (
337
+ helpers: ActionsFactoryHelpers<T, A>
338
+ ) => Record<string, AnyApiAction>
339
+ )({
340
+ api: proxy,
341
+ defineAction: makeDefineAction<ApiClient<T, A>>()
342
+ }) as Record<string, AnyApiAction>
343
+ } else if (rawActions) {
344
+ actions = rawActions as Record<string, AnyApiAction>
345
+ }
346
+
347
+ const collisions = Object.keys(actions).filter(
348
+ (key) => key in apiConfig.endpoints
349
+ )
350
+ if (collisions.length > 0) {
351
+ throw new ConfigError(
352
+ `Action name(s) collide with endpoint name(s): ${collisions.join(', ')}`,
353
+ {
354
+ endpoint: collisions[0]!,
355
+ method: 'GET',
356
+ url: '',
357
+ attempt: 0,
358
+ maxRetries: 0,
359
+ elapsedMs: 0
360
+ }
361
+ )
362
+ }
363
+
364
+ return proxy
365
+ }
366
+
367
+ export const createApiClient: CreateApiClientFn = ((apiConfig: unknown) =>
368
+ createApiClientImpl(
369
+ apiConfig as ApiClientConfig<
370
+ Record<string, ApiEndpoint>,
371
+ Record<string, ApiAction>
372
+ >
373
+ )) 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
+ }