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;
 | 
						|
  }
 | 
						|
}
 |