Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions library/version-utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,91 @@ class VersionUtilities {
return url;
}
}


static isAnInteger(version) {
return /^\d+$/.test(version);
}

static appearsToBeDate(version) {
if (!version || typeof version !== 'string') return false;
// Strip optional time portion (T...) before checking
const datePart = version.split('T')[0];
return /^\d{4}-?\d{2}(-?\d{2})?$/.test(datePart);

}

static guessVersionAlgorithmFromVersion(version) {
if (VersionUtilities.isSemVerWithWildcards(version)) {
return 'semver';
}
if (this.appearsToBeDate(version)) {
return 'date';
}
if (this.isAnInteger(version)) {
return 'integer';
}
return 'alpha';
}

static dateIsMoreRecent(date, date2) {
return VersionUtilities.normaliseDateString(date) > VersionUtilities.normaliseDateString(date2);
}

static normaliseDateString(date) {
// Strip time portion, then remove dashes so all formats compare uniformly as YYYYMMDD or YYYYMM
return date.split('T')[0].replace(/-/g, '');
}


/**
* guesses the correct format, then compares accordingly
*/
static compareVersionsGeneral(version1, version2) {
if (version1 && version2) {
if (version1 == version2) {
return 0;
}
const fmt1 = VersionUtilities.guessVersionAlgorithmFromVersion(version1);
const fmt2 = VersionUtilities.guessVersionAlgorithmFromVersion(version2);
if (fmt1 != fmt2) {
return version1.localeCompare(version2);
}
switch (fmt1) {
case 'semver': {
let b1 = VersionUtilities.isThisOrLater(version1, version2, VersionPrecision.PATCH);
let b2 = VersionUtilities.isThisOrLater(version2, version1, VersionPrecision.PATCH);
if (b1 && b2) {
return 0;
} else if (b2) {
return 1;
} else {
return -1;
}
}
case 'date':
if (VersionUtilities.dateIsMoreRecent(version1, version2)) {
return 1;
} else if (VersionUtilities.dateIsMoreRecent(version2, version1)) {
return -1;
} else {
return 0;
}
case 'integer':
return parseInt(version1, 10) - parseInt(version2, 10);
case 'alpha':
return version1.localeCompare(version2);
default:
return version1.localeCompare(version2);
}
} else if (version1) {
return 1;
} else if (version2) {
return -1;
} else {
return 0;
}
}
}

module.exports = { VersionUtilities, VersionPrecision, SemverParser };
31 changes: 23 additions & 8 deletions registry/crawler.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class RegistryCrawler {
this.errors = [];
this.totalBytes = 0;
this.log = console;
this.abortController = null;
}

