Files
delphi-database/static/js/fetch-wrapper.js
2025-08-11 21:58:25 -05:00

174 lines
6.3 KiB
JavaScript

/**
* Lightweight fetch wrapper that:
* - Adds an X-Correlation-ID header to every request
* - Exposes helpers to parse the standard error envelope
* - Provides helpers to build user-facing error messages with correlation ID
*/
(function () {
// Ensure global app object exists
window.app = window.app || {};
const CORRELATION_HEADER = 'X-Correlation-ID';
let warnedTokenStorage = false;
let warnedDeprecatedPatch = false;
function generateCorrelationId() {
try {
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
return window.crypto.randomUUID();
}
} catch (_) {}
// Fallback RFC4122-ish UUID
const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).slice(1);
return (
s4() + s4() + '-' + s4() + '-' + s4() + '-' +
s4() + '-' + s4() + s4() + s4()
);
}
function normalizeHeaders(input) {
if (!input) return new Headers();
return input instanceof Headers ? input : new Headers(input);
}
const originalFetch = window.fetch.bind(window);
async function wrappedFetch(resource, options = {}) {
const url = typeof resource === 'string' ? resource : (resource && resource.url) || '';
const headers = normalizeHeaders(options.headers);
const method = (options.method || 'GET').toUpperCase();
const body = options.body;
// Inject correlation id if not present
let outgoingCid = headers.get(CORRELATION_HEADER);
if (!outgoingCid) {
outgoingCid = generateCorrelationId();
headers.set(CORRELATION_HEADER, outgoingCid);
}
// Inject Authorization header if JWT token is available and not already provided
try {
const storedToken = (window.app && window.app.token) || (typeof localStorage !== 'undefined' && localStorage.getItem('auth_token'));
if (storedToken && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${storedToken}`);
}
// One-time security note if we detect token in localStorage
if (!warnedTokenStorage && typeof localStorage !== 'undefined' && localStorage.getItem('auth_token')) {
warnedTokenStorage = true;
// eslint-disable-next-line no-console
console.warn('Security note: auth tokens are read from localStorage. If this app is exposed to the internet, migrate to HttpOnly cookies.');
}
} catch (_) {
// Ignore storage access errors (e.g., privacy mode)
}
// Inject Content-Type: application/json for JSON string bodies when missing
try {
const hasContentType = headers.has('Content-Type');
const methodAllowsBody = method !== 'GET' && method !== 'HEAD';
if (methodAllowsBody && body != null && !hasContentType) {
// Only auto-set for stringified JSON bodies to avoid interfering with FormData or other types
if (typeof body === 'string') {
headers.set('Content-Type', 'application/json');
}
}
} catch (_) {
// Best-effort only; ignore header normalization errors
}
const requestInit = { ...options, headers };
const response = await originalFetch(resource, requestInit);
try {
const incomingCid = (response && response.headers && response.headers.get(CORRELATION_HEADER)) || null;
// Track last seen correlation id for diagnostics/UI messaging
window.app.lastCorrelationId = incomingCid || outgoingCid || null;
} catch (_) {}
return response;
}
/**
* Parse the standardized error envelope from a failed response.
* Returns a normalized object with message, correlationId, code, details.
*/
async function parseErrorEnvelope(response) {
let body = null;
try {
body = await response.clone().json();
} catch (_) {
// not JSON; try text as last resort
try {
const txt = await response.clone().text();
body = { detail: txt };
} catch (_) {
body = null;
}
}
const headerCid = response && response.headers ? response.headers.get(CORRELATION_HEADER) : null;
const envelopeCid = body && (body.correlation_id || (body.error && body.error.correlation_id));
const correlationId = headerCid || envelopeCid || window.app.lastCorrelationId || null;
const message = body && body.error && body.error.message
? String(body.error.message)
: body && typeof body.detail === 'string' && body.detail.trim()
? String(body.detail)
: `HTTP ${response.status}`;
const code = body && body.error ? (body.error.code || null) : null;
const details = body && body.error ? (body.error.details || null) : null;
return { message, correlationId, code, details, raw: body };
}
/**
* Create an Error populated from a failed Response and optional fallback message.
*/
async function toError(response, fallbackMessage = null) {
const parsed = await parseErrorEnvelope(response);
const msg = parsed && parsed.message ? parsed.message : (fallbackMessage || `HTTP ${response.status}`);
const err = new Error(msg);
err.status = response.status;
err.correlationId = parsed.correlationId || null;
if (parsed && parsed.code) err.code = parsed.code;
if (parsed && parsed.details) err.details = parsed.details;
return err;
}
/**
* Format a message suitable for UI alerts with correlation id reference.
* Example: formatAlert(err, 'Error saving customer')
*/
function formatAlert(errorOrMessage, prefix = null) {
const isError = errorOrMessage instanceof Error;
const message = isError ? errorOrMessage.message : String(errorOrMessage || '');
const correlationId = isError && errorOrMessage.correlationId
? errorOrMessage.correlationId
: (window.app && window.app.lastCorrelationId) || null;
const base = prefix ? `${prefix}: ${message}` : message;
return correlationId ? `${base} (Ref: ${correlationId})` : base;
}
// Expose helpers
window.http = {
parseErrorEnvelope,
toError,
formatAlert,
wrappedFetch,
};
// Install wrapper (deprecated). Keep for backward compatibility, but nudge callers to use window.http.wrappedFetch
window.fetch = async function(...args) {
if (!warnedDeprecatedPatch) {
warnedDeprecatedPatch = true;
// eslint-disable-next-line no-console
console.warn('Deprecated: global fetch() is wrapped. Prefer window.http.wrappedFetch for clarity and testability.');
}
return wrappedFetch(...args);
};
})();