clean-jsdoc-theme TypeDoc APIv0.0.0
docs-site/typedoc-src/services/HttpClient.tstypescript
/**
 * HTTP Client Service
 *
 * @remarks
 * Provides a typed HTTP client for making API requests with
 * automatic retry, timeout, and error handling.
 *
 * @packageDocumentation
 */

/**
 * HTTP methods supported by the client
 *
 * @public
 */
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'

/**
 * Request configuration options
 *
 * @public
 */
export interface RequestConfig {
  /**
   * Request headers
   */
  headers?: Record<string, string>

  /**
   * Query parameters
   */
  params?: Record<string, string | number | boolean>

  /**
   * Request timeout in milliseconds
   * @defaultValue 30000
   */
  timeout?: number

  /**
   * Number of retry attempts on failure
   * @defaultValue 0
   */
  retries?: number

  /**
   * Delay between retries in milliseconds
   * @defaultValue 1000
   */
  retryDelay?: number

  /**
   * Whether to include credentials
   * @defaultValue false
   */
  withCredentials?: boolean

  /**
   * Response type expectation
   * @defaultValue 'json'
   */
  responseType?: 'json' | 'text' | 'blob' | 'arraybuffer'

  /**
   * Abort signal for cancellation
   */
  signal?: AbortSignal
}

/**
 * HTTP response wrapper
 *
 * @typeParam T - Response data type
 * @public
 */
export interface HttpResponse<T = unknown> {
  /** Response data */
  data: T
  /** HTTP status code */
  status: number
  /** Status text */
  statusText: string
  /** Response headers */
  headers: Record<string, string>
  /** Original request config */
  config: RequestConfig
  /** Request duration in milliseconds */
  duration: number
}

/**
 * HTTP error class
 *
 * @remarks
 * Thrown when an HTTP request fails. Contains detailed
 * information about the failure.
 *
 * @example
 * ```typescript
 * try {
 *   await httpClient.get('/api/users')
 * } catch (error) {
 *   if (error instanceof HttpError) {
 *     console.log(`Request failed: ${error.status} - ${error.message}`)
 *   }
 * }
 * ```
 *
 * @public
 */
export class HttpError extends Error {
  /**
   * HTTP status code
   */
  public readonly status: number

  /**
   * Response data if available
   */
  public readonly data?: unknown

  /**
   * Request configuration that caused the error
   */
  public readonly config: RequestConfig

  /**
   * Request URL
   */
  public readonly url: string

  /**
   * Creates a new HttpError
   *
   * @param message - Error message
   * @param status - HTTP status code
   * @param url - Request URL
   * @param config - Request configuration
   * @param data - Response data if available
   */
  constructor(message: string, status: number, url: string, config: RequestConfig, data?: unknown) {
    super(message)
    this.name = 'HttpError'
    this.status = status
    this.url = url
    this.config = config
    this.data = data
  }

  /**
   * Checks if error is a client error (4xx)
   */
  public isClientError(): boolean {
    return this.status >= 400 && this.status < 500
  }

  /**
   * Checks if error is a server error (5xx)
   */
  public isServerError(): boolean {
    return this.status >= 500 && this.status < 600
  }
}

/**
 * Request interceptor function type
 *
 * @param config - Request configuration
 * @returns Modified configuration or promise
 * @public
 */
export type RequestInterceptor = (config: RequestConfig) => RequestConfig | Promise<RequestConfig>

/**
 * Response interceptor function type
 *
 * @typeParam T - Response data type
 * @param response - HTTP response
 * @returns Modified response or promise
 * @public
 */
export type ResponseInterceptor<T = unknown> = (
  response: HttpResponse<T>
) => HttpResponse<T> | Promise<HttpResponse<T>>