useLog(logv) {
Expand All @@ -48,6 +49,7 @@ class RegistryCrawler {
this.addLogEntry('warn', 'Crawl already in progress, skipping...');
return this.currentData;
}
this.abortController = new AbortController();

this.isCrawling = true;
const startTime = new Date();
Expand Down Expand Up @@ -75,6 +77,7 @@ class RegistryCrawler {
// Process each registry
const registries = masterJson.registries || [];
for (const registryConfig of registries) {
if (this.abortController?.signal.aborted) break;
const registry = await this.processRegistry(registryConfig);
if (registry) {
newData.registries.push(registry);
Expand All @@ -86,7 +89,7 @@ class RegistryCrawler {
// Update the current data
this.currentData = newData;
} catch (error) {
console.log(error);
console.log(error.message);
this.addLogEntry('error', 'Exception Scanning:', error);
this.currentData.outcome = `Error: ${error.message}`;
this.errors.push({
Expand Down Expand Up @@ -134,14 +137,15 @@ class RegistryCrawler {
// Process each server in the registry
const servers = registryJson.servers || [];
for (const serverConfig of servers) {
if (this.abortController?.signal.aborted) break;
const server = await this.processServer(serverConfig, registry.address);
if (server) {
registry.servers.push(server);
}
}

} catch (error) {
console.log(error);
console.log(error.message);
registry.error = error.message;
this.addLogEntry('error', `Exception processing registry ${registry.name}: ${error.message}`, registry.address);
}
Expand Down Expand Up @@ -177,6 +181,7 @@ class RegistryCrawler {
// Process each FHIR version
const fhirVersions = serverConfig.fhirVersions || [];
for (const versionConfig of fhirVersions) {
if (this.abortController?.signal.aborted) break;
const version = await this.processServerVersion(versionConfig, server, serverConfig.exclusions);
if (version) {
server.versions.push(version);
Expand Down Expand Up @@ -231,7 +236,7 @@ class RegistryCrawler {
this.addLogEntry('info', ` Server ${version.address}: ${version.lastTat} for ${version.codeSystems.length} CodeSystems and ${version.valueSets.length} ValueSets`);

} catch (error) {
console.log(error);
console.log(error.message);
const elapsed = Date.now() - startTime;
this.addLogEntry('error', `Server ${version.address}: Error after ${elapsed}ms: ${error.message}`);
version.error = error.message;
Expand Down Expand Up @@ -276,10 +281,11 @@ class RegistryCrawler {
});
}
} catch (error) {
console.log(error);
console.log(error.message);
this.addLogEntry('error', `Could not fetch terminology capabilities: ${error.message}`);
}


if (this.abortController?.signal.aborted) return;
// Search for value sets
await this.fetchValueSets(version, server, exclusions);
}
Expand Down Expand Up @@ -324,7 +330,7 @@ class RegistryCrawler {
});
}
} catch (error) {
console.log(error);
console.log(error.message);
this.addLogEntry('error', `Could not fetch terminology capabilities: ${error.message}`);
}

Expand All @@ -350,6 +356,7 @@ class RegistryCrawler {

// Continue fetching while we have a URL
while (searchUrl) {
if (this.abortController?.signal.aborted) break;
this.log.debug(`Fetching value sets from ${searchUrl}`);
const bundle = await this.fetchJson(searchUrl, server.code);

Expand Down Expand Up @@ -382,7 +389,7 @@ class RegistryCrawler {
version.valueSets = Array.from(valueSetUrls).sort();

} catch (error) {
console.log(error);
console.log(error.message);
this.addLogEntry('error', `Could not fetch value sets: ${error.message} from ${searchUrl}`);
}
}
Expand Down Expand Up @@ -441,6 +448,7 @@ class RegistryCrawler {
const response = await axios.get(fetchUrl, {
timeout: this.config.timeout,
headers: headers,
signal: this.abortController?.signal,
validateStatus: (status) => status < 500 // Don't throw on 4xx
});

Expand All @@ -459,7 +467,7 @@ class RegistryCrawler {
return response.data;

} catch (error) {
console.log(error);
console.log(error.message);
if (error.response) {
throw new Error(`HTTP ${error.response.status}: ${error.response.statusText}`);
} else if (error.request) {
Expand Down Expand Up @@ -648,6 +656,13 @@ class RegistryCrawler {
}
return false;
}

shutdown() {
if (this.abortController) {
this.abortController.abort();
}
}

}

module.exports = RegistryCrawler;
3 changes: 3 additions & 0 deletions registry/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,9 @@ class RegistryModule {
this.crawlInterval = null;
}

if (this.crawler) {
this.crawler.shutdown();
}
// Save current data
if (this.crawler && this.currentData) {
await this.saveData();
Expand Down
4 changes: 4 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,9 +436,11 @@ app.get('/', async (req, res) => {
const html = htmlServer.renderPage('root', escape(config.hostName) || 'FHIRsmith Server', content, stats);
res.setHeader('Content-Type', 'text/html');
res.send(html);
return;
} catch (error) {
serverLog.error('Error rendering root page:', error);
htmlServer.sendErrorResponse(res, 'root', error);
return;
}
}
return serveFhirsmithHome(req, res);
Expand Down Expand Up @@ -623,9 +625,11 @@ async function serveFhirsmithHome(req, res) {
const html = htmlServer.renderPage('root', escape(config.hostName) || 'FHIRsmith Server', content, stats);
res.setHeader('Content-Type', 'text/html');
res.send(html);
return;
} catch (error) {
serverLog.error('Error rendering root page:', error);
htmlServer.sendErrorResponse(res, 'root', error);
return;
}
} else {
// Return JSON response for API clients
Expand Down
96 changes: 96 additions & 0 deletions tests/library/version-utilities.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -702,4 +702,100 @@ describe('VersionUtilities', () => {
function test_compareVersions(ver1, ver2, expected) {
expect(VersionUtilities.compareVersions(ver1, ver2)).toBe(expected);
}

describe('compareVersionsGeneral', () => {

// Basic sanity - same format comparisons
test('should compare semver versions correctly', () => {
expect(VersionUtilities.compareVersionsGeneral("1.0.0", "2.0.0")).toBe(-1);
expect(VersionUtilities.compareVersionsGeneral("2.0.0", "1.0.0")).toBe(1);
expect(VersionUtilities.compareVersionsGeneral("1.0.0", "1.0.0")).toBe(0);
expect(VersionUtilities.compareVersionsGeneral("1.0.0", "1.0.1")).toBe(-1);
expect(VersionUtilities.compareVersionsGeneral("1.1.0", "1.0.9")).toBe(1);
});

test('should compare integer versions correctly', () => {
expect(VersionUtilities.compareVersionsGeneral("1", "2")).toBe(-1);
expect(VersionUtilities.compareVersionsGeneral("2", "1")).toBe(1);
expect(VersionUtilities.compareVersionsGeneral("5", "5")).toBe(0);
expect(VersionUtilities.compareVersionsGeneral("9", "10")).toBeLessThan(0);
expect(VersionUtilities.compareVersionsGeneral("10", "9")).toBeGreaterThan(0);
});

test('should compare date versions correctly', () => {
expect(VersionUtilities.compareVersionsGeneral("2024-01-01", "2024-06-15")).toBe(-1);
expect(VersionUtilities.compareVersionsGeneral("2024-06-15", "2024-01-01")).toBe(1);
expect(VersionUtilities.compareVersionsGeneral("2024-01-01", "2024-01-01")).toBe(0);
});

test('should compare alpha versions correctly', () => {
expect(VersionUtilities.compareVersionsGeneral("abc", "def")).toBeLessThan(0);
expect(VersionUtilities.compareVersionsGeneral("def", "abc")).toBeGreaterThan(0);
expect(VersionUtilities.compareVersionsGeneral("abc", "abc")).toBe(0);
});

// Bug: date normalisation can make different strings equal, but code returns -1
test('should return 0 for equivalent date formats', () => {
expect(VersionUtilities.compareVersionsGeneral("2024-01", "202401")).toBe(0);
expect(VersionUtilities.compareVersionsGeneral("2024-01-15", "20240115")).toBe(0);
});

// Bug: null/undefined asymmetry — version present vs absent
// A real version should arguably sort AFTER null (i.e., null is "less")
test('should handle null/undefined versions consistently', () => {
const v1null = VersionUtilities.compareVersionsGeneral(null, "1.0.0");
const v2null = VersionUtilities.compareVersionsGeneral("1.0.0", null);
// At minimum these should be opposite signs
expect(Math.sign(v1null)).toBe(-Math.sign(v2null));
expect(VersionUtilities.compareVersionsGeneral(null, null)).toBe(0);
expect(VersionUtilities.compareVersionsGeneral(undefined, undefined)).toBe(0);
});

// Bug: format guessed from version1 only — mismatched formats
test('should handle mismatched version formats', () => {
// semver vs integer
const r1 = VersionUtilities.compareVersionsGeneral("1.0.0", "2");
const r2 = VersionUtilities.compareVersionsGeneral("2", "1.0.0");
expect(Math.sign(r1)).toBe(-Math.sign(r2));

// semver vs date
const r3 = VersionUtilities.compareVersionsGeneral("1.0.0", "2024-01-01");
const r4 = VersionUtilities.compareVersionsGeneral("2024-01-01", "1.0.0");
expect(Math.sign(r3)).toBe(-Math.sign(r4));

// date vs integer
const r5 = VersionUtilities.compareVersionsGeneral("2024-01-01", "5");
const r6 = VersionUtilities.compareVersionsGeneral("5", "2024-01-01");
expect(Math.sign(r5)).toBe(-Math.sign(r6));
});

// Bug: semver equal-after-normalization but not string-equal
test('should handle semver versions that differ only in part count', () => {
// "1.0" and "1.0.0" are not string-equal, so the == guard won't catch them
const r1 = VersionUtilities.compareVersionsGeneral("1.0", "1.0.0");
const r2 = VersionUtilities.compareVersionsGeneral("1.0.0", "1.0");
// Should be 0 or at least antisymmetric
expect(Math.sign(r1)).toBe(-Math.sign(r2));
});

// Symmetry: compare(a,b) should always be -compare(b,a)
test('should be antisymmetric', () => {
const pairs = [
["1.0.0", "2.0.0"],
["2024-01-01", "2025-06-15"],
["3", "7"],
["alpha", "beta"],
["1.0.0-alpha", "1.0.0"],
];
for (const [a, b] of pairs) {
const ab = VersionUtilities.compareVersionsGeneral(a, b);
const ba = VersionUtilities.compareVersionsGeneral(b, a);
if ((ab == 0)) {
expect(ba).toBe(0);
} else {
expect(Math.sign(ab)).toBe(-Math.sign(ba));
}
}
});
});
});
Loading
Loading