268 lines
14 KiB
JavaScript
268 lines
14 KiB
JavaScript
// Generated, do not edit. sha256:e3489a6085d47798380dafd4c4992473c5fdddb26fbfb6bf3b1a4ff191ab8437
|
|
import { AUTH_KINDS, CONFIG } from '../../shared/config.js';
|
|
import { AppError, fail } from '../../shared/app-error.js';
|
|
import { toLocalIso } from '../../shared/time.js';
|
|
|
|
const TIMEOUT_MS = 12000;
|
|
const CAPABILITIES = ['spot', 'daily_history', 'history_point'];
|
|
|
|
function tokenize(path, externalId) {
|
|
return path.split('.').map(seg => {
|
|
if (seg === '{external_id}') {
|
|
if (externalId == null) fail('integrations.price.externalIdMissing', null, 'The output path uses {external_id} but there is no external id.');
|
|
return externalId;
|
|
}
|
|
return /^\d+$/.test(seg) ? Number(seg) : seg;
|
|
});
|
|
}
|
|
export function resolveScalar(obj, path, externalId) {
|
|
let cur = obj;
|
|
for (const key of tokenize(path, externalId)) {
|
|
if (cur == null) return undefined;
|
|
cur = cur[key];
|
|
}
|
|
return cur;
|
|
}
|
|
export function resolveColumn(obj, path, externalId) {
|
|
const o = path.indexOf('{}');
|
|
if (o >= 0 && (path.indexOf('[]') < 0 || o < path.indexOf('[]'))) {
|
|
const before = path.slice(0, o);
|
|
const after = path.slice(o + 2).replace(/^\./, '');
|
|
const map = before ? resolveScalar(obj, before, externalId) : obj;
|
|
if (map == null || typeof map !== 'object' || Array.isArray(map)) {
|
|
fail('integrations.price.expectedObject', { pad: before || '(root)' }, `Expected an object at '${before || '(root)'}'.`);
|
|
}
|
|
return Object.entries(map).map(([key, value]) =>
|
|
(after === '@key' ? key : after ? resolveScalar(value, after, externalId) : value));
|
|
}
|
|
const i = path.indexOf('[]');
|
|
if (i < 0) fail('integrations.price.listPathWithoutBrackets', { pad: path }, `List path without []: '${path}'.`);
|
|
const before = path.slice(0, i);
|
|
const after = path.slice(i + 2).replace(/^\./, '');
|
|
const arr = before ? resolveScalar(obj, before, externalId) : obj;
|
|
if (!Array.isArray(arr)) fail('integrations.price.expectedArray', { pad: before || '(root)' }, `Expected an array at '${before || '(root)'}'.`);
|
|
return arr.map(el => (after ? resolveScalar(el, after, externalId) : el));
|
|
}
|
|
function applyScalarTransform(v, transform) {
|
|
switch (transform) {
|
|
case 'none': return v;
|
|
case 'to_number': { const n = Number(v); return Number.isFinite(n) ? n : null; }
|
|
case 'ms_to_s': { const n = Number(v); return Number.isFinite(n) ? Math.floor(n / 1000) : null; }
|
|
case 'last_per_day': return v;
|
|
case 'troy_ounce_to_gram': { const n = Number(v); const f = CONFIG.METALS.GRAMS_PER_TROY_OUNCE;
|
|
return (Number.isFinite(n) && f) ? n / f : null; }
|
|
case 'iso_date_to_s': {
|
|
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return null;
|
|
const ms = Date.parse(`${v}T00:00:00Z`);
|
|
return Number.isFinite(ms) ? Math.floor(ms / 1000) : null;
|
|
}
|
|
case 'ms_to_s_local_close': {
|
|
const n = Number(v);
|
|
if (!Number.isFinite(n)) return null;
|
|
const utc = new Date(n);
|
|
if (n % 86400000 === 0 && utc.getUTCDate() === 1) return null;
|
|
const lokaal = toLocalIso(utc.toISOString());
|
|
if (/T00:00:00(\.000)?$/.test(lokaal)) return Date.parse(`${lokaal.slice(0, 10)}T00:00:00Z`) / 1000;
|
|
return Math.floor(n / 1000);
|
|
}
|
|
default: fail('integrations.price.unknownTransform', { transform }, `Unknown transform: '${transform}'.`);
|
|
}
|
|
}
|
|
|
|
function outputsFor(provider, capability) {
|
|
const rows = provider.outputs.filter(o => o.capability === capability);
|
|
if (!rows.length) fail('integrations.price.noOutputMapping', { provider: provider.key, capability }, `Provider ${provider.key}: no output mapping for '${capability}'.`);
|
|
return rows;
|
|
}
|
|
function assertPriced(value, provider) {
|
|
if (Number.isFinite(value)) return;
|
|
fail('integrations.price.noValidPrice', { provider: provider.key }, `Provider ${provider.key}: no valid price in the response.`);
|
|
}
|
|
|
|
export function mapSpot(parsed, provider, externalId) {
|
|
const out = {};
|
|
for (const o of outputsFor(provider, 'spot'))
|
|
out[o.internalField] = applyScalarTransform(resolveScalar(parsed, o.jsonPath, externalId), o.transform);
|
|
assertPriced(out.price, provider);
|
|
return { price: out.price };
|
|
}
|
|
export function mapHistoryPoint(parsed, provider, externalId, dateIso) {
|
|
const out = {};
|
|
for (const o of outputsFor(provider, 'history_point'))
|
|
out[o.internalField] = applyScalarTransform(resolveScalar(parsed, o.jsonPath, externalId), o.transform);
|
|
const time = Number.isFinite(out.time) ? out.time : Math.floor(Date.parse(`${dateIso}T00:00:00Z`) / 1000);
|
|
if (!Number.isFinite(time)) fail('integrations.price.invalidDate', { provider: provider.key, datum: dateIso }, `Provider ${provider.key}: invalid date '${dateIso}'.`);
|
|
assertPriced(out.price, provider);
|
|
return { time, price: out.price };
|
|
}
|
|
export function mapDailyHistory(parsed, provider, externalId) {
|
|
const cols = {}; let len = null; let lastPerDay = false;
|
|
for (const o of outputsFor(provider, 'daily_history')) {
|
|
if (o.transform === 'last_per_day') lastPerDay = true;
|
|
const vals = resolveColumn(parsed, o.jsonPath, externalId)
|
|
.map(v => applyScalarTransform(v, o.transform === 'last_per_day' ? 'none' : o.transform));
|
|
cols[o.internalField] = vals;
|
|
if (len == null) len = vals.length;
|
|
else if (vals.length !== len) fail('integrations.price.columnsUneven', { provider: provider.key }, `Provider ${provider.key}: the output columns have unequal length.`);
|
|
}
|
|
let rows = [];
|
|
for (let i = 0; i < (len || 0); i++) {
|
|
const time = cols.time ? cols.time[i] : null;
|
|
const price = cols.price?.[i];
|
|
if (Number.isFinite(time) && Number.isFinite(price)) rows.push({ time, price });
|
|
}
|
|
rows.sort((a, b) => a.time - b.time);
|
|
if (lastPerDay) {
|
|
const byDay = new Map();
|
|
for (const r of rows) byDay.set(new Date(r.time * 1000).toISOString().slice(0, 10), r);
|
|
rows = [...byDay.values()].sort((a, b) => a.time - b.time);
|
|
}
|
|
return rows;
|
|
}
|
|
function mapResult(parsed, provider, capability, externalId, dateIso) {
|
|
if (capability === 'spot') return mapSpot(parsed, provider, externalId);
|
|
if (capability === 'history_point') return mapHistoryPoint(parsed, provider, externalId, dateIso);
|
|
return mapDailyHistory(parsed, provider, externalId);
|
|
}
|
|
|
|
function endpointFor(provider, capability) {
|
|
const e = provider.endpoints.find(ep => ep.capability === capability);
|
|
if (!e) fail('integrations.price.capabilityNotConfigured', { provider: provider.key, capability }, `Provider ${provider.key}: capability '${capability}' is not configured.`);
|
|
return e;
|
|
}
|
|
function mapRowFor(provider, symbol) {
|
|
const rows = provider.symbols || [];
|
|
return rows.find(s => !s.isCatchAll && s.symbol === symbol) || rows.find(s => s.isCatchAll) || null;
|
|
}
|
|
function externalIdFor(provider, symbol) {
|
|
const row = mapRowFor(provider, symbol);
|
|
return row ? row.externalId : (symbol != null ? symbol : null);
|
|
}
|
|
function subst(tpl, externalId, symbol, dateIso, providerKey) {
|
|
if (tpl == null) return tpl;
|
|
let s = tpl;
|
|
if (s.includes('{external_id}')) {
|
|
if (externalId == null) fail('integrations.price.symbolMissing', { provider: providerKey }, `Provider ${providerKey}: {external_id} is used but no symbol was given.`);
|
|
s = s.replaceAll('{external_id}', encodeURIComponent(externalId));
|
|
}
|
|
if (s.includes('{symbol}')) s = s.replaceAll('{symbol}', encodeURIComponent(symbol ?? ''));
|
|
if (s.includes('{date}')) {
|
|
const ymd = String(dateIso || '').slice(0, 10).replace(/-/g, '');
|
|
if (ymd.length !== 8) fail('integrations.price.templateDateInvalid', { provider: providerKey, datum: dateIso }, `Provider ${providerKey}: the template uses {date} but date '${dateIso}' is invalid.`);
|
|
s = s.replaceAll('{date}', ymd);
|
|
}
|
|
return s;
|
|
}
|
|
const toHex = buf => [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
|
|
export async function hmacHeaders(hmac, method, fullPath, body, creds) {
|
|
const ts = Date.now();
|
|
const tokens = { timestamp: String(ts), method, full_path: fullPath, body: body || '' };
|
|
const message = hmac.messageParts.split(',').map(tok => {
|
|
if (!(tok in tokens)) fail('integrations.price.unknownHmacToken', { token: tok }, `Unknown hmac message token: '${tok}'.`);
|
|
return tokens[tok];
|
|
}).join('');
|
|
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(creds.api_secret),
|
|
{ name: 'HMAC', hash: hmac.hashAlgorithm }, false, ['sign']);
|
|
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
|
|
const signature = hmac.encoding === 'hex' ? toHex(sig) : Buffer.from(sig).toString('base64');
|
|
const headers = { [hmac.keyHeader]: creds.api_key, [hmac.signatureHeader]: signature, [hmac.timestampHeader]: String(ts) };
|
|
if (hmac.windowHeader && hmac.windowMs != null) headers[hmac.windowHeader] = String(hmac.windowMs);
|
|
return headers;
|
|
}
|
|
export async function buildRequest(provider, capability, symbol, dateIso, creds) {
|
|
if (!CAPABILITIES.includes(capability)) fail('integrations.price.unknownCapability', { capability }, `Unknown capability: '${capability}'.`);
|
|
const e = endpointFor(provider, capability);
|
|
if (e.method !== 'GET') fail('integrations.price.onlyGet', { provider: provider.key }, `Provider ${provider.key}: only GET is supported.`);
|
|
const externalId = externalIdFor(provider, symbol);
|
|
|
|
const kind = provider.authenticationKind;
|
|
if (kind !== 'none' && !AUTH_KINDS[kind]) fail('integrations.price.unknownAuthKind', { soort: kind }, `Unknown authenticationKind: '${kind}'.`);
|
|
const fields = kind === 'none' ? [] : AUTH_KINDS[kind].secret_fields;
|
|
const hasCreds = kind !== 'none' && fields.every(f => creds && creds[f]);
|
|
if (provider.authenticationRequired && !hasCreds)
|
|
fail('integrations.price.keyMissing', { provider: provider.key }, `Provider ${provider.key}: a key is required but missing or incomplete.`);
|
|
|
|
const path = subst(e.pathTemplate, externalId, symbol, dateIso, provider.key);
|
|
let query = subst(e.queryTemplate, externalId, symbol, dateIso, provider.key) || '';
|
|
if (kind === 'query_key' && hasCreds) {
|
|
if (!provider.authenticationParameterName) fail('integrations.price.parameterNameMissing', { provider: provider.key }, `Provider ${provider.key}: authenticationParameterName is missing for query_key.`);
|
|
const qp = `${encodeURIComponent(provider.authenticationParameterName)}=${encodeURIComponent(creds.api_key)}`;
|
|
query = query ? `${query}&${qp}` : qp;
|
|
}
|
|
const fullPath = path + (query ? `?${query}` : '');
|
|
|
|
const headers = {};
|
|
if (hasCreds) {
|
|
if (kind === 'header_key') {
|
|
if (!provider.authenticationHeaderName) fail('integrations.price.headerNameMissing', { provider: provider.key }, `Provider ${provider.key}: authenticationHeaderName is missing for header_key.`);
|
|
headers[provider.authenticationHeaderName] = creds.api_key;
|
|
} else if (kind === 'bearer') {
|
|
headers['Authorization'] = `Bearer ${creds.api_key}`;
|
|
} else if (kind === 'basic') {
|
|
headers['Authorization'] = `Basic ${Buffer.from(`${creds.api_key}:${creds.api_secret}`).toString('base64')}`;
|
|
} else if (kind === 'hmac') {
|
|
if (!provider.hmac) fail('integrations.price.hmacConfigMissing', { provider: provider.key }, `Provider ${provider.key}: the hmac config is missing.`);
|
|
Object.assign(headers, await hmacHeaders(provider.hmac, e.method, fullPath, '', creds));
|
|
}
|
|
}
|
|
return { url: provider.baseUrl + fullPath, method: e.method, headers, externalId };
|
|
}
|
|
|
|
function describeBody(res, text) {
|
|
const type = String(res.headers?.get?.('content-type') || 'no type').split(';')[0].trim();
|
|
return `no JSON (${type}, ${Buffer.byteLength(text || '', 'utf8')} bytes)`;
|
|
}
|
|
|
|
function tagged(e, extra) { return Object.assign(e, extra); }
|
|
|
|
export async function priceCall(provider, capability, symbol, dateIso, creds, { withRaw = false } = {}) {
|
|
let built;
|
|
try { built = await buildRequest(provider, capability, symbol, dateIso, creds); } catch (e) { throw tagged(e, { notSent: true }); }
|
|
const { url, method, headers, externalId } = built;
|
|
let res;
|
|
try {
|
|
res = await fetch(url, { method, headers, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
} catch (err) {
|
|
fail('integrations.price.networkError', { provider: provider.key, capability, reden: err.name === 'TimeoutError' ? 'timeout' : err.message },
|
|
`${provider.key} ${capability}: network error (${err.name === 'TimeoutError' ? 'timeout' : err.message}).`);
|
|
}
|
|
const text = await res.text().catch(() => '');
|
|
let parsed;
|
|
let notJson = null;
|
|
try { parsed = JSON.parse(text); } catch { notJson = describeBody(res, text); }
|
|
const httpStatus = res.status;
|
|
if (!res.ok) {
|
|
const m = parsed && (parsed.error || parsed.message);
|
|
const detail = typeof m === 'string' && m ? ` ${m}` : notJson ? ` ${notJson}` : '';
|
|
throw tagged(new AppError('integrations.price.httpFailed', { provider: provider.key, capability, status: res.status, detail },
|
|
`${provider.key} ${capability} failed (${res.status})${detail}.`), { httpStatus });
|
|
}
|
|
if (notJson) {
|
|
throw tagged(new AppError('integrations.price.notJson', { provider: provider.key, capability, detail: notJson },
|
|
`${provider.key} ${capability}: the answer is ${notJson}.`), { httpStatus });
|
|
}
|
|
let result;
|
|
try {
|
|
result = inStorageUnit(mapResult(parsed, provider, capability, externalId, dateIso), provider, symbol);
|
|
} catch (e) { throw tagged(e, { httpStatus }); }
|
|
return withRaw ? { result, raw: parsed } : result;
|
|
}
|
|
|
|
export function failureEntry(provider, capability, symbol, e, latencyMs) {
|
|
const outcome = e?.notSent ? 'refused' : e?.key === 'integrations.price.noValidPrice' ? 'empty' : 'fail';
|
|
return {
|
|
target: 'provider', providerKey: provider?.key, operation: capability,
|
|
subject: typeof symbol === 'string' && symbol ? symbol : null, outcome,
|
|
httpStatus: e?.httpStatus ?? null, errorKey: e?.key ?? null, detail: e?.message ?? String(e),
|
|
latencyMs: e?.notSent ? null : latencyMs,
|
|
};
|
|
}
|
|
|
|
function inStorageUnit(result, provider, symbol) {
|
|
const row = mapRowFor(provider, symbol);
|
|
if (!row || row.quoteUnit !== 'ozt') return result;
|
|
const f = CONFIG.METALS.GRAMS_PER_TROY_OUNCE;
|
|
const perGram = r => (Number.isFinite(r.price) ? { ...r, price: r.price / f } : r);
|
|
return Array.isArray(result) ? result.map(perGram) : perGram(result);
|
|
}
|