diff --git a/.dockerignore b/.dockerignore index 3f3adc84..f8ecdeed 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,12 @@ -node_modules -npm-debug.log -.git -.gitignore -logs -data -package-cache -*.md -.env +node_modules +npm-debug.log +.git +.gitignore +logs +data +package-cache +*.md +# the release dates of FHIRsmith versions (CapabilityStatement.software.releaseDate, the registry) +!CHANGELOG.md +.env .env.* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c597d1..c5ff84d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to Health Intersections FHIRsmith will be documented in this The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.15.0] - 2026-mm-dd + +### Security + +- + +### Added + +- Library YAML: a source can give the id prefix for the resources it loads (`npm:hl7.terminology=tho`), so their ids stay the same when sources are added, removed or reordered. Sources without one are numbered, as before. See Resource Ids in tx/README.md +- `CapabilityStatement.software.releaseDate` (and the TerminologyCapabilities equivalent) is the date the running version was released, taken from its heading in CHANGELOG.md, which is now included in the Docker image. A snapshot reports the time the server started +- Registry: the crawler records each server's `software.releaseDate`. The Software page uses it to date software other than FHIRsmith, and FHIRsmith releases (including development builds) that the release list doesn't know about yet + +### Changed + +- **Breaking - resource ids:** every CodeSystem, ValueSet and ConceptMap is served with an id in the id space of its source - `prefix-id`, e.g. `CodeSystem/tho-v3-ActCode` - and the FHIR core package each endpoint loads uses `core` (`CodeSystem/core-administrative-gender`). An unprefixed id is not found by read or by any `[type]/[id]/$operation`. Library CodeSystems used to keep the ids they had in their packages (ValueSets and ConceptMaps were already numbered), and so could clash with the endpoint's core CodeSystems +- THO overrides the FHIR core packages the same way the Java validator does: where hl7.terminology has a url, the core package's CodeSystems, ValueSets and ConceptMaps with that url are not loaded at all. Older core versions used to stay available by `url|version`, so the server reported versions the validator doesn't know (e.g. v3-MaritalStatus `2018-08-12`). The exception is the R4 v2 tables 0006, 0360 and 0391, which R4 core has in two versions each with the version in the url: those versions stay available, and THO's is the default +- `software.releaseDate` is no longer the time of the request + +### Fixed + +- Search: two CodeSystems with the same id from different packages (e.g. v3-MaritalStatus from hl7.fhir.r4.core and hl7.terminology) linked to the same resource, and a read of that id always returned the core one +- On the R4 endpoint, a ValueSet asked for without a version (e.g. `http://terminology.hl7.org/ValueSet/v3-MaritalStatus`) was the core package's copy rather than hl7.terminology's + +### Tx Conformance Statement + +(paste) + ## [0.14.2] - 2026-10-06 ### Security diff --git a/library/changelog.js b/library/changelog.js new file mode 100644 index 00000000..1622db65 --- /dev/null +++ b/library/changelog.js @@ -0,0 +1,57 @@ +// library/changelog.js +// Release dates from CHANGELOG.md. A release is dated in its heading when it is made - +// "## [0.14.2] - 2026-10-06" (or "## [v0.13.4] - ...") - so the changelog that ships with +// the code is where a server finds out when its own version was released. A snapshot's +// heading has no date yet, so it has no release date. + +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_PATH = path.join(__dirname, '..', 'CHANGELOG.md'); +const HEADING = /^##\s*\[v?(\d+\.\d+\.\d+)\]\s*-\s*(\d{4}-\d{2}-\d{2})\s*$/gm; + +/** + * @param {string} [changelogPath] + * @returns {Map} version (no leading v) -> release date (YYYY-MM-DD). + * Throws if the file can't be read. + */ +function readReleaseDates(changelogPath = DEFAULT_PATH) { + const text = fs.readFileSync(changelogPath, 'utf8'); + const dates = new Map(); + let m; + HEADING.lastIndex = 0; + while ((m = HEADING.exec(text)) !== null) { + if (!dates.has(m[1])) { + dates.set(m[1], m[2]); + } + } + return dates; +} + +/** + * The release date of a version, or null if it isn't a dated release (a snapshot, or no + * changelog to read) + */ +function releaseDateOf(version, changelogPath = DEFAULT_PATH) { + try { + return readReleaseDates(changelogPath).get(String(version || '').replace(/^v/, '')) || null; + } catch (e) { + return null; + } +} + +/** + * The release date a server reports for its version: the date of the release, or, for a + * version that hasn't been released (a snapshot), the time the server started - which is + * when that build went into service. + * + * @param {string} version + * @param {Date} started - when the server started + * @param {string} [changelogPath] + * @returns {string} YYYY-MM-DD for a release, an ISO dateTime for a snapshot + */ +function reportedReleaseDate(version, started, changelogPath = DEFAULT_PATH) { + return releaseDateOf(version, changelogPath) || started.toISOString(); +} + +module.exports = { readReleaseDates, releaseDateOf, reportedReleaseDate }; diff --git a/package-lock.json b/package-lock.json index f41b6c87..a682e46c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fhirsmith", - "version": "0.14.2", + "version": "0.15.0-snapshot", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fhirsmith", - "version": "0.14.2", + "version": "0.15.0-snapshot", "license": "BSD-3", "dependencies": { "axios": "^1.13.4", diff --git a/package.json b/package.json index 5212dc97..f9820064 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fhirsmith", - "version": "0.14.2", + "version": "0.15.0-snapshot", "txVersion": "1.9.5-SNAPSHOT", "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem", "main": "server.js", diff --git a/registry/crawler.js b/registry/crawler.js index c2ec2cd1..d933eb50 100644 --- a/registry/crawler.js +++ b/registry/crawler.js @@ -339,6 +339,20 @@ class RegistryCrawler { return version; } + /** + * CapabilityStatement.software.releaseDate, if it means anything. A date within a few + * minutes of now is the server's clock, not a release date: FHIRsmith up to 0.14.2 filled + * it in with the time of the request, and other software may do the same. + */ + reportedReleaseDate(software, now = Date.now()) { + const value = software && software.releaseDate; + const t = value ? new Date(value).getTime() : NaN; + if (isNaN(t) || Math.abs(now - t) < 10 * 60 * 1000) { + return ''; + } + return String(value); + } + /** * A server that can't be reached this time is still running whatever it was running * last time we saw it - keep that, so the software page doesn't lose track of it @@ -353,6 +367,7 @@ class RegistryCrawler { if (prev.address === version.address && prev.software) { version.software = prev.software; version.softwareVersion = prev.softwareVersion || ''; + version.softwareReleaseDate = prev.softwareReleaseDate || ''; return; } } @@ -371,6 +386,7 @@ class RegistryCrawler { version.version = capability.fhirVersion || '3.0.2'; version.software = capability.software ? capability.software.name : "unknown"; version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : ''; + version.softwareReleaseDate = this.reportedReleaseDate(capability.software); // Get terminology capabilities (R3 uses Parameters resource) try { @@ -416,6 +432,7 @@ class RegistryCrawler { version.version = capability.fhirVersion || defVersion; version.software = capability.software ? capability.software.name : "unknown"; version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : ''; + version.softwareReleaseDate = this.reportedReleaseDate(capability.software); let set = new Set(); diff --git a/registry/fhirsmith-releases.js b/registry/fhirsmith-releases.js index bbd470ab..07fc4271 100644 --- a/registry/fhirsmith-releases.js +++ b/registry/fhirsmith-releases.js @@ -6,7 +6,7 @@ // succeeds (or if it can't be reached), the dated headings in this server's own // CHANGELOG.md are used instead - those only go up to this server's own version. -const fs = require('fs'); +const { readReleaseDates } = require('../library/changelog'); const path = require('path'); const axios = require('axios'); @@ -72,12 +72,9 @@ class FhirsmithReleases { */ loadFromChangelog(changelogPath = path.join(__dirname, '..', 'CHANGELOG.md')) { try { - const text = fs.readFileSync(changelogPath, 'utf8'); const list = []; - const re = /^##\s*\[v?(\d+\.\d+\.\d+)\]\s*-\s*(\d{4}-\d{2}-\d{2})\s*$/gm; - let m; - while ((m = re.exec(text)) !== null) { - list.push({ version: m[1], date: new Date(m[2] + 'T00:00:00Z') }); + for (const [version, date] of readReleaseDates(changelogPath)) { + list.push({ version, date: new Date(date + 'T00:00:00Z') }); } this.setReleases(list, 'CHANGELOG.md'); } catch (error) { diff --git a/registry/model.js b/registry/model.js index d06d9531..ed6cdcf7 100644 --- a/registry/model.js +++ b/registry/model.js @@ -12,6 +12,7 @@ class ServerVersionInformation { this.lastTat = ''; this.software = ''; // what software is running this.softwareVersion = ''; // CapabilityStatement.software.version, if the server reports one + this.softwareReleaseDate = ''; // CapabilityStatement.software.releaseDate, if it is a real date this.codeSystems = []; // Array of strings (sorted, unique) this.valueSets = []; // Array of strings (sorted, unique) } @@ -60,6 +61,7 @@ class ServerVersionInformation { terminologies: this.codeSystems, software: this.software, 'software-version': this.softwareVersion, + 'software-release-date': this.softwareReleaseDate, valuesets: this.valueSets }; } @@ -74,6 +76,7 @@ class ServerVersionInformation { instance.lastTat = json.lastTat || ''; instance.software = json.software; instance.softwareVersion = json['software-version'] || ''; + instance.softwareReleaseDate = json['software-release-date'] || ''; instance.codeSystems = json.terminologies || []; instance.valueSets = json.valuesets || []; return instance; diff --git a/registry/registry.js b/registry/registry.js index 04a7982e..3a9f4937 100644 --- a/registry/registry.js +++ b/registry/registry.js @@ -512,6 +512,7 @@ class RegistryModule { fhirVersion: version.version || '', software: version.software && version.software !== 'unknown' ? version.software : '', softwareVersion: version.softwareVersion || '', + softwareReleaseDate: version.softwareReleaseDate || '', error: version.error, lastSuccess: version.lastSuccess }); @@ -556,7 +557,7 @@ class RegistryModule { _renderReleaseCells(row, now) { if (!isFhirsmith(row.software) || !this.releases) { - return ''; + return this._renderReportedReleaseCells(row, now); } const d = this.releases.describe(row.softwareVersion, now); const behind = d.behind ? ` (${d.behind} release${d.behind === 1 ? '' : 's'} behind)` : ''; @@ -571,11 +572,28 @@ class RegistryModule { `${describeAge(d.ageDays)}${behind}`; } case 'dev': - return 'development build' + + return `${this._reportedReleaseDay(row)}development build` + (d.release ? ` after v${escape(d.release.version)}` : '') + behind + ''; default: - return 'unknown'; + return this._renderReportedReleaseCells(row, now); + } + } + + // what the server says about its release (CapabilityStatement.software.releaseDate) - + // the only source for software other than FHIRsmith + _reportedReleaseDay(row) { + // the day as the server gives it - converting to UTC can move it a day + const m = /^(\d{4}-\d{2}-\d{2})/.exec(row.softwareReleaseDate || ''); + return m ? m[1] : ''; + } + + _renderReportedReleaseCells(row, now) { + const day = this._reportedReleaseDay(row); + if (!day) { + return ''; } + const days = Math.max(0, Math.floor((now - new Date(row.softwareReleaseDate).getTime()) / (24 * 60 * 60 * 1000))); + return `${day}${describeAge(days)}`; } /** diff --git a/tests/cm/cm-package.test.js b/tests/cm/cm-package.test.js index 26091ad6..bca23db0 100644 --- a/tests/cm/cm-package.test.js +++ b/tests/cm/cm-package.test.js @@ -259,4 +259,67 @@ describe('PackageConceptMapProvider', () => { expect(mapSize).toBeGreaterThanOrEqual(stats.totalConceptMaps); }); }); -}); \ No newline at end of file + + describe('id space', () => { + let spaced; + + beforeAll(async () => { + // a provider of its own, so the other tests see the concept maps as they are loaded + spaced = new PackageConceptMapProvider(new PackageContentLoader(path.join(packageCacheDir, packagePath))); + await spaced.initialize(); + spaced.assignIds('ch'); + }); + + test('concept maps carry their prefixed id, and are fetched by it', async () => { + const cm = [...spaced.conceptMapMap.values()][0]; + expect(cm.id.startsWith('ch-')).toBe(true); + expect(cm.jsonObj.id).toBe(cm.id); + expect(await spaced.fetchConceptMapById(cm.id)).toBe(cm); + }); + + test('a concept map is not found by its unprefixed id', async () => { + const cm = [...spaced.conceptMapMap.values()][0]; + expect(await spaced.fetchConceptMapById(cm.id.substring('ch-'.length))).toBeNull(); + }); + + test('search returns prefixed ids', async () => { + for (const elements of [null, ['id', 'url']]) { + const results = await spaced.searchConceptMaps([], elements); + expect(results.length).toBeGreaterThan(0); + for (const r of results) { + expect(r.id.startsWith('ch-')).toBe(true); + expect(r.id.startsWith('ch-ch-')).toBe(false); + } + } + }); + }); + + describe('excludeUrls', () => { + let reduced; + let gone; + + beforeAll(async () => { + reduced = new PackageConceptMapProvider(new PackageContentLoader(path.join(packageCacheDir, packagePath))); + await reduced.initialize(); + reduced.assignIds('core'); + gone = [...reduced.conceptMapMap.values()][0]; + reduced.excludeUrls(new Set([gone.url])); + }); + + test('an excluded concept map is not fetched, by url or by id', async () => { + expect(await reduced.fetchConceptMap(gone.url, null)).toBeNull(); + expect(await reduced.fetchConceptMapById(gone.id)).toBeNull(); + }); + + test('an excluded concept map is not found by search', async () => { + for (const elements of [null, ['id', 'url']]) { + const results = await reduced.searchConceptMaps([], elements); + expect(results.some(r => r.id === gone.id)).toBe(false); + } + }); + + test('the package says where it came from', () => { + expect(reduced.sourcePackage()).toBe('ch.fhir.ig.ch-core'); + }); + }); +}); diff --git a/tests/library/changelog.test.js b/tests/library/changelog.test.js new file mode 100644 index 00000000..2fcf1fa1 --- /dev/null +++ b/tests/library/changelog.test.js @@ -0,0 +1,65 @@ +// Release dates from CHANGELOG.md, and what FHIRsmith reports as its release date + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { readReleaseDates, releaseDateOf, reportedReleaseDate } = require('../../library/changelog'); +const { MetadataHandler } = require('../../tx/workers/metadata'); + +function changelog(text) { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'changelog-')), 'CHANGELOG.md'); + fs.writeFileSync(file, text); + return file; +} + +const TEXT = '# Changelog\n\n## [0.14.3] - \n\n## [0.14.2] - 2026-10-06\n\n### Added\n\n## [v0.13.4] - 2026-09-17\n\n## [v0.11.0] - 2026-mm-dd\n'; + +describe('changelog release dates', () => { + test('dated headings, with or without a leading v', () => { + const dates = readReleaseDates(changelog(TEXT)); + expect([...dates.entries()]).toEqual([['0.14.2', '2026-10-06'], ['0.13.4', '2026-09-17']]); + }); + + test('a version without a dated heading has no release date', () => { + const file = changelog(TEXT); + expect(releaseDateOf('0.14.2', file)).toBe('2026-10-06'); + expect(releaseDateOf('v0.13.4', file)).toBe('2026-09-17'); + expect(releaseDateOf('0.14.3', file)).toBeNull(); + expect(releaseDateOf('0.14.3-snapshot', file)).toBeNull(); + expect(releaseDateOf('0.11.0', file)).toBeNull(); + expect(releaseDateOf('0.14.2', '/no/such/CHANGELOG.md')).toBeNull(); + }); + + test('the reported release date is the release, or for a snapshot when the server started', () => { + const file = changelog(TEXT); + const started = new Date('2026-10-08T01:02:03.000Z'); + expect(reportedReleaseDate('0.14.2', started, file)).toBe('2026-10-06'); + expect(reportedReleaseDate('0.14.3-snapshot', started, file)).toBe('2026-10-08T01:02:03.000Z'); + }); + + test('the shipped CHANGELOG.md dates the shipped version, unless it is a snapshot', () => { + const version = require('../../package.json').version; + const date = releaseDateOf(version); + if (version.includes('-')) { + expect(date).toBeNull(); + } else { + expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + } + }); +}); + +describe('CapabilityStatement.software.releaseDate', () => { + const endpoint = { path: '/r4', fhirVersion: '4.0' }; + + test('is the configured release date', () => { + const handler = new MetadataHandler({ serverVersion: '0.14.2', softwareName: 'FHIRsmith', releaseDate: '2026-10-06' }); + expect(handler.buildCapabilityStatement(endpoint, null, null).software) + .toEqual({ name: 'FHIRsmith', version: '0.14.2', releaseDate: '2026-10-06' }); + }); + + test('is left out when not known, rather than being the time of the request', () => { + const handler = new MetadataHandler({ serverVersion: '0.14.3-snapshot', softwareName: 'FHIRsmith' }); + expect(handler.buildCapabilityStatement(endpoint, null, null).software) + .toEqual({ name: 'FHIRsmith', version: '0.14.3-snapshot' }); + }); +}); diff --git a/tests/ocl/ocl-cm-provider.test.js b/tests/ocl/ocl-cm-provider.test.js index a1e2fc4a..1c369b1f 100644 --- a/tests/ocl/ocl-cm-provider.test.js +++ b/tests/ocl/ocl-cm-provider.test.js @@ -55,37 +55,38 @@ describe('OCLConceptMapProvider', () => { // assignIds // ----------------------------------------------------------- describe('assignIds', () => { - it('should be a no-op when spaceId is not set', () => { + it('should need a spaceId', () => { const provider = new OCLConceptMapProvider(); - const ids = new Set(); - provider.assignIds(ids); - expect(ids.size).toBe(0); + expect(() => provider.assignIds()).toThrow(/no id space/); }); - it('should prefix ids when spaceId is set and conceptMaps exist', () => { + it('should not move to a different id space', () => { const provider = new OCLConceptMapProvider(); - provider.spaceId = '3'; + provider.assignIds('3'); + provider.assignIds('3'); + expect(() => provider.assignIds('4')).toThrow(/already in the id space '3'/); + expect(provider.spaceId).toBe('3'); + }); + it('should prefix ids when spaceId is set and conceptMaps exist', () => { + const provider = new OCLConceptMapProvider(); // Manually inject a ConceptMap via the internal map const fakeCm = { id: 'map-1', url: 'http://x/map-1', jsonObj: { id: 'map-1' } }; provider.conceptMapMap.set('map-1', fakeCm); - const ids = new Set(); - provider.assignIds(ids); + provider.assignIds('3'); expect(fakeCm.id).toBe('3-map-1'); - expect(ids.has('ConceptMap/3-map-1')).toBe(true); + expect(fakeCm.jsonObj.id).toBe('3-map-1'); + expect(provider._idMap.get('3-map-1')).toBe(fakeCm); }); it('should not double-prefix', () => { const provider = new OCLConceptMapProvider(); - provider.spaceId = '3'; - const fakeCm = { id: '3-map-1', url: 'http://x/map-1', jsonObj: { id: '3-map-1' } }; provider.conceptMapMap.set('map-1', fakeCm); - const ids = new Set(); - provider.assignIds(ids); + provider.assignIds('3'); expect(fakeCm.id).toBe('3-map-1'); }); @@ -104,14 +105,16 @@ describe('OCLConceptMapProvider', () => { expect(result).toBe(fakeCm); }); - it('should strip spaceId prefix and lookup rawId', async () => { - const provider = createProvider(); - provider.spaceId = '5'; - const fakeCm = { id: 'raw-id', url: 'http://x' }; - provider._idMap.set('raw-id', fakeCm); + it('should only find a cached ConceptMap by its prefixed id', async () => { + const getMock = jest.fn().mockRejectedValue(new Error('404')); + const provider = createProvider({ get: getMock }); + provider.assignIds('5'); + const fakeCm = { id: '5-raw-id', url: 'http://x' }; + provider._idMap.set('5-raw-id', fakeCm); - const result = await provider.fetchConceptMapById('5-raw-id'); - expect(result).toBe(fakeCm); + expect(await provider.fetchConceptMapById('5-raw-id')).toBe(fakeCm); + expect(await provider.fetchConceptMapById('raw-id')).toBeNull(); + expect(getMock).not.toHaveBeenCalled(); }); it('should fetch from OCL when not cached', async () => { @@ -128,8 +131,11 @@ describe('OCLConceptMapProvider', () => { 'http://example.org/SourceB' ); - const result = await provider.fetchConceptMapById('map-1'); + provider.assignIds('5'); + + const result = await provider.fetchConceptMapById('5-map-1'); expect(result).not.toBeNull(); + expect(result.id).toBe('5-map-1'); expect(getMock).toHaveBeenCalledWith('/mappings/map-1/'); }); diff --git a/tests/ocl/ocl-cs-provider.test.js b/tests/ocl/ocl-cs-provider.test.js index 590869c6..ca7562ff 100644 --- a/tests/ocl/ocl-cs-provider.test.js +++ b/tests/ocl/ocl-cs-provider.test.js @@ -10,9 +10,12 @@ describe('OCLCodeSystemProvider', () => { it('should assign ids', () => { const provider = new OCLCodeSystemProvider(); - const ids = new Set(); - provider.assignIds(ids); - expect(ids.size).toBeGreaterThanOrEqual(0); + const cs = { id: 'src', jsonObj: { id: 'src' } }; + provider._idToCodeSystem.set('src', cs); + provider.assignIds('ocl'); + expect(cs.id).toBe('ocl-src'); + expect(cs.jsonObj.id).toBe('ocl-src'); + expect(provider._idToCodeSystem.get('ocl-src')).toBe(cs); }); // Adicione mais testes para métodos públicos e fluxos de erro diff --git a/tests/registry/registry-software.test.js b/tests/registry/registry-software.test.js index 47d8826b..93508ef7 100644 --- a/tests/registry/registry-software.test.js +++ b/tests/registry/registry-software.test.js @@ -6,6 +6,7 @@ const path = require('path'); const { FhirsmithReleases, isFhirsmith, describeAge } = require('../../registry/fhirsmith-releases'); const { ServerRegistries, ServerRegistry, ServerInformation, ServerVersionInformation } = require('../../registry/model'); const RegistryModule = require('../../registry/registry'); +const RegistryCrawler = require('../../registry/crawler'); const NOW = new Date('2026-10-06T00:00:00Z').getTime(); @@ -86,18 +87,40 @@ describe('FhirsmithReleases', () => { }); describe('model', () => { - test('the software version survives a save and load', () => { + test('the software version and release date survive a save and load', () => { const v = new ServerVersionInformation(); v.software = 'FHIRsmith'; v.softwareVersion = '0.9.7'; + v.softwareReleaseDate = '2026-06-12'; const back = ServerVersionInformation.fromJSON(JSON.parse(JSON.stringify(v.toJSON()))); expect(back.softwareVersion).toBe('0.9.7'); + expect(back.softwareReleaseDate).toBe('2026-06-12'); expect(ServerVersionInformation.fromJSON({}).softwareVersion).toBe(''); + expect(ServerVersionInformation.fromJSON({}).softwareReleaseDate).toBe(''); + }); +}); + +describe('crawler: reported release date', () => { + const crawler = new RegistryCrawler({}); + const now = NOW; + + test('a real release date is kept', () => { + expect(crawler.reportedReleaseDate({ releaseDate: '2026-09-05T00:37:46+10:00' }, now)).toBe('2026-09-05T00:37:46+10:00'); + }); + + test("the server's own clock is not a release date", () => { + expect(crawler.reportedReleaseDate({ releaseDate: new Date(now - 2000).toISOString() }, now)).toBe(''); + }); + + test('missing or unparseable', () => { + expect(crawler.reportedReleaseDate(undefined, now)).toBe(''); + expect(crawler.reportedReleaseDate({}, now)).toBe(''); + expect(crawler.reportedReleaseDate({ releaseDate: 'soon' }, now)).toBe(''); }); }); describe('software page', () => { - function addServer(registry, name, url, software, softwareVersion, error) { + function addServer(registry, name, url, software, softwareVersion, error, releaseDate) { const server = new ServerInformation(); server.code = name.toLowerCase(); server.name = name; @@ -107,6 +130,7 @@ describe('software page', () => { v.software = software; v.softwareVersion = softwareVersion; v.error = error || ''; + v.softwareReleaseDate = releaseDate || ''; server.versions.push(v); registry.servers.push(server); } @@ -120,6 +144,8 @@ describe('software page', () => { addServer(registry, 'Dev', 'https://dev.example.org/r4', 'FHIRsmith', '0.14.2-snapshot'); addServer(registry, 'Other', 'https://other.example.org/r4', 'Other ', '6.1', 'HTTP 503'); addServer(registry, 'Silent', 'https://silent.example.org/r4', 'unknown', ''); + addServer(registry, 'Onto', 'https://onto.example.org/fhir', 'Ontoserver', '6.29.0', '', '2026-09-05T00:37:46+10:00'); + addServer(registry, 'Future', 'https://future.example.org/r4', 'FHIRsmith', '0.15.3', '', '2026-12-01'); data.registries.push(registry); const module = new RegistryModule({}); @@ -145,7 +171,17 @@ describe('software page', () => { expect(html).toContain('development build after v0.14.1'); }); - test('does not date software that is not FHIRsmith', () => { + test('other software is dated by the release date it reports', () => { + const row = page().split('').find(r => r.startsWith('Onto')); + expect(row).toContain('2026-09-054 weeks'); + }); + + test('a FHIRsmith release the release list does not know yet falls back to its reported date', () => { + const row = page().split('').find(r => r.startsWith('Future')); + expect(row).toContain('2026-12-01'); + }); + + test('does not date software that reports no release date', () => { const html = page(); // find the row by its server name cell (CodeQL flags a substring match on a host name) const otherRow = html.split('').find(r => r.startsWith('Other')); diff --git a/tests/tx/codesystem-operations-tab.test.js b/tests/tx/codesystem-operations-tab.test.js index f998c905..a1841d4f 100644 --- a/tests/tx/codesystem-operations-tab.test.js +++ b/tests/tx/codesystem-operations-tab.test.js @@ -25,7 +25,7 @@ const request = require('supertest'); const { getTestApp, shutdownTestApp } = require('./setup'); -const OPS_TAB = '/tx/r5/CodeSystem/administrative-gender?_format=html/ops'; +const OPS_TAB = '/tx/r5/CodeSystem/core-administrative-gender?_format=html/ops'; // ValueSet.compose.include.filter.op in R5. All of them are offered: which ones a given // code system actually supports is the server's business at expansion time, not the form's. diff --git a/tests/tx/cs-provider-list.test.js b/tests/tx/cs-provider-list.test.js index 4455f346..e375549c 100644 --- a/tests/tx/cs-provider-list.test.js +++ b/tests/tx/cs-provider-list.test.js @@ -30,14 +30,17 @@ describe('ListCodeSystemProvider.codeSystems contract', () => { expect(list.map(c => c.url)).toEqual(['http://a', 'http://b']); }); - test('assignIds iterates the array and assigns unique ids', () => { + test('assignIds prefixes every id with the spaceId', () => { const cp = new ListCodeSystemProvider(); - cp.codeSystems.push({ url: 'http://a' }); // no id - cp.codeSystems.push({ url: 'http://b', id: 'a' }); // collides after first gets id - const ids = new Set(); - cp.assignIds(ids); - const assigned = cp.codeSystems.map(c => c.id); - expect(new Set(assigned).size).toBe(2); // all unique - expect(ids.size).toBe(2); + cp.codeSystems.push({ url: 'http://a', jsonObj: { id: 'a' } }); + cp.codeSystems.push({ url: 'http://b', jsonObj: {} }); // no id: gets its position + cp.assignIds('tho'); + expect(cp.spaceId).toBe('tho'); + expect(cp.codeSystems.map(c => c.jsonObj.id)).toEqual(['tho-a', 'tho-2']); + // the same space again does nothing + cp.assignIds('tho'); + expect(cp.codeSystems.map(c => c.jsonObj.id)).toEqual(['tho-a', 'tho-2']); + // a different one is an error + expect(() => cp.assignIds('us')).toThrow(/already in the id space 'tho'/); }); }); diff --git a/tests/tx/expand.test.js b/tests/tx/expand.test.js index 82ab9fb4..29d7082a 100644 --- a/tests/tx/expand.test.js +++ b/tests/tx/expand.test.js @@ -33,7 +33,7 @@ describe('Expand Worker', () => { describe('GET /tx/r5/ValueSet/:id/$expand', () => { test('should expand ValueSet by id', async () => { const response = await request(app) - .get('/tx/r5/ValueSet/administrative-gender/$expand') + .get('/tx/r5/ValueSet/core-administrative-gender/$expand') .set('Accept', 'application/json'); expect(response.status).toBe(200); @@ -191,7 +191,7 @@ describe('Expand Worker', () => { describe('POST /tx/r5/ValueSet/:id/$expand', () => { test('should expand ValueSet by id with POST', async () => { const response = await request(app) - .post('/tx/r5/ValueSet/administrative-gender/$expand') + .post('/tx/r5/ValueSet/core-administrative-gender/$expand') .set('Accept', 'application/json') .set('Content-Type', 'application/json') .send({ @@ -208,7 +208,7 @@ describe('Expand Worker', () => { describe('Expansion response structure', () => { test('should include expansion metadata', async () => { const response = await request(app) - .get('/tx/r5/ValueSet/administrative-gender/$expand') + .get('/tx/r5/ValueSet/core-administrative-gender/$expand') .set('Accept', 'application/json'); expect(response.status).toBe(200); @@ -223,7 +223,7 @@ describe('Expand Worker', () => { test('should preserve ValueSet metadata', async () => { const response = await request(app) - .get('/tx/r5/ValueSet/administrative-gender/$expand') + .get('/tx/r5/ValueSet/core-administrative-gender/$expand') .set('Accept', 'application/json'); expect(response.status).toBe(200); @@ -443,11 +443,11 @@ describe('Expand Worker', () => { test('should return same expansion for identical requests', async () => { // Make the same expand request twice const response1 = await request(app) - .get('/tx/r5/ValueSet/administrative-gender/$expand') + .get('/tx/r5/ValueSet/core-administrative-gender/$expand') .set('Accept', 'application/json'); const response2 = await request(app) - .get('/tx/r5/ValueSet/administrative-gender/$expand') + .get('/tx/r5/ValueSet/core-administrative-gender/$expand') .set('Accept', 'application/json'); expect(response1.status).toBe(200); diff --git a/tests/tx/library-id-prefixes.test.js b/tests/tx/library-id-prefixes.test.js new file mode 100644 index 00000000..280e633b --- /dev/null +++ b/tests/tx/library-id-prefixes.test.js @@ -0,0 +1,104 @@ +/** + * The library gives every provider an id space: the prefix the library YAML gives for its + * source (folder:some/where=mine), or an autonumbered one that doesn't clash with any given + * prefix - and every resource is served with its id in that space. + */ +const { Library } = require('../../tx/library'); +const path = require('path'); +const fs = require('fs').promises; +const os = require('os'); + +describe('Library id prefixes', () => { + let tmpDir; + let yamlPath; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lib-prefix-test-')); + yamlPath = path.join(tmpDir, 'library.yml'); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + function createLibrary() { + const log = { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }; + const stats = { addStat: jest.fn() }; + return new Library(yamlPath, undefined, log, stats); + } + + // a folder source holding a CodeSystem, a ValueSet and a ConceptMap, all with the same id + async function folder(name) { + const dir = path.join(tmpDir, name); + await fs.mkdir(dir); + const url = `http://example.org/${name}`; + await fs.writeFile(path.join(dir, 'cs.json'), JSON.stringify({ + resourceType: 'CodeSystem', id: 'thing', url: `${url}/CodeSystem/thing`, version: '1.0.0', + status: 'active', content: 'complete', concept: [{ code: 'a' }] + })); + await fs.writeFile(path.join(dir, 'vs.json'), JSON.stringify({ + resourceType: 'ValueSet', id: 'thing', url: `${url}/ValueSet/thing`, version: '1.0.0', + status: 'active', compose: { include: [{ system: `${url}/CodeSystem/thing` }] } + })); + await fs.writeFile(path.join(dir, 'cm.json'), JSON.stringify({ + resourceType: 'ConceptMap', id: 'thing', url: `${url}/ConceptMap/thing`, version: '1.0.0', + status: 'active', group: [{ source: `${url}/CodeSystem/thing`, target: `${url}/CodeSystem/thing`, + element: [{ code: 'a', target: [{ code: 'a', relationship: 'equivalent' }] }] }] + })); + return dir; + } + + async function writeYaml(sources) { + await fs.writeFile(yamlPath, [ + 'base:', + ' url: https://storage.googleapis.com/tx-fhir-org', + 'sources:', + ...sources.map(s => ` - ${s}`) + ].join('\n')); + } + + test('given prefixes are used, the rest are numbered around them, and every id is prefixed', async () => { + const a = await folder('a'); + const b = await folder('b'); + const c = await folder('c'); + await writeYaml([`folder:${a}`, `folder:${b}=mine`, `folder:${c}=1`]); + + const library = createLibrary(); + await library.load(); + + // a is autonumbered, and skips 1 because c has it + expect(library.codeSystemProviders.map(p => p.spaceId)).toEqual(['2', 'mine', '1']); + expect(library.valueSetProviders.map(p => p.spaceId)).toEqual(['2', 'mine', '1']); + expect(library.conceptMapProviders.map(p => p.spaceId)).toEqual(['2', 'mine', '1']); + + const csIds = library.codeSystemProviders.map(p => p.codeSystems[0].id); + expect(csIds).toEqual(['2-thing', 'mine-thing', '1-thing']); + + const mine = library.valueSetProviders[1]; + const vs = await mine.fetchValueSetById('mine-thing'); + expect(vs).not.toBeNull(); + expect(vs.jsonObj.id).toBe('mine-thing'); + expect(await mine.fetchValueSetById('thing')).toBeNull(); + + const cmp = library.conceptMapProviders[1]; + const cm = await cmp.fetchConceptMapById('mine-thing'); + expect(cm).not.toBeNull(); + expect(cm.jsonObj.id).toBe('mine-thing'); + expect(await cmp.fetchConceptMapById('thing')).toBeNull(); + }, 30000); + + test('a prefix given twice is an error', async () => { + const a = await folder('a'); + const b = await folder('b'); + await writeYaml([`folder:${a}=same`, `folder:${b}=same`]); + await expect(createLibrary().load()).rejects.toThrow(/'same' is given for both/); + }); + + test('a prefix that is not a valid id start, or is reserved, is an error', async () => { + const a = await folder('a'); + for (const prefix of ['a-b', 'core', 'x', '']) { + await writeYaml([`folder:${a}=${prefix}`]); + await expect(createLibrary().load()).rejects.toThrow(/Invalid source/); + } + }); +}); diff --git a/tests/tx/lookup.test.js b/tests/tx/lookup.test.js index 987a1d68..2e3e224b 100644 --- a/tests/tx/lookup.test.js +++ b/tests/tx/lookup.test.js @@ -226,7 +226,7 @@ describe('Lookup Worker', () => { describe('GET /tx/r5/CodeSystem/:id/$lookup', () => { test('should lookup by instance id', async () => { const response = await request(app) - .get('/tx/r5/CodeSystem/administrative-gender/$lookup') + .get('/tx/r5/CodeSystem/core-administrative-gender/$lookup') .query({ code: 'male' }) .set('Accept', 'application/json'); @@ -249,7 +249,7 @@ describe('Lookup Worker', () => { test('should return 400 when code is missing for instance lookup', async () => { const response = await request(app) - .get('/tx/r5/CodeSystem/administrative-gender/$lookup') + .get('/tx/r5/CodeSystem/core-administrative-gender/$lookup') .set('Accept', 'application/json'); expect(response.status).toBe(400); @@ -260,7 +260,7 @@ describe('Lookup Worker', () => { describe('POST /tx/r5/CodeSystem/:id/$lookup', () => { test('should lookup by instance id with POST', async () => { const response = await request(app) - .post('/tx/r5/CodeSystem/administrative-gender/$lookup') + .post('/tx/r5/CodeSystem/core-administrative-gender/$lookup') .set('Accept', 'application/json') .set('Content-Type', 'application/json') .send({ diff --git a/tests/tx/openapi.test.js b/tests/tx/openapi.test.js index 2bd3f194..8df62575 100644 --- a/tests/tx/openapi.test.js +++ b/tests/tx/openapi.test.js @@ -260,7 +260,7 @@ describe('tx OpenAPI description', () => { }); test('read and search', async () => { - let res = await get('/CodeSystem/administrative-gender'); + let res = await get('/CodeSystem/core-administrative-gender'); expect(res.status).toBe(200); expect(validate('CodeSystem', res.body)).toEqual([]); res = await get('/ValueSet?_count=20'); diff --git a/tests/tx/provider-tho-precedence.test.js b/tests/tx/provider-tho-precedence.test.js index 81bba07c..8f9d3abf 100644 --- a/tests/tx/provider-tho-precedence.test.js +++ b/tests/tx/provider-tho-precedence.test.js @@ -5,24 +5,22 @@ const { Provider } = require('../../tx/provider'); * core: when content was moved from the FHIR core packages to * terminology.hl7.org (THO/UTG), the resource versions went backwards (e.g. * http://terminology.hl7.org/CodeSystem/coverage-selfpay is 4.0.1 in - * hl7.fhir.r4.core (2019) but 1.0.1 in hl7.terminology (2024)). Plain - * version precedence therefore left the stale core copy as the unversioned - * default, so $lookup with no version couldn't find codes (like 'payconc') - * that only exist in the THO copy. A THO resource must displace same-URL - * resources from core packages as the unversioned default, regardless of - * version — but explicitly versioned entries survive, because the core - * packages carry historical versions (the R4 v2 tables 0006/0360/0391 at - * 2.1/2.3.1/2.4/2.6/2.7, normalized from their pipe-versioned urls at load - * time) that THO does not publish at all. + * hl7.fhir.r4.core (2019) but 1.0.1 in hl7.terminology (2024)), and THO's copy + * has had a lot of QA work since. So a THO resource replaces every same-URL + * resource from a core package, whatever its version - except the R4 v2 tables + * 0006/0360/0391, which R4 core has in two versions each with the version in the + * url (urlHadVersion). THO doesn't publish those versions, so they stay + * addressable by url|version, and THO's copy is the default. */ const SELFPAY = 'http://terminology.hl7.org/CodeSystem/coverage-selfpay'; -function cs(url, version, sourcePackage) { +function cs(url, version, sourcePackage, urlHadVersion) { return { url, version, sourcePackage, + urlHadVersion, vurl: version ? `${url}|${version}` : url, // string-compare "more recent" — fine for the versions used here isMoreRecent(other) { return String(this.version) > String(other.version); } @@ -37,7 +35,7 @@ function newProvider(...systems) { } describe('Provider.addCodeSystem — THO vs core package precedence', () => { - test('THO resource displaces an older-versioned core resource with the same url (coverage-selfpay case)', () => { + test('THO resource replaces an older-versioned core resource with the same url (coverage-selfpay case)', () => { const p = newProvider( cs(SELFPAY, '4.0.1', 'hl7.fhir.r4.core#4.0.1'), cs(SELFPAY, '1.0.1', 'hl7.terminology.r4#6.0.2') @@ -45,30 +43,32 @@ describe('Provider.addCodeSystem — THO vs core package precedence', () => { // the unversioned default must be the THO copy despite its lower version expect(p.codeSystems.get(SELFPAY).sourcePackage).toBe('hl7.terminology.r4#6.0.2'); expect(p.codeSystems.get(SELFPAY).version).toBe('1.0.1'); - // the core copy loses only the unversioned default slot; it stays addressable - // by explicit version, because core packages carry historical versions (e.g. the - // normalized R4 v2 tables 0006/0360/0391) that THO does not publish at all - expect(p.codeSystems.has(SELFPAY + '|4.0.1')).toBe(true); - expect(p.codeSystems.get(SELFPAY + '|4.0.1').sourcePackage).toBe('hl7.fhir.r4.core#4.0.1'); + // and the core copy is gone - it isn't available by version either + expect(p.codeSystems.has(SELFPAY + '|4.0.1')).toBe(false); expect(p.codeSystems.get(SELFPAY + '|1.0.1').sourcePackage).toBe('hl7.terminology.r4#6.0.2'); }); - test('a core resource never displaces an already-loaded THO resource (reverse load order)', () => { + test('a core resource is not added when THO already has the url (reverse load order)', () => { const p = newProvider( cs(SELFPAY, '1.0.1', 'hl7.terminology.r4#6.0.2'), cs(SELFPAY, '4.0.1', 'hl7.fhir.r4.core#4.0.1') ); expect(p.codeSystems.get(SELFPAY).sourcePackage).toBe('hl7.terminology.r4#6.0.2'); - // versioned addressability is kept in this load order too - expect(p.codeSystems.get(SELFPAY + '|4.0.1').sourcePackage).toBe('hl7.fhir.r4.core#4.0.1'); + expect(p.codeSystems.has(SELFPAY + '|4.0.1')).toBe(false); }); - test('a core resource yields its versioned slot to THO only when THO provides that exact version', () => { - const p = newProvider( - cs(SELFPAY, '1.0.1', 'hl7.terminology.r4#6.0.2'), - cs(SELFPAY, '1.0.1', 'hl7.fhir.r4.core#4.0.1') - ); - expect(p.codeSystems.get(SELFPAY + '|1.0.1').sourcePackage).toBe('hl7.terminology.r4#6.0.2'); + test('a core resource that had its version in its url stays available by version, in either load order', () => { + const V2 = 'http://terminology.hl7.org/CodeSystem/v2-0006'; + for (const order of ['core first', 'THO first']) { + const core21 = cs(V2, '2.1', 'hl7.fhir.r4.core#4.0.1', true); + const core24 = cs(V2, '2.4', 'hl7.fhir.r4.core#4.0.1', true); + const tho = cs(V2, '3.0.0', 'hl7.terminology.r4#6.0.2'); + const p = order === 'core first' ? newProvider(core21, core24, tho) : newProvider(tho, core21, core24); + expect(p.codeSystems.get(V2)).toBe(tho); + expect(p.codeSystems.get(V2 + '|2.1')).toBe(core21); + expect(p.codeSystems.get(V2 + '|2.4')).toBe(core24); + expect(p.codeSystems.get(V2 + '|3.0.0')).toBe(tho); + } }); test('THO does not displace same-url resources from non-core packages', () => { diff --git a/tests/tx/read-conceptmap-id.test.js b/tests/tx/read-conceptmap-id.test.js index b8788ded..75a6feda 100644 --- a/tests/tx/read-conceptmap-id.test.js +++ b/tests/tx/read-conceptmap-id.test.js @@ -1,13 +1,15 @@ /** - * Issue #256: a resource read at [type]/[id] must carry that id. + * A resource is read at [type]/[id] in its provider's id space, and carries that id. * - * Package ConceptMaps are served under a space-prefixed id ("1-foo") but are held - * under their original id ("foo"). The read returned the resource unchanged, so its - * id disagreed with its URL, and the HTML tabs (JSON, Original Narrative, Translate), - * which link to ConceptMap/[resource.id], pointed at a 404. + * Issue #256 was package ConceptMaps served under a space-prefixed id ("1-foo") but held + * under their original id ("foo"), so a read returned a resource whose id disagreed with + * its URL. Now every provider gives its resources their prefixed id (see + * tx/library/resource-ids.js), so read returns the resource as it is - and an + * unprefixed id finds nothing. */ const ReadWorker = require('../../tx/workers/read'); +const { applyIdPrefix, stripIdPrefix, checkIdPrefix } = require('../../tx/library/resource-ids'); function fakeRes() { return { @@ -21,50 +23,76 @@ function fakeRes() { function worker(provider) { const log = { debug() {}, error() {}, info() {}, warn() {} }; const opContext = { deadCheck() {} }; - const w = new ReadWorker(opContext, log, provider, null, null); - return w; + return new ReadWorker(opContext, log, provider, null, null); } -describe('read returns the id it was read at (#256)', () => { - const held = { resourceType: 'ConceptMap', id: 'ch-vacd-vaccines-targetdiseases-cm', url: 'http://example.org/cm' }; +describe('read returns the resource in its id space', () => { + const held = { id: 'vaccines-cm', jsonObj: { resourceType: 'ConceptMap', id: 'vaccines-cm', url: 'http://example.org/cm' } }; + applyIdPrefix(held, 'ch'); const provider = { conceptMapProviders: [{ - // like cm-package with spaceId "1": strips the prefix and returns the held object + spaceId: 'ch', + // like cm-package: only ids in its space, held under the original id async fetchConceptMapById(id) { - return id === '1-' + held.id ? { jsonObj: held } : null; + return stripIdPrefix(id, this.spaceId) === 'vaccines-cm' ? held : null; } }], - valueSetProviders: [{ - async fetchValueSetById(id) { - return id === 'vs1' ? { jsonObj: { resourceType: 'ValueSet', id: 'vs1' } } : null; - } - }] + valueSetProviders: [] }; - test('a space-prefixed ConceptMap read carries the prefixed id', async () => { + test('a prefixed ConceptMap read carries the prefixed id', async () => { const res = fakeRes(); - await worker(provider).handle({ params: { id: '1-' + held.id } }, res, 'ConceptMap'); + await worker(provider).handle({ params: { id: 'ch-vaccines-cm' } }, res, 'ConceptMap'); expect(res.statusCode).toBe(200); - expect(res.body.id).toBe('1-' + held.id); - expect(res.body.url).toBe(held.url); + expect(res.body.id).toBe('ch-vaccines-cm'); + expect(res.body.url).toBe('http://example.org/cm'); }); - test('the provider\'s object is not mutated, so later lookups by its own id still work', async () => { + test('the unprefixed id is a 404', async () => { const res = fakeRes(); - await worker(provider).handle({ params: { id: '1-' + held.id } }, res, 'ConceptMap'); - expect(held.id).toBe('ch-vacd-vaccines-targetdiseases-cm'); - expect(res.body).not.toBe(held); + await worker(provider).handle({ params: { id: 'vaccines-cm' } }, res, 'ConceptMap'); + expect(res.statusCode).toBe(404); }); +}); - test('a resource whose id already matches is returned as is', async () => { - const res = fakeRes(); - await worker(provider).handle({ params: { id: 'vs1' } }, res, 'ValueSet'); - expect(res.body.id).toBe('vs1'); +describe('resource ids', () => { + test('applyIdPrefix prefixes the wrapper and its json, once', () => { + const r = { id: 'a', jsonObj: { id: 'a' } }; + applyIdPrefix(r, 'tho'); + applyIdPrefix(r, 'tho'); + expect(r.id).toBe('tho-a'); + expect(r.jsonObj.id).toBe('tho-a'); }); - test('an unknown id is still a 404', async () => { - const res = fakeRes(); - await worker(provider).handle({ params: { id: held.id } }, res, 'ConceptMap'); - expect(res.statusCode).toBe(404); + test('applyIdPrefix still prefixes a raw id that looks prefixed', () => { + const r = { jsonObj: { id: 'tho-a' } }; + applyIdPrefix(r, 'tho'); + expect(r.jsonObj.id).toBe('tho-tho-a'); + }); + + test('applyIdPrefix leaves a resource without an id, or without a spaceId, alone', () => { + const r = { jsonObj: {} }; + applyIdPrefix(r, 'tho'); + expect(r.jsonObj.id).toBeUndefined(); + const s = { jsonObj: { id: 'a' } }; + applyIdPrefix(s, undefined); + expect(s.jsonObj.id).toBe('a'); + }); + + test('stripIdPrefix only accepts ids in the space', () => { + expect(stripIdPrefix('tho-v3-ActCode', 'tho')).toBe('v3-ActCode'); + expect(stripIdPrefix('v3-ActCode', 'tho')).toBeNull(); + expect(stripIdPrefix('tho-', 'tho')).toBeNull(); + expect(stripIdPrefix('tho-a', undefined)).toBeNull(); + }); + + test('checkIdPrefix', () => { + expect(checkIdPrefix('tho')).toBeNull(); + expect(checkIdPrefix('us.core')).toBeNull(); + expect(checkIdPrefix('3')).toBeNull(); + expect(checkIdPrefix('a-b')).not.toBeNull(); + expect(checkIdPrefix('')).not.toBeNull(); + expect(checkIdPrefix('core')).not.toBeNull(); + expect(checkIdPrefix('x')).not.toBeNull(); }); }); diff --git a/tests/tx/read.test.js b/tests/tx/read.test.js index 3ed2875a..aedfcca9 100644 --- a/tests/tx/read.test.js +++ b/tests/tx/read.test.js @@ -21,15 +21,25 @@ describe('Read Worker', () => { describe('GET /tx/r5/CodeSystem/:id', () => { test('should return CodeSystem by id', async () => { const response = await request(app) - .get('/tx/r5/CodeSystem/administrative-gender') + .get('/tx/r5/CodeSystem/core-administrative-gender') .set('Accept', 'application/json'); expect(response.status).toBe(200); expect(response.body.resourceType).toBe('CodeSystem'); - expect(response.body.id).toBe('administrative-gender'); + expect(response.body.id).toBe('core-administrative-gender'); expect(response.body.url).toBe('http://hl7.org/fhir/administrative-gender'); }); + test('should return 404 for the id the CodeSystem has in its package', async () => { + // core resources are only served in the 'core' id space - hl7.terminology can have + // the same ids + const response = await request(app) + .get('/tx/r5/CodeSystem/administrative-gender') + .set('Accept', 'application/json'); + + expect(response.status).toBe(404); + }); + test('should return 404 for non-existent CodeSystem', async () => { const response = await request(app) .get('/tx/r5/CodeSystem/non-existent-id') @@ -42,7 +52,7 @@ describe('Read Worker', () => { test('should include X-Request-Id header', async () => { const response = await request(app) - .get('/tx/r5/CodeSystem/administrative-gender') + .get('/tx/r5/CodeSystem/core-administrative-gender') .set('Accept', 'application/json'); expect(response.headers['x-request-id']).toBeDefined(); @@ -53,12 +63,12 @@ describe('Read Worker', () => { describe('GET /tx/r5/ValueSet/:id', () => { test('should return ValueSet by id', async () => { const response = await request(app) - .get('/tx/r5/ValueSet/administrative-gender') + .get('/tx/r5/ValueSet/core-administrative-gender') .set('Accept', 'application/json'); expect(response.status).toBe(200); expect(response.body.resourceType).toBe('ValueSet'); - expect(response.body.id).toBe('administrative-gender'); + expect(response.body.id).toBe('core-administrative-gender'); }); test('should return 404 for non-existent ValueSet', async () => { @@ -75,7 +85,7 @@ describe('Read Worker', () => { describe('Unsupported methods', () => { test('should return 405 for PUT', async () => { const response = await request(app) - .put('/tx/r5/CodeSystem/administrative-gender') + .put('/tx/r5/CodeSystem/core-administrative-gender') .set('Accept', 'application/json') .send({}); @@ -86,7 +96,7 @@ describe('Read Worker', () => { test('should return 405 for DELETE', async () => { const response = await request(app) - .delete('/tx/r5/CodeSystem/administrative-gender') + .delete('/tx/r5/CodeSystem/core-administrative-gender') .set('Accept', 'application/json'); expect(response.status).toBe(405); diff --git a/tests/tx/subsumes.test.js b/tests/tx/subsumes.test.js index f410e301..6ab46de3 100644 --- a/tests/tx/subsumes.test.js +++ b/tests/tx/subsumes.test.js @@ -268,7 +268,7 @@ describe('Subsumes Worker', () => { describe('GET /tx/r5/CodeSystem/:id/$subsumes', () => { test('should check subsumption by instance id', async () => { const response = await request(app) - .get('/tx/r5/CodeSystem/administrative-gender/$subsumes') + .get('/tx/r5/CodeSystem/core-administrative-gender/$subsumes') .query({ codeA: 'male', codeB: 'female' @@ -297,7 +297,7 @@ describe('Subsumes Worker', () => { test('should return 400 when codes are missing for instance subsumes', async () => { const response = await request(app) - .get('/tx/r5/CodeSystem/administrative-gender/$subsumes') + .get('/tx/r5/CodeSystem/core-administrative-gender/$subsumes') .set('Accept', 'application/json'); expect(response.status).toBe(400); @@ -308,7 +308,7 @@ describe('Subsumes Worker', () => { describe('POST /tx/r5/CodeSystem/:id/$subsumes', () => { test('should check subsumption by instance id with POST', async () => { const response = await request(app) - .post('/tx/r5/CodeSystem/administrative-gender/$subsumes') + .post('/tx/r5/CodeSystem/core-administrative-gender/$subsumes') .set('Accept', 'application/json') .set('Content-Type', 'application/json') .send({ diff --git a/tests/tx/translate.test.js b/tests/tx/translate.test.js index 910319e4..39cfb5ba 100644 --- a/tests/tx/translate.test.js +++ b/tests/tx/translate.test.js @@ -319,7 +319,7 @@ describe('Translate Worker', () => { describe('GET /tx/r5/ConceptMap/:id/$translate', () => { test('should translate by instance id', async () => { const response = await request(app) - .get('/tx/r5/ConceptMap/example2/$translate') + .get('/tx/r5/ConceptMap/core-example2/$translate') .query({ system: 'http://example.org/source', sourceCode: 'source-code' @@ -345,7 +345,7 @@ describe('Translate Worker', () => { test('should return 400 when source code/coding is missing for instance translate', async () => { const response = await request(app) - .get('/tx/r5/ConceptMap/example2/$translate') + .get('/tx/r5/ConceptMap/core-example2/$translate') .set('Accept', 'application/json'); expect(response.status).toBe(400); @@ -354,7 +354,7 @@ describe('Translate Worker', () => { test('should translate by instance id with sourceCoding', async () => { const response = await request(app) - .post('/tx/r5/ConceptMap/example/$translate') + .post('/tx/r5/ConceptMap/core-example/$translate') .set('Accept', 'application/json') .set('Content-Type', 'application/json') .send({ @@ -377,7 +377,7 @@ describe('Translate Worker', () => { describe('POST /tx/r5/ConceptMap/:id/$translate', () => { test('should translate by instance id with POST', async () => { const response = await request(app) - .post('/tx/r5/ConceptMap/example/$translate') + .post('/tx/r5/ConceptMap/core-example/$translate') .set('Accept', 'application/json') .set('Content-Type', 'application/json') .send({ diff --git a/tests/vs/vs-package.test.js b/tests/vs/vs-package.test.js index ce3c5baf..41c940e4 100644 --- a/tests/vs/vs-package.test.js +++ b/tests/vs/vs-package.test.js @@ -315,4 +315,71 @@ describe('PackageValueSetProvider', () => { expect(mapSize).toBeGreaterThanOrEqual(stats.totalValueSets); }); }); -}); \ No newline at end of file + + describe('id space', () => { + let spaced; + + beforeAll(async () => { + // a provider of its own, so the other tests see the value sets as they are loaded + spaced = new PackageValueSetProvider(new PackageContentLoader(path.join(packageCacheDir, packagePath))); + await spaced.initialize(); + spaced.assignIds('tools'); + }); + + test('value sets carry their prefixed id, and are fetched by it', async () => { + const vs = [...spaced.valueSetMap.values()][0]; + expect(vs.id.startsWith('tools-')).toBe(true); + expect(vs.jsonObj.id).toBe(vs.id); + expect(await spaced.fetchValueSetById(vs.id)).toBe(vs); + }); + + test('a value set is not found by its unprefixed id, or by its url', async () => { + const vs = [...spaced.valueSetMap.values()][0]; + expect(await spaced.fetchValueSetById(vs.id.substring('tools-'.length))).toBeNull(); + expect(await spaced.fetchValueSetById('tools-' + vs.url)).toBeNull(); + }); + + test('search returns prefixed ids', async () => { + for (const elements of [null, ['id', 'url']]) { + const results = await spaced.searchValueSets([], elements); + expect(results.length).toBeGreaterThan(0); + for (const r of results) { + expect((r.jsonObj || r).id.startsWith('tools-')).toBe(true); + expect((r.jsonObj || r).id.startsWith('tools-tools-')).toBe(false); + } + } + }); + }); + + describe('excludeUrls', () => { + let reduced; + let gone; + + beforeAll(async () => { + reduced = new PackageValueSetProvider(new PackageContentLoader(path.join(packageCacheDir, packagePath))); + await reduced.initialize(); + reduced.assignIds('core'); + gone = [...reduced.valueSetMap.values()][0]; + reduced.excludeUrls(new Set([gone.url])); + }); + + test('an excluded value set is not fetched, by url or by id', async () => { + expect(await reduced.fetchValueSet(gone.url, null)).toBeNull(); + expect(await reduced.fetchValueSetById(gone.id)).toBeNull(); + }); + + test('an excluded value set is not found by search, or listed', async () => { + for (const elements of [null, ['id', 'url']]) { + const results = await reduced.searchValueSets([], elements); + expect(results.length).toBeGreaterThan(0); + expect(results.some(r => (r.jsonObj || r).id === gone.id)).toBe(false); + } + expect(await reduced.listAllValueSets()).not.toContain(gone.url); + }); + + test('the other value sets are still there', async () => { + const other = [...reduced.valueSetMap.values()].find(vs => vs.url !== gone.url); + expect(await reduced.fetchValueSetById(other.id)).toBe(other); + }); + }); +}); diff --git a/tx/README.md b/tx/README.md index c973ec44..b0d8df85 100644 --- a/tx/README.md +++ b/tx/README.md @@ -237,12 +237,28 @@ shouldn't be - too big. Note that that files in the list below are considered to Each source entry follows the format: ``` -type[!]:details +type[!]:details[=prefix] ``` - **type**: The source type (see Source Types below) - **!** (optional): Append to type to mark this source as the default for its code system. When multiple versions of the same code system are loaded, the default is used when no version is specified. - **details**: Type-specific configuration (filename, package name, or internal provider name) +- **prefix** (optional): The id prefix for the resources the source provides - see [Resource Ids](#resource-ids). Everything after the last `=` in the entry is the prefix, so a `url` source whose url contains a `=` must give one. + +### Resource Ids + +Every CodeSystem, ValueSet and ConceptMap is served with an id in the id space of the source it came from: the source's prefix, `-`, and the id the resource has in the source. With + +```yaml +- npm:hl7.terminology=tho +``` + +THO's v3-ActCode code system is `CodeSystem/tho-v3-ActCode`. No resource is served on the id it has in its source - different sources can use the same ids (hl7.terminology and the FHIR core packages both have a CodeSystem `v3-MaritalStatus`), so an unprefixed id can't say which one is meant, and it finds nothing. + +- A prefix may contain letters, digits and `.` (no `-`), up to 32 characters, and each prefix may only be given once +- If a source isn't given a prefix, it gets a number. Numbers depend on the order of the sources (and on which sources provide which kinds of resource), so they change when the YAML changes. Give every source that provides resources a prefix if its ids should stay the same - i.e. on any server where people link to resources by id +- `core` is the FHIR core package each endpoint loads for its FHIR version (`CodeSystem/core-administrative-gender`), and `x` is the code systems the server implements natively (`CodeSystem/x-...`); neither can be given as a prefix +- Sources that only provide code systems natively (`internal`, `snomed`, `loinc` etc.) don't serve resources by id, so a prefix does nothing for them ### Source Types @@ -464,9 +480,9 @@ sources: - snomed:sct_us_20250901.cache # FHIR packages - - npm:hl7.terminology - - npm:hl7.fhir.us.core - - npm:us.nlm.vsac + - npm:hl7.terminology=tho + - npm:hl7.fhir.us.core=us + - npm:us.nlm.vsac=vsac ``` Also see tx.fhir.org.yml for the production configuration for tx.fhir.org diff --git a/tx/cm/cm-api.js b/tx/cm/cm-api.js index 480d38a3..d9b888dc 100644 --- a/tx/cm/cm-api.js +++ b/tx/cm/cm-api.js @@ -1,22 +1,37 @@ +const { assignIdSpace } = require('../library/resource-ids'); + /** * Abstract base class for Concept Map providers * Defines the interface that all Concept Map providers must implement */ class AbstractConceptMapProvider { /** - * {int} Unique number assigned to this provider + * {string} The prefix of this provider's id space (set by assignIds) - given for its source in the library + * YAML, autonumbered, or 'core' for an endpoint's core package. Every concept map this + * provider provides has an id that starts with spaceId + '-' (see tx/library/resource-ids.js) */ spaceId; /** - * ensure that the ids on the Concept Maps are unique, if they are - * in the global namespace + * Put this provider in an id space: set spaceId, and prefix the ids of the concept maps it + * provides with spaceId + '-'. A provider is put in one id space, once - asking for a + * different one later is an error (see assignIdSpace) * - * @param {Set} ids + * @param {string} spaceId */ - // eslint-disable-next-line no-unused-vars - assignIds(ids) { - throw new Error('assignIds must be implemented by AbstractConceptMapProvider subclass'); + assignIds(spaceId) { + if (assignIdSpace(this, spaceId)) { + this.prefixIds(); + } + } + + /** + * Prefix the ids of the concept maps this provider provides with spaceId + '-', once spaceId + * is set (by assignIds). Concept maps the provider loads later must be given ids + * in the same space. + */ + prefixIds() { + throw new Error('prefixIds must be implemented by AbstractConceptMapProvider subclass'); } /** @@ -32,11 +47,11 @@ class AbstractConceptMapProvider { } /** - * Fetches a specific Concept Map by id. ConceptMap providers must enforce that Concept Map ids are unique - * either globally (as enforced by assignIds) or in their space + * Fetches a specific Concept Map by id. The id is in this provider's space (spaceId + '-' + + * the concept map's own id); an id that isn't - including an unprefixed one - finds nothing * * @param {string} id - The id of the Concept Map - * @returns {Promise} The requested Concept Map + * @returns {Promise} The requested Concept Map, or null * @throws {Error} Must be implemented by subclasses */ // eslint-disable-next-line no-unused-vars diff --git a/tx/cm/cm-database.js b/tx/cm/cm-database.js index 90efa41f..981c9779 100644 --- a/tx/cm/cm-database.js +++ b/tx/cm/cm-database.js @@ -733,10 +733,6 @@ class ConceptMapDatabase { return { query, params }; } - // eslint-disable-next-line no-unused-vars - assignIds(ids) { - // nothing - we don't do any assigning. - } } module.exports = { diff --git a/tx/cm/cm-package.js b/tx/cm/cm-package.js index b987fe9d..3db97226 100644 --- a/tx/cm/cm-package.js +++ b/tx/cm/cm-package.js @@ -5,6 +5,7 @@ const { ConceptMapDatabase } = require('./cm-database'); const { VersionUtilities } = require('../../library/version-utilities'); const {validateParameter} = require("../../library/utilities"); const {ConceptMap} = require("../library/conceptmap"); +const { applyIdPrefix, stripIdPrefix } = require('../library/resource-ids'); /** * Package-based ConceptMap provider using shared database layer @@ -34,6 +35,7 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider { if (this.initialized) { return; } + await this.packageLoader.initialize(); const dbExists = await this.database.exists(); @@ -43,6 +45,8 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider { } this.conceptMapMap = await this.database.loadAllConceptMaps(); + // nothing, unless the spaceId was assigned before the provider was initialized + this.#applyIdPrefix(); this.initialized = true; } @@ -128,7 +132,9 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider { this._validateSearchParams(searchParams); if (this.USE_DATABASE_SEARCH) { - return await this.database.search(this.spaceId, searchParams, elements); + const results = await this.database.search(this.spaceId, searchParams, elements); + return this.#excludedIds.size === 0 ? results + : results.filter(r => !this.#excludedIds.has(stripIdPrefix(r.id, this.spaceId))); } else { const matches = []; const seen = new Set(); // Track by URL to avoid duplicates from versioned keys @@ -194,9 +200,8 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider { if (isMatch) { seen.add(vsUrl); - // Return with prefixed id - const result = { ...json, id: `${this.spaceId}-${json.id}` }; - matches.push(result); + // the id is already prefixed (see assignIds) + matches.push(json); } } @@ -298,19 +303,53 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider { } async fetchConceptMapById(id) { - if (!this.spaceId) { - return this.conceptMapMap.get(id); - } else if (id.startsWith(this.spaceId+"-")) { - let key = id.substring(this.spaceId.length + 1); - return this.conceptMapMap.get(key); - } else { - return null; + // the map is keyed by the concept map's own id (and its url); the concept map itself + // carries the prefixed id, which is how a url key that happens to match is excluded + const cm = this.conceptMapMap.get(stripIdPrefix(id, this.spaceId)); + return cm && cm.id === id ? cm : null; + } + + sourcePackage() { + return this.packageLoader.id(); + } + + // the concept maps left out by excludeUrls, by their own (unprefixed) id + #excludedIds = new Set(); + + /** + * Leave out the concept maps with any of these urls: they can't be fetched, they aren't + * found by search, and they aren't used for translation. The FHIR core package's concept + * maps are left out where hl7.terminology provides the same url (see + * Provider.applyTHOPrecedence). Must be called once the provider is initialized - the + * database is shared, so it isn't changed. + * + * @param {Set} urls + */ + excludeUrls(urls) { + const excluded = new Set(); + for (const cm of this.conceptMapMap.values()) { + if (cm && urls.has(cm.url)) { + excluded.add(cm); + } + } + for (const [key, cm] of [...this.conceptMapMap]) { + if (excluded.has(cm)) { + this.conceptMapMap.delete(key); + } } + for (const cm of excluded) { + this.#excludedIds.add(stripIdPrefix(cm.id, this.spaceId) ?? cm.id); + } + } + + prefixIds() { + this.#applyIdPrefix(); } - // eslint-disable-next-line no-unused-vars - assignIds(ids) { - // nothing - we don't do any assigning. + #applyIdPrefix() { + for (const cm of new Set(this.conceptMapMap.values())) { + applyIdPrefix(cm, this.spaceId); + } } async findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem) { diff --git a/tx/cs/cs-provider-api.js b/tx/cs/cs-provider-api.js index cc7131e0..0e2f00ff 100644 --- a/tx/cs/cs-provider-api.js +++ b/tx/cs/cs-provider-api.js @@ -1,22 +1,37 @@ +const { assignIdSpace } = require('../library/resource-ids'); + /** * Abstract base class for value set providers * Defines the interface that all value set providers must implement */ class AbstractCodeSystemProvider { /** - * {int} Unique number assigned to this provider + * {string} The prefix of this provider's id space (set by assignIds) - given for its source in the library + * YAML, or autonumbered. Every code system this provider provides has an id that starts + * with spaceId + '-' (see tx/library/resource-ids.js) */ spaceId; /** - * ensure that the ids on the code systems are unique, if they are - * in the global namespace + * Put this provider in an id space: set spaceId, and prefix the ids of the code systems it + * provides with spaceId + '-'. A provider is put in one id space, once - asking for a + * different one later is an error (see assignIdSpace) * - * @param {Set} ids + * @param {string} spaceId */ - // eslint-disable-next-line no-unused-vars - assignIds(ids) { - throw new Error('assignIds must be implemented by subclass'); + assignIds(spaceId) { + if (assignIdSpace(this, spaceId)) { + this.prefixIds(); + } + } + + /** + * Prefix the ids of the code systems this provider provides with spaceId + '-', once spaceId + * is set (by assignIds). Code systems the provider provides later (see + * getCodeSystemChanges) must be given ids in the same space. + */ + prefixIds() { + throw new Error('prefixIds must be implemented by subclass'); } /** diff --git a/tx/cs/cs-provider-list.js b/tx/cs/cs-provider-list.js index 7dedccc5..dcc65e83 100644 --- a/tx/cs/cs-provider-list.js +++ b/tx/cs/cs-provider-list.js @@ -1,4 +1,5 @@ const { AbstractCodeSystemProvider } = require('./cs-provider-api'); +const { applyIdPrefix } = require('../library/resource-ids'); /** * Package-based ValueSet provider using shared database layer @@ -11,18 +12,18 @@ class ListCodeSystemProvider extends AbstractCodeSystemProvider { codeSystems = []; /** - * ensure that the ids on the code systems are unique, if they are - * in the global namespace - * - * @param {Set} ids + * prefix the ids of the code systems with this provider's spaceId. A code system without + * an id gets its position in the list */ - // eslint-disable-next-line no-unused-vars - assignIds(ids) { + prefixIds() { + let i = 0; for (const cs of this.codeSystems) { - if (!cs.id || ids.has("CodeSystem/"+cs.id)) { - cs.id = ""+ids.size; + i++; + const json = cs.jsonObj || cs; + if (!json.id) { + json.id = String(i); } - ids.add("CodeSystem/"+cs.id); + applyIdPrefix(cs, this.spaceId); } } diff --git a/tx/fixtures/test-cases.yml b/tx/fixtures/test-cases.yml index 8b2f966a..41eac9c8 100644 --- a/tx/fixtures/test-cases.yml +++ b/tx/fixtures/test-cases.yml @@ -16,5 +16,5 @@ sources: - snomed:sct_test_20250909b.cache - omop:omop_v20250227.db - icd11:icd11-2026-01.db - - npm:hl7.terminology - - npm:fhir.tx.support.r4 + - npm:hl7.terminology=tho + - npm:fhir.tx.support=tx diff --git a/tx/library.js b/tx/library.js index 0314ad3a..ff7414f7 100644 --- a/tx/library.js +++ b/tx/library.js @@ -39,6 +39,7 @@ const { OCLValueSetProvider } = require('./ocl/vs-ocl'); const { OCLConceptMapProvider } = require('./ocl/cm-ocl'); const {UriServicesFactory} = require("./cs/cs-uri"); const {debugLog} = require("./operation-context"); +const { checkIdPrefix } = require("./library/resource-ids"); /** * This class holds all the loaded content ready for processing @@ -72,6 +73,17 @@ class Library { packageSources = []; externalSources = []; + /** + * {Map} the id prefix the library YAML gives for the source a provider was + * loaded from (npm:hl7.terminology=tho), by provider + */ + #sourcePrefixes = new Map(); + + /** + * {Set} every id prefix the library YAML gives - autonumbered prefixes skip them + */ + #givenPrefixes = new Set(); + baseUrl = null; cacheFolder = null; startTime = Date.now(); @@ -171,6 +183,8 @@ class Library { this.log.info('Fetching Data from '+this.baseUrl); + this.#checkSourcePrefixes(config.sources); + for (const source of config.sources) { try { await this.processSource(source, this.packageManager, "fetch"); @@ -223,15 +237,20 @@ class Library { this.assignIds(); } - async processSource(source, packageManager, mode) { - // Parse the source string + /** + * Parse a source entry from the library YAML: type[!]:details[=prefix] + * + * @param {string} source + * @returns {{type: string, details: string, isDefault: boolean, prefix: string|null}} + */ + #parseSource(source) { const colonIndex = source.indexOf(':'); if (colonIndex === -1) { throw new Error(`Invalid source format: ${source}`); } let type = source.substring(0, colonIndex); - const details = source.substring(colonIndex + 1); + let details = source.substring(colonIndex + 1); // Handle special markers (like ! for default) let isDefault = false; @@ -240,6 +259,59 @@ class Library { isDefault = true; } + // the id prefix for the resources the source provides: everything after the last '=' + let prefix = null; + const eqIndex = details.lastIndexOf('='); + if (eqIndex > -1) { + prefix = details.substring(eqIndex + 1); + details = details.substring(0, eqIndex); + const problem = checkIdPrefix(prefix); + if (problem) { + throw new Error(`Invalid source '${source}': ${problem}`); + } + } + return { type, details, isDefault, prefix }; + } + + /** + * Check the id prefixes the library YAML gives before anything is loaded - each id space + * must belong to one source + * + * @param {string[]} sources + */ + #checkSourcePrefixes(sources) { + const seen = new Map(); + for (const source of sources) { + const { prefix } = this.#parseSource(source); + if (prefix) { + if (seen.has(prefix)) { + throw new Error(`The id prefix '${prefix}' is given for both '${seen.get(prefix)}' and '${source}'`); + } + seen.set(prefix, source); + } + } + } + + async processSource(source, packageManager, mode) { + const { type, details, isDefault, prefix } = this.#parseSource(source); + + // the providers this source adds, so they can be given its id prefix + const csCount = this.codeSystemProviders.length; + const vsCount = this.valueSetProviders.length; + const cmCount = this.conceptMapProviders.length; + + await this.#loadSource(type, details, isDefault, mode, packageManager); + + if (prefix) { + this.#givenPrefixes.add(prefix); + for (const provider of [...this.codeSystemProviders.slice(csCount), + ...this.valueSetProviders.slice(vsCount), ...this.conceptMapProviders.slice(cmCount)]) { + this.#sourcePrefixes.set(provider, prefix); + } + } + } + + async #loadSource(type, details, isDefault, mode, packageManager) { // Switch statement for different source types switch (type) { case 'internal': @@ -1000,6 +1072,7 @@ class Library { // Now add the existing value set providers after the FHIR core packages provider.valueSetProviders.push(...this.valueSetProviders); provider.conceptMapProviders.push(...this.conceptMapProviders); + provider.applyTHOPrecedence(); // bind UCUM common value set let ucum = provider.codeSystemFactories.get("http://unitsofmeasure.org"); @@ -1030,30 +1103,29 @@ class Library { } /** - * all the loaded resources must have unique IDs for the get operation - * they must be assigned by the library on loading. providers can either assign - * ids from the global space at start up, or, if they can provide new resources - * later in an ongoing fashion, allocate them in their own space + * Every loaded resource is served in the id space of the provider that loaded it: its id is + * the provider's spaceId, '-', and the id it had in its source (see + * tx/library/resource-ids.js). The spaceId is the prefix the library YAML gives for the + * provider's source, or, if it gives none, a number - the next one, counting providers of + * the same kind, that the YAML doesn't use as a prefix. Numbers depend on the order of the + * sources, so a source whose ids should stay the same when the YAML changes needs a prefix. + * + * (Code system factories don't have ids - they are not available directly.) */ assignIds() { - let ids = new Set(); - // these don't have ids - not available directly for (const cs of this.codeSystemFactories) { .. } - let i = 0; - for (const cp of this.codeSystemProviders) { - cp.spaceId = String(++i); - cp.assignIds(ids); - } - i = 0; - for (const vp of this.valueSetProviders) { - vp.spaceId = String(++i); - vp.assignIds(ids); - } - i = 0; - for (const cmp of this.conceptMapProviders) { - cmp.spaceId = String(++i); - cmp.assignIds(ids); + for (const providers of [this.codeSystemProviders, this.valueSetProviders, this.conceptMapProviders]) { + let n = 0; + for (const provider of providers) { + let spaceId = this.#sourcePrefixes.get(provider); + if (!spaceId) { + do { + n++; + } while (this.#givenPrefixes.has(String(n))); + spaceId = String(n); + } + provider.assignIds(spaceId); + } } - } async close() { diff --git a/tx/library/resource-ids.js b/tx/library/resource-ids.js new file mode 100644 index 00000000..5283d659 --- /dev/null +++ b/tx/library/resource-ids.js @@ -0,0 +1,107 @@ +// +// Resource ids in the server's id spaces +// +// Every CodeSystem, ValueSet and ConceptMap the server serves by id lives in the id space of +// the provider that loaded it, and its id is the provider's prefix, a '-', then the id the +// resource had in its source: "tho-v3-ActCode", "core-v3-MaritalStatus". The prefix is the +// provider's spaceId: given in the library YAML (npm:hl7.terminology=tho), autonumbered by +// the library when the YAML doesn't give one, and "core" for the FHIR core package each +// endpoint loads for itself. +// +// No resource is served under its original id. Two sources can carry resources with the same +// id (hl7.terminology and hl7.fhir.r4.core both have a CodeSystem v3-MaritalStatus), so an +// unprefixed id can't say which one it means - and anything that looks a resource up by its +// raw id must fail, rather than find whichever one happens to come first. +// + +// resources whose id has been prefixed - so prefixing is idempotent, and a raw id that +// happens to start with the prefix still gets one +const prefixed = new WeakSet(); + +/** + * Prefix a resource's id with spaceId. The resource is either a wrapper with a jsonObj + * (CodeSystem, ValueSet, ConceptMap) or a plain json object. Does nothing to a resource + * that has already been prefixed, or that has no id. + * + * @param {Object} resource + * @param {string} spaceId + */ +function applyIdPrefix(resource, spaceId) { + if (!resource || !spaceId || prefixed.has(resource)) { + return; + } + const json = resource.jsonObj || resource; + if (!json.id) { + return; + } + const id = `${spaceId}-${json.id}`; + json.id = id; + if (resource.jsonObj && Object.prototype.hasOwnProperty.call(resource, 'id')) { + resource.id = id; + } + prefixed.add(resource); +} + +/** + * The id a resource had in its source, for an id served in spaceId - or null, if the id + * isn't in that space (including when it's a raw id, or there's no spaceId). + * + * @param {string} id + * @param {string} spaceId + * @returns {string|null} + */ +function stripIdPrefix(id, spaceId) { + if (!spaceId || typeof id !== 'string') { + return null; + } + const prefix = `${spaceId}-`; + return id.startsWith(prefix) && id.length > prefix.length ? id.substring(prefix.length) : null; +} + +/** + * Whether a prefix given in the library YAML is acceptable. It's the start of an id, so it + * may only use id characters, and no '-', so that the prefix of any id is unambiguous. + * 'core' is the endpoints' own core package, and 'x' is the code system factories + * (CodeSystem/x-...), so neither is available. + * + * @param {string} prefix + * @returns {string|null} why the prefix isn't acceptable, or null if it is + */ +function checkIdPrefix(prefix) { + if (!/^[A-Za-z0-9.]{1,32}$/.test(prefix)) { + return `an id prefix may only contain letters, digits and '.' (up to 32 characters)`; + } + if (prefix === CORE_PREFIX || prefix === 'x') { + return `the id prefix '${prefix}' is reserved`; + } + return null; +} + +/** + * Put a provider in an id space - the shared part of the providers' assignIds(spaceId). + * A provider is put in one id space, once: its resources are prefixed when it is, so + * moving it to another would leave them in the old one. Asking again for the same space + * does nothing. + * + * @param {Object} provider - a code system, value set or concept map provider + * @param {string} spaceId + * @returns {boolean} true if the provider has just been put in the space, and must now + * prefix its resources' ids + */ +function assignIdSpace(provider, spaceId) { + if (!spaceId) { + throw new Error(`${provider.constructor.name}: no id space given`); + } + if (provider.spaceId) { + if (provider.spaceId === spaceId) { + return false; + } + throw new Error(`${provider.constructor.name} is already in the id space '${provider.spaceId}', so it can't be put in '${spaceId}'`); + } + provider.spaceId = spaceId; + return true; +} + +const CORE_PREFIX = 'core'; + +module.exports = { applyIdPrefix, stripIdPrefix, checkIdPrefix, assignIdSpace, CORE_PREFIX }; diff --git a/tx/ocl/cm-ocl.cjs b/tx/ocl/cm-ocl.cjs index d726ef0a..be238cb8 100644 --- a/tx/ocl/cm-ocl.cjs +++ b/tx/ocl/cm-ocl.cjs @@ -3,6 +3,7 @@ const { ConceptMap } = require('../library/conceptmap'); const { PAGE_SIZE } = require('./shared/constants'); const { createOclHttpClient } = require('./http/client'); const { fetchAllPages, extractItemsAndNext } = require('./http/pagination'); +const { stripIdPrefix } = require('../library/resource-ids'); const DEFAULT_MAX_SEARCH_PAGES = 10; @@ -24,7 +25,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { this._canonicalBySourceUrl = new Map(); } - assignIds(ids) { + prefixIds() { if (!this.spaceId) { return; } @@ -33,13 +34,18 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { this._idMap.clear(); for (const cm of unique) { - if (!cm.id.startsWith(`${this.spaceId}-`)) { - const nextId = `${this.spaceId}-${cm.id}`; - cm.id = nextId; - cm.jsonObj.id = nextId; - } + this.#prefixId(cm); this._idMap.set(cm.id, cm); - ids.add(`ConceptMap/${cm.id}`); + } + } + + // concept maps are served in this provider's id space - including the ones that arrive + // after the spaceId is assigned + #prefixId(cm) { + if (this.spaceId && cm.id && !cm.id.startsWith(`${this.spaceId}-`)) { + const nextId = `${this.spaceId}-${cm.id}`; + cm.id = nextId; + cm.jsonObj.id = nextId; } } @@ -56,7 +62,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { const mappingId = this.#extractMappingId(url); if (mappingId) { - return await this.fetchConceptMapById(mappingId); + return await this.#fetchMappingById(mappingId); } try { @@ -81,14 +87,15 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { if (this._idMap.has(id)) { return this._idMap.get(id); } + // only ids in this provider's space - the rest is the OCL mapping id + const rawId = stripIdPrefix(id, this.spaceId); + return rawId ? await this.#fetchMappingById(rawId) : null; + } - let rawId = id; - if (this.spaceId && id.startsWith(`${this.spaceId}-`)) { - rawId = id.substring(this.spaceId.length + 1); - } - - if (this._idMap.has(rawId)) { - return this._idMap.get(rawId); + async #fetchMappingById(rawId) { + const id = this.spaceId ? `${this.spaceId}-${rawId}` : rawId; + if (this._idMap.has(id)) { + return this._idMap.get(id); } try { @@ -378,6 +385,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { } #indexConceptMap(cm) { + this.#prefixId(cm); this.conceptMapMap.set(cm.url, cm); if (cm.version) { this.conceptMapMap.set(`${cm.url}|${cm.version}`, cm); diff --git a/tx/ocl/cs-ocl.cjs b/tx/ocl/cs-ocl.cjs index 89d06e2e..f3f9a1c0 100644 --- a/tx/ocl/cs-ocl.cjs +++ b/tx/ocl/cs-ocl.cjs @@ -92,15 +92,12 @@ class OCLCodeSystemProvider extends AbstractCodeSystemProvider { } } - assignIds(ids) { + prefixIds() { + const codeSystems = [...new Set(this._idToCodeSystem.values())]; this._usedIds.clear(); - for (const cs of this._idToCodeSystem.values()) { - if (!cs.id || ids.has(`CodeSystem/${cs.id}`)) { - cs.id = String(ids.size); - cs.jsonObj.id = cs.id; - } - ids.add(`CodeSystem/${cs.id}`); - this._usedIds.add(cs.id); + this._idToCodeSystem.clear(); + for (const cs of codeSystems) { + this.#trackCodeSystemId(cs); this._idToCodeSystem.set(cs.id, cs); } } @@ -358,9 +355,11 @@ class OCLCodeSystemProvider extends AbstractCodeSystemProvider { return; } - if (!cs.id || this._usedIds.has(cs.id)) { + // code systems are served in this provider's id space + const prefix = this.spaceId ? `${this.spaceId}-` : ''; + if (!cs.id || !cs.id.startsWith(prefix) || this._usedIds.has(cs.id)) { const raw = cs.id || cs.name || cs.url || 'ocl-cs'; - const base = this.spaceId ? `${this.spaceId}-${raw}` : String(raw); + const base = raw.startsWith(prefix) ? String(raw) : `${prefix}${raw}`; let candidate = base; let index = 1; while (this._usedIds.has(candidate)) { diff --git a/tx/ocl/vs-ocl.cjs b/tx/ocl/vs-ocl.cjs index 46fd1c6e..2235cc23 100644 --- a/tx/ocl/vs-ocl.cjs +++ b/tx/ocl/vs-ocl.cjs @@ -114,6 +114,7 @@ class OCLValueSetProvider extends AbstractValueSetProvider { id: cached.canonicalUrl }, 'R5'); this.#applyCachedCompose(valueSetObj, paramsKey); + this.#prefixId(valueSetObj); // Indexa o ValueSet restaurado para torná-lo disponível via fetchValueSet this.valueSetMap.set(valueSetObj.url, valueSetObj); if (valueSetObj.version) { @@ -224,7 +225,7 @@ class OCLValueSetProvider extends AbstractValueSetProvider { } } - assignIds(ids) { + prefixIds() { if (!this.spaceId) { return; } @@ -233,13 +234,18 @@ class OCLValueSetProvider extends AbstractValueSetProvider { this._idMap.clear(); for (const vs of unique) { - if (!vs.id.startsWith(`${this.spaceId}-`)) { - const nextId = `${this.spaceId}-${vs.id}`; - vs.id = nextId; - vs.jsonObj.id = nextId; - } + this.#prefixId(vs); this._idMap.set(vs.id, vs); - ids.add(`ValueSet/${vs.id}`); + } + } + + // value sets are served in this provider's id space - including the ones that arrive + // after the spaceId is assigned + #prefixId(vs) { + if (this.spaceId && vs.id && !vs.id.startsWith(`${this.spaceId}-`)) { + const nextId = `${this.spaceId}-${vs.id}`; + vs.id = nextId; + vs.jsonObj.id = nextId; } } @@ -345,16 +351,9 @@ class OCLValueSetProvider extends AbstractValueSetProvider { } #getLocalValueSetById(id) { - if (this._idMap.has(id)) { - return this._idMap.get(id); - } - - if (this.spaceId && id.startsWith(`${this.spaceId}-`)) { - const unprefixed = id.substring(this.spaceId.length + 1); - return this._idMap.get(id) || this._idMap.get(unprefixed) || this.valueSetMap.get(unprefixed) || null; - } - - return this._idMap.get(id) || this.valueSetMap.get(id) || null; + // _idMap is keyed by the ids the value sets are served with - prefixed, once the + // spaceId is assigned + return this._idMap.get(id) || null; } // eslint-disable-next-line no-unused-vars @@ -391,6 +390,7 @@ class OCLValueSetProvider extends AbstractValueSetProvider { } #indexValueSet(vs) { + this.#prefixId(vs); const existing = this.valueSetMap.get(vs.url) || null; // When fresh discovery metadata replaces a cold-cached entry, carry over diff --git a/tx/provider.js b/tx/provider.js index 42d6cc16..896063a3 100644 --- a/tx/provider.js +++ b/tx/provider.js @@ -9,6 +9,7 @@ const {PackageContentLoader} = require("../library/package-manager"); const {PackageValueSetProvider} = require("./vs/vs-package"); const ValueSet = require("./library/valueset"); const {PackageConceptMapProvider} = require("./cm/cm-package"); +const { applyIdPrefix, CORE_PREFIX } = require("./library/resource-ids"); /** * This class holds what information is in context @@ -153,6 +154,12 @@ class Provider { } + /** + * Load the FHIR core package for this endpoint. Its resources are served in the 'core' id + * space (CodeSystem/core-v3-MaritalStatus): the core packages for different FHIR versions + * share ids, and so does other content (hl7.terminology), so no endpoint can serve them on + * their own ids (see tx/library/resource-ids.js) + */ async loadNpm(packageManager, cacheFolder, details, isDefault, mode) { // Parse packageId and version from details (e.g., "hl7.terminology.r4#6.0.2") let packageId = details; @@ -171,6 +178,9 @@ class Provider { await contentLoader.initialize(); const resources = await contentLoader.getResourcesByType("CodeSystem"); + let i = 0; + // the index has the urls as they are in the package; the version is taken out of the url + // when the code system is loaded (see normalizeVersionedCanonicals in package-manager.js) for (const resource of resources) { let cs; try { @@ -183,13 +193,24 @@ class Provider { continue; } cs.sourcePackage = contentLoader.pid(); + if (typeof resource.url === 'string' && resource.url.includes('|')) { + cs.urlHadVersion = true; // see addCodeSystem + } + // these objects belong to this endpoint, so they can be given ids here + i++; + if (!cs.id) { + cs.id = String(i); + } + applyIdPrefix(cs, CORE_PREFIX); this.addCodeSystem(cs); } const vs = new PackageValueSetProvider(contentLoader); await vs.initialize(); + vs.assignIds(CORE_PREFIX); this.valueSetProviders.push(vs); const cm = new PackageConceptMapProvider(contentLoader); await cm.initialize(); + cm.assignIds(CORE_PREFIX); this.conceptMapProviders.push(cm); } @@ -582,27 +603,38 @@ class Provider { return pid != null && pid.startsWith("hl7.terminology"); } + /** + * Add a code system to the ones this endpoint knows, by url and by url|version. + * + * THO precedence, as in CanonicalResourceManager.see() in the Java core: content that + * moved from the FHIR core packages to terminology.hl7.org has been QA'd there since, and + * its versions went backwards (coverage-selfpay is 4.0.1 in hl7.fhir.r4.core but 1.0.1 in + * hl7.terminology), so version precedence can't decide between them. Instead, a code system + * from an hl7.terminology package replaces every code system with the same url from a FHIR + * core package - none of the core versions stay available. + * + * Except: hl7.fhir.r4.core has the v2 tables 0006, 0360 and 0391 in two versions each, + * with the version in the url (http://terminology.hl7.org/CodeSystem/v2-0006|2.1 - see + * urlHadVersion in loadNpm). hl7.terminology doesn't publish those versions, so they stay + * available by url|version; THO's code system is the default for the url. + */ addCodeSystem(cs) { - // Special case, mirroring CanonicalResourceManager.see() in the Java core: - // when content moved from the FHIR core packages to terminology.hl7.org, - // the versions went backwards (e.g. coverage-selfpay is 4.0.1 in - // hl7.fhir.r4.core (2019) but 1.0.1 in hl7.terminology (2024)), so plain - // version precedence would leave the stale core copy as the default. A - // resource from an hl7.terminology package displaces any same-URL - // resource that came from a FHIR core package, regardless of version. if (Provider.#isTHOPackage(cs.sourcePackage)) { for (const [key, t] of [...this.codeSystems]) { - // only the bare-url (default) entry is displaced; explicitly versioned - // entries (key = url|version) stay addressable, because the core packages - // carry historical versions (e.g. the normalized R4 v2 tables 0006/0360/0391 - // at 2.1/2.3.1/2.4/2.6/2.7) that hl7.terminology does not publish at all - if (key === t.url && t.url === cs.url && VersionUtilities.isCorePackage(t.sourcePackage)) { + if (t.url === cs.url && VersionUtilities.isCorePackage(t.sourcePackage) + && (key === t.url || !t.urlHadVersion)) { this.codeSystems.delete(key); } } + } else if (VersionUtilities.isCorePackage(cs.sourcePackage) && !cs.urlHadVersion) { + // THO's code system is always the default for its url + const t = this.codeSystems.get(cs.url); + if (t && Provider.#isTHOPackage(t.sourcePackage)) { + return; + } } const existing = this.codeSystems.get(cs.url); - // the reverse guard: a core-package resource never displaces a THO one + // a core-package resource never displaces a THO one as the default const yieldsToTHO = existing && Provider.#isTHOPackage(existing.sourcePackage) && VersionUtilities.isCorePackage(cs.sourcePackage); if (!existing || (!yieldsToTHO && cs.isMoreRecent(existing))) { @@ -619,6 +651,41 @@ class Provider { } } + /** + * THO precedence for value sets and concept maps, as for code systems (see addCodeSystem): + * once the library's providers have been added after this endpoint's core package, the core + * package's value sets and concept maps whose urls an hl7.terminology package also provides + * are left out. (No core value set or concept map has the version in its url.) + */ + applyTHOPrecedence() { + const thoValueSets = new Set(); + for (const vp of this.valueSetProviders) { + if (vp instanceof PackageValueSetProvider && Provider.#isTHOPackage(vp.sourcePackage())) { + for (const vs of vp.valueSetMap.values()) { + thoValueSets.add(vs.url); + } + } + } + const thoConceptMaps = new Set(); + for (const cmp of this.conceptMapProviders) { + if (cmp instanceof PackageConceptMapProvider && Provider.#isTHOPackage(cmp.sourcePackage())) { + for (const cm of cmp.conceptMapMap.values()) { + thoConceptMaps.add(cm.url); + } + } + } + for (const vp of this.valueSetProviders) { + if (vp instanceof PackageValueSetProvider && VersionUtilities.isCorePackage(vp.sourcePackage())) { + vp.excludeUrls(thoValueSets); + } + } + for (const cmp of this.conceptMapProviders) { + if (cmp instanceof PackageConceptMapProvider && VersionUtilities.isCorePackage(cmp.sourcePackage())) { + cmp.excludeUrls(thoConceptMaps); + } + } + } + deleteCodeSystem(cs) { this.codeSystems.delete(cs.vurl); this.codeSystems.delete(cs.url); diff --git a/tx/tx.fhir.org.yml b/tx/tx.fhir.org.yml index 2097bc18..61c81ac1 100644 --- a/tx/tx.fhir.org.yml +++ b/tx/tx.fhir.org.yml @@ -1,6 +1,14 @@ base: url: https://storage.googleapis.com/tx-fhir-org +# Each source is type[!]:details[=prefix] - see tx/README.md. +# +# The prefix (after the last '=') is the id space for the resources the source provides: +# with npm:hl7.terminology=tho, THO's v3-ActCode is CodeSystem/tho-v3-ActCode. Every +# resource is served with a prefixed id - never the id it has in its source, which other +# sources may share - and each endpoint's own FHIR core package uses 'core'. A source with +# no prefix gets a number, which changes when sources are added, removed or reordered, so +# any source whose resources people link to by id should have one. sources: - internal:lang - internal:country @@ -29,14 +37,14 @@ sources: - snomed:sct_test_20250909b.cache - cpt:CodeSystem-cpt.db|cpt-2023-fragment-0.1.db - omop:omop_v20250227.db - - npm:hl7.terminology - - npm:fhir.tx.support - - npm:ihe.formatcode.fhir - - npm:fhir.dicom - - npm:hl7.fhir.us.core - - npm:us.cdc.phinvads - - npm:hl7.fhir.uv.sdc - - internal:vsac + - npm:hl7.terminology=tho + - npm:fhir.tx.support=tx + - npm:ihe.formatcode.fhir=ihe + - npm:fhir.dicom=dicom + - npm:hl7.fhir.us.core=us + - npm:us.cdc.phinvads=cdc + - npm:hl7.fhir.uv.sdc=sdc + - internal:vsac=vsac - icd11:icd11-2026-01.db ignored: diff --git a/tx/tx.js b/tx/tx.js index 93d53d7e..7f055dfe 100644 --- a/tx/tx.js +++ b/tx/tx.js @@ -5,6 +5,10 @@ // with support for multiple endpoints at different FHIR versions. // +const { reportedReleaseDate } = require('../library/changelog'); + +// when this server process started (the release date of a snapshot build) +const SERVER_STARTED = new Date(Date.now() - process.uptime() * 1000); const express = require('express'); const path = require('path'); const fs = require('fs'); @@ -223,7 +227,9 @@ class TXModule { description: config.description || 'FHIR Terminology Server', contactUrl: config.contactUrl, contact: config.contact, - releaseDate: config.releaseDate, + // when this version was released, from its CHANGELOG.md heading - or, for a snapshot, + // when the server started + releaseDate: config.releaseDate || reportedReleaseDate(packageJson.version, SERVER_STARTED), host: config.host ? config.host : "localhost" }); diff --git a/tx/vs/vs-api.js b/tx/vs/vs-api.js index 92df0a4c..f1892052 100644 --- a/tx/vs/vs-api.js +++ b/tx/vs/vs-api.js @@ -1,10 +1,14 @@ +const { assignIdSpace } = require('../library/resource-ids'); + /** * Abstract base class for value set providers * Defines the interface that all value set providers must implement */ class AbstractValueSetProvider { /** - * {int} Unique number assigned to this provider + * {string} The prefix of this provider's id space (set by assignIds) - given for its source in the library + * YAML, autonumbered, or 'core' for an endpoint's core package. Every value set this + * provider provides has an id that starts with spaceId + '-' (see tx/library/resource-ids.js) */ spaceId; @@ -12,14 +16,25 @@ class AbstractValueSetProvider { throw new Error('code must be implemented by AbstractValueSetProvider subclass'); } /** - * ensure that the ids on the value sets are unique, if they are - * in the global namespace + * Put this provider in an id space: set spaceId, and prefix the ids of the value sets it + * provides with spaceId + '-'. A provider is put in one id space, once - asking for a + * different one later is an error (see assignIdSpace) * - * @param {Set} ids + * @param {string} spaceId */ - // eslint-disable-next-line no-unused-vars - assignIds(ids) { - throw new Error('assignIds must be implemented by AbstractValueSetProvider subclass'); + assignIds(spaceId) { + if (assignIdSpace(this, spaceId)) { + this.prefixIds(); + } + } + + /** + * Prefix the ids of the value sets this provider provides with spaceId + '-', once spaceId + * is set (by assignIds). Value sets the provider loads later must be given ids + * in the same space. + */ + prefixIds() { + throw new Error('prefixIds must be implemented by AbstractValueSetProvider subclass'); } /** @@ -35,11 +50,11 @@ class AbstractValueSetProvider { } /** - * Fetches a specific value set by id. ValueSet providers must enforce that value set ids are unique - * either globally (as enforced by assignIds) or in their space + * Fetches a specific value set by id. The id is in this provider's space (spaceId + '-' + the + * value set's own id); an id that isn't - including an unprefixed one - finds nothing * * @param {string} id - The id of the value set - * @returns {Promise} The requested value set + * @returns {Promise} The requested value set, or null * @throws {Error} Must be implemented by subclasses */ // eslint-disable-next-line no-unused-vars diff --git a/tx/vs/vs-database.js b/tx/vs/vs-database.js index 88a32b1f..b417e95c 100644 --- a/tx/vs/vs-database.js +++ b/tx/vs/vs-database.js @@ -1114,10 +1114,6 @@ class ValueSetDatabase { return { query, params }; } - // eslint-disable-next-line no-unused-vars - assignIds(ids) { - // nothing - we don't do any assigning. - } /** * Get a list of all ValueSet URLs in the database diff --git a/tx/vs/vs-package.js b/tx/vs/vs-package.js index 13ff6b87..8532fdce 100644 --- a/tx/vs/vs-package.js +++ b/tx/vs/vs-package.js @@ -6,6 +6,7 @@ const { ValueSetDatabase } = require('./vs-database'); const { checkContained } = require('../library/canonical-resource'); const { VersionUtilities } = require('../../library/version-utilities'); const {validateParameter} = require("../../library/utilities"); +const { applyIdPrefix, stripIdPrefix } = require('../library/resource-ids'); /** * Package-based ValueSet provider using shared database layer @@ -78,6 +79,8 @@ class PackageValueSetProvider extends AbstractValueSetProvider { Object.defineProperty(vs, 'isCached', { value: true, enumerable: false, configurable: true, writable: true }); } } + // nothing, unless the spaceId was assigned before the provider was initialized + this.#applyIdPrefix(); this.initialized = true; } @@ -212,7 +215,9 @@ class PackageValueSetProvider extends AbstractValueSetProvider { this._validateSearchParams(searchParams); if (this.USE_DATABASE_SEARCH) { - return await this.database.search(this.spaceId, this.valueSetMap, searchParams, elements); + const results = await this.database.search(this.spaceId, this.valueSetMap, searchParams, elements); + return this.#excludedIds.size === 0 ? results + : results.filter(r => !this.#excludedIds.has(stripIdPrefix((r.jsonObj || r).id, this.spaceId))); } else { const matches = []; const seen = new Set(); // Track by URL to avoid duplicates from versioned keys @@ -398,38 +403,19 @@ class PackageValueSetProvider extends AbstractValueSetProvider { } async fetchValueSetById(id) { - return this.valueSetMap.get(id); + // the map is keyed by the value set's own id (and its url); the value set itself + // carries the prefixed id, which is how a url key that happens to match is excluded + const vs = this.valueSetMap.get(stripIdPrefix(id, this.spaceId)); + return vs && vs.id === id ? vs : null; } - // eslint-disable-next-line no-unused-vars - assignIds(ids) { - if (!this.spaceId) { - return; - } - - const prefix = this.spaceId + '-'; - const alreadyPrefixed = new Set(); - - // Get all current entries - we'll iterate and modify - const entries = Array.from(this.valueSetMap.entries()); - - // eslint-disable-next-line no-unused-vars - for (const [key, vs] of entries) { - // Skip if we've already processed this ValueSet instance - if (alreadyPrefixed.has(vs)) { - continue; - } - - // Update the id on the ValueSet itself - if (vs.id && !vs.id.startsWith(prefix)) { - const oldId = vs.id; - vs.id = prefix + oldId; - - // Add to map under the new id as well - this.valueSetMap.set(vs.id, vs); - } + prefixIds() { + this.#applyIdPrefix(); + } - alreadyPrefixed.add(vs); + #applyIdPrefix() { + for (const vs of new Set(this.valueSetMap.values())) { + applyIdPrefix(vs, this.spaceId); } } @@ -438,7 +424,38 @@ class PackageValueSetProvider extends AbstractValueSetProvider { } async listAllValueSets() { - return await this.database.listAllValueSets(); + const urls = await this.database.listAllValueSets(); + return this.#excludedUrls.size === 0 ? urls : urls.filter(url => !this.#excludedUrls.has(url)); + } + + // the value sets left out by excludeUrls - by url, and by their own (unprefixed) id + #excludedUrls = new Set(); + #excludedIds = new Set(); + + /** + * Leave out the value sets with any of these urls: they can't be fetched, and they aren't + * found by search. The FHIR core package's value sets are left out where hl7.terminology + * provides the same url (see Provider.applyTHOPrecedence). Must be called once the + * provider is initialized - the database is shared, so it isn't changed. + * + * @param {Set} urls + */ + excludeUrls(urls) { + const excluded = new Set(); + for (const vs of this.valueSetMap.values()) { + if (vs && urls.has(vs.url)) { + excluded.add(vs); + } + } + for (const [key, vs] of [...this.valueSetMap]) { + if (excluded.has(vs)) { + this.valueSetMap.delete(key); + } + } + for (const vs of excluded) { + this.#excludedUrls.add(vs.url); + this.#excludedIds.add(stripIdPrefix(vs.id, this.spaceId) ?? vs.id); + } } } diff --git a/tx/vs/vs-vsac.js b/tx/vs/vs-vsac.js index 4d275a73..ad566382 100644 --- a/tx/vs/vs-vsac.js +++ b/tx/vs/vs-vsac.js @@ -6,6 +6,7 @@ const { ValueSetDatabase } = require('./vs-database'); const { VersionUtilities } = require('../../library/version-utilities'); const folders = require('../../library/folder-setup'); const {debugLog} = require("../operation-context"); +const { applyIdPrefix, stripIdPrefix } = require("../library/resource-ids"); // Persisted watermark for the phase-1b _lastUpdated scan. const VSAC_LAST_UPDATED_KEY = 'vsac_last_updated_date'; @@ -422,6 +423,10 @@ class VSACValueSetProvider extends AbstractValueSetProvider { } } } + // the value sets are served in this provider's id space + for (const vs of new Set(newMap.values())) { + applyIdPrefix(vs, this.spaceId); + } // Atomic replacement of the map this.valueSetMap = newMap; } @@ -466,7 +471,10 @@ class VSACValueSetProvider extends AbstractValueSetProvider { } async fetchValueSetById(id) { - return await this.checkFullVS(this.valueSetMap.get(id)); + // the map is keyed by the value set's own id (and its url); the value set itself + // carries the prefixed id, which is how a url key that happens to match is excluded + const vs = this.valueSetMap.get(stripIdPrefix(id, this.spaceId)); + return vs && vs.id === id ? await this.checkFullVS(vs) : null; } /** * Searches for value sets based on criteria @@ -556,9 +564,16 @@ class VSACValueSetProvider extends AbstractValueSetProvider { await this.database.close(); } - // eslint-disable-next-line no-unused-vars - assignIds(ids) { - // nothing? + prefixIds() { + this.#applyIdPrefix(); + } + + // the map is replaced every time it's reloaded from the database (see _reloadMap), and that + // can happen before or after the spaceId is assigned - so both prefix whatever is there + #applyIdPrefix() { + for (const vs of new Set(this.valueSetMap.values())) { + applyIdPrefix(vs, this.spaceId); + } } // when we get a valueset from vsac via search, the compose is not @@ -898,7 +913,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider { html += ``; html += `${escape(timeOnly(row.ts))}`; html += `${label}`; - html += `${escape(this.urlTail(row.url) || '')} v ${escape(row.version || '')}`; + html += `${escape(this.urlTail(row.url) || '')} v ${escape(row.version || '')}`; html += ``; } } diff --git a/tx/workers/metadata.js b/tx/workers/metadata.js index d71c0a55..d77ada01 100644 --- a/tx/workers/metadata.js +++ b/tx/workers/metadata.js @@ -202,7 +202,8 @@ class MetadataHandler { software: { name: this.config.softwareName || 'FHIR Terminology Server', version: serverVersion, - releaseDate: this.config.releaseDate || now + // only when it's known - this used to fall back to the time of the request + ...(this.config.releaseDate ? { releaseDate: this.config.releaseDate } : {}) }, implementation: { description: `FHIR Server running at ${baseUrl}`, diff --git a/tx/workers/read.js b/tx/workers/read.js index bd811648..7ec1b1c5 100644 --- a/tx/workers/read.js +++ b/tx/workers/read.js @@ -61,21 +61,6 @@ class ReadWorker extends TerminologyWorker { } } - /** - * The resource as served at [type]/[id] must carry that id. Providers that - * share the server with others prefix their ids with a space id ("1-foo") but - * may keep the resource itself under its original id ("foo") - package - * ConceptMaps do - and then the HTML tabs, which link to [type]/[resource.id], - * point at a resource that doesn't exist (issue #256). Copy rather than - * mutate: the provider's object is shared and keyed on its original id. - */ - withId(json, id) { - if (!json || json.id === id) { - return json; - } - return { ...json, id: id }; - } - /** * Handle CodeSystem read */ @@ -83,7 +68,7 @@ class ReadWorker extends TerminologyWorker { let cs = this.provider.getCodeSystemById(this.opContext, id); if (cs != null) { req.sourcePackage = cs.sourcePackage; - return res.json(this.withId(cs.jsonObj, id)); + return res.json(cs.jsonObj); } if (id.startsWith("x-")) { @@ -142,7 +127,7 @@ class ReadWorker extends TerminologyWorker { const vs = await vsp.fetchValueSetById(id); if (vs) { req.sourcePackage = vs.sourcePackage; - return res.json(this.withId(vs.jsonObj, id)); + return res.json(vs.jsonObj); } } @@ -158,7 +143,7 @@ class ReadWorker extends TerminologyWorker { const cm = await cmsp.fetchConceptMapById(id); if (cm) { req.sourcePackage = cm.sourcePackage; - return res.json(this.withId(cm.jsonObj, id)); + return res.json(cm.jsonObj); } }