const fs = require('fs'); const path = require('path'); const configPath = path.join(__dirname, 'APIinvima.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf8').replace(/^\uFEFF/, '')); const INVIMA_DATASETS = Object.fromEntries(config.datasets.map((dataset) => [dataset.key, dataset])); const INVIMA_DATASET_IDS = Object.fromEntries(config.datasets.map((dataset) => [dataset.id, dataset])); const ALLOWED_SODA_PARAMS = ['$limit', '$offset', '$select', '$where', '$order']; const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const clampInt = (value, fallback, min, max) => { const parsed = Number.parseInt(String(value ?? ''), 10); if (Number.isNaN(parsed)) return fallback; return Math.max(min, Math.min(max, parsed)); }; const computeBackoffMs = (attempt, baseMs = 800, maxMs = 8000) => { const exp = baseMs * 2 ** attempt; const jitter = Math.floor(Math.random() * 250); return Math.min(maxMs, exp + jitter); }; const isNetworkRetryableError = (error) => { const code = String(error?.cause?.code || error?.code || '').toUpperCase(); if (code.includes('TIMEOUT') || code.includes('ECONNRESET') || code.includes('EAI_AGAIN')) return true; const message = String(error?.message || '').toLowerCase(); return ( message.includes('fetch failed') || message.includes('timeout') || message.includes('socket hang up') || message.includes('temporar') ); }; const cleanParamValue = (value) => { if (value === undefined || value === null) return null; const text = String(value).trim(); return text ? text : null; }; const buildInvimaUrl = (datasetId, params = {}) => { const url = new URL(`${config.baseUrl}/${datasetId}.json`); for (const key of ALLOWED_SODA_PARAMS) { const value = cleanParamValue(params[key]); if (value !== null) { url.searchParams.set(key, value); } } return url.toString(); }; const resolveDataset = (datasetKeyOrId) => { return INVIMA_DATASETS[datasetKeyOrId] || INVIMA_DATASET_IDS[datasetKeyOrId] || null; }; const fetchInvimaDataset = async ( datasetKeyOrId, options = {}, appToken = process.env.SOCRATA_APP_TOKEN, requestOptions = {} ) => { const dataset = resolveDataset(datasetKeyOrId); if (!dataset) { throw new Error(`Dataset no reconocido: ${datasetKeyOrId}`); } const url = buildInvimaUrl(dataset.id, options); const headers = { Accept: 'application/json' }; const maxRetries = clampInt(requestOptions.retries, 4, 0, 8); const timeoutMs = clampInt(requestOptions.timeoutMs, 35000, 4000, 120000); if (appToken) { headers['X-App-Token'] = appToken; } let lastError = null; for (let attempt = 0; attempt <= maxRetries; attempt += 1) { const attemptLabel = `${attempt + 1}/${maxRetries + 1}`; try { const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined; const response = await fetch(url, { headers, signal }); if (!response.ok) { const body = await response.text(); const error = new Error( `Error consultando ${dataset.key} (${dataset.id}): ${response.status} ${response.statusText}. ${body}` ); lastError = error; if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < maxRetries) { await sleep(computeBackoffMs(attempt)); continue; } throw error; } return response.json(); } catch (error) { lastError = error; if (attempt < maxRetries && isNetworkRetryableError(error)) { await sleep(computeBackoffMs(attempt)); continue; } if (error instanceof Error && error.message.includes('Error consultando')) { throw error; } throw new Error( `Error consultando ${dataset.key} (${dataset.id}) [intento ${attemptLabel}]: ${String(error?.message || error)}` ); } } throw lastError || new Error(`No fue posible consultar dataset ${dataset.key} (${dataset.id}).`); }; module.exports = { apiConfig: config, INVIMA_DATASETS, INVIMA_DATASET_IDS, ALLOWED_SODA_PARAMS, buildInvimaUrl, fetchInvimaDataset };