/**
 * HTTP Client class for making API requests
 *
 * @remarks
 * A comprehensive HTTP client with support for:
 * - All standard HTTP methods
 * - Request/response interceptors
 * - Automatic retries
 * - Timeout handling
 * - TypeScript generics for type-safe responses
 *
 * @example
 * Creating and configuring a client:
 * ```typescript
 * const client = new HttpClient({
 *   baseUrl: 'https://api.example.com',
 *   defaultTimeout: 5000,
 *   defaultHeaders: {
 *     'Content-Type': 'application/json',
 *     'Authorization': 'Bearer token'
 *   }
 * })
 *
 * // Add request interceptor
 * client.addRequestInterceptor((config) => {
 *   config.headers = {
 *     ...config.headers,
 *     'X-Request-ID': generateRequestId()
 *   }
 *   return config
 * })
 * ```
 *
 * @example
 * Making requests:
 * ```typescript
 * // GET request with type safety
 * const users = await client.get<User[]>('/users')
 *
 * // POST request with body
 * const newUser = await client.post<User>('/users', {
 *   name: 'John',
 *   email: 'john@example.com'
 * })
 *
 * // Request with custom config
 * const data = await client.get<Data>('/data', {
 *   timeout: 60000,
 *   retries: 3
 * })
 * ```
 *
 * @public
 */
export class HttpClient {
  /**
   * Base URL for all requests
   * @readonly
   */
  public readonly baseUrl: string

  /**
   * Default request timeout
   */
  private defaultTimeout: number

  /**
   * Default headers applied to all requests
   */
  private defaultHeaders: Record<string, string>

  /**
   * Request interceptors
   */
  private requestInterceptors: RequestInterceptor[] = []

  /**
   * Response interceptors
   */
  private responseInterceptors: ResponseInterceptor[] = []

  /**
   * Creates a new HttpClient instance
   *
   * @param options - Client configuration options
   * @param options.baseUrl - Base URL for all requests
   * @param options.defaultTimeout - Default timeout in milliseconds
   * @param options.defaultHeaders - Default headers for all requests
   */
  constructor(options: {
    baseUrl: string
    defaultTimeout?: number
    defaultHeaders?: Record<string, string>
  }) {
    this.baseUrl = options.baseUrl.replace(/\/$/, '')
    this.defaultTimeout = options.defaultTimeout ?? 30000
    this.defaultHeaders = options.defaultHeaders ?? {}
  }

  /**
   * Adds a request interceptor
   *
   * @param interceptor - Interceptor function
   * @returns Function to remove the interceptor
   *
   * @example
   * ```typescript
   * const remove = client.addRequestInterceptor((config) => {
   *   console.log('Request:', config)
   *   return config
   * })
   *
   * // Later: remove the interceptor
   * remove()
   * ```
   */
  public addRequestInterceptor(interceptor: RequestInterceptor): () => void {
    this.requestInterceptors.push(interceptor)
    return () => {
      const index = this.requestInterceptors.indexOf(interceptor)
      if (index !== -1) {
        this.requestInterceptors.splice(index, 1)
      }
    }
  }

  /**
   * Adds a response interceptor
   *
   * @param interceptor - Interceptor function
   * @returns Function to remove the interceptor
   */
  public addResponseInterceptor<T = unknown>(interceptor: ResponseInterceptor<T>): () => void {
    this.responseInterceptors.push(interceptor as ResponseInterceptor)
    return () => {
      const index = this.responseInterceptors.indexOf(interceptor as ResponseInterceptor)
      if (index !== -1) {
        this.responseInterceptors.splice(index, 1)
      }
    }
  }

  /**
   * Performs a GET request
   *
   * @typeParam T - Expected response data type
   * @param path - Request path
   * @param config - Request configuration
   * @returns Promise resolving to the response
   *
   * @example
   * ```typescript
   * const user = await client.get<User>('/users/123')
   * console.log(user.data.name)
   * ```
   */
  public async get<T = unknown>(path: string, config?: RequestConfig): Promise<HttpResponse<T>> {
    return this.request<T>('GET', path, undefined, config)
  }

