Move middleware sources into core/, refresh config paths, and update design/user docs to reflect the raw payload pipeline.
277 lines
8.8 KiB
JavaScript
277 lines
8.8 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const config = require('./config');
|
|
const logger = require('../logger');
|
|
|
|
let cache = new Map();
|
|
let refreshInterval;
|
|
|
|
function resolveTranslatorFilePath(filePath, configFilePath) {
|
|
if (!filePath || typeof filePath !== 'string') return '';
|
|
if (path.isAbsolute(filePath)) return filePath;
|
|
|
|
const candidates = [
|
|
path.resolve(process.cwd(), filePath)
|
|
];
|
|
|
|
if (configFilePath) {
|
|
candidates.push(path.resolve(path.dirname(configFilePath), filePath));
|
|
}
|
|
|
|
const matched = candidates.find((candidate) => fs.existsSync(candidate));
|
|
return matched || candidates[0];
|
|
}
|
|
|
|
function normalizeConnectorType(type) {
|
|
const value = String(type || '').trim().toLowerCase();
|
|
if (value === 'serial' || value === 'astm-serial') return 'astm-serial';
|
|
if (value === 'tcp-server' || value === 'hl7-tcp') return 'hl7-tcp';
|
|
if (value === 'http-json' || value === 'http') return 'http-json';
|
|
if (value === 'tcp-client') return 'tcp-client';
|
|
return '';
|
|
}
|
|
|
|
function toEntityRows(entities = []) {
|
|
return entities.map((entity) => {
|
|
const connector = entity.connector && typeof entity.connector === 'object' ? entity.connector : {};
|
|
return {
|
|
instrument_id: entity.instrument_id,
|
|
enabled: entity.enabled,
|
|
connector: connector.type,
|
|
connectorConfig: connector,
|
|
config: entity.config,
|
|
translator: entity.translator
|
|
};
|
|
});
|
|
}
|
|
|
|
class InstrumentConfigValidationError extends Error {
|
|
constructor(errors) {
|
|
super(`instrument config validation failed with ${errors.length} issue(s)`);
|
|
this.name = 'InstrumentConfigValidationError';
|
|
this.errors = errors;
|
|
}
|
|
}
|
|
|
|
function validateAndLoadInstrumentConfigs({
|
|
instruments = config.instrumentEntities?.length ? toEntityRows(config.instrumentEntities) : config.instruments,
|
|
configFilePath = config.configPath
|
|
} = {}) {
|
|
const errors = [];
|
|
const entries = [];
|
|
const instrumentIds = new Set();
|
|
|
|
if (configFilePath && !fs.existsSync(configFilePath)) {
|
|
errors.push(`config file not found: ${configFilePath}`);
|
|
throw new InstrumentConfigValidationError(errors);
|
|
}
|
|
|
|
if (!Array.isArray(instruments)) {
|
|
errors.push('config.instruments: expected an array');
|
|
throw new InstrumentConfigValidationError(errors);
|
|
}
|
|
|
|
if (!instruments.length) {
|
|
errors.push('config.instruments: array cannot be empty');
|
|
}
|
|
|
|
for (let index = 0; index < instruments.length; index += 1) {
|
|
const item = instruments[index];
|
|
const label = `instrument[${index}]`;
|
|
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
errors.push(`${label}: must be an object`);
|
|
continue;
|
|
}
|
|
|
|
const instrumentId = item.instrument_id;
|
|
const connector = normalizeConnectorType(item.connector);
|
|
const translator = item.translator;
|
|
|
|
if (!instrumentId || typeof instrumentId !== 'string') {
|
|
errors.push(`${label}: instrument_id is required`);
|
|
continue;
|
|
}
|
|
if (instrumentIds.has(instrumentId)) {
|
|
errors.push(`${label}: duplicate instrument_id "${instrumentId}"`);
|
|
continue;
|
|
}
|
|
instrumentIds.add(instrumentId);
|
|
|
|
if (!connector) {
|
|
errors.push(`${label}: connector.type is required`);
|
|
continue;
|
|
}
|
|
|
|
if (connector === 'tcp-client') {
|
|
errors.push(`${label}: connector.type tcp-client is not supported yet`);
|
|
continue;
|
|
}
|
|
|
|
if (!translator || typeof translator !== 'object' || Array.isArray(translator)) {
|
|
item.translator = {};
|
|
}
|
|
|
|
const resolvedTranslator = item.translator;
|
|
|
|
if (resolvedTranslator.engine && typeof resolvedTranslator.engine !== 'string') {
|
|
errors.push(`${label}: translator.engine must be a string`);
|
|
continue;
|
|
}
|
|
|
|
const translatorEngine = String(resolvedTranslator.engine || 'overrides').trim().toLowerCase();
|
|
if (translatorEngine === 'template') {
|
|
if (!resolvedTranslator.file || typeof resolvedTranslator.file !== 'string') {
|
|
errors.push(`${label}: translator.file is required when translator.engine=template`);
|
|
continue;
|
|
}
|
|
const resolvedTranslatorFilePath = resolveTranslatorFilePath(resolvedTranslator.file, configFilePath);
|
|
if (!fs.existsSync(resolvedTranslatorFilePath)) {
|
|
errors.push(`${label}: translator.file not found: ${resolvedTranslator.file}`);
|
|
continue;
|
|
}
|
|
resolvedTranslator.resolvedFile = resolvedTranslatorFilePath;
|
|
}
|
|
|
|
const connectorConfig = item.connectorConfig && typeof item.connectorConfig === 'object'
|
|
? item.connectorConfig
|
|
: {};
|
|
const match = item.match && typeof item.match === 'object' ? { ...item.match } : {};
|
|
|
|
if (connector === 'astm-serial') {
|
|
const comPort = connectorConfig.port || connectorConfig.comPort;
|
|
if (!comPort || typeof comPort !== 'string') {
|
|
errors.push(`${label}: connector.port is required for serial`);
|
|
continue;
|
|
}
|
|
match.comPort = comPort;
|
|
}
|
|
|
|
if (connector === 'hl7-tcp' || connector === 'http-json') {
|
|
const localPort = connectorConfig.port || connectorConfig.localPort;
|
|
if (localPort !== undefined && localPort !== null && localPort !== '') {
|
|
match.localPort = Number(localPort);
|
|
}
|
|
}
|
|
|
|
entries.push({
|
|
instrument_id: instrumentId,
|
|
connector,
|
|
enabled: item.enabled !== false,
|
|
match,
|
|
config: item.config || {},
|
|
translator: resolvedTranslator,
|
|
connectorConfig,
|
|
files: {
|
|
config: configFilePath,
|
|
translator: resolvedTranslator.resolvedFile || null
|
|
}
|
|
});
|
|
}
|
|
|
|
if (errors.length) {
|
|
throw new InstrumentConfigValidationError(errors);
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
async function reload() {
|
|
const rows = validateAndLoadInstrumentConfigs();
|
|
const next = new Map();
|
|
rows.forEach((row) => {
|
|
try {
|
|
if (row.error) {
|
|
throw new Error(row.error);
|
|
}
|
|
if (!row.instrument_id || !row.connector) {
|
|
throw new Error('instrument_id and connector are required');
|
|
}
|
|
next.set(row.instrument_id, {
|
|
instrument_id: row.instrument_id,
|
|
connector: row.connector,
|
|
enabled: Boolean(row.enabled),
|
|
config: row.config || {},
|
|
match: row.match || {},
|
|
translator: row.translator || {},
|
|
connectorConfig: row.connectorConfig || {},
|
|
files: row.files || null
|
|
});
|
|
} catch (err) {
|
|
logger.warn({ instrument: row.instrument_id, err: err.message }, 'failed parsing instrument config, skipping');
|
|
}
|
|
});
|
|
cache = next;
|
|
}
|
|
|
|
async function init({ refreshMs = 30_000 } = {}) {
|
|
await reload();
|
|
if (refreshInterval) clearInterval(refreshInterval);
|
|
refreshInterval = setInterval(() => {
|
|
reload().catch((err) => logger.error({ err: err.message }, 'instrument config reload failed'));
|
|
}, refreshMs);
|
|
}
|
|
|
|
function list() {
|
|
return Array.from(cache.values());
|
|
}
|
|
|
|
function get(instrumentId) {
|
|
return cache.get(instrumentId) || null;
|
|
}
|
|
|
|
function byConnector(connector) {
|
|
return list().filter((entry) => entry.connector === connector && entry.enabled);
|
|
}
|
|
|
|
function addressesEqual(expected, actual) {
|
|
if (!expected) return true;
|
|
if (!actual) return false;
|
|
const normalize = (value) => String(value).replace('::ffff:', '');
|
|
return normalize(expected) === normalize(actual);
|
|
}
|
|
|
|
function portsEqual(expected, actual) {
|
|
if (expected === undefined || expected === null || expected === '') return true;
|
|
if (actual === undefined || actual === null || actual === '') return false;
|
|
return Number(expected) === Number(actual);
|
|
}
|
|
|
|
function comPortsEqual(expected, actual) {
|
|
if (expected === undefined || expected === null || expected === '') return true;
|
|
if (actual === undefined || actual === null || actual === '') return false;
|
|
return String(expected).trim().toLowerCase() === String(actual).trim().toLowerCase();
|
|
}
|
|
|
|
function matches(entry, connector, context = {}) {
|
|
if (context.instrument_id && context.instrument_id !== entry.instrument_id) return false;
|
|
if (!entry.enabled || entry.connector !== connector) return false;
|
|
const rule = entry.match || {};
|
|
if (!portsEqual(rule.localPort, context.localPort)) return false;
|
|
if (!portsEqual(rule.remotePort, context.remotePort)) return false;
|
|
if (!addressesEqual(rule.remoteAddress, context.remoteAddress)) return false;
|
|
if (!comPortsEqual(rule.comPort || rule.serialPort, context.comPort || context.serialPort)) return false;
|
|
return true;
|
|
}
|
|
|
|
function resolveForMessage(connector, context = {}) {
|
|
const candidates = byConnector(connector).filter((entry) => matches(entry, connector, context));
|
|
if (!candidates.length) {
|
|
return { status: 'no_match', matches: [] };
|
|
}
|
|
if (candidates.length > 1) {
|
|
return { status: 'ambiguous', matches: candidates.map((entry) => entry.instrument_id) };
|
|
}
|
|
return { status: 'matched', entry: candidates[0] };
|
|
}
|
|
|
|
module.exports = {
|
|
InstrumentConfigValidationError,
|
|
validateAndLoadInstrumentConfigs,
|
|
init,
|
|
list,
|
|
get,
|
|
byConnector,
|
|
reload,
|
|
resolveForMessage
|
|
};
|