/** * HTTP client for UniSat API */ /// import type { ApiResponse, ClientConfig, RequestConfig } from '../types' import { ApiClientError, NetworkError, HttpError, TimeoutError, ParseError, RateLimitError, ServiceUnavailableError, isRetryableError, getRetryDelay, } from '../utils/errors' /** * HTTP request options */ export interface RequestOptions extends RequestConfig { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' body?: any query?: Record } export interface BaseHttpClient { get(url: string, options?: RequestOptions): Promise post(url: string, body?: any, options?: RequestOptions): Promise setBaseURL(baseURL: string): void setHeaders(headers: Record): void } /** * HTTP client implementation */ export class HttpClient implements BaseHttpClient { private readonly baseURL: string private readonly defaultConfig: Required> private readonly defaultHeaders: Record constructor(config: ClientConfig = {}) { this.baseURL = config.endpoint || 'https://api.unisat.io' this.defaultConfig = { timeout: config.timeout || 30000, retries: config.retries || 3, } this.defaultHeaders = { 'Content-Type': 'application/json', 'User-Agent': config.userAgent || 'UniSat-API-Client/1.0', 'X-Client': 'UniSat Wallet', ...config.headers, } if (config.apiKey) { this.defaultHeaders['Authorization'] = `Bearer ${config.apiKey}` } } /** * Make an HTTP request */ async request(path: string, options: RequestOptions = {}): Promise { const { method = 'GET', body, query, timeout = this.defaultConfig.timeout, retries = this.defaultConfig.retries, headers = {}, } = options const url = this.buildUrl(path, query) const requestHeaders = { ...this.defaultHeaders, ...headers } let lastError: Error for (let attempt = 1; attempt <= retries + 1; attempt++) { try { const requestInit: RequestInit = { method, headers: requestHeaders, signal: AbortSignal.timeout(timeout), } if (body) { requestInit.body = JSON.stringify(body) } const response = await this.makeRequest(url, requestInit) return await this.handleResponse(response) } catch (error) { lastError = error as Error // Don't retry on the last attempt or non-retryable errors if (attempt === retries + 1 || !isRetryableError(lastError)) { break } // Wait before retrying const delay = getRetryDelay(lastError, attempt) await this.sleep(delay) } } throw lastError! } /** * Make a GET request */ async get( path: string, options: Omit = {} ): Promise { return this.request(path, { ...options, method: 'GET' }) } /** * Make a POST request */ async post( path: string, body?: any, options: Omit = {} ): Promise { return this.request(path, { ...options, method: 'POST', body }) } /** * Make a PUT request */ async put( path: string, body?: any, options: Omit = {} ): Promise { return this.request(path, { ...options, method: 'PUT', body }) } /** * Make a DELETE request */ async delete( path: string, options: Omit = {} ): Promise { return this.request(path, { ...options, method: 'DELETE' }) } /** * Build URL with query parameters */ private buildUrl(path: string, query?: Record): string { // Ensure baseURL ends with / and path doesn't start with / const normalizedBaseURL = this.baseURL.endsWith('/') ? this.baseURL : this.baseURL + '/' const normalizedPath = path.startsWith('/') ? path.slice(1) : path const url = new URL(normalizedPath, normalizedBaseURL) if (query) { Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== null) { url.searchParams.append(key, String(value)) } }) } return url.toString() } /** * Make the actual HTTP request */ private async makeRequest(url: string, init: RequestInit): Promise { try { const response = await fetch(url, init) return response } catch (error: any) { if (error.name === 'AbortError') { throw new TimeoutError(this.defaultConfig.timeout) } throw new NetworkError(`Network error: ${error.message}`, error) } } /** * Handle HTTP response */ private async handleResponse(response: Response): Promise { // Handle rate limiting if (response.status === 429) { const retryAfter = parseInt(response.headers.get('Retry-After') || '60') throw new RateLimitError('Rate limit exceeded', retryAfter) } // Handle other HTTP errors if (!response.ok) { if (response.status >= 500) { throw new ServiceUnavailableError( `Service unavailable: ${response.status} ${response.statusText}` ) } throw new HttpError(response.status, response.statusText) } // Parse response let data: any try { data = await response.json() } catch (error) { throw new ParseError('Failed to parse JSON response', error) } // Handle API-level errors if (this.isApiResponse(data)) { if (data.code !== 0) { // Assuming 0 is success throw new ApiClientError(data.msg || 'API error', data.code) } return data.data } return data } /** * Check if response is in API format */ private isApiResponse(data: any): data is ApiResponse { return data && typeof data === 'object' && 'code' in data && 'msg' in data } /** * Sleep utility for retries */ private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } /** * Update base URL */ setBaseURL(baseURL: string): void { ;(this as any).baseURL = baseURL } /** * Update default headers */ setHeaders(headers: Record): void { Object.assign(this.defaultHeaders, headers) } /** * Remove a header */ removeHeader(name: string): void { delete this.defaultHeaders[name] } }