UNPKG

autotrader-connect-api

Version:

Production-ready TypeScript wrapper for Auto Trader UK Connect APIs

376 lines 13.4 kB
/** * HTTP client for AutoTrader API * Configures Axios with authentication, rate limiting, and error handling */ import axios from 'axios'; import Bottleneck from 'bottleneck'; import { getToken } from './auth'; /** * Default configuration values */ /** * Determine if we should use sandbox based on environment */ function shouldUseSandbox() { // Explicit sandbox flag takes precedence if (process.env['AT_USE_SANDBOX'] !== undefined) { return process.env['AT_USE_SANDBOX'] === 'true'; } // Default to sandbox in development return process.env['NODE_ENV'] === 'development' || process.env['NODE_ENV'] === 'test'; } /** * Get the appropriate base URL for the current environment */ function getBaseURL(useSandbox) { const shouldSandbox = useSandbox !== undefined ? useSandbox : shouldUseSandbox(); if (shouldSandbox) { return process.env['AT_SANDBOX_BASE_URL'] || 'https://sandbox-api.autotrader.co.uk'; } return process.env['AT_BASE_URL'] || 'https://api.autotrader.co.uk'; } /** * Get the appropriate API credentials for the current environment */ function getApiCredentials(useSandbox) { const shouldSandbox = useSandbox !== undefined ? useSandbox : shouldUseSandbox(); const result = {}; if (shouldSandbox) { const apiKey = process.env['AT_SANDBOX_API_KEY']; const apiSecret = process.env['AT_SANDBOX_API_SECRET']; if (apiKey) result.apiKey = apiKey; if (apiSecret) result.apiSecret = apiSecret; } else { const apiKey = process.env['AT_API_KEY']; const apiSecret = process.env['AT_API_SECRET']; if (apiKey) result.apiKey = apiKey; if (apiSecret) result.apiSecret = apiSecret; } return result; } const DEFAULT_CONFIG = { baseURL: getBaseURL(), timeout: parseInt(process.env['AT_TIMEOUT'] || '30000', 10), maxRetries: 3, rateLimitRequests: parseInt(process.env['AT_RATE_LIMIT_REQUESTS'] || '100', 10), rateLimitWindow: parseInt(process.env['AT_RATE_LIMIT_WINDOW'] || '60000', 10), debug: process.env['AT_DEBUG'] === 'true', useSandbox: shouldUseSandbox(), }; /** * HTTP client class with rate limiting and authentication */ export class ApiClient { constructor(config) { this.config = { ...DEFAULT_CONFIG, ...config }; // Initialize rate limiter this.rateLimiter = new Bottleneck({ reservoir: this.config.rateLimitRequests, reservoirRefreshAmount: this.config.rateLimitRequests, reservoirRefreshInterval: this.config.rateLimitWindow, maxConcurrent: 10, minTime: Math.floor(this.config.rateLimitWindow / this.config.rateLimitRequests), retryCount: this.config.maxRetries, retry: this.shouldRetryRequest.bind(this), }); // Initialize Axios instance this.axiosInstance = axios.create({ baseURL: this.config.baseURL, timeout: this.config.timeout, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'AutoTrader-Connect-API/1.0.0', }, }); // Setup interceptors this.setupRequestInterceptor(); this.setupResponseInterceptor(); } /** * Make a GET request */ async get(url, options) { return this.request('GET', url, undefined, options); } /** * Make a POST request */ async post(url, data, options) { return this.request('POST', url, data, options); } /** * Make a PUT request */ async put(url, data, options) { return this.request('PUT', url, data, options); } /** * Make a PATCH request */ async patch(url, data, options) { return this.request('PATCH', url, data, options); } /** * Make a DELETE request */ async delete(url, options) { return this.request('DELETE', url, undefined, options); } /** * Make a generic HTTP request with rate limiting */ async request(method, url, data, options) { return this.rateLimiter.schedule(async () => { const config = { method, url, data, timeout: options?.timeout || this.config.timeout, }; if (options?.params) { config.params = options.params; } if (options?.headers) { config.headers = options.headers; } try { const response = await this.axiosInstance(config); if (this.config.debug) { console.log(`[AutoTrader API] ${method} ${url}:`, { status: response.status, data: response.data, }); } return response.data; } catch (error) { if (this.config.debug) { console.error(`[AutoTrader API] ${method} ${url} Error:`, error); } throw this.handleApiError(error); } }); } /** * Setup request interceptor for authentication */ setupRequestInterceptor() { this.axiosInstance.interceptors.request.use(async (config) => { try { // Get and inject authentication token const token = await getToken(); config.headers = config.headers || {}; config.headers.Authorization = `Bearer ${token}`; if (this.config.debug) { console.log(`[AutoTrader API] Request: ${config.method?.toUpperCase()} ${config.url}`); } return config; } catch (error) { console.error('Failed to get authentication token:', error); return Promise.reject(error); } }, (error) => { console.error('Request interceptor error:', error); return Promise.reject(error); }); } /** * Setup response interceptor for error handling and token refresh */ setupResponseInterceptor() { this.axiosInstance.interceptors.response.use((response) => { // Update rate limit info if available this.updateRateLimitInfo(response); return response; }, async (error) => { const originalRequest = error.config; // Handle 401 Unauthorized - attempt token refresh and retry if (error.response?.status === 401 && originalRequest && !originalRequest._retry) { originalRequest._retry = true; try { if (this.config.debug) { console.log('[AutoTrader API] Token expired, attempting refresh...'); } // Force token refresh await getToken(); // Retry the original request const token = await getToken(); originalRequest.headers = originalRequest.headers || {}; originalRequest.headers.Authorization = `Bearer ${token}`; return this.axiosInstance(originalRequest); } catch (refreshError) { console.error('Token refresh failed:', refreshError); return Promise.reject(error); } } return Promise.reject(error); }); } /** * Update rate limit information from response headers */ updateRateLimitInfo(response) { const rateLimitRemaining = response.headers['x-ratelimit-remaining']; const rateLimitReset = response.headers['x-ratelimit-reset']; if (rateLimitRemaining !== undefined) { const remaining = parseInt(rateLimitRemaining, 10); if (!isNaN(remaining) && remaining < 10) { console.warn(`[AutoTrader API] Rate limit warning: ${remaining} requests remaining`); } } if (rateLimitReset !== undefined) { const resetTime = parseInt(rateLimitReset, 10); if (!isNaN(resetTime)) { const resetDate = new Date(resetTime * 1000); if (this.config.debug) { console.log(`[AutoTrader API] Rate limit resets at: ${resetDate.toISOString()}`); } } } } /** * Handle API errors and convert to standardized format */ handleApiError(error) { if (axios.isAxiosError(error)) { const axiosError = error; // Extract error information const status = axiosError.response?.status; const statusText = axiosError.response?.statusText; const data = axiosError.response?.data; // Create detailed error message let message = `API request failed: ${axiosError.message}`; if (status) { message += ` (${status}`; if (statusText) { message += ` ${statusText}`; } message += ')'; } // Extract API error details if available if (data && typeof data === 'object') { const apiData = data; if (apiData.message) { message += ` - ${apiData.message}`; } if (apiData.error_description) { message += ` - ${apiData.error_description}`; } } // Create enhanced error object const enhancedError = new Error(message); enhancedError.status = status; enhancedError.statusText = statusText; enhancedError.response = axiosError.response; enhancedError.request = axiosError.request; enhancedError.isAxiosError = true; return enhancedError; } // Return original error if not an Axios error return error instanceof Error ? error : new Error(String(error)); } /** * Determine if a request should be retried */ shouldRetryRequest(error, retryCount) { // Don't retry more than the configured maximum if (retryCount >= this.config.maxRetries) { return false; } // Check if it's an axios error if (!error.isAxiosError) { return false; } const status = error.status; // Retry on 5xx server errors and 429 rate limit if (status >= 500 || status === 429) { // For rate limiting, use the retry-after header if available if (status === 429) { const retryAfter = error.response?.headers['retry-after']; if (retryAfter) { const delay = parseInt(retryAfter, 10); return isNaN(delay) ? 1000 : delay * 1000; // Convert to milliseconds } } // Exponential backoff for other retryable errors return Math.min(1000 * Math.pow(2, retryCount), 10000); } // Don't retry 4xx client errors (except 429) return false; } /** * Get current rate limiter status */ getRateLimitStatus() { return { reservoir: this.rateLimiter.reservoir?.() || 0, running: this.rateLimiter.running?.() || 0, queued: this.rateLimiter.queued?.() || 0, }; } /** * Get client configuration */ getConfig() { return { ...this.config }; } } /** * Default client instance */ let defaultClient = null; /** * Get or create the default API client */ export function getClient(config) { if (!defaultClient) { if (!config) { const useSandbox = shouldUseSandbox(); const credentials = getApiCredentials(useSandbox); if (!credentials.apiKey || !credentials.apiSecret) { const envPrefix = useSandbox ? 'AT_SANDBOX_' : 'AT_'; throw new Error(`API configuration required. Provide config or set ${envPrefix}API_KEY and ${envPrefix}API_SECRET environment variables. ` + `Current environment: ${useSandbox ? 'sandbox' : 'production'}`); } config = { ...DEFAULT_CONFIG, apiKey: credentials.apiKey, apiSecret: credentials.apiSecret, baseURL: getBaseURL(useSandbox), useSandbox, }; } defaultClient = new ApiClient(config); } return defaultClient; } /** * Default client instance export */ const client = { get: (url, options) => { return getClient().get(url, options); }, post: (url, data, options) => { return getClient().post(url, data, options); }, put: (url, data, options) => { return getClient().put(url, data, options); }, patch: (url, data, options) => { return getClient().patch(url, data, options); }, delete: (url, options) => { return getClient().delete(url, options); }, }; export default client; //# sourceMappingURL=client.js.map