  /**
   * Performs a POST request
   *
   * @typeParam T - Expected response data type
   * @typeParam D - Request body data type
   * @param path - Request path
   * @param data - Request body data
   * @param config - Request configuration
   * @returns Promise resolving to the response
   */
  public async post<T = unknown, D = unknown>(
    path: string,
    data?: D,
    config?: RequestConfig
  ): Promise<HttpResponse<T>> {
    return this.request<T>('POST', path, data, config)
  }

  /**
   * Performs a PUT request
   *
   * @typeParam T - Expected response data type
   * @typeParam D - Request body data type
   * @param path - Request path
   * @param data - Request body data
   * @param config - Request configuration
   * @returns Promise resolving to the response
   */
  public async put<T = unknown, D = unknown>(
    path: string,
    data?: D,
    config?: RequestConfig
  ): Promise<HttpResponse<T>> {
    return this.request<T>('PUT', path, data, config)
  }

  /**
   * Performs a PATCH request
   *
   * @typeParam T - Expected response data type
   * @typeParam D - Request body data type
   * @param path - Request path
   * @param data - Request body data
   * @param config - Request configuration
   * @returns Promise resolving to the response
   */
  public async patch<T = unknown, D = unknown>(
    path: string,
    data?: D,
    config?: RequestConfig
  ): Promise<HttpResponse<T>> {
    return this.request<T>('PATCH', path, data, config)
  }

  /**
   * Performs a DELETE request
   *
   * @typeParam T - Expected response data type
   * @param path - Request path
   * @param config - Request configuration
   * @returns Promise resolving to the response
   */
  public async delete<T = unknown>(path: string, config?: RequestConfig): Promise<HttpResponse<T>> {
    return this.request<T>('DELETE', path, undefined, config)
  }

  /**
   * Performs an HTTP request
   *
   * @typeParam T - Expected response data type
   * @param method - HTTP method
   * @param path - Request path
   * @param data - Request body data
   * @param config - Request configuration
   * @returns Promise resolving to the response
   * @throws {@link HttpError} On request failure
   *
   * @internal
   */
  private async request<T>(
    method: HttpMethod,
    path: string,
    data?: unknown,
    config: RequestConfig = {}
  ): Promise<HttpResponse<T>> {
    const startTime = Date.now()

    // Apply request interceptors
    let finalConfig: RequestConfig = {
      ...config,
      headers: { ...this.defaultHeaders, ...config.headers },
      timeout: config.timeout ?? this.defaultTimeout,
    }

    for (const interceptor of this.requestInterceptors) {
      finalConfig = await interceptor(finalConfig)
    }

    // Build URL with query params
    const url = this.buildUrl(path, finalConfig.params)

    // Simulate HTTP request (in real implementation, use fetch/axios)
    const response: HttpResponse<T> = {
      data: {} as T,
      status: 200,
      statusText: 'OK',
      headers: {},
      config: finalConfig,
      duration: Date.now() - startTime,
    }

    // Apply response interceptors
    let finalResponse = response
    for (const interceptor of this.responseInterceptors) {
      finalResponse = (await interceptor(finalResponse)) as HttpResponse<T>
    }

    return finalResponse
  }

  /**
   * Builds a full URL with query parameters
   *
   * @param path - Request path
   * @param params - Query parameters
   * @returns Full URL string
   *
   * @internal
   */
  private buildUrl(path: string, params?: Record<string, string | number | boolean>): string {
    const url = new URL(path.startsWith('/') ? path.slice(1) : path, this.baseUrl + '/')

    if (params) {
      for (const [key, value] of Object.entries(params)) {
        url.searchParams.set(key, String(value))
      }
    }

    return url.toString()
  }
}

/**
 * Creates a pre-configured HTTP client
 *
 * @param baseUrl - Base URL for the client
 * @returns Configured HttpClient instance
 *
 * @example
 * ```typescript
 * const api = createHttpClient('https://api.example.com')
 * const users = await api.get<User[]>('/users')
 * ```
 *
 * @public
 */
export function createHttpClient(baseUrl: string): HttpClient {
  return new HttpClient({
    baseUrl,
    defaultTimeout: 30000,
    defaultHeaders: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
  })
}