hanzio 1.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 +510 -0
- package/dist/api-wrapper/index.d.ts +84 -0
- package/dist/api-wrapper/index.js +250 -0
- package/dist/array/index.d.ts +22 -0
- package/dist/cache/index.d.ts +19 -0
- package/dist/date/index.d.ts +11 -0
- package/dist/gzip/index.d.ts +2 -0
- package/dist/gzip/index.js +16 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1565 -0
- package/dist/jwt/index.d.ts +3 -0
- package/dist/jwt/index.js +19 -0
- package/dist/math/index.d.ts +2 -0
- package/dist/math/linearRegressionTrend.d.ts +5 -0
- package/dist/math/metricsSeriesColors.d.ts +42 -0
- package/dist/p-queue/index.d.ts +95 -0
- package/dist/p-queue/index.js +439 -0
- package/dist/promise/index.d.ts +21 -0
- package/dist/sitemap/index.d.ts +14 -0
- package/dist/sitemap/index.js +552 -0
- package/dist/state/index.d.ts +101 -0
- package/dist/string/index.d.ts +8 -0
- package/dist/typedswitch/index.d.ts +124 -0
- package/dist/url/index.d.ts +1 -0
- package/dist/zod/index.d.ts +8 -0
- package/dist/zod/index.js +210 -0
- package/dist/zod/jsonToZodSchemaMapper.d.ts +6 -0
- package/dist/zod/zodMinMaxFilter.d.ts +14 -0
- package/dist/zod/zodToTypeString.d.ts +9 -0
- package/package.json +102 -0
- package/src/api-wrapper/index.ts +492 -0
- package/src/array/index.ts +187 -0
- package/src/cache/index.ts +154 -0
- package/src/date/index.ts +54 -0
- package/src/gzip/index.ts +29 -0
- package/src/index.ts +12 -0
- package/src/jwt/index.ts +25 -0
- package/src/math/index.ts +11 -0
- package/src/math/linearRegressionTrend.ts +36 -0
- package/src/math/metricsSeriesColors.ts +208 -0
- package/src/p-queue/index.ts +690 -0
- package/src/promise/index.ts +73 -0
- package/src/sitemap/index.ts +195 -0
- package/src/state/index.ts +331 -0
- package/src/string/index.ts +86 -0
- package/src/typedswitch/index.ts +309 -0
- package/src/url/index.ts +5 -0
- package/src/zod/index.ts +29 -0
- package/src/zod/jsonToZodSchemaMapper.ts +44 -0
- package/src/zod/zodMinMaxFilter.ts +28 -0
- package/src/zod/zodToTypeString.ts +222 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { type z, ZodError } from 'zod'
|
|
2
|
+
|
|
3
|
+
export type HttpMethod =
|
|
4
|
+
| 'GET'
|
|
5
|
+
| 'POST'
|
|
6
|
+
| 'PUT'
|
|
7
|
+
| 'DELETE'
|
|
8
|
+
| 'PATCH'
|
|
9
|
+
| 'HEAD'
|
|
10
|
+
| 'OPTIONS'
|
|
11
|
+
|
|
12
|
+
export type RequestBodyFormat = 'json' | 'form-data'
|
|
13
|
+
export type BaseApiUrl = string | (() => string)
|
|
14
|
+
export type PathParams = Record<string, string | number>
|
|
15
|
+
export type QueryParams = Record<string, string | number | boolean | undefined>
|
|
16
|
+
|
|
17
|
+
export class ResponseValidationError extends Error {
|
|
18
|
+
public readonly zodError: ZodError
|
|
19
|
+
public readonly rawResponse: unknown
|
|
20
|
+
public readonly validationIssues: string
|
|
21
|
+
|
|
22
|
+
constructor(zodError: ZodError, rawResponse: unknown) {
|
|
23
|
+
const issues = zodError.issues
|
|
24
|
+
.map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)
|
|
25
|
+
.join('; ')
|
|
26
|
+
|
|
27
|
+
super(`Response validation failed: ${issues}`)
|
|
28
|
+
this.name = 'ResponseValidationError'
|
|
29
|
+
this.zodError = zodError
|
|
30
|
+
this.rawResponse = rawResponse
|
|
31
|
+
this.validationIssues = issues
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class HttpResponseError extends Error {
|
|
36
|
+
public readonly status: number
|
|
37
|
+
public readonly body: string
|
|
38
|
+
|
|
39
|
+
constructor(status: number, body: string) {
|
|
40
|
+
super(`HTTP error: ${status} - ${body}`)
|
|
41
|
+
this.name = 'HttpResponseError'
|
|
42
|
+
this.status = status
|
|
43
|
+
this.body = body
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ApiEndpoint<
|
|
48
|
+
TReqBody extends z.ZodType | undefined = z.ZodType | undefined,
|
|
49
|
+
TReqParams extends z.ZodType | undefined = z.ZodType | undefined,
|
|
50
|
+
TReqQuery extends z.ZodType | undefined = z.ZodType | undefined,
|
|
51
|
+
TReqHeaders extends z.ZodType | undefined = z.ZodType | undefined,
|
|
52
|
+
TResponse extends z.ZodType = z.ZodType
|
|
53
|
+
> {
|
|
54
|
+
method: HttpMethod
|
|
55
|
+
path: string | ((baseUrl: string) => string)
|
|
56
|
+
reqBodySchema?: TReqBody
|
|
57
|
+
reqParamsSchema?: TReqParams
|
|
58
|
+
reqQuerySchema?: TReqQuery
|
|
59
|
+
reqHeadersSchema?: TReqHeaders
|
|
60
|
+
resSchema: TResponse
|
|
61
|
+
baseApiUrl?: string
|
|
62
|
+
reqBodyFormat?: RequestBodyFormat
|
|
63
|
+
defaultHeaders?: Record<string, string>
|
|
64
|
+
resFormatter?: (
|
|
65
|
+
data: unknown,
|
|
66
|
+
headers: Record<string, string>
|
|
67
|
+
) => z.infer<TResponse>
|
|
68
|
+
reqDefaultQueryParams?: QueryParams
|
|
69
|
+
doNotEncodeQueryParams?: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ApiWrapperResponse<T> {
|
|
73
|
+
data: T
|
|
74
|
+
requestSizeMb: number
|
|
75
|
+
responseSizeMb: number
|
|
76
|
+
requestSizeTotalMb: number
|
|
77
|
+
responseTimeMs: number
|
|
78
|
+
httpStatus: number
|
|
79
|
+
retryCount: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type RetryContext = {
|
|
83
|
+
error?: unknown
|
|
84
|
+
response?: Response
|
|
85
|
+
retryCount: number
|
|
86
|
+
maxRetries: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ApiClientConfig<T extends Record<string, ApiEndpoint>> {
|
|
90
|
+
name?: string
|
|
91
|
+
baseApiUrls: Record<string, BaseApiUrl>
|
|
92
|
+
defaultBaseApiUrl?: string
|
|
93
|
+
endpoints: T
|
|
94
|
+
defaultHeaders?: Record<string, string>
|
|
95
|
+
timeoutMs?: number
|
|
96
|
+
retries?: number
|
|
97
|
+
retryDelayMs?: number
|
|
98
|
+
shouldRetry?: (context: RetryContext) => boolean
|
|
99
|
+
logger?: Pick<Console, 'debug' | 'error'>
|
|
100
|
+
fetch?: typeof fetch
|
|
101
|
+
onRequestStart?: (context: { endpoint: string; url: string }) => void
|
|
102
|
+
onRequestEnd?: (context: {
|
|
103
|
+
endpoint: string
|
|
104
|
+
url: string
|
|
105
|
+
status: number
|
|
106
|
+
retryCount: number
|
|
107
|
+
responseTimeMs: number
|
|
108
|
+
}) => void
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type RequestInput<TEndpoint extends ApiEndpoint> = {
|
|
112
|
+
reqBody?: InferOptionalSchema<TEndpoint['reqBodySchema']>
|
|
113
|
+
reqParams?: InferOptionalSchema<TEndpoint['reqParamsSchema']>
|
|
114
|
+
reqQuery?: InferOptionalSchema<TEndpoint['reqQuerySchema']>
|
|
115
|
+
reqHeaders?: InferOptionalSchema<TEndpoint['reqHeadersSchema']>
|
|
116
|
+
url?: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type InferOptionalSchema<TSchema> = TSchema extends z.ZodType
|
|
120
|
+
? z.infer<TSchema>
|
|
121
|
+
: undefined
|
|
122
|
+
|
|
123
|
+
type RequestConfig = {
|
|
124
|
+
method: HttpMethod
|
|
125
|
+
url: string
|
|
126
|
+
queryParams: Record<string, string>
|
|
127
|
+
body: unknown
|
|
128
|
+
headers: Record<string, string>
|
|
129
|
+
doNotEncodeQueryParams?: boolean
|
|
130
|
+
timeoutMs?: number
|
|
131
|
+
fetchFn: typeof fetch
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createApiClient<T extends Record<string, ApiEndpoint>>(
|
|
135
|
+
apiConfig: ApiClientConfig<T>
|
|
136
|
+
) {
|
|
137
|
+
const request = async <K extends keyof T>(
|
|
138
|
+
endpointKey: K,
|
|
139
|
+
input: RequestInput<T[K]> = {}
|
|
140
|
+
): Promise<ApiWrapperResponse<z.infer<T[K]['resSchema']>>> => {
|
|
141
|
+
const endpoint = apiConfig.endpoints[endpointKey]
|
|
142
|
+
|
|
143
|
+
if (!endpoint) {
|
|
144
|
+
throw new Error(`Unknown API endpoint: ${String(endpointKey)}`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { finalUrl, fullUrl } = buildUrl(
|
|
148
|
+
endpoint,
|
|
149
|
+
input.reqParams as PathParams | undefined,
|
|
150
|
+
input.reqQuery as QueryParams | undefined,
|
|
151
|
+
apiConfig.baseApiUrls,
|
|
152
|
+
apiConfig.defaultBaseApiUrl,
|
|
153
|
+
input.url
|
|
154
|
+
)
|
|
155
|
+
const endpointName = apiConfig.name
|
|
156
|
+
? `${apiConfig.name}.${String(endpointKey)}`
|
|
157
|
+
: String(endpointKey)
|
|
158
|
+
const startedAt = Date.now()
|
|
159
|
+
let retryCount = 0
|
|
160
|
+
let httpStatus = 0
|
|
161
|
+
|
|
162
|
+
apiConfig.onRequestStart?.({ endpoint: endpointName, url: fullUrl })
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const config: RequestConfig = {
|
|
166
|
+
method: endpoint.method,
|
|
167
|
+
url: finalUrl,
|
|
168
|
+
queryParams: buildQueryParams(
|
|
169
|
+
endpoint,
|
|
170
|
+
input.reqQuery as QueryParams | undefined
|
|
171
|
+
),
|
|
172
|
+
body: buildRequestBody(endpoint, input.reqBody),
|
|
173
|
+
headers: buildHeaders(
|
|
174
|
+
endpoint,
|
|
175
|
+
input.reqHeaders as Record<string, string> | undefined,
|
|
176
|
+
apiConfig.defaultHeaders ?? {}
|
|
177
|
+
),
|
|
178
|
+
doNotEncodeQueryParams: endpoint.doNotEncodeQueryParams,
|
|
179
|
+
timeoutMs: apiConfig.timeoutMs,
|
|
180
|
+
fetchFn: apiConfig.fetch ?? fetch
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const result = await makeRequestWithRetry(
|
|
184
|
+
config,
|
|
185
|
+
apiConfig.retries ?? 3,
|
|
186
|
+
apiConfig.retryDelayMs ?? 300,
|
|
187
|
+
(response) => validateAndTransformResponse(endpoint, response),
|
|
188
|
+
apiConfig.shouldRetry ?? defaultShouldRetry,
|
|
189
|
+
apiConfig.logger
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
retryCount = result.retryCount
|
|
193
|
+
httpStatus = result.httpStatus
|
|
194
|
+
|
|
195
|
+
const responseTimeMs = Date.now() - startedAt
|
|
196
|
+
const responseSizeMb = calculateSizeInMb(result.validatedData)
|
|
197
|
+
const requestSizeMb = calculateSizeInMb({
|
|
198
|
+
method: config.method,
|
|
199
|
+
url: fullUrl,
|
|
200
|
+
body: config.body,
|
|
201
|
+
headers: config.headers
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
apiConfig.onRequestEnd?.({
|
|
205
|
+
endpoint: endpointName,
|
|
206
|
+
url: fullUrl,
|
|
207
|
+
status: httpStatus,
|
|
208
|
+
retryCount,
|
|
209
|
+
responseTimeMs
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
data: result.validatedData as z.infer<T[K]['resSchema']>,
|
|
214
|
+
requestSizeMb,
|
|
215
|
+
responseSizeMb,
|
|
216
|
+
requestSizeTotalMb: requestSizeMb + responseSizeMb,
|
|
217
|
+
responseTimeMs,
|
|
218
|
+
httpStatus,
|
|
219
|
+
retryCount
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
apiConfig.logger?.error?.(error)
|
|
223
|
+
throw error
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { request }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export const createApiWrapper = createApiClient
|
|
231
|
+
|
|
232
|
+
async function validateResponse<T extends z.ZodType>(
|
|
233
|
+
schema: T,
|
|
234
|
+
data: unknown
|
|
235
|
+
): Promise<z.infer<T>> {
|
|
236
|
+
try {
|
|
237
|
+
return await schema.parseAsync(data)
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error instanceof ZodError) {
|
|
240
|
+
throw new ResponseValidationError(error, data)
|
|
241
|
+
}
|
|
242
|
+
throw error
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function httpRequest(config: RequestConfig): Promise<Response> {
|
|
247
|
+
const queryString = config.doNotEncodeQueryParams
|
|
248
|
+
? Object.entries(config.queryParams)
|
|
249
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
250
|
+
.join('&')
|
|
251
|
+
: new URLSearchParams(config.queryParams).toString()
|
|
252
|
+
const fullUrl = queryString ? `${config.url}?${queryString}` : config.url
|
|
253
|
+
const abortController = new AbortController()
|
|
254
|
+
const timeout =
|
|
255
|
+
config.timeoutMs === undefined
|
|
256
|
+
? undefined
|
|
257
|
+
: setTimeout(() => abortController.abort(), config.timeoutMs)
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
return await config.fetchFn(fullUrl, {
|
|
261
|
+
method: config.method,
|
|
262
|
+
headers: config.headers,
|
|
263
|
+
body: buildFetchBody(config.body, config.headers),
|
|
264
|
+
signal: abortController.signal
|
|
265
|
+
})
|
|
266
|
+
} finally {
|
|
267
|
+
if (timeout) clearTimeout(timeout)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function makeRequestWithRetry<TValidated>(
|
|
272
|
+
config: RequestConfig,
|
|
273
|
+
maxRetries: number,
|
|
274
|
+
retryDelayMs: number,
|
|
275
|
+
validateFn: (response: Response) => Promise<TValidated>,
|
|
276
|
+
shouldRetry: (context: RetryContext) => boolean,
|
|
277
|
+
logger?: Pick<Console, 'debug'>
|
|
278
|
+
): Promise<{
|
|
279
|
+
validatedData: TValidated
|
|
280
|
+
retryCount: number
|
|
281
|
+
httpStatus: number
|
|
282
|
+
}> {
|
|
283
|
+
let retryCount = 0
|
|
284
|
+
|
|
285
|
+
while (true) {
|
|
286
|
+
try {
|
|
287
|
+
logger?.debug?.('API request', config)
|
|
288
|
+
const response = await httpRequest(config)
|
|
289
|
+
|
|
290
|
+
if (response.status >= 400) {
|
|
291
|
+
if (
|
|
292
|
+
retryCount < maxRetries &&
|
|
293
|
+
shouldRetry({ response, retryCount, maxRetries })
|
|
294
|
+
) {
|
|
295
|
+
retryCount++
|
|
296
|
+
await delay(retryDelayMs)
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
throw new HttpResponseError(response.status, await response.text())
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
validatedData: await validateFn(response),
|
|
305
|
+
retryCount,
|
|
306
|
+
httpStatus: response.status
|
|
307
|
+
}
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error instanceof HttpResponseError) throw error
|
|
310
|
+
|
|
311
|
+
if (
|
|
312
|
+
retryCount >= maxRetries ||
|
|
313
|
+
!shouldRetry({ error, retryCount, maxRetries })
|
|
314
|
+
) {
|
|
315
|
+
throw error
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
retryCount++
|
|
319
|
+
await delay(retryDelayMs)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function validateAndTransformResponse<T extends z.ZodType>(
|
|
325
|
+
endpoint: ApiEndpoint<
|
|
326
|
+
z.ZodType | undefined,
|
|
327
|
+
z.ZodType | undefined,
|
|
328
|
+
z.ZodType | undefined,
|
|
329
|
+
z.ZodType | undefined,
|
|
330
|
+
T
|
|
331
|
+
>,
|
|
332
|
+
response: Response
|
|
333
|
+
): Promise<z.infer<T>> {
|
|
334
|
+
const responseHeaders = getHeadersAsObject(response.headers)
|
|
335
|
+
const contentType = responseHeaders['content-type'] ?? ''
|
|
336
|
+
const responseData: unknown = contentType.includes('application/json')
|
|
337
|
+
? await response.json()
|
|
338
|
+
: await response.text()
|
|
339
|
+
const transformedData = endpoint.resFormatter
|
|
340
|
+
? endpoint.resFormatter(responseData, responseHeaders)
|
|
341
|
+
: responseData
|
|
342
|
+
|
|
343
|
+
return validateResponse(endpoint.resSchema, transformedData)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function buildUrl(
|
|
347
|
+
endpoint: ApiEndpoint,
|
|
348
|
+
params: PathParams | undefined,
|
|
349
|
+
reqQuery: QueryParams | undefined,
|
|
350
|
+
baseApiUrls: Record<string, BaseApiUrl>,
|
|
351
|
+
defaultBaseApiUrl?: string,
|
|
352
|
+
url?: string
|
|
353
|
+
) {
|
|
354
|
+
if (url) {
|
|
355
|
+
const finalUrl = replacePathParams(url, params)
|
|
356
|
+
const queryString = new URLSearchParams(
|
|
357
|
+
buildQueryParams(endpoint, reqQuery)
|
|
358
|
+
).toString()
|
|
359
|
+
return {
|
|
360
|
+
finalUrl,
|
|
361
|
+
fullUrl: queryString ? `${finalUrl}?${queryString}` : finalUrl
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const defaultBaseUrl = defaultBaseApiUrl ?? Object.keys(baseApiUrls)[0]
|
|
366
|
+
if (!defaultBaseUrl) {
|
|
367
|
+
throw new Error('At least one base API URL is required')
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const baseUrlKey = endpoint.baseApiUrl ?? defaultBaseUrl
|
|
371
|
+
const configBaseUrl = baseApiUrls[baseUrlKey]
|
|
372
|
+
if (!configBaseUrl) {
|
|
373
|
+
throw new Error(`Unknown base API URL: ${baseUrlKey}`)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const baseUrl =
|
|
377
|
+
typeof configBaseUrl === 'function' ? configBaseUrl() : configBaseUrl
|
|
378
|
+
const path =
|
|
379
|
+
typeof endpoint.path === 'function' ? endpoint.path(baseUrl) : endpoint.path
|
|
380
|
+
const finalUrl = replacePathParams(`${baseUrl}${path}`, params)
|
|
381
|
+
const queryString = new URLSearchParams(
|
|
382
|
+
buildQueryParams(endpoint, reqQuery)
|
|
383
|
+
).toString()
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
finalUrl,
|
|
387
|
+
fullUrl: queryString ? `${finalUrl}?${queryString}` : finalUrl
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function buildQueryParams(
|
|
392
|
+
endpoint: ApiEndpoint,
|
|
393
|
+
query: QueryParams | undefined
|
|
394
|
+
): Record<string, string> {
|
|
395
|
+
const defaultParams = endpoint.reqDefaultQueryParams ?? {}
|
|
396
|
+
const parsedQuery = endpoint.reqQuerySchema
|
|
397
|
+
? (endpoint.reqQuerySchema.parse(query) as QueryParams)
|
|
398
|
+
: (query ?? {})
|
|
399
|
+
const merged = { ...defaultParams, ...parsedQuery }
|
|
400
|
+
|
|
401
|
+
return Object.fromEntries(
|
|
402
|
+
Object.entries(merged)
|
|
403
|
+
.filter(([, value]) => value !== undefined)
|
|
404
|
+
.map(([key, value]) => [key, String(value)])
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function buildRequestBody(endpoint: ApiEndpoint, body: unknown): unknown {
|
|
409
|
+
if (!endpoint.reqBodySchema) return body
|
|
410
|
+
|
|
411
|
+
const parsedBody = endpoint.reqBodySchema.parse(body) as Record<
|
|
412
|
+
string,
|
|
413
|
+
unknown
|
|
414
|
+
>
|
|
415
|
+
|
|
416
|
+
if (endpoint.reqBodyFormat !== 'form-data') {
|
|
417
|
+
return parsedBody
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const formData = new FormData()
|
|
421
|
+
for (const [key, value] of Object.entries(parsedBody)) {
|
|
422
|
+
if (value == null) continue
|
|
423
|
+
formData.set(key, value instanceof Blob ? value : String(value))
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return formData
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function buildHeaders(
|
|
430
|
+
endpoint: ApiEndpoint,
|
|
431
|
+
headers: Record<string, string> | undefined,
|
|
432
|
+
defaultHeaders: Record<string, string>
|
|
433
|
+
): Record<string, string> {
|
|
434
|
+
const contentType =
|
|
435
|
+
endpoint.reqBodyFormat === 'json' ? 'application/json' : undefined
|
|
436
|
+
const parsedHeaders = endpoint.reqHeadersSchema
|
|
437
|
+
? (endpoint.reqHeadersSchema.parse(headers) as Record<string, string>)
|
|
438
|
+
: (headers ?? {})
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
...defaultHeaders,
|
|
442
|
+
...(contentType && { 'Content-Type': contentType }),
|
|
443
|
+
...endpoint.defaultHeaders,
|
|
444
|
+
...parsedHeaders
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function buildFetchBody(
|
|
449
|
+
body: unknown,
|
|
450
|
+
headers: Record<string, string>
|
|
451
|
+
): RequestInit['body'] {
|
|
452
|
+
if (body === undefined || body === null) return undefined
|
|
453
|
+
if (body instanceof FormData || body instanceof Blob) return body
|
|
454
|
+
if (headers['Content-Type'] === 'application/json')
|
|
455
|
+
return JSON.stringify(body)
|
|
456
|
+
return body as RequestInit['body']
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function replacePathParams(
|
|
460
|
+
path: string,
|
|
461
|
+
params: PathParams | undefined
|
|
462
|
+
): string {
|
|
463
|
+
return path.replace(/:(\w+)/g, (_, key: string) => {
|
|
464
|
+
const value = params?.[key]
|
|
465
|
+
if (value === undefined) {
|
|
466
|
+
throw new Error(`Missing required path parameter: ${key}`)
|
|
467
|
+
}
|
|
468
|
+
return encodeURIComponent(String(value))
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function getHeadersAsObject(headers: Headers): Record<string, string> {
|
|
473
|
+
const result: Record<string, string> = {}
|
|
474
|
+
headers.forEach((value, key) => {
|
|
475
|
+
result[key] = value
|
|
476
|
+
})
|
|
477
|
+
return result
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function calculateSizeInMb(data: unknown): number {
|
|
481
|
+
const sizeInBytes = new TextEncoder().encode(JSON.stringify(data)).length
|
|
482
|
+
return sizeInBytes / (1024 * 1024)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function defaultShouldRetry({ error, response }: RetryContext): boolean {
|
|
486
|
+
if (error) return true
|
|
487
|
+
return response ? response.status >= 500 : false
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function delay(ms: number): Promise<void> {
|
|
491
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
492
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
export interface ArrayDifference<TCurrent, TNext> {
|
|
2
|
+
new: TNext[]
|
|
3
|
+
upsert: TNext[]
|
|
4
|
+
delete: TCurrent[]
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
type PropertyKeyValue = string | number | symbol
|
|
8
|
+
type NonNullableValue<T> = T extends null | undefined ? never : T
|
|
9
|
+
type ItemsMatchingKeyValue<T, TKey extends keyof T, TValue> = [
|
|
10
|
+
Extract<T, Record<TKey, TValue>>
|
|
11
|
+
] extends [never]
|
|
12
|
+
? T
|
|
13
|
+
: Extract<T, Record<TKey, TValue>>
|
|
14
|
+
|
|
15
|
+
export function chunkArray<T>(array: readonly T[], chunkSize: number): T[][] {
|
|
16
|
+
if (!Number.isInteger(chunkSize) || chunkSize <= 0) {
|
|
17
|
+
throw new Error('Chunk size must be a positive integer')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const chunks: T[][] = []
|
|
21
|
+
for (let index = 0; index < array.length; index += chunkSize) {
|
|
22
|
+
chunks.push(array.slice(index, index + chunkSize))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return chunks
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function findArrayDifferenceByKey<
|
|
29
|
+
TCurrent,
|
|
30
|
+
TNext,
|
|
31
|
+
TKey extends keyof TCurrent & keyof TNext
|
|
32
|
+
>(
|
|
33
|
+
current: readonly TCurrent[],
|
|
34
|
+
next: readonly TNext[],
|
|
35
|
+
key: TKey
|
|
36
|
+
): ArrayDifference<TCurrent, TNext> {
|
|
37
|
+
const currentByKey = new Map(
|
|
38
|
+
current.map((item) => [String(item[key]), item] as const)
|
|
39
|
+
)
|
|
40
|
+
const itemsToDelete = [...current]
|
|
41
|
+
const newItems: TNext[] = []
|
|
42
|
+
const upsertItems: TNext[] = []
|
|
43
|
+
|
|
44
|
+
for (const item of next) {
|
|
45
|
+
const keyValue = String(item[key])
|
|
46
|
+
|
|
47
|
+
if (!currentByKey.has(keyValue)) {
|
|
48
|
+
newItems.push(item)
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
upsertItems.push(item)
|
|
53
|
+
const deleteIndex = itemsToDelete.findIndex(
|
|
54
|
+
(currentItem) => String(currentItem[key]) === keyValue
|
|
55
|
+
)
|
|
56
|
+
if (deleteIndex >= 0) {
|
|
57
|
+
itemsToDelete.splice(deleteIndex, 1)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
new: newItems,
|
|
63
|
+
upsert: upsertItems,
|
|
64
|
+
delete: itemsToDelete
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getUniqueValues<T>(array: readonly T[]): T[] {
|
|
69
|
+
const seen = new Map<string, T>()
|
|
70
|
+
|
|
71
|
+
for (const element of array) {
|
|
72
|
+
const key = JSON.stringify(element)
|
|
73
|
+
if (!seen.has(key)) {
|
|
74
|
+
seen.set(key, element)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return Array.from(seen.values())
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function compact<T>(array: readonly T[]): NonNullableValue<T>[] {
|
|
82
|
+
return array.filter((item): item is NonNullableValue<T> => item != null)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getUniqueValuesByKey<T, TKey extends keyof T>(
|
|
86
|
+
array: readonly T[],
|
|
87
|
+
key: TKey
|
|
88
|
+
): T[] {
|
|
89
|
+
const seen = new Set<T[TKey]>()
|
|
90
|
+
const result: T[] = []
|
|
91
|
+
|
|
92
|
+
for (const item of array) {
|
|
93
|
+
const value = item[key]
|
|
94
|
+
if (seen.has(value)) continue
|
|
95
|
+
seen.add(value)
|
|
96
|
+
result.push(item)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return result
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function groupBy<
|
|
103
|
+
T,
|
|
104
|
+
TKey extends keyof T,
|
|
105
|
+
TGroupKey extends Extract<T[TKey], PropertyKeyValue>
|
|
106
|
+
>(array: readonly T[], key: TKey): Record<TGroupKey, T[]>
|
|
107
|
+
export function groupBy<T, TGroupKey extends PropertyKeyValue>(
|
|
108
|
+
array: readonly T[],
|
|
109
|
+
keyFn: (item: T) => TGroupKey
|
|
110
|
+
): Record<TGroupKey, T[]>
|
|
111
|
+
export function groupBy<T, TGroupKey extends PropertyKeyValue>(
|
|
112
|
+
array: readonly T[],
|
|
113
|
+
keyOrFn: keyof T | ((item: T) => TGroupKey)
|
|
114
|
+
): Record<TGroupKey, T[]> {
|
|
115
|
+
return array.reduce(
|
|
116
|
+
(result, item) => {
|
|
117
|
+
const groupKey =
|
|
118
|
+
typeof keyOrFn === 'function'
|
|
119
|
+
? keyOrFn(item)
|
|
120
|
+
: (item[keyOrFn] as TGroupKey)
|
|
121
|
+
|
|
122
|
+
result[groupKey] ??= []
|
|
123
|
+
result[groupKey].push(item)
|
|
124
|
+
return result
|
|
125
|
+
},
|
|
126
|
+
{} as Record<TGroupKey, T[]>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function keyBy<
|
|
131
|
+
T,
|
|
132
|
+
TKey extends keyof T,
|
|
133
|
+
TRecordKey extends Extract<T[TKey], PropertyKeyValue>
|
|
134
|
+
>(array: readonly T[], key: TKey): Record<TRecordKey, T>
|
|
135
|
+
export function keyBy<T, TRecordKey extends PropertyKeyValue>(
|
|
136
|
+
array: readonly T[],
|
|
137
|
+
keyFn: (item: T) => TRecordKey
|
|
138
|
+
): Record<TRecordKey, T>
|
|
139
|
+
export function keyBy<T, TRecordKey extends PropertyKeyValue>(
|
|
140
|
+
array: readonly T[],
|
|
141
|
+
keyOrFn: keyof T | ((item: T) => TRecordKey)
|
|
142
|
+
): Record<TRecordKey, T> {
|
|
143
|
+
const result = {} as Record<TRecordKey, T>
|
|
144
|
+
|
|
145
|
+
for (const item of array) {
|
|
146
|
+
const recordKey =
|
|
147
|
+
typeof keyOrFn === 'function'
|
|
148
|
+
? keyOrFn(item)
|
|
149
|
+
: (item[keyOrFn] as TRecordKey)
|
|
150
|
+
result[recordKey] = item
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return result
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function partition<T>(
|
|
157
|
+
array: readonly T[],
|
|
158
|
+
predicate: (item: T, index: number) => boolean
|
|
159
|
+
): [matched: T[], unmatched: T[]] {
|
|
160
|
+
const matched: T[] = []
|
|
161
|
+
const unmatched: T[] = []
|
|
162
|
+
|
|
163
|
+
array.forEach((item, index) => {
|
|
164
|
+
if (predicate(item, index)) {
|
|
165
|
+
matched.push(item)
|
|
166
|
+
} else {
|
|
167
|
+
unmatched.push(item)
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
return [matched, unmatched]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function pickItemsInArray<
|
|
175
|
+
T,
|
|
176
|
+
TKey extends keyof T,
|
|
177
|
+
const TValue extends readonly T[TKey][]
|
|
178
|
+
>(
|
|
179
|
+
array: readonly T[],
|
|
180
|
+
key: TKey,
|
|
181
|
+
values: TValue
|
|
182
|
+
): ItemsMatchingKeyValue<T, TKey, TValue[number]>[] {
|
|
183
|
+
return array.filter(
|
|
184
|
+
(item): item is ItemsMatchingKeyValue<T, TKey, TValue[number]> =>
|
|
185
|
+
values.includes(item[key])
|
|
186
|
+
)
|
|
187
|
+
}
|