521 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			521 lines
		
	
	
		
			13 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,
 | |
|   RawStreamFunction,
 | |
| } 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 raw buffer data for the request
 | |
|    */
 | |
|   buffer(data: Buffer | Uint8Array, contentType?: string): this {
 | |
|     if (!this._options.headers) {
 | |
|       this._options.headers = {};
 | |
|     }
 | |
|     this._options.headers['Content-Type'] = contentType || 'application/octet-stream';
 | |
|     this._options.requestBody = data;
 | |
|     return this;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Stream data for the request
 | |
|    * Accepts Node.js Readable streams or web ReadableStream
 | |
|    */
 | |
|   stream(stream: NodeJS.ReadableStream | ReadableStream<Uint8Array>, contentType?: string): this {
 | |
|     if (!this._options.headers) {
 | |
|       this._options.headers = {};
 | |
|     }
 | |
|     
 | |
|     // Set content type if provided
 | |
|     if (contentType) {
 | |
|       this._options.headers['Content-Type'] = contentType;
 | |
|     }
 | |
|     
 | |
|     // Check if it's a Node.js stream (has pipe method)
 | |
|     if ('pipe' in stream && typeof (stream as any).pipe === 'function') {
 | |
|       // For Node.js streams, we need to use a custom approach
 | |
|       // Store the stream to be used later
 | |
|       (this._options as any).__nodeStream = stream;
 | |
|     } else {
 | |
|       // For web ReadableStream, pass directly
 | |
|       this._options.requestBody = stream;
 | |
|     }
 | |
|     
 | |
|     return this;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Provide a custom function to handle raw request streaming
 | |
|    * This gives full control over the request body streaming
 | |
|    * Note: Only works in Node.js environment, not supported in browsers
 | |
|    */
 | |
|   raw(streamFunc: RawStreamFunction): this {
 | |
|     // Store the raw streaming function to be used later
 | |
|     (this._options as any).__rawStreamFunc = streamFunc;
 | |
|     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;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Enable or disable auto-drain for unconsumed response bodies (Node.js only)
 | |
|    * Default is true to prevent socket hanging
 | |
|    */
 | |
|   autoDrain(enabled: boolean): this {
 | |
|     this._options.autoDrain = enabled;
 | |
|     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 {
 | |
|         // Check if we have a Node.js stream or raw function that needs special handling
 | |
|         let requestDataFunc = null;
 | |
|         if ((this._options as any).__nodeStream) {
 | |
|           const nodeStream = (this._options as any).__nodeStream;
 | |
|           requestDataFunc = (req: any) => {
 | |
|             nodeStream.pipe(req);
 | |
|           };
 | |
|           // Remove the temporary stream reference
 | |
|           delete (this._options as any).__nodeStream;
 | |
|         } else if ((this._options as any).__rawStreamFunc) {
 | |
|           requestDataFunc = (this._options as any).__rawStreamFunc;
 | |
|           // Remove the temporary function reference
 | |
|           delete (this._options as any).__rawStreamFunc;
 | |
|         }
 | |
|         
 | |
|         const request = new CoreRequest(this._url, this._options as any, requestDataFunc);
 | |
|         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;
 | |
|   }
 | |
| }
 |