1000fetches 0.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.
- package/dist/index.cjs.js +2 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.es.js +918 -0
- package/dist/index.es.js.map +1 -0
- package/package.json +75 -0
package/dist/index.es.js
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
class HttpError extends Error {
|
|
2
|
+
name = "HttpError";
|
|
3
|
+
status;
|
|
4
|
+
statusText;
|
|
5
|
+
data;
|
|
6
|
+
response;
|
|
7
|
+
url;
|
|
8
|
+
method;
|
|
9
|
+
cause;
|
|
10
|
+
constructor(message, status, statusText, data, response, url, method, cause) {
|
|
11
|
+
const enhancedMessage = `HTTP ${status} ${statusText}: ${message} (${method} ${url})`;
|
|
12
|
+
super(enhancedMessage, { cause });
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.statusText = statusText;
|
|
15
|
+
this.data = data;
|
|
16
|
+
this.response = response;
|
|
17
|
+
this.url = url;
|
|
18
|
+
this.method = method;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
class NetworkError extends Error {
|
|
22
|
+
name = "NetworkError";
|
|
23
|
+
cause;
|
|
24
|
+
constructor(message, cause) {
|
|
25
|
+
super(message, { cause });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
class SchemaValidationError extends Error {
|
|
29
|
+
name = "SchemaValidationError";
|
|
30
|
+
schema;
|
|
31
|
+
data;
|
|
32
|
+
cause;
|
|
33
|
+
constructor(message, schema, data, cause) {
|
|
34
|
+
super(message, { cause });
|
|
35
|
+
this.schema = schema;
|
|
36
|
+
this.data = data;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
class TimeoutError extends Error {
|
|
40
|
+
name = "TimeoutError";
|
|
41
|
+
cause;
|
|
42
|
+
constructor(message, cause) {
|
|
43
|
+
super(message, { cause });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
class PathParameterError extends Error {
|
|
47
|
+
name = "PathParameterError";
|
|
48
|
+
url;
|
|
49
|
+
requiredParams;
|
|
50
|
+
providedParams;
|
|
51
|
+
cause;
|
|
52
|
+
constructor(message, url, requiredParams, providedParams, cause) {
|
|
53
|
+
super(message, { cause });
|
|
54
|
+
this.url = url;
|
|
55
|
+
this.requiredParams = requiredParams;
|
|
56
|
+
this.providedParams = providedParams;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
class InterceptorError extends Error {
|
|
60
|
+
name = "InterceptorError";
|
|
61
|
+
interceptorType;
|
|
62
|
+
url;
|
|
63
|
+
method;
|
|
64
|
+
cause;
|
|
65
|
+
constructor(message, interceptorType, url, method, cause) {
|
|
66
|
+
super(message, { cause });
|
|
67
|
+
this.interceptorType = interceptorType;
|
|
68
|
+
this.url = url;
|
|
69
|
+
this.method = method;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
class SerializationError extends Error {
|
|
73
|
+
name = "SerializationError";
|
|
74
|
+
cause;
|
|
75
|
+
constructor(message, cause) {
|
|
76
|
+
super(message, { cause });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function isStandardSchema(obj) {
|
|
80
|
+
return obj !== null && (typeof obj === "object" || typeof obj === "function") && "~standard" in obj && typeof obj["~standard"] === "object" && obj["~standard"] !== null && "validate" in obj["~standard"] && typeof obj["~standard"].validate === "function" && "version" in obj["~standard"] && typeof obj["~standard"].version === "number" && "vendor" in obj["~standard"] && typeof obj["~standard"].vendor === "string";
|
|
81
|
+
}
|
|
82
|
+
function standardSchemaAdapter(schema) {
|
|
83
|
+
return {
|
|
84
|
+
/**
|
|
85
|
+
* Parses and validates data using the Standard Schema
|
|
86
|
+
*
|
|
87
|
+
* @param data - The data to validate
|
|
88
|
+
* @returns The validated and typed data
|
|
89
|
+
* @throws Error if validation fails
|
|
90
|
+
*/
|
|
91
|
+
parse: (data) => {
|
|
92
|
+
const result = schema["~standard"].validate(data);
|
|
93
|
+
if (result instanceof Promise) {
|
|
94
|
+
throw new Error("Async Standard Schema validation is not supported");
|
|
95
|
+
}
|
|
96
|
+
if ("issues" in result && result.issues) {
|
|
97
|
+
const errorMessages = Array.from(result.issues).map((issue) => issue.message).join("; ");
|
|
98
|
+
throw new Error(`Standard Schema validation failed: ${errorMessages}`);
|
|
99
|
+
}
|
|
100
|
+
return result.value;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function createStandardSchemaValidator() {
|
|
105
|
+
return {
|
|
106
|
+
/**
|
|
107
|
+
* Validates data against a Standard Schema V1 implementation
|
|
108
|
+
*
|
|
109
|
+
* @param schema - The Standard Schema V1 to validate against
|
|
110
|
+
* @param data - The data to validate
|
|
111
|
+
* @returns The validated and typed data
|
|
112
|
+
* @throws Error if validation fails or schema is invalid
|
|
113
|
+
*/
|
|
114
|
+
validate(schema, data) {
|
|
115
|
+
if (!isStandardSchema(schema)) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
"Invalid schema: Schema must implement the Standard Schema interface"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const adapter = standardSchemaAdapter(schema);
|
|
121
|
+
return adapter.parse(data);
|
|
122
|
+
},
|
|
123
|
+
/**
|
|
124
|
+
* Checks if an object is a valid Standard Schema V1 implementation
|
|
125
|
+
*
|
|
126
|
+
* @param obj - The object to check
|
|
127
|
+
* @returns True if the object is a valid Standard Schema V1
|
|
128
|
+
*/
|
|
129
|
+
isSchema(obj) {
|
|
130
|
+
return isStandardSchema(obj);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function createSchemaValidator() {
|
|
135
|
+
return {
|
|
136
|
+
validate(schema, data) {
|
|
137
|
+
if (!isStandardSchema(schema)) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
"Invalid schema: Schema must implement the Standard Schema interface"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return createStandardSchemaValidator().validate(schema, data);
|
|
143
|
+
},
|
|
144
|
+
isSchema(obj) {
|
|
145
|
+
return isStandardSchema(obj);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function generatePath(path, params = {}) {
|
|
150
|
+
return path.replace(/:([a-zA-Z0-9_]+)/g, (_match, paramName) => {
|
|
151
|
+
if (params[paramName] === void 0) {
|
|
152
|
+
throw new PathParameterError(
|
|
153
|
+
`Missing required path parameter: ${paramName}`,
|
|
154
|
+
path,
|
|
155
|
+
[paramName],
|
|
156
|
+
Object.keys(params)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return String(params[paramName]);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const DEFAULT_RETRY_OPTIONS = {
|
|
163
|
+
maxRetries: 3,
|
|
164
|
+
retryDelay: 300,
|
|
165
|
+
backoffFactor: 2,
|
|
166
|
+
retryStatusCodes: [429, 500, 502, 503, 504],
|
|
167
|
+
retryNetworkErrors: true,
|
|
168
|
+
maxRetryDelay: 3e4,
|
|
169
|
+
shouldRetry: () => true
|
|
170
|
+
};
|
|
171
|
+
function validateAndNormalizeBaseUrl(baseUrl) {
|
|
172
|
+
if (!baseUrl) {
|
|
173
|
+
return "";
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
new URL(baseUrl);
|
|
177
|
+
} catch {
|
|
178
|
+
throw new Error(`Invalid baseUrl: "${baseUrl}". Must be a valid URL.`);
|
|
179
|
+
}
|
|
180
|
+
return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
181
|
+
}
|
|
182
|
+
class HttpClient {
|
|
183
|
+
baseUrl;
|
|
184
|
+
defaultHeaders;
|
|
185
|
+
defaultTimeout;
|
|
186
|
+
schemaValidator;
|
|
187
|
+
requestInterceptors;
|
|
188
|
+
responseInterceptors;
|
|
189
|
+
defaultRetryOptions;
|
|
190
|
+
/**
|
|
191
|
+
* Create a new HttpClient instance.
|
|
192
|
+
*
|
|
193
|
+
* @param options - Configuration options for the HTTP client
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* ```ts
|
|
197
|
+
* // Basic configuration
|
|
198
|
+
* const client = new HttpClient({
|
|
199
|
+
* baseUrl: 'https://api.example.com'
|
|
200
|
+
* });
|
|
201
|
+
*
|
|
202
|
+
* // With custom headers and timeout
|
|
203
|
+
* const client = new HttpClient({
|
|
204
|
+
* baseUrl: 'https://api.example.com',
|
|
205
|
+
* headers: { 'Authorization': 'Bearer token' },
|
|
206
|
+
* timeout: 10000
|
|
207
|
+
* });
|
|
208
|
+
*
|
|
209
|
+
* // With custom schema validator
|
|
210
|
+
* const client = new HttpClient({
|
|
211
|
+
* baseUrl: 'https://api.example.com',
|
|
212
|
+
* schemaValidator: createCustomValidator()
|
|
213
|
+
* });
|
|
214
|
+
*
|
|
215
|
+
* // With retry configuration
|
|
216
|
+
* const client = new HttpClient({
|
|
217
|
+
* baseUrl: 'https://api.example.com',
|
|
218
|
+
* retryOptions: {
|
|
219
|
+
* maxRetries: 5,
|
|
220
|
+
* retryDelay: 1000,
|
|
221
|
+
* retryStatusCodes: [500, 502, 503]
|
|
222
|
+
* }
|
|
223
|
+
* });
|
|
224
|
+
* ```
|
|
225
|
+
*/
|
|
226
|
+
constructor(options = {}) {
|
|
227
|
+
this.baseUrl = validateAndNormalizeBaseUrl(options.baseUrl);
|
|
228
|
+
this.defaultHeaders = options.headers || {};
|
|
229
|
+
this.defaultTimeout = options.timeout || 3e4;
|
|
230
|
+
this.schemaValidator = options.schemaValidator || createSchemaValidator();
|
|
231
|
+
this.requestInterceptors = options.requestInterceptors || [];
|
|
232
|
+
this.responseInterceptors = options.responseInterceptors || [];
|
|
233
|
+
this.defaultRetryOptions = options.retryOptions;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Add a request interceptor to modify requests before they're sent.
|
|
237
|
+
*
|
|
238
|
+
* Request interceptors are executed in the order they were added and can modify
|
|
239
|
+
* the request context before it's sent to the server. This is useful for adding
|
|
240
|
+
* authentication headers, logging, or transforming request data.
|
|
241
|
+
*
|
|
242
|
+
* @param interceptor - The interceptor function to add
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* ```ts
|
|
246
|
+
* // Add authentication headers to all requests
|
|
247
|
+
* client.addRequestInterceptor((context) => {
|
|
248
|
+
* context.headers.set('Authorization', `Bearer ${getToken()}`);
|
|
249
|
+
* return context;
|
|
250
|
+
* });
|
|
251
|
+
*
|
|
252
|
+
* // Change the base URL for specific endpoints
|
|
253
|
+
* client.addRequestInterceptor((context) => {
|
|
254
|
+
* if (context.url.includes('/special/')) {
|
|
255
|
+
* context.url = context.url.replace('api.example.com', 'special-api.example.com');
|
|
256
|
+
* }
|
|
257
|
+
* return context;
|
|
258
|
+
* });
|
|
259
|
+
*
|
|
260
|
+
* // Log all outgoing requests
|
|
261
|
+
* client.addRequestInterceptor((context) => {
|
|
262
|
+
* console.log(`Making ${context.method} request to ${context.url}`);
|
|
263
|
+
* return context;
|
|
264
|
+
* });
|
|
265
|
+
* ```
|
|
266
|
+
*/
|
|
267
|
+
addRequestInterceptor(interceptor) {
|
|
268
|
+
this.requestInterceptors.push(interceptor);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Add a response interceptor to process responses before they're returned.
|
|
272
|
+
*
|
|
273
|
+
* Response interceptors are executed in the order they were added and can modify
|
|
274
|
+
* the response data before it's returned to the caller. This is useful for
|
|
275
|
+
* transforming data, logging responses, or handling global error conditions.
|
|
276
|
+
*
|
|
277
|
+
* @param interceptor - The interceptor function to add
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* ```ts
|
|
281
|
+
* // Log all responses
|
|
282
|
+
* client.addResponseInterceptor((response) => {
|
|
283
|
+
* console.log(`Response from ${response.raw.url}: ${response.status}`);
|
|
284
|
+
* return response;
|
|
285
|
+
* });
|
|
286
|
+
*
|
|
287
|
+
* // Transform response data
|
|
288
|
+
* client.addResponseInterceptor((response) => {
|
|
289
|
+
* if (Array.isArray(response.data)) {
|
|
290
|
+
* response.data = response.data.map(item => transformItem(item));
|
|
291
|
+
* }
|
|
292
|
+
* return response;
|
|
293
|
+
* });
|
|
294
|
+
*
|
|
295
|
+
* // Handle 401 responses by refreshing the token and retrying
|
|
296
|
+
* client.addResponseInterceptor(async (response) => {
|
|
297
|
+
* if (response.status === 401) {
|
|
298
|
+
* await refreshToken();
|
|
299
|
+
* // Make a new request (will use updated token)
|
|
300
|
+
* return await client.request(response.raw.method, response.raw.url);
|
|
301
|
+
* }
|
|
302
|
+
* return response;
|
|
303
|
+
* });
|
|
304
|
+
* ```
|
|
305
|
+
*/
|
|
306
|
+
addResponseInterceptor(interceptor) {
|
|
307
|
+
this.responseInterceptors.push(interceptor);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Set a custom schema validator for request/response validation
|
|
311
|
+
*
|
|
312
|
+
* @param validator - The schema validator to use for validation
|
|
313
|
+
*
|
|
314
|
+
* @example
|
|
315
|
+
* ```ts
|
|
316
|
+
* // Set a custom validator
|
|
317
|
+
* client.setSchemaValidator(customValidator);
|
|
318
|
+
* ```
|
|
319
|
+
*/
|
|
320
|
+
/**
|
|
321
|
+
* Set a custom schema validator for the client.
|
|
322
|
+
*
|
|
323
|
+
* This allows you to use a different validation library or custom validation logic
|
|
324
|
+
* for all requests made by this client instance.
|
|
325
|
+
*
|
|
326
|
+
* @param validator - The schema validator to use for all validation operations
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* ```ts
|
|
330
|
+
* import { createCustomValidator } from './custom-validator'
|
|
331
|
+
*
|
|
332
|
+
* // Set a custom validator
|
|
333
|
+
* client.setSchemaValidator(createCustomValidator());
|
|
334
|
+
*
|
|
335
|
+
* // Now all requests will use the custom validator
|
|
336
|
+
* const response = await client.post('/users', userData, {
|
|
337
|
+
* schema: customUserSchema
|
|
338
|
+
* });
|
|
339
|
+
* ```
|
|
340
|
+
*/
|
|
341
|
+
setSchemaValidator(validator) {
|
|
342
|
+
this.schemaValidator = validator;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Determines if a request should be retried based on error type and retry configuration
|
|
346
|
+
*
|
|
347
|
+
* @param error - The error that occurred during the request
|
|
348
|
+
* @param retryCount - Current retry attempt number
|
|
349
|
+
* @param retryOptions - Retry configuration for this request
|
|
350
|
+
* @returns Promise resolving to whether the request should be retried
|
|
351
|
+
*/
|
|
352
|
+
async shouldRetry(error, retryCount, retryOptions) {
|
|
353
|
+
if (retryOptions === false) return false;
|
|
354
|
+
const options = this.getRetryOptions(retryOptions);
|
|
355
|
+
if (retryCount >= options.maxRetries) return false;
|
|
356
|
+
if (typeof options.shouldRetry === "function") {
|
|
357
|
+
try {
|
|
358
|
+
return await options.shouldRetry(error, retryCount);
|
|
359
|
+
} catch (retryError) {
|
|
360
|
+
console.warn("Custom shouldRetry function failed:", retryError);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (error instanceof NetworkError && options.retryNetworkErrors) {
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
if (error instanceof HttpError && options.retryStatusCodes.includes(error.status)) {
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
if (error instanceof SchemaValidationError) {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
if (error instanceof TimeoutError) {
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Calculates the delay for the next retry attempt using exponential backoff with jitter
|
|
379
|
+
*
|
|
380
|
+
* @param retryCount - Current retry attempt number
|
|
381
|
+
* @param options - Retry configuration options
|
|
382
|
+
* @returns Delay in milliseconds before the next retry attempt
|
|
383
|
+
*/
|
|
384
|
+
getRetryDelay(retryCount, options) {
|
|
385
|
+
const baseDelay = options.retryDelay * Math.pow(options.backoffFactor, retryCount);
|
|
386
|
+
const cappedDelay = Math.min(baseDelay, options.maxRetryDelay);
|
|
387
|
+
const jitterRange = cappedDelay * 0.2;
|
|
388
|
+
const jitter = jitterRange * (Math.random() * 2 - 1);
|
|
389
|
+
const finalDelay = cappedDelay + jitter;
|
|
390
|
+
return Math.max(1, Math.floor(finalDelay));
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Merges provided retry options with default configuration
|
|
394
|
+
*
|
|
395
|
+
* @param retryOptions - Retry options to merge with defaults
|
|
396
|
+
* @returns Complete retry configuration with all required properties
|
|
397
|
+
*/
|
|
398
|
+
getRetryOptions(retryOptions) {
|
|
399
|
+
if (retryOptions === false) {
|
|
400
|
+
return {
|
|
401
|
+
...DEFAULT_RETRY_OPTIONS,
|
|
402
|
+
maxRetries: 0
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
if (retryOptions === true) {
|
|
406
|
+
return {
|
|
407
|
+
...DEFAULT_RETRY_OPTIONS,
|
|
408
|
+
...this.defaultRetryOptions
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (typeof retryOptions === "object") {
|
|
412
|
+
return {
|
|
413
|
+
...DEFAULT_RETRY_OPTIONS,
|
|
414
|
+
...this.defaultRetryOptions,
|
|
415
|
+
...retryOptions
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
...DEFAULT_RETRY_OPTIONS,
|
|
420
|
+
...this.defaultRetryOptions
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Creates a promise that resolves after the specified delay
|
|
425
|
+
*
|
|
426
|
+
* @param ms - Delay duration in milliseconds
|
|
427
|
+
* @returns Promise that resolves after the delay
|
|
428
|
+
*/
|
|
429
|
+
sleep(ms) {
|
|
430
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Makes an HTTP request with automatic retry capability
|
|
434
|
+
*
|
|
435
|
+
* @param method - HTTP method to use for the request
|
|
436
|
+
* @param url - URL path for the request (supports path parameters)
|
|
437
|
+
* @param options - Request configuration options
|
|
438
|
+
* @returns Promise resolving to the response data
|
|
439
|
+
*
|
|
440
|
+
* @example
|
|
441
|
+
* ```ts
|
|
442
|
+
* // Simple GET request
|
|
443
|
+
* const response = await client.request('GET', '/users/1');
|
|
444
|
+
*
|
|
445
|
+
* // POST request with body and path parameters
|
|
446
|
+
* const response = await client.request('POST', '/users/:id/posts', {
|
|
447
|
+
* pathParams: { id: 1 },
|
|
448
|
+
* body: { title: 'New Post', content: 'Hello World' },
|
|
449
|
+
* schema: userPostSchema
|
|
450
|
+
* });
|
|
451
|
+
* ```
|
|
452
|
+
*/
|
|
453
|
+
async request(method, url, options = {}) {
|
|
454
|
+
let retryCount = 0;
|
|
455
|
+
while (true) {
|
|
456
|
+
try {
|
|
457
|
+
return await this.executeRequest(
|
|
458
|
+
method,
|
|
459
|
+
url,
|
|
460
|
+
options
|
|
461
|
+
);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (!(error instanceof Error)) {
|
|
464
|
+
throw new NetworkError(String(error), void 0);
|
|
465
|
+
}
|
|
466
|
+
if (await this.shouldRetry(error, retryCount, options.retry)) {
|
|
467
|
+
const retryOptions = this.getRetryOptions(options.retry);
|
|
468
|
+
const delay = this.getRetryDelay(retryCount, retryOptions);
|
|
469
|
+
await this.sleep(delay);
|
|
470
|
+
retryCount++;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Executes a single HTTP request without retry logic
|
|
479
|
+
*
|
|
480
|
+
* @param method - HTTP method for the request
|
|
481
|
+
* @param url - URL path with optional path parameters
|
|
482
|
+
* @param options - Request configuration and validation options
|
|
483
|
+
* @returns Promise resolving to the response data
|
|
484
|
+
*/
|
|
485
|
+
async executeRequest(method, url, options = {}) {
|
|
486
|
+
let resolvedUrl = this.resolveUrl(url, options.pathParams);
|
|
487
|
+
let requestContext = {
|
|
488
|
+
url: resolvedUrl,
|
|
489
|
+
method,
|
|
490
|
+
params: options.params,
|
|
491
|
+
headers: this.prepareHeaders(options.headers),
|
|
492
|
+
body: options.body,
|
|
493
|
+
fetchOptions: {
|
|
494
|
+
credentials: options.credentials,
|
|
495
|
+
cache: options.cache,
|
|
496
|
+
mode: options.mode,
|
|
497
|
+
redirect: options.redirect
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
for (const interceptor of this.requestInterceptors) {
|
|
501
|
+
try {
|
|
502
|
+
requestContext = await interceptor(requestContext);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
throw new InterceptorError(
|
|
505
|
+
`Request interceptor failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
506
|
+
"request",
|
|
507
|
+
resolvedUrl,
|
|
508
|
+
method,
|
|
509
|
+
error instanceof Error ? error : void 0
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
resolvedUrl = requestContext.url;
|
|
514
|
+
if (requestContext.params && Object.keys(requestContext.params).length > 0) {
|
|
515
|
+
const searchParams = new URLSearchParams();
|
|
516
|
+
for (const [key, value] of Object.entries(requestContext.params)) {
|
|
517
|
+
if (value !== void 0) {
|
|
518
|
+
searchParams.append(key, String(value));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const queryString = searchParams.toString();
|
|
522
|
+
if (queryString) {
|
|
523
|
+
resolvedUrl += (resolvedUrl.includes("?") ? "&" : "?") + queryString;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const requestInit = {
|
|
527
|
+
method: requestContext.method,
|
|
528
|
+
headers: requestContext.headers,
|
|
529
|
+
...requestContext.fetchOptions
|
|
530
|
+
};
|
|
531
|
+
if (requestContext.body !== void 0 && (requestContext.method === "POST" || requestContext.method === "PUT" || requestContext.method === "PATCH")) {
|
|
532
|
+
if (requestContext.body instanceof FormData || requestContext.body instanceof Blob || requestContext.body instanceof ArrayBuffer || requestContext.body instanceof URLSearchParams || typeof requestContext.body === "string") {
|
|
533
|
+
requestInit.body = requestContext.body;
|
|
534
|
+
} else {
|
|
535
|
+
try {
|
|
536
|
+
requestInit.body = JSON.stringify(requestContext.body);
|
|
537
|
+
} catch (error) {
|
|
538
|
+
throw new SerializationError(
|
|
539
|
+
`Failed to serialize request body: ${error instanceof Error ? error.message : "Invalid data structure"}`,
|
|
540
|
+
error instanceof Error ? error : void 0
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const timeoutDuration = options.timeout || this.defaultTimeout;
|
|
546
|
+
const controller = new AbortController();
|
|
547
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutDuration);
|
|
548
|
+
const requestSignal = requestContext.signal || options.signal;
|
|
549
|
+
let cleanupSignals;
|
|
550
|
+
const signal = requestSignal ? (() => {
|
|
551
|
+
const result = this.mergeAbortSignals(
|
|
552
|
+
requestSignal,
|
|
553
|
+
controller.signal
|
|
554
|
+
);
|
|
555
|
+
cleanupSignals = result.cleanup;
|
|
556
|
+
return result.signal;
|
|
557
|
+
})() : controller.signal;
|
|
558
|
+
try {
|
|
559
|
+
const response = await fetch(resolvedUrl, {
|
|
560
|
+
...requestInit,
|
|
561
|
+
signal
|
|
562
|
+
});
|
|
563
|
+
let responseData = await this.processResponse(
|
|
564
|
+
response,
|
|
565
|
+
options,
|
|
566
|
+
method,
|
|
567
|
+
resolvedUrl
|
|
568
|
+
);
|
|
569
|
+
if (options.schema) {
|
|
570
|
+
responseData.data = this.validateResponseData(
|
|
571
|
+
options.schema,
|
|
572
|
+
responseData.data
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
return responseData;
|
|
576
|
+
} catch (error) {
|
|
577
|
+
if (error instanceof DOMException && error.name === "AbortError" || error instanceof Error && error.name === "AbortError" || error instanceof Error && error.message.toLowerCase().includes("timeout")) {
|
|
578
|
+
throw new TimeoutError(
|
|
579
|
+
`Request timed out after ${timeoutDuration}ms`,
|
|
580
|
+
error instanceof Error ? error : void 0
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
if (error instanceof HttpError || error instanceof SchemaValidationError || error instanceof InterceptorError || error instanceof PathParameterError || error instanceof SerializationError) {
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
throw new NetworkError(
|
|
587
|
+
error instanceof Error ? error.message : String(error),
|
|
588
|
+
error instanceof Error ? error : void 0
|
|
589
|
+
);
|
|
590
|
+
} finally {
|
|
591
|
+
clearTimeout(timeoutId);
|
|
592
|
+
cleanupSignals?.();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
validateWithSchema(schema, data, errorPrefix) {
|
|
596
|
+
try {
|
|
597
|
+
return this.schemaValidator.validate(schema, data);
|
|
598
|
+
} catch (error) {
|
|
599
|
+
throw new SchemaValidationError(
|
|
600
|
+
`${errorPrefix}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
601
|
+
schema,
|
|
602
|
+
data,
|
|
603
|
+
error instanceof Error ? error : void 0
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
validateResponseData(schema, data) {
|
|
608
|
+
return this.validateWithSchema(
|
|
609
|
+
schema,
|
|
610
|
+
data,
|
|
611
|
+
"Response schema validation failed"
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Public method to validate data against any schema
|
|
616
|
+
*
|
|
617
|
+
* @param schema - Schema to validate against
|
|
618
|
+
* @param data - Data to validate
|
|
619
|
+
* @returns Validated data
|
|
620
|
+
*
|
|
621
|
+
* @example
|
|
622
|
+
* ```ts
|
|
623
|
+
* const validatedUser = client.validateSchema(userSchema, userData);
|
|
624
|
+
* ```
|
|
625
|
+
*/
|
|
626
|
+
/**
|
|
627
|
+
* Validate data against a schema using the configured schema validator.
|
|
628
|
+
*
|
|
629
|
+
* This method uses the same schema validator that's configured for the client,
|
|
630
|
+
* ensuring consistent validation behavior across all requests.
|
|
631
|
+
*
|
|
632
|
+
* @template T - The expected type after validation
|
|
633
|
+
* @param schema - The schema to validate against (Zod, Valibot, or Arktype)
|
|
634
|
+
* @param data - The data to validate
|
|
635
|
+
*
|
|
636
|
+
* @returns The validated data with the correct type
|
|
637
|
+
*
|
|
638
|
+
* @throws {SchemaValidationError} If the data doesn't match the schema
|
|
639
|
+
*
|
|
640
|
+
* @example
|
|
641
|
+
* ```ts
|
|
642
|
+
* import { z } from 'zod'
|
|
643
|
+
*
|
|
644
|
+
* const userSchema = z.object({
|
|
645
|
+
* id: z.string(),
|
|
646
|
+
* name: z.string(),
|
|
647
|
+
* email: z.email()
|
|
648
|
+
* });
|
|
649
|
+
*
|
|
650
|
+
* // Validate data manually
|
|
651
|
+
* try {
|
|
652
|
+
* const validatedUser = client.validateSchema(userSchema, userData);
|
|
653
|
+
* console.log('Valid user:', validatedUser);
|
|
654
|
+
* } catch (error) {
|
|
655
|
+
* console.error('Invalid user data:', error);
|
|
656
|
+
* }
|
|
657
|
+
*
|
|
658
|
+
* // This is equivalent to the validation that happens automatically
|
|
659
|
+
* // when you use schema in requests
|
|
660
|
+
* ```
|
|
661
|
+
*/
|
|
662
|
+
validateSchema(schema, data) {
|
|
663
|
+
return this.validateWithSchema(schema, data, "Schema validation failed");
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Prepares headers for HTTP requests by merging default and custom headers
|
|
667
|
+
*
|
|
668
|
+
* @param customHeaders - Additional headers to include in the request
|
|
669
|
+
* @returns Headers object with all headers properly set
|
|
670
|
+
*/
|
|
671
|
+
prepareHeaders(customHeaders = {}) {
|
|
672
|
+
const headers = new Headers();
|
|
673
|
+
for (const [key, value] of Object.entries(this.defaultHeaders)) {
|
|
674
|
+
if (value != null) {
|
|
675
|
+
headers.set(key, String(value));
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
for (const [key, value] of Object.entries(customHeaders)) {
|
|
679
|
+
if (value != null) {
|
|
680
|
+
headers.set(key, String(value));
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return headers;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Extracts headers from Headers object into a plain record
|
|
687
|
+
*
|
|
688
|
+
* @param headers - Headers object to extract from
|
|
689
|
+
* @returns Record containing header key-value pairs
|
|
690
|
+
*/
|
|
691
|
+
extractHeaders(headers) {
|
|
692
|
+
const result = {};
|
|
693
|
+
headers.forEach((value, key) => {
|
|
694
|
+
result[key] = value;
|
|
695
|
+
});
|
|
696
|
+
return result;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Creates an HttpError instance with response details
|
|
700
|
+
*
|
|
701
|
+
* @param response - The HTTP response that caused the error
|
|
702
|
+
* @param data - Response data (if any)
|
|
703
|
+
* @param url - The URL that was requested
|
|
704
|
+
* @param method - The HTTP method used
|
|
705
|
+
* @param customStatusText - Optional custom status text
|
|
706
|
+
* @param cause - Optional underlying error that caused this HTTP error
|
|
707
|
+
* @returns HttpError instance with complete error information
|
|
708
|
+
*/
|
|
709
|
+
createHttpError(response, data, url, method, customStatusText, cause) {
|
|
710
|
+
return new HttpError(
|
|
711
|
+
`Request failed with status code ${response.status}`,
|
|
712
|
+
response.status,
|
|
713
|
+
customStatusText || response.statusText,
|
|
714
|
+
data,
|
|
715
|
+
response,
|
|
716
|
+
url,
|
|
717
|
+
method,
|
|
718
|
+
cause
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
async parseResponseBody(response, options) {
|
|
722
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
723
|
+
try {
|
|
724
|
+
if (options.responseType === "text") {
|
|
725
|
+
return await response.text();
|
|
726
|
+
} else if (options.responseType === "blob") {
|
|
727
|
+
return await response.blob();
|
|
728
|
+
} else if (options.responseType === "arrayBuffer") {
|
|
729
|
+
return await response.arrayBuffer();
|
|
730
|
+
} else if (contentType.includes("application/json")) {
|
|
731
|
+
const text = await response.text();
|
|
732
|
+
if (!text) {
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
return JSON.parse(text);
|
|
736
|
+
} else {
|
|
737
|
+
return await response.text();
|
|
738
|
+
}
|
|
739
|
+
} catch (e) {
|
|
740
|
+
if (response.status === 204) {
|
|
741
|
+
return void 0;
|
|
742
|
+
}
|
|
743
|
+
if (contentType.includes("application/json") && e instanceof SyntaxError) {
|
|
744
|
+
throw new Error(`Failed to parse JSON response: ${e.message}`);
|
|
745
|
+
}
|
|
746
|
+
throw e;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
async processResponse(response, options, method, url) {
|
|
750
|
+
const headers = this.extractHeaders(response.headers);
|
|
751
|
+
const validateStatus = options.validateStatus || ((status) => status >= 200 && status < 300);
|
|
752
|
+
const data = await this.parseResponseBody(response, options);
|
|
753
|
+
if (!validateStatus(response.status)) {
|
|
754
|
+
throw this.createHttpError(response, data, url, method);
|
|
755
|
+
}
|
|
756
|
+
const responseObj = {
|
|
757
|
+
data,
|
|
758
|
+
status: response.status,
|
|
759
|
+
statusText: response.statusText,
|
|
760
|
+
headers,
|
|
761
|
+
method,
|
|
762
|
+
url,
|
|
763
|
+
raw: response
|
|
764
|
+
};
|
|
765
|
+
let processedResponse = responseObj;
|
|
766
|
+
const originalStatus = responseObj.status;
|
|
767
|
+
for (const interceptor of this.responseInterceptors) {
|
|
768
|
+
try {
|
|
769
|
+
processedResponse = await interceptor(processedResponse);
|
|
770
|
+
} catch (error) {
|
|
771
|
+
throw new InterceptorError(
|
|
772
|
+
`Response interceptor failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
773
|
+
"response",
|
|
774
|
+
url,
|
|
775
|
+
method,
|
|
776
|
+
error instanceof Error ? error : void 0
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (processedResponse.status !== originalStatus && !validateStatus(processedResponse.status)) {
|
|
781
|
+
throw this.createHttpError(
|
|
782
|
+
response,
|
|
783
|
+
processedResponse.data,
|
|
784
|
+
url,
|
|
785
|
+
method,
|
|
786
|
+
processedResponse.statusText
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
return processedResponse;
|
|
790
|
+
}
|
|
791
|
+
mergeAbortSignals(signal1, signal2) {
|
|
792
|
+
if (signal1.aborted || signal2.aborted) {
|
|
793
|
+
const controller2 = new AbortController();
|
|
794
|
+
controller2.abort();
|
|
795
|
+
return { signal: controller2.signal };
|
|
796
|
+
}
|
|
797
|
+
const controller = new AbortController();
|
|
798
|
+
const abortHandler = () => {
|
|
799
|
+
controller.abort();
|
|
800
|
+
cleanup();
|
|
801
|
+
};
|
|
802
|
+
const cleanup = () => {
|
|
803
|
+
signal1.removeEventListener("abort", abortHandler);
|
|
804
|
+
signal2.removeEventListener("abort", abortHandler);
|
|
805
|
+
};
|
|
806
|
+
signal1.addEventListener("abort", abortHandler);
|
|
807
|
+
signal2.addEventListener("abort", abortHandler);
|
|
808
|
+
return { signal: controller.signal, cleanup };
|
|
809
|
+
}
|
|
810
|
+
get(url, options) {
|
|
811
|
+
return this.request(
|
|
812
|
+
"GET",
|
|
813
|
+
url,
|
|
814
|
+
options || {}
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
post(url, body, options) {
|
|
818
|
+
return this.request("POST", url, {
|
|
819
|
+
...options || {},
|
|
820
|
+
body
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
put(url, body, options) {
|
|
824
|
+
return this.request("PUT", url, {
|
|
825
|
+
...options || {},
|
|
826
|
+
body
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
patch(url, body, options) {
|
|
830
|
+
return this.request("PATCH", url, {
|
|
831
|
+
...options || {},
|
|
832
|
+
body
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
delete(url, options) {
|
|
836
|
+
return this.request(
|
|
837
|
+
"DELETE",
|
|
838
|
+
url,
|
|
839
|
+
options || {}
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Validates path parameters against the URL template
|
|
844
|
+
*
|
|
845
|
+
* @param url - URL template with path parameters (e.g., '/users/:id')
|
|
846
|
+
* @param pathParams - Path parameters to validate
|
|
847
|
+
* @throws Error if required path parameters are missing or invalid
|
|
848
|
+
*/
|
|
849
|
+
validatePathParams(url, pathParams) {
|
|
850
|
+
if (!pathParams) {
|
|
851
|
+
const requiredParams2 = this.extractRequiredPathParams(url);
|
|
852
|
+
if (requiredParams2.length > 0) {
|
|
853
|
+
throw new PathParameterError(
|
|
854
|
+
`Missing required path parameters for URL "${url}": ${requiredParams2.join(", ")}. Please provide pathParams: { ${requiredParams2.map((p) => `${p}: value`).join(", ")} }`,
|
|
855
|
+
url,
|
|
856
|
+
requiredParams2,
|
|
857
|
+
[]
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const requiredParams = this.extractRequiredPathParams(url);
|
|
863
|
+
const providedParams = Object.keys(pathParams);
|
|
864
|
+
const missingParams = requiredParams.filter(
|
|
865
|
+
(param) => !providedParams.includes(param)
|
|
866
|
+
);
|
|
867
|
+
if (missingParams.length > 0) {
|
|
868
|
+
throw new PathParameterError(
|
|
869
|
+
`Missing required path parameters: ${missingParams.join(", ")}. Provided: ${providedParams.join(", ")}`,
|
|
870
|
+
url,
|
|
871
|
+
requiredParams,
|
|
872
|
+
providedParams
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Extracts required path parameters from a URL template
|
|
878
|
+
*
|
|
879
|
+
* @param url - URL template to extract parameters from
|
|
880
|
+
* @returns Array of required parameter names
|
|
881
|
+
*/
|
|
882
|
+
extractRequiredPathParams(url) {
|
|
883
|
+
const matches = url.match(/:([a-zA-Z0-9_]+)/g);
|
|
884
|
+
if (!matches) return [];
|
|
885
|
+
return matches.map((match) => match.slice(1));
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Resolves a URL template with path parameters and base URL
|
|
889
|
+
*
|
|
890
|
+
* @param url - URL template with optional path parameters
|
|
891
|
+
* @param pathParams - Path parameters to interpolate into the URL
|
|
892
|
+
* @returns Fully resolved URL with base URL and path parameters applied
|
|
893
|
+
*/
|
|
894
|
+
resolveUrl(url, pathParams) {
|
|
895
|
+
this.validatePathParams(url, pathParams);
|
|
896
|
+
const interpolatedUrl = pathParams ? generatePath(url, pathParams) : url;
|
|
897
|
+
if (/^https?:\/\//i.test(interpolatedUrl)) {
|
|
898
|
+
return interpolatedUrl;
|
|
899
|
+
}
|
|
900
|
+
const path = interpolatedUrl.startsWith("/") ? interpolatedUrl.slice(1) : interpolatedUrl;
|
|
901
|
+
return `${this.baseUrl}/${path}`;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
export {
|
|
905
|
+
HttpClient,
|
|
906
|
+
HttpError,
|
|
907
|
+
InterceptorError,
|
|
908
|
+
NetworkError,
|
|
909
|
+
PathParameterError,
|
|
910
|
+
SchemaValidationError,
|
|
911
|
+
TimeoutError,
|
|
912
|
+
createSchemaValidator,
|
|
913
|
+
createStandardSchemaValidator,
|
|
914
|
+
generatePath,
|
|
915
|
+
isStandardSchema,
|
|
916
|
+
standardSchemaAdapter
|
|
917
|
+
};
|
|
918
|
+
//# sourceMappingURL=index.es.js.map
|