426 lines
11 KiB
TypeScript
426 lines
11 KiB
TypeScript
import { CoreRequest, CoreResponse } from '../core/index.js';
|
|
import type { ICoreResponse } from '../core_base/types.js';
|
|
import * as plugins from './plugins.js';
|
|
import type { ICoreRequestOptions } from '../core_base/types.js';
|
|
|
|
import type { HttpMethod, ResponseType, FormField, RateLimitConfig } from './types/common.js';
|
|
import {
|
|
type TPaginationConfig,
|
|
PaginationStrategy,
|
|
type OffsetPaginationConfig,
|
|
type CursorPaginationConfig,
|
|
type CustomPaginationConfig,
|
|
type TPaginatedResponse
|
|
} from './types/pagination.js';
|
|
import { createPaginatedResponse } from './features/pagination.js';
|
|
|
|
/**
|
|
* Parse Retry-After header value to milliseconds
|
|
* @param retryAfter - The Retry-After header value (seconds or HTTP date)
|
|
* @returns Delay in milliseconds
|
|
*/
|
|
function parseRetryAfter(retryAfter: string | string[]): number {
|
|
// Handle array of values (take first)
|
|
const value = Array.isArray(retryAfter) ? retryAfter[0] : retryAfter;
|
|
|
|
if (!value) return 0;
|
|
|
|
// Try to parse as seconds (number)
|
|
const seconds = parseInt(value, 10);
|
|
if (!isNaN(seconds)) {
|
|
return seconds * 1000;
|
|
}
|
|
|
|
// Try to parse as HTTP date
|
|
const retryDate = new Date(value);
|
|
if (!isNaN(retryDate.getTime())) {
|
|
return Math.max(0, retryDate.getTime() - Date.now());
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Modern fluent client for making HTTP requests
|
|
*/
|
|
export class SmartRequest<T = any> {
|
|
private _url: string;
|
|
private _options: ICoreRequestOptions = {};
|
|
private _retries: number = 0;
|
|
private _queryParams: Record<string, string> = {};
|
|
private _paginationConfig?: TPaginationConfig;
|
|
private _rateLimitConfig?: RateLimitConfig;
|
|
|
|
/**
|
|
* Create a new SmartRequest instance
|
|
*/
|
|
static create<T = any>(): SmartRequest<T> {
|
|
return new SmartRequest<T>();
|
|
}
|
|
|
|
/**
|
|
* Set the URL for the request
|
|
*/
|
|
url(url: string): this {
|
|
this._url = url;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set the HTTP method
|
|
*/
|
|
method(method: HttpMethod): this {
|
|
this._options.method = method;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set JSON body for the request
|
|
*/
|
|
json(data: any): this {
|
|
if (!this._options.headers) {
|
|
this._options.headers = {};
|
|
}
|
|
this._options.headers['Content-Type'] = 'application/json';
|
|
this._options.requestBody = data;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set form data for the request
|
|
*/
|
|
formData(data: FormField[]): this {
|
|
const form = new plugins.formData();
|
|
|
|
for (const item of data) {
|
|
if (Buffer.isBuffer(item.value)) {
|
|
form.append(item.name, item.value, {
|
|
filename: item.filename || 'file',
|
|
contentType: item.contentType || 'application/octet-stream'
|
|
});
|
|
} else {
|
|
form.append(item.name, item.value);
|
|
}
|
|
}
|
|
|
|
if (!this._options.headers) {
|
|
this._options.headers = {};
|
|
}
|
|
|
|
this._options.headers = {
|
|
...this._options.headers,
|
|
...form.getHeaders()
|
|
};
|
|
|
|
this._options.requestBody = form;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set request timeout in milliseconds
|
|
*/
|
|
timeout(ms: number): this {
|
|
this._options.timeout = ms;
|
|
this._options.hardDataCuttingTimeout = ms;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set number of retry attempts
|
|
*/
|
|
retry(count: number): this {
|
|
this._retries = count;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Enable automatic 429 (Too Many Requests) handling with configurable backoff
|
|
*/
|
|
handle429Backoff(config?: RateLimitConfig): this {
|
|
this._rateLimitConfig = {
|
|
maxRetries: config?.maxRetries ?? 3,
|
|
respectRetryAfter: config?.respectRetryAfter ?? true,
|
|
maxWaitTime: config?.maxWaitTime ?? 60000,
|
|
fallbackDelay: config?.fallbackDelay ?? 1000,
|
|
backoffFactor: config?.backoffFactor ?? 2,
|
|
onRateLimit: config?.onRateLimit
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set HTTP headers
|
|
*/
|
|
headers(headers: Record<string, string>): this {
|
|
if (!this._options.headers) {
|
|
this._options.headers = {};
|
|
}
|
|
this._options.headers = {
|
|
...this._options.headers,
|
|
...headers
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set a single HTTP header
|
|
*/
|
|
header(name: string, value: string): this {
|
|
if (!this._options.headers) {
|
|
this._options.headers = {};
|
|
}
|
|
this._options.headers[name] = value;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set query parameters
|
|
*/
|
|
query(params: Record<string, string>): this {
|
|
this._queryParams = {
|
|
...this._queryParams,
|
|
...params
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set additional request options
|
|
*/
|
|
options(options: Partial<ICoreRequestOptions>): this {
|
|
this._options = {
|
|
...this._options,
|
|
...options
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set the Accept header to indicate what content type is expected
|
|
*/
|
|
accept(type: ResponseType): this {
|
|
// Map response types to Accept header values
|
|
const acceptHeaders: Record<ResponseType, string> = {
|
|
'json': 'application/json',
|
|
'text': 'text/plain',
|
|
'binary': 'application/octet-stream',
|
|
'stream': '*/*'
|
|
};
|
|
|
|
return this.header('Accept', acceptHeaders[type]);
|
|
}
|
|
|
|
/**
|
|
* Configure pagination for requests
|
|
*/
|
|
pagination(config: TPaginationConfig): this {
|
|
this._paginationConfig = config;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Configure offset-based pagination (page & limit)
|
|
*/
|
|
withOffsetPagination(config: Omit<OffsetPaginationConfig, 'strategy'> = {}): this {
|
|
this._paginationConfig = {
|
|
strategy: PaginationStrategy.OFFSET,
|
|
pageParam: config.pageParam || 'page',
|
|
limitParam: config.limitParam || 'limit',
|
|
startPage: config.startPage || 1,
|
|
pageSize: config.pageSize || 20,
|
|
totalPath: config.totalPath || 'total'
|
|
};
|
|
|
|
// Add initial pagination parameters
|
|
this.query({
|
|
[this._paginationConfig.pageParam]: String(this._paginationConfig.startPage),
|
|
[this._paginationConfig.limitParam]: String(this._paginationConfig.pageSize)
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Configure cursor-based pagination
|
|
*/
|
|
withCursorPagination(config: Omit<CursorPaginationConfig, 'strategy'> = {}): this {
|
|
this._paginationConfig = {
|
|
strategy: PaginationStrategy.CURSOR,
|
|
cursorParam: config.cursorParam || 'cursor',
|
|
cursorPath: config.cursorPath || 'nextCursor',
|
|
hasMorePath: config.hasMorePath || 'hasMore'
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Configure Link header-based pagination
|
|
*/
|
|
withLinkPagination(): this {
|
|
this._paginationConfig = {
|
|
strategy: PaginationStrategy.LINK_HEADER
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Configure custom pagination
|
|
*/
|
|
withCustomPagination(config: Omit<CustomPaginationConfig, 'strategy'>): this {
|
|
this._paginationConfig = {
|
|
strategy: PaginationStrategy.CUSTOM,
|
|
hasNextPage: config.hasNextPage,
|
|
getNextPageParams: config.getNextPageParams
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Make a GET request
|
|
*/
|
|
async get<R = T>(): Promise<ICoreResponse<R>> {
|
|
return this.execute<R>('GET');
|
|
}
|
|
|
|
/**
|
|
* Make a POST request
|
|
*/
|
|
async post<R = T>(): Promise<ICoreResponse<R>> {
|
|
return this.execute<R>('POST');
|
|
}
|
|
|
|
/**
|
|
* Make a PUT request
|
|
*/
|
|
async put<R = T>(): Promise<ICoreResponse<R>> {
|
|
return this.execute<R>('PUT');
|
|
}
|
|
|
|
/**
|
|
* Make a DELETE request
|
|
*/
|
|
async delete<R = T>(): Promise<ICoreResponse<R>> {
|
|
return this.execute<R>('DELETE');
|
|
}
|
|
|
|
/**
|
|
* Make a PATCH request
|
|
*/
|
|
async patch<R = T>(): Promise<ICoreResponse<R>> {
|
|
return this.execute<R>('PATCH');
|
|
}
|
|
|
|
/**
|
|
* Get paginated response
|
|
*/
|
|
async getPaginated<ItemType = T>(): Promise<TPaginatedResponse<ItemType>> {
|
|
if (!this._paginationConfig) {
|
|
throw new Error('Pagination not configured. Call one of the pagination methods first.');
|
|
}
|
|
|
|
// Default to GET if no method specified
|
|
if (!this._options.method) {
|
|
this._options.method = 'GET';
|
|
}
|
|
|
|
const response = await this.execute();
|
|
|
|
return await createPaginatedResponse<ItemType>(
|
|
response,
|
|
this._paginationConfig,
|
|
this._queryParams,
|
|
(nextPageParams) => {
|
|
// Create a new client with the same configuration but updated query params
|
|
const nextClient = new SmartRequest<ItemType>();
|
|
Object.assign(nextClient, this);
|
|
nextClient._queryParams = nextPageParams;
|
|
|
|
return nextClient.getPaginated<ItemType>();
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get all pages at once (use with caution for large datasets)
|
|
*/
|
|
async getAllPages<ItemType = T>(): Promise<ItemType[]> {
|
|
const firstPage = await this.getPaginated<ItemType>();
|
|
return firstPage.getAllPages();
|
|
}
|
|
|
|
/**
|
|
* Execute the HTTP request
|
|
*/
|
|
private async execute<R = T>(method?: HttpMethod): Promise<ICoreResponse<R>> {
|
|
if (method) {
|
|
this._options.method = method;
|
|
}
|
|
|
|
this._options.queryParams = this._queryParams;
|
|
|
|
// Track rate limit attempts separately
|
|
let rateLimitAttempt = 0;
|
|
let lastError: Error;
|
|
|
|
// Main retry loop
|
|
for (let attempt = 0; attempt <= this._retries; attempt++) {
|
|
try {
|
|
const request = new CoreRequest(this._url, this._options as any);
|
|
const response = await request.fire() as ICoreResponse<R>;
|
|
|
|
// Check for 429 status if rate limit handling is enabled
|
|
if (this._rateLimitConfig && response.status === 429) {
|
|
if (rateLimitAttempt >= this._rateLimitConfig.maxRetries) {
|
|
// Max rate limit retries reached, return the 429 response
|
|
return response;
|
|
}
|
|
|
|
let waitTime: number;
|
|
|
|
if (this._rateLimitConfig.respectRetryAfter && response.headers['retry-after']) {
|
|
// Parse Retry-After header
|
|
waitTime = parseRetryAfter(response.headers['retry-after']);
|
|
|
|
// Cap wait time to maxWaitTime
|
|
waitTime = Math.min(waitTime, this._rateLimitConfig.maxWaitTime);
|
|
} else {
|
|
// Use exponential backoff
|
|
waitTime = Math.min(
|
|
this._rateLimitConfig.fallbackDelay * Math.pow(this._rateLimitConfig.backoffFactor, rateLimitAttempt),
|
|
this._rateLimitConfig.maxWaitTime
|
|
);
|
|
}
|
|
|
|
// Call rate limit callback if provided
|
|
if (this._rateLimitConfig.onRateLimit) {
|
|
this._rateLimitConfig.onRateLimit(rateLimitAttempt + 1, waitTime);
|
|
}
|
|
|
|
// Wait before retrying
|
|
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
|
|
rateLimitAttempt++;
|
|
// Decrement attempt to retry this attempt
|
|
attempt--;
|
|
continue;
|
|
}
|
|
|
|
// Success or non-429 error response
|
|
return response;
|
|
} catch (error) {
|
|
lastError = error as Error;
|
|
|
|
// If this is the last attempt, throw the error
|
|
if (attempt === this._retries) {
|
|
throw lastError;
|
|
}
|
|
|
|
// Otherwise, wait before retrying
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
}
|
|
}
|
|
|
|
// This should never be reached due to the throw in the loop above
|
|
throw lastError;
|
|
}
|
|
} |