hanzio 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +786 -239
- package/dist/api-wrapper/builders.d.ts +4 -3
- package/dist/api-wrapper/create-client.d.ts +1 -1
- package/dist/api-wrapper/describe.d.ts +16 -0
- package/dist/api-wrapper/endpoint.d.ts +18 -0
- package/dist/api-wrapper/errors.d.ts +39 -2
- package/dist/api-wrapper/index.d.ts +5 -0
- package/dist/api-wrapper/otel.d.ts +44 -0
- package/dist/api-wrapper/paginate.d.ts +12 -0
- package/dist/api-wrapper/redaction.d.ts +2 -0
- package/dist/api-wrapper/shared.d.ts +16 -2
- package/dist/api-wrapper/testing.d.ts +23 -0
- package/dist/api-wrapper/transport.d.ts +4 -5
- package/dist/api-wrapper/types.d.ts +103 -18
- package/dist/array/index.d.ts +50 -4
- package/dist/cache/createCache.d.ts +31 -0
- package/dist/cache/index.d.ts +19 -2
- package/dist/color/contrast.d.ts +40 -0
- package/dist/color/index.d.ts +2 -0
- package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
- package/dist/cool-console-log/index.d.ts +34 -5
- package/dist/crypto/encoding.d.ts +18 -0
- package/dist/crypto/hash.d.ts +33 -0
- package/dist/crypto/index.d.ts +4 -0
- package/dist/crypto/sealer.d.ts +25 -0
- package/dist/crypto/webhook.d.ts +20 -0
- package/dist/date/index.d.ts +46 -2
- package/dist/emitter/index.d.ts +21 -0
- package/dist/error/index.d.ts +12 -0
- package/dist/format/index.d.ts +102 -0
- package/dist/function/index.d.ts +60 -0
- package/dist/guard/index.d.ts +20 -0
- package/dist/i18n/index.d.ts +37 -0
- package/dist/index-0zvtcbzy.js +224 -0
- package/dist/index-181e6vfw.js +29 -0
- package/dist/index-8r2vdzjg.js +87 -0
- package/dist/index-hy7kbb99.js +157 -0
- package/dist/{src/secrets/infisical.js → index-j1ybktbr.js} +500 -538
- package/dist/index-k9yx6bne.js +89 -0
- package/dist/index-kj1phgrx.js +169 -0
- package/dist/index-mnkcfyx8.js +78 -0
- package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
- package/dist/index-rp5pqghz.js +157 -0
- package/dist/index-srt06xd2.js +15 -0
- package/dist/index-wjyb9bsd.js +263 -0
- package/dist/index.d.ts +7 -7
- package/dist/jwt/index.d.ts +50 -2
- package/dist/math/index.d.ts +2 -2
- package/dist/math/linearRegressionTrend.d.ts +14 -1
- package/dist/math/scale.d.ts +8 -0
- package/dist/object/index.d.ts +19 -0
- package/dist/p-queue/index.d.ts +1 -1
- package/dist/promise/index.d.ts +97 -2
- package/dist/sitemap/index.d.ts +48 -6
- package/dist/src/api-wrapper/index.js +60 -1130
- package/dist/src/api-wrapper/testing.js +20 -0
- package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
- package/dist/src/crypto/index.js +195 -0
- package/dist/src/format/index.js +211 -0
- package/dist/src/i18n/index.js +36 -0
- package/dist/src/index.js +739 -3145
- package/dist/src/jwt/index.js +138 -92
- package/dist/src/p-queue/index.js +9 -437
- package/dist/src/secrets/index.js +44 -1428
- package/dist/src/secrets/vite.js +6 -39
- package/dist/src/secrets/worker.js +10 -273
- package/dist/src/sitemap/index.js +183 -511
- package/dist/src/storage/index.js +119 -0
- package/dist/src/zod/index.js +340 -167
- package/dist/state/index.d.ts +21 -0
- package/dist/storage/index.d.ts +58 -0
- package/dist/string/index.d.ts +75 -2
- package/dist/typedswitch/index.d.ts +31 -24
- package/dist/types/index.d.ts +11 -0
- package/dist/url/index.d.ts +50 -1
- package/dist/zod/index.d.ts +32 -3
- package/dist/zod/introspect.d.ts +19 -0
- package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
- package/dist/zod/zodMinMaxFilter.d.ts +22 -3
- package/dist/zod/zodToTypeString.d.ts +8 -3
- package/package.json +78 -7
- package/src/api-wrapper/actions-impl.ts +11 -72
- package/src/api-wrapper/builders.ts +32 -25
- package/src/api-wrapper/create-client.ts +171 -41
- package/src/api-wrapper/describe.ts +157 -0
- package/src/api-wrapper/endpoint.ts +26 -0
- package/src/api-wrapper/error-reporting.ts +7 -1
- package/src/api-wrapper/errors.ts +107 -2
- package/src/api-wrapper/headers.ts +11 -6
- package/src/api-wrapper/index.ts +5 -0
- package/src/api-wrapper/otel.ts +139 -0
- package/src/api-wrapper/paginate.ts +28 -0
- package/src/api-wrapper/redaction.ts +42 -0
- package/src/api-wrapper/shared.ts +21 -2
- package/src/api-wrapper/testing.ts +48 -0
- package/src/api-wrapper/transport.ts +41 -43
- package/src/api-wrapper/types.ts +185 -18
- package/src/array/index.ts +195 -42
- package/src/cache/createCache.ts +157 -0
- package/src/cache/index.ts +132 -147
- package/src/color/contrast.ts +163 -0
- package/src/color/index.ts +22 -0
- package/src/{math → color}/metricsSeriesColors.ts +47 -49
- package/src/cool-console-log/index.ts +128 -41
- package/src/crypto/encoding.ts +83 -0
- package/src/crypto/hash.ts +170 -0
- package/src/crypto/index.ts +26 -0
- package/src/crypto/sealer.ts +168 -0
- package/src/crypto/webhook.ts +86 -0
- package/src/date/index.ts +109 -34
- package/src/emitter/index.ts +82 -0
- package/src/error/index.ts +63 -0
- package/src/format/index.ts +389 -0
- package/src/function/index.ts +201 -0
- package/src/guard/index.ts +80 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +11 -7
- package/src/jwt/index.ts +199 -125
- package/src/math/index.ts +5 -10
- package/src/math/linearRegressionTrend.ts +33 -8
- package/src/math/scale.ts +17 -0
- package/src/object/index.ts +99 -0
- package/src/p-queue/index.ts +2 -0
- package/src/promise/index.ts +304 -9
- package/src/sitemap/index.ts +293 -130
- package/src/state/index.ts +133 -81
- package/src/storage/index.ts +187 -0
- package/src/string/index.ts +261 -23
- package/src/typedswitch/index.ts +58 -38
- package/src/types/index.ts +14 -0
- package/src/url/index.ts +146 -1
- package/src/zod/index.ts +105 -9
- package/src/zod/introspect.ts +103 -0
- package/src/zod/jsonToZodSchemaMapper.ts +44 -21
- package/src/zod/zodMinMaxFilter.ts +37 -13
- package/src/zod/zodToTypeString.ts +184 -181
- package/dist/api-wrapper/index.js +0 -250
- package/dist/gzip/index.d.ts +0 -2
- package/dist/gzip/index.js +0 -16
- package/dist/index.js +0 -1823
- package/dist/jwt/index.js +0 -19
- package/dist/sitemap/index.js +0 -552
- package/dist/src/gzip/index.js +0 -16
- package/dist/src/secrets/loaders.js +0 -1529
- package/dist/zod/index.js +0 -210
|
@@ -11,10 +11,38 @@ import {
|
|
|
11
11
|
|
|
12
12
|
const MAX_BODY_PREVIEW = 500
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Where a request failed, for logs and alerts:
|
|
16
|
+
* - `server_error`: the API returned 5xx
|
|
17
|
+
* - `client_error`: the API rejected the request (4xx: auth, params, body)
|
|
18
|
+
* - `input_validation`: the caller's data failed a request schema; nothing was sent
|
|
19
|
+
* - `output_validation`: the response wasn't JSON or didn't match `resSchema`
|
|
20
|
+
* - `response_rejected`: `checkResponse` rejected a valid response
|
|
21
|
+
* - `network` / `timeout` / `aborted`: the request didn't complete
|
|
22
|
+
* - `config`: the client or call is misconfigured
|
|
23
|
+
* - `action`: a composite action handler threw
|
|
24
|
+
*/
|
|
25
|
+
export type ApiErrorCategory =
|
|
26
|
+
| 'server_error'
|
|
27
|
+
| 'client_error'
|
|
28
|
+
| 'input_validation'
|
|
29
|
+
| 'output_validation'
|
|
30
|
+
| 'response_rejected'
|
|
31
|
+
| 'network'
|
|
32
|
+
| 'timeout'
|
|
33
|
+
| 'aborted'
|
|
34
|
+
| 'config'
|
|
35
|
+
| 'action'
|
|
36
|
+
| 'unknown'
|
|
37
|
+
|
|
14
38
|
export class ApiError extends Error {
|
|
15
39
|
public context: ApiErrorContext
|
|
16
40
|
public override cause?: unknown
|
|
17
41
|
|
|
42
|
+
get category(): ApiErrorCategory {
|
|
43
|
+
return 'unknown'
|
|
44
|
+
}
|
|
45
|
+
|
|
18
46
|
constructor(
|
|
19
47
|
message: string,
|
|
20
48
|
context: ApiErrorContext,
|
|
@@ -29,6 +57,7 @@ export class ApiError extends Error {
|
|
|
29
57
|
toJSON(): Record<string, unknown> {
|
|
30
58
|
return {
|
|
31
59
|
name: this.name,
|
|
60
|
+
category: this.category,
|
|
32
61
|
message: this.message,
|
|
33
62
|
context: this.context
|
|
34
63
|
}
|
|
@@ -66,6 +95,10 @@ export class HttpResponseError extends ApiError {
|
|
|
66
95
|
this.bodyJson = tryParseJson(body)
|
|
67
96
|
}
|
|
68
97
|
|
|
98
|
+
override get category(): ApiErrorCategory {
|
|
99
|
+
return this.status >= 500 ? 'server_error' : 'client_error'
|
|
100
|
+
}
|
|
101
|
+
|
|
69
102
|
override toJSON(): Record<string, unknown> {
|
|
70
103
|
return {
|
|
71
104
|
...super.toJSON(),
|
|
@@ -83,11 +116,14 @@ export class ResponseValidationError extends ApiError {
|
|
|
83
116
|
public readonly rawResponse: unknown
|
|
84
117
|
public readonly issues: FormattedZodIssue[]
|
|
85
118
|
public readonly validationIssues: string
|
|
119
|
+
/** True when the body wasn't valid JSON (rather than failing `resSchema`). */
|
|
120
|
+
public readonly invalidJson: boolean
|
|
86
121
|
|
|
87
122
|
constructor(
|
|
88
123
|
zodError: ZodError,
|
|
89
124
|
rawResponse: unknown,
|
|
90
|
-
context: ApiErrorContext
|
|
125
|
+
context: ApiErrorContext,
|
|
126
|
+
options: { invalidJson?: boolean } = {}
|
|
91
127
|
) {
|
|
92
128
|
const issues = formatZodIssues(zodError, rawResponse)
|
|
93
129
|
const summary = renderIssueSummary(issues)
|
|
@@ -97,6 +133,7 @@ export class ResponseValidationError extends ApiError {
|
|
|
97
133
|
{ cause: zodError }
|
|
98
134
|
)
|
|
99
135
|
this.name = 'ResponseValidationError'
|
|
136
|
+
this.invalidJson = options.invalidJson ?? false
|
|
100
137
|
this.zodError = zodError
|
|
101
138
|
this.rawResponse = rawResponse
|
|
102
139
|
this.issues = issues
|
|
@@ -105,9 +142,14 @@ export class ResponseValidationError extends ApiError {
|
|
|
105
142
|
.join('; ')
|
|
106
143
|
}
|
|
107
144
|
|
|
145
|
+
override get category(): ApiErrorCategory {
|
|
146
|
+
return 'output_validation'
|
|
147
|
+
}
|
|
148
|
+
|
|
108
149
|
override toJSON(): Record<string, unknown> {
|
|
109
150
|
return {
|
|
110
151
|
...super.toJSON(),
|
|
152
|
+
invalidJson: this.invalidJson,
|
|
111
153
|
issues: this.issues,
|
|
112
154
|
validationIssues: this.validationIssues,
|
|
113
155
|
rawResponsePreview: previewValue(this.rawResponse, 1000)
|
|
@@ -115,7 +157,13 @@ export class ResponseValidationError extends ApiError {
|
|
|
115
157
|
}
|
|
116
158
|
}
|
|
117
159
|
|
|
118
|
-
|
|
160
|
+
/** Which request schema failed; `input` is an action's input schema. */
|
|
161
|
+
export type RequestValidationTarget =
|
|
162
|
+
| 'body'
|
|
163
|
+
| 'query'
|
|
164
|
+
| 'params'
|
|
165
|
+
| 'headers'
|
|
166
|
+
| 'input'
|
|
119
167
|
|
|
120
168
|
export class RequestValidationError extends ApiError {
|
|
121
169
|
public readonly zodError: ZodError
|
|
@@ -147,6 +195,10 @@ export class RequestValidationError extends ApiError {
|
|
|
147
195
|
.join('; ')
|
|
148
196
|
}
|
|
149
197
|
|
|
198
|
+
override get category(): ApiErrorCategory {
|
|
199
|
+
return 'input_validation'
|
|
200
|
+
}
|
|
201
|
+
|
|
150
202
|
override toJSON(): Record<string, unknown> {
|
|
151
203
|
return {
|
|
152
204
|
...super.toJSON(),
|
|
@@ -168,6 +220,10 @@ export class RequestTimeoutError extends ApiError {
|
|
|
168
220
|
this.name = 'RequestTimeoutError'
|
|
169
221
|
this.timeoutMs = timeoutMs
|
|
170
222
|
}
|
|
223
|
+
|
|
224
|
+
override get category(): ApiErrorCategory {
|
|
225
|
+
return 'timeout'
|
|
226
|
+
}
|
|
171
227
|
}
|
|
172
228
|
|
|
173
229
|
export class NetworkError extends ApiError {
|
|
@@ -175,6 +231,10 @@ export class NetworkError extends ApiError {
|
|
|
175
231
|
super(message, context, { cause })
|
|
176
232
|
this.name = 'NetworkError'
|
|
177
233
|
}
|
|
234
|
+
|
|
235
|
+
override get category(): ApiErrorCategory {
|
|
236
|
+
return 'network'
|
|
237
|
+
}
|
|
178
238
|
}
|
|
179
239
|
|
|
180
240
|
export class RequestAbortedError extends ApiError {
|
|
@@ -184,6 +244,10 @@ export class RequestAbortedError extends ApiError {
|
|
|
184
244
|
})
|
|
185
245
|
this.name = 'RequestAbortedError'
|
|
186
246
|
}
|
|
247
|
+
|
|
248
|
+
override get category(): ApiErrorCategory {
|
|
249
|
+
return 'aborted'
|
|
250
|
+
}
|
|
187
251
|
}
|
|
188
252
|
|
|
189
253
|
export class ConfigError extends ApiError {
|
|
@@ -191,6 +255,40 @@ export class ConfigError extends ApiError {
|
|
|
191
255
|
super(message, context)
|
|
192
256
|
this.name = 'ConfigError'
|
|
193
257
|
}
|
|
258
|
+
|
|
259
|
+
override get category(): ApiErrorCategory {
|
|
260
|
+
return 'config'
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Thrown when the client's `checkResponse` rejects a response that passed
|
|
266
|
+
* schema validation (e.g. HTTP 200 with `{ success: false }`).
|
|
267
|
+
*/
|
|
268
|
+
export class ApiResponseError extends ApiError {
|
|
269
|
+
public readonly data: unknown
|
|
270
|
+
|
|
271
|
+
constructor(
|
|
272
|
+
message: string,
|
|
273
|
+
data: unknown,
|
|
274
|
+
context: ApiErrorContext,
|
|
275
|
+
cause?: unknown
|
|
276
|
+
) {
|
|
277
|
+
super(message, context, { cause })
|
|
278
|
+
this.name = 'ApiResponseError'
|
|
279
|
+
this.data = data
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
override get category(): ApiErrorCategory {
|
|
283
|
+
return 'response_rejected'
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
override toJSON(): Record<string, unknown> {
|
|
287
|
+
return {
|
|
288
|
+
...super.toJSON(),
|
|
289
|
+
dataPreview: previewValue(this.data, 1000)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
194
292
|
}
|
|
195
293
|
|
|
196
294
|
export class ActionError extends ApiError {
|
|
@@ -198,6 +296,10 @@ export class ActionError extends ApiError {
|
|
|
198
296
|
super(message, context, { cause })
|
|
199
297
|
this.name = 'ActionError'
|
|
200
298
|
}
|
|
299
|
+
|
|
300
|
+
override get category(): ApiErrorCategory {
|
|
301
|
+
return 'action'
|
|
302
|
+
}
|
|
201
303
|
}
|
|
202
304
|
|
|
203
305
|
export const isApiError = (e: unknown): e is ApiError => e instanceof ApiError
|
|
@@ -219,11 +321,14 @@ export const isConfigError = (e: unknown): e is ConfigError =>
|
|
|
219
321
|
e instanceof ConfigError
|
|
220
322
|
export const isActionError = (e: unknown): e is ActionError =>
|
|
221
323
|
e instanceof ActionError
|
|
324
|
+
export const isApiResponseError = (e: unknown): e is ApiResponseError =>
|
|
325
|
+
e instanceof ApiResponseError
|
|
222
326
|
|
|
223
327
|
export function isNonRetryableApiError(error: unknown): boolean {
|
|
224
328
|
return (
|
|
225
329
|
error instanceof HttpResponseError ||
|
|
226
330
|
error instanceof ResponseValidationError ||
|
|
331
|
+
error instanceof ApiResponseError ||
|
|
227
332
|
error instanceof RequestValidationError ||
|
|
228
333
|
error instanceof ConfigError ||
|
|
229
334
|
error instanceof RequestAbortedError
|
|
@@ -57,12 +57,17 @@ export function buildFetchBody(
|
|
|
57
57
|
if (body === undefined || body === null) return undefined
|
|
58
58
|
if (body instanceof FormData || body instanceof Blob) return body
|
|
59
59
|
if (hasJsonContentType(headers)) return JSON.stringify(body)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
(
|
|
63
|
-
Object.getPrototypeOf(body) ===
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
const isJsonLike =
|
|
61
|
+
Array.isArray(body) ||
|
|
62
|
+
(typeof body === 'object' &&
|
|
63
|
+
(Object.getPrototypeOf(body) === Object.prototype ||
|
|
64
|
+
Object.getPrototypeOf(body) === null))
|
|
65
|
+
// Objects and arrays default to JSON when no Content-Type was chosen.
|
|
66
|
+
if (isJsonLike && getHeader(headers, 'content-type') === undefined) {
|
|
67
|
+
headers['Content-Type'] = 'application/json'
|
|
68
|
+
return JSON.stringify(body)
|
|
69
|
+
}
|
|
70
|
+
if (isJsonLike && configError) {
|
|
66
71
|
throw configError(
|
|
67
72
|
'Request body is a plain object but Content-Type is not application/json'
|
|
68
73
|
)
|
package/src/api-wrapper/index.ts
CHANGED
|
@@ -3,3 +3,8 @@ export * from './types'
|
|
|
3
3
|
export * from './errors'
|
|
4
4
|
export { createApiClient } from './create-client'
|
|
5
5
|
export { defineAction } from './actions-impl'
|
|
6
|
+
export { type PaginateOptions, paginate } from './paginate'
|
|
7
|
+
export { defaultShouldRetry } from './transport'
|
|
8
|
+
export { apiErrorLogAttributes, describeApiError } from './describe'
|
|
9
|
+
export * from './otel'
|
|
10
|
+
export { defineEndpoint } from './endpoint'
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { ApiError, HttpResponseError } from './errors'
|
|
2
|
+
import { redactUrl } from './redaction'
|
|
3
|
+
import type { ApiMiddleware, ApiMiddlewareContext } from './types'
|
|
4
|
+
|
|
5
|
+
// Structural subsets of @opentelemetry/api, so hanzio never imports it. The
|
|
6
|
+
// app passes its own tracer (`trace.getTracer('app')`); a type test keeps
|
|
7
|
+
// these compatible with the real `Tracer`.
|
|
8
|
+
export type OtelAttributes = Record<
|
|
9
|
+
string,
|
|
10
|
+
string | number | boolean | undefined
|
|
11
|
+
>
|
|
12
|
+
|
|
13
|
+
export type OtelSpanLike = {
|
|
14
|
+
setAttributes(attributes: OtelAttributes): unknown
|
|
15
|
+
setStatus(status: { code: number; message?: string }): unknown
|
|
16
|
+
recordException(exception: Error | string): unknown
|
|
17
|
+
end(): void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type OtelTracerLike = {
|
|
21
|
+
startActiveSpan<F extends (span: OtelSpanLike) => unknown>(
|
|
22
|
+
name: string,
|
|
23
|
+
options: { kind?: number; attributes?: OtelAttributes },
|
|
24
|
+
fn: F
|
|
25
|
+
): ReturnType<F>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type OtelMiddlewareOptions = {
|
|
29
|
+
/** Default: `client.endpoint`, or the endpoint key when the client has no `name`. */
|
|
30
|
+
spanName?: (context: ApiMiddlewareContext) => string
|
|
31
|
+
/** Extra attributes set when the span starts. */
|
|
32
|
+
attributes?: (context: ApiMiddlewareContext) => OtelAttributes
|
|
33
|
+
/**
|
|
34
|
+
* `url.full` keeps the query string with sensitive values redacted
|
|
35
|
+
* (default). Set `false` to drop the query string entirely.
|
|
36
|
+
*/
|
|
37
|
+
includeQuery?: boolean
|
|
38
|
+
/**
|
|
39
|
+
* Adds trace-context headers to the outgoing request, e.g.
|
|
40
|
+
* `(headers) => propagation.inject(context.active(), headers)`. Runs inside
|
|
41
|
+
* the active span. Not needed when fetch is already auto-instrumented.
|
|
42
|
+
*/
|
|
43
|
+
inject?: (headers: Record<string, string>) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Frozen numeric values from the OpenTelemetry API.
|
|
47
|
+
const SPAN_KIND_CLIENT = 2
|
|
48
|
+
const SPAN_STATUS_ERROR = 2
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One CLIENT span per logical request (all retries, validation and
|
|
52
|
+
* `checkResponse`), with OpenTelemetry HTTP semantic-convention attributes.
|
|
53
|
+
* It's the active span, so auto-instrumented fetch attempts become children.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* import { trace } from '@opentelemetry/api'
|
|
57
|
+
* createApiClient({ ...config, use: [otelMiddleware(trace.getTracer('app'))] })
|
|
58
|
+
*/
|
|
59
|
+
export function otelMiddleware(
|
|
60
|
+
tracer: OtelTracerLike,
|
|
61
|
+
options: OtelMiddlewareOptions = {}
|
|
62
|
+
): ApiMiddleware {
|
|
63
|
+
const { spanName, attributes, includeQuery = true, inject } = options
|
|
64
|
+
|
|
65
|
+
return (context, next) => {
|
|
66
|
+
const url = includeQuery
|
|
67
|
+
? redactUrl(context.url)
|
|
68
|
+
: context.url.split(/[?#]/, 1)[0]!
|
|
69
|
+
const name =
|
|
70
|
+
spanName?.(context) ??
|
|
71
|
+
(context.client
|
|
72
|
+
? `${context.client}.${context.endpoint}`
|
|
73
|
+
: context.endpoint)
|
|
74
|
+
|
|
75
|
+
return tracer.startActiveSpan(
|
|
76
|
+
name,
|
|
77
|
+
{
|
|
78
|
+
kind: SPAN_KIND_CLIENT,
|
|
79
|
+
attributes: {
|
|
80
|
+
'http.request.method': context.method,
|
|
81
|
+
'url.full': url,
|
|
82
|
+
'server.address': hostOf(context.url),
|
|
83
|
+
'api.client': context.client,
|
|
84
|
+
'api.endpoint': context.endpoint,
|
|
85
|
+
...attributes?.(context)
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
async (span) => {
|
|
89
|
+
inject?.(context.headers)
|
|
90
|
+
try {
|
|
91
|
+
const result = await next()
|
|
92
|
+
span.setAttributes({
|
|
93
|
+
'http.response.status_code': result.httpStatus,
|
|
94
|
+
'http.request.resend_count': result.retryCount || undefined,
|
|
95
|
+
'http.response.body.size': Math.round(
|
|
96
|
+
result.responseSizeMb * 1024 * 1024
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
return result
|
|
100
|
+
} catch (error) {
|
|
101
|
+
recordFailure(span, error)
|
|
102
|
+
throw error
|
|
103
|
+
} finally {
|
|
104
|
+
span.end()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function recordFailure(span: OtelSpanLike, error: unknown): void {
|
|
112
|
+
if (error instanceof ApiError) {
|
|
113
|
+
const isHttp = error instanceof HttpResponseError
|
|
114
|
+
span.setAttributes({
|
|
115
|
+
'error.type': isHttp ? String(error.status) : error.name,
|
|
116
|
+
'api.error.category': error.category,
|
|
117
|
+
'http.response.status_code': isHttp ? error.status : undefined,
|
|
118
|
+
'http.request.resend_count': error.context.attempt || undefined
|
|
119
|
+
})
|
|
120
|
+
} else {
|
|
121
|
+
span.setAttributes({
|
|
122
|
+
'error.type': error instanceof Error ? error.name : 'unknown'
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
span.recordException(error instanceof Error ? error : String(error))
|
|
126
|
+
span.setStatus({
|
|
127
|
+
code: SPAN_STATUS_ERROR,
|
|
128
|
+
message:
|
|
129
|
+
error instanceof Error ? error.message.split('\n', 1)[0] : String(error)
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function hostOf(url: string): string | undefined {
|
|
134
|
+
try {
|
|
135
|
+
return new URL(url).hostname
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type PaginateOptions = {
|
|
2
|
+
/** Stop after this many pages (default: no limit). */
|
|
3
|
+
maxPages?: number
|
|
4
|
+
/** Checked before each page fetch; throws `signal.reason` when aborted. */
|
|
5
|
+
signal?: AbortSignal
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Yields pages until `getNext` returns `null`/`undefined`. The first call to
|
|
10
|
+
* `fetchPage` gets `undefined` as cursor. Pages are fetched lazily, so
|
|
11
|
+
* breaking out of `for await` stops further requests.
|
|
12
|
+
*/
|
|
13
|
+
export async function* paginate<TPage, TCursor>(
|
|
14
|
+
fetchPage: (cursor: TCursor | undefined) => Promise<TPage>,
|
|
15
|
+
getNext: (page: TPage) => TCursor | null | undefined,
|
|
16
|
+
options: PaginateOptions = {}
|
|
17
|
+
): AsyncGenerator<TPage, void, undefined> {
|
|
18
|
+
const { maxPages = Number.POSITIVE_INFINITY, signal } = options
|
|
19
|
+
let cursor: TCursor | undefined
|
|
20
|
+
for (let count = 0; count < maxPages; count++) {
|
|
21
|
+
signal?.throwIfAborted()
|
|
22
|
+
const page = await fetchPage(cursor)
|
|
23
|
+
yield page
|
|
24
|
+
const next = getNext(page)
|
|
25
|
+
if (next == null) return
|
|
26
|
+
cursor = next
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -22,6 +22,18 @@ const SENSITIVE_BODY_KEYS = new Set([
|
|
|
22
22
|
'api_key'
|
|
23
23
|
])
|
|
24
24
|
|
|
25
|
+
// Query strings also carry API keys and presigned-URL signatures.
|
|
26
|
+
const SENSITIVE_QUERY_KEYS = new Set([
|
|
27
|
+
...SENSITIVE_BODY_KEYS,
|
|
28
|
+
'key',
|
|
29
|
+
'code',
|
|
30
|
+
'sig',
|
|
31
|
+
'signature',
|
|
32
|
+
'x-amz-signature',
|
|
33
|
+
'x-amz-credential',
|
|
34
|
+
'x-amz-security-token'
|
|
35
|
+
])
|
|
36
|
+
|
|
25
37
|
function redactHeaders(
|
|
26
38
|
headers: Record<string, string> | undefined
|
|
27
39
|
): Record<string, string> | undefined {
|
|
@@ -52,9 +64,39 @@ function redactBody(body: unknown): unknown {
|
|
|
52
64
|
return out
|
|
53
65
|
}
|
|
54
66
|
|
|
67
|
+
/** Replaces values of sensitive query parameters (`?api_key=…`) in a URL. */
|
|
68
|
+
export function redactUrl(url: string): string {
|
|
69
|
+
const queryStart = url.indexOf('?')
|
|
70
|
+
if (queryStart === -1) return url
|
|
71
|
+
const hashStart = url.indexOf('#', queryStart)
|
|
72
|
+
const query = url.slice(
|
|
73
|
+
queryStart + 1,
|
|
74
|
+
hashStart === -1 ? undefined : hashStart
|
|
75
|
+
)
|
|
76
|
+
const redacted = query
|
|
77
|
+
.split('&')
|
|
78
|
+
.map((pair) => {
|
|
79
|
+
const [key = ''] = pair.split('=', 1)
|
|
80
|
+
return SENSITIVE_QUERY_KEYS.has(decodeKey(key).toLowerCase())
|
|
81
|
+
? `${key}=[REDACTED]`
|
|
82
|
+
: pair
|
|
83
|
+
})
|
|
84
|
+
.join('&')
|
|
85
|
+
return `${url.slice(0, queryStart + 1)}${redacted}${hashStart === -1 ? '' : url.slice(hashStart)}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function decodeKey(key: string): string {
|
|
89
|
+
try {
|
|
90
|
+
return decodeURIComponent(key.replace(/\+/g, ' '))
|
|
91
|
+
} catch {
|
|
92
|
+
return key
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
55
96
|
export function defaultRedactContext(ctx: ApiErrorContext): ApiErrorContext {
|
|
56
97
|
return {
|
|
57
98
|
...ctx,
|
|
99
|
+
url: redactUrl(ctx.url),
|
|
58
100
|
requestHeaders: redactHeaders(ctx.requestHeaders),
|
|
59
101
|
requestBody: redactBody(ctx.requestBody),
|
|
60
102
|
meta: ctx.meta
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { UrlQueryParams } from '../url'
|
|
2
|
+
|
|
1
3
|
export type HttpMethod =
|
|
2
4
|
| 'GET'
|
|
3
5
|
| 'POST'
|
|
@@ -10,7 +12,24 @@ export type HttpMethod =
|
|
|
10
12
|
export type RequestBodyFormat = 'json' | 'form-data'
|
|
11
13
|
export type BaseApiUrl = string | (() => string)
|
|
12
14
|
export type PathParams = Record<string, string | number>
|
|
13
|
-
|
|
15
|
+
/** Array values repeat the key (`a=1&a=2`); `null`/`undefined` are skipped. */
|
|
16
|
+
export type QueryParams = UrlQueryParams
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Augment to type `meta` on requests, actions, hooks, middleware and errors:
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* declare module 'hanzio/api-wrapper' {
|
|
23
|
+
* interface ApiRequestMeta { traceId: string; tenantId?: string }
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
// biome-ignore lint/suspicious/noEmptyInterface: augmentation target
|
|
27
|
+
export interface ApiRequestMeta {}
|
|
28
|
+
|
|
29
|
+
/** `ApiRequestMeta` once augmented, otherwise any record. */
|
|
30
|
+
export type RequestMeta = keyof ApiRequestMeta extends never
|
|
31
|
+
? Record<string, unknown>
|
|
32
|
+
: ApiRequestMeta
|
|
14
33
|
|
|
15
34
|
export type ApiErrorContext = {
|
|
16
35
|
endpoint: string
|
|
@@ -21,5 +40,5 @@ export type ApiErrorContext = {
|
|
|
21
40
|
elapsedMs: number
|
|
22
41
|
requestHeaders?: Record<string, string>
|
|
23
42
|
requestBody?: unknown
|
|
24
|
-
meta?:
|
|
43
|
+
meta?: RequestMeta
|
|
25
44
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { FetchLike } from './types'
|
|
2
|
+
|
|
3
|
+
export type MockFetch = FetchLike & {
|
|
4
|
+
/** Every request received, in order. Bodies are still readable. */
|
|
5
|
+
readonly calls: Request[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A fetch stand-in for tests: pass it as the client's `fetch` option instead
|
|
10
|
+
* of replacing `globalThis.fetch`. Test-runner agnostic.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const fetch = createMockFetch((request) =>
|
|
14
|
+
* request.url.endsWith('/users/1')
|
|
15
|
+
* ? jsonResponse({ id: 1 })
|
|
16
|
+
* : jsonResponse({ error: 'not found' }, { status: 404 })
|
|
17
|
+
* )
|
|
18
|
+
* const api = createApiClient({ ...config, fetch })
|
|
19
|
+
*/
|
|
20
|
+
export function createMockFetch(
|
|
21
|
+
handler: (request: Request, callIndex: number) => Response | Promise<Response>
|
|
22
|
+
): MockFetch {
|
|
23
|
+
const calls: Request[] = []
|
|
24
|
+
const mockFetch = async (
|
|
25
|
+
input: string | URL | Request,
|
|
26
|
+
init?: RequestInit
|
|
27
|
+
): Promise<Response> => {
|
|
28
|
+
const request: Request =
|
|
29
|
+
input instanceof Request
|
|
30
|
+
? new Request(input, init)
|
|
31
|
+
: new Request(input.toString(), init)
|
|
32
|
+
// Bun types clone() as the undici Request; it is the same runtime object.
|
|
33
|
+
calls.push(request.clone() as Request)
|
|
34
|
+
return handler(request, calls.length - 1)
|
|
35
|
+
}
|
|
36
|
+
return Object.assign(mockFetch, { calls })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A JSON `Response` with `content-type: application/json`. */
|
|
40
|
+
export function jsonResponse(
|
|
41
|
+
body: unknown,
|
|
42
|
+
init: { status?: number; headers?: Record<string, string> } = {}
|
|
43
|
+
): Response {
|
|
44
|
+
return new Response(JSON.stringify(body), {
|
|
45
|
+
status: init.status ?? 200,
|
|
46
|
+
headers: { 'content-type': 'application/json', ...init.headers }
|
|
47
|
+
})
|
|
48
|
+
}
|