From d5ec0cd9db80b82236d3cbc24f4103c076849188 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Wed, 28 Jan 2026 22:16:51 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20utility=20functions=20for=20A?= =?UTF-8?q?PI=20client=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/utilities/api-client.js | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 code/websites/pokedex.online/src/utilities/api-client.js diff --git a/code/websites/pokedex.online/src/utilities/api-client.js b/code/websites/pokedex.online/src/utilities/api-client.js new file mode 100644 index 0000000..e218370 --- /dev/null +++ b/code/websites/pokedex.online/src/utilities/api-client.js @@ -0,0 +1,192 @@ +/** + * API Client Utility + * + * Centralized fetch wrapper with: + * - Automatic error handling + * - Retry logic with exponential backoff + * - Request/response interceptors + * - Request deduplication + * - Timeout support + * + * @example + * const client = createApiClient({ baseURL: '/api' }); + * const data = await client.get('/users'); + */ + +const activeRequests = new Map(); + +/** + * Create an API client with configuration + * @param {Object} config - Client configuration + * @returns {Object} API client with methods + */ +export function createApiClient(config = {}) { + const { + baseURL = '', + timeout = 30000, + maxRetries = 3, + retryDelay = 1000, + headers: defaultHeaders = {}, + onRequest = null, + onResponse = null, + onError = null + } = config; + + /** + * Make HTTP request + * @param {string} url - Request URL + * @param {Object} options - Fetch options + * @returns {Promise} Response data + */ + async function request(url, options = {}) { + const fullURL = url.startsWith('http') ? url : `${baseURL}${url}`; + const requestKey = `${options.method || 'GET'}:${fullURL}`; + + // Check for duplicate request + if (options.deduplicate !== false && activeRequests.has(requestKey)) { + return activeRequests.get(requestKey); + } + + const requestPromise = makeRequest(fullURL, options); + + // Store active request + if (options.deduplicate !== false) { + activeRequests.set(requestKey, requestPromise); + } + + try { + const result = await requestPromise; + return result; + } finally { + activeRequests.delete(requestKey); + } + } + + /** + * Make the actual HTTP request with retries + */ + async function makeRequest(url, options) { + const { + retries = maxRetries, + ...fetchOptions + } = options; + + // Merge headers + const headers = { + 'Content-Type': 'application/json', + ...defaultHeaders, + ...fetchOptions.headers + }; + + // Create abort controller for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + let lastError; + let attempt = 0; + + while (attempt <= retries) { + try { + // Call request interceptor + let requestOptions = { ...fetchOptions, headers, signal: controller.signal }; + if (onRequest) { + requestOptions = await onRequest(url, requestOptions) || requestOptions; + } + + const response = await fetch(url, requestOptions); + clearTimeout(timeoutId); + + // Call response interceptor + let processedResponse = response; + if (onResponse) { + processedResponse = await onResponse(response.clone()) || response; + } + + // Handle HTTP errors + if (!processedResponse.ok) { + const error = new Error(`HTTP ${processedResponse.status}: ${processedResponse.statusText}`); + error.status = processedResponse.status; + error.response = processedResponse; + + // Try to parse error body + try { + const contentType = processedResponse.headers.get('content-type'); + if (contentType?.includes('application/json')) { + error.data = await processedResponse.json(); + } else { + error.data = await processedResponse.text(); + } + } catch (e) { + // Ignore parse errors + } + + throw error; + } + + // Parse response + const contentType = processedResponse.headers.get('content-type'); + if (contentType?.includes('application/json')) { + return await processedResponse.json(); + } + return await processedResponse.text(); + + } catch (error) { + clearTimeout(timeoutId); + lastError = error; + + // Don't retry on abort + if (error.name === 'AbortError') { + const timeoutError = new Error('Request timeout'); + timeoutError.isTimeout = true; + throw timeoutError; + } + + // Don't retry on 4xx errors (client errors) + if (error.status && error.status >= 400 && error.status < 500) { + throw error; + } + + attempt++; + + // If more retries remaining, wait before retrying + if (attempt <= retries) { + await new Promise(resolve => setTimeout(resolve, retryDelay * attempt)); + } + } + } + + // All retries exhausted + if (onError) { + onError(lastError); + } + + throw lastError; + } + + // Convenience methods + return { + request, + get: (url, options = {}) => request(url, { ...options, method: 'GET' }), + post: (url, data, options = {}) => request(url, { + ...options, + method: 'POST', + body: JSON.stringify(data) + }), + put: (url, data, options = {}) => request(url, { + ...options, + method: 'PUT', + body: JSON.stringify(data) + }), + patch: (url, data, options = {}) => request(url, { + ...options, + method: 'PATCH', + body: JSON.stringify(data) + }), + delete: (url, options = {}) => request(url, { ...options, method: 'DELETE' }) + }; +} + +// Export default client instance +export const apiClient = createApiClient({ + baseURL: '/api' +});