fhirsmith 0.10.1 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/changelog-template.md +22 -0
- package/library/logger.js +8 -1
- package/library/package-manager.js +40 -2
- package/npmprojector/watcher.js +1 -1
- package/package.json +3 -2
- package/packages/packages.js +28 -15
- package/publisher/publisher.js +8 -8
- package/registry/api.js +92 -31
- package/registry/crawler.js +15 -1
- package/registry/model.js +158 -8
- package/registry/readme.md +5 -13
- package/registry/registry.js +87 -46
- package/registry/test-runner.js +1 -1
- package/server.js +8 -1
- package/stats.js +5 -1
- package/token/token.js +1 -1
- package/translations/Messages.properties +3 -1
- package/translations/rendering-phrases.properties +1227 -1218
- package/translations/rendering-phrases_ar.properties +1097 -1091
- package/translations/rendering-phrases_fr.properties +626 -624
- package/translations/rendering-phrases_nl.properties +976 -970
- package/translations/rendering-phrases_pt.properties +1026 -1020
- package/translations/rendering-phrases_ru.properties +1100 -1094
- package/tsconfig.eslint.json +9 -0
- package/tx/cs/cs-api.js +57 -4
- package/tx/cs/cs-cs.js +98 -25
- package/tx/cs/cs-hgvs.js +4 -0
- package/tx/cs/cs-lang.js +8 -0
- package/tx/cs/cs-mimetypes.js +4 -0
- package/tx/cs/cs-snomed.js +530 -75
- package/tx/cs/cs-uri.js +4 -0
- package/tx/data/snomed-subset.txt +40 -3
- package/tx/html/home-metrics.liquid +16 -1
- package/tx/importers/import-sct.module.js +18 -0
- package/tx/library/designations.js +3 -5
- package/tx/library/renderer.js +227 -30
- package/tx/library.js +5 -1
- package/tx/operation-context.js +131 -5
- package/tx/provider.js +38 -9
- package/tx/sct/ecl.js +30 -11
- package/tx/sct/expressions.js +28 -1
- package/tx/sct/structures.js +5 -5
- package/tx/tx-html.js +24 -15
- package/tx/tx.fhir.org.yml +1 -4
- package/tx/tx.js +18 -2
- package/tx/vs/vs-package.js +52 -0
- package/tx/workers/batch-validate.js +125 -18
- package/tx/workers/cache-control.js +31 -2
- package/tx/workers/expand.js +150 -76
- package/tx/workers/metadata.js +27 -6
- package/tx/workers/search.js +7 -1
- package/tx/workers/validate.js +30 -18
- package/tx/workers/worker.js +39 -7
- package/tx/xversion/xv-valueset.js +5 -2
- package/xig/xig.js +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,76 @@ All notable changes to the Health Intersections Node Server will be documented i
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.11.1] - 2026-07-30
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Cooperative yielding for long terminology operations: after every 25ms of continuous compute the
|
|
13
|
+
operation yields the event loop (`checkAndYield`), so a heavy $expand can no longer stall every
|
|
14
|
+
other request on the server. Operations whose client has disconnected are aborted at their next
|
|
15
|
+
yield point instead of computing a response nobody will read
|
|
16
|
+
- The operation time limit is now configurable (`operationTimeLimit`, seconds) and defaults to 20s
|
|
17
|
+
(below common client timeouts, so clients see the too-costly OperationOutcome rather than a
|
|
18
|
+
socket timeout), and is measured against compute time consumed rather than wall-clock, so
|
|
19
|
+
concurrent operations time-sharing the event loop don't abort each other
|
|
20
|
+
- The Node Blocking chart on the status page now shows the worst single event-loop stall per
|
|
21
|
+
window as well as the mean
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- fix whole-code-system SNOMED CT expansions blocking the server for 30 seconds and then failing:
|
|
26
|
+
the hierarchy walk read a non-existent `.code` property, so every concept was treated as a
|
|
27
|
+
duplicate of the first and no expansion size guard could ever stop the walk. This is what caused
|
|
28
|
+
the rash of client timeouts on tx.fhir.org after the 0.11.0 upgrade
|
|
29
|
+
- the hierarchy walk now visits each concept once rather than once per path to it (SNOMED CT is a
|
|
30
|
+
poly-hierarchy: the old walk made ~15.8M visits for ~520k concepts)
|
|
31
|
+
- restore the up-front too-costly refusal for expansions that cannot fit the expansion limit (the
|
|
32
|
+
totalCount size guard was silently dead - method read as a property); expansions with a
|
|
33
|
+
count/offset window inside the limit still succeed as partial, unclosed expansions
|
|
34
|
+
- re-enable the CPT 1000-code expansion cap (same method-read-as-property bug)
|
|
35
|
+
- fix parameter misalignment in the no-details expansion path (19 arguments passed to a
|
|
36
|
+
20-parameter function)
|
|
37
|
+
|
|
38
|
+
### Tx Conformance Statement
|
|
39
|
+
|
|
40
|
+
FHIRsmith passed all 2729 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.2, runner v6.9.12)
|
|
41
|
+
|
|
42
|
+
## [0.11.0] - 2026-mm-dd
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- Upgrade the tx registry module to support exclusions and languages
|
|
47
|
+
- Add support for sealed caches
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- rework caching
|
|
52
|
+
- Remember filter analysis on cached value sets
|
|
53
|
+
- Improved Rendering for CodeSystem and ValueSets
|
|
54
|
+
- Update ECL & tests given ECL functionality confirmations
|
|
55
|
+
- Update snomed subset from test IG
|
|
56
|
+
- improved error when multiple cache-id headers passed to the server
|
|
57
|
+
- fix slow crawling for FHIRsmith servers by the tx registry
|
|
58
|
+
- make tx search page size default to 1000 for json/xml and leave it at 20 for html
|
|
59
|
+
- rework batch processing for caches
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- Report the FIRST matching coding, not the last, and only one message about status
|
|
64
|
+
- rework how expansions on complex codesystems are handled
|
|
65
|
+
- better handling of expansions on grammar based systems
|
|
66
|
+
- (SNOMED CT) consistent use of designations
|
|
67
|
+
- give preferredForLanguage a display in expansions
|
|
68
|
+
- fix various ECL processing bugs
|
|
69
|
+
- fix precedence order problem loading THO content after core content
|
|
70
|
+
- fix links in metadata statements
|
|
71
|
+
- fix unhandled promises
|
|
72
|
+
- fix handling of properties in R4 expansions
|
|
73
|
+
- fix bug validating codes from erroneously coded R4 v2 tables
|
|
74
|
+
|
|
75
|
+
### Tx Conformance Statement
|
|
76
|
+
|
|
77
|
+
FHIRsmith passed all 2729 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.2, runner v6.9.12
|
|
8
78
|
|
|
9
79
|
## [v0.10.1] - 2026-06-27
|
|
10
80
|
|
package/library/logger.js
CHANGED
|
@@ -226,7 +226,14 @@ class Logger {
|
|
|
226
226
|
|
|
227
227
|
if (messageOrError instanceof Error) {
|
|
228
228
|
message = messageOrError.message;
|
|
229
|
-
|
|
229
|
+
// Expected, client-facing errors - an OperationOutcome/Issue carrying a 4xx
|
|
230
|
+
// statusCode (e.g. "code not found", "cache-id unknown") - are routine and
|
|
231
|
+
// don't warrant a stack trace in the log. Keep the stack for genuinely
|
|
232
|
+
// unexpected errors (5xx, or no statusCode at all), and for everything when
|
|
233
|
+
// debug logging is enabled.
|
|
234
|
+
const sc = messageOrError.statusCode;
|
|
235
|
+
const expected = typeof sc === 'number' && sc >= 400 && sc < 500;
|
|
236
|
+
stack = (expected && !this._shouldLog('debug')) ? undefined : messageOrError.stack;
|
|
230
237
|
} else {
|
|
231
238
|
message = String(messageOrError);
|
|
232
239
|
stack = meta?.stack;
|
|
@@ -823,7 +823,7 @@ class PackageContentLoader {
|
|
|
823
823
|
|
|
824
824
|
try {
|
|
825
825
|
const content = await fs.readFile(filePath, 'utf8');
|
|
826
|
-
return JSON.parse(content);
|
|
826
|
+
return normalizeVersionedCanonicals(JSON.parse(content));
|
|
827
827
|
} catch (error) {
|
|
828
828
|
throw new Error(`Failed to load file ${entry.filename}: ${error.message}`);
|
|
829
829
|
}
|
|
@@ -923,4 +923,42 @@ class PackageContentLoader {
|
|
|
923
923
|
}
|
|
924
924
|
|
|
925
925
|
|
|
926
|
-
|
|
926
|
+
/**
|
|
927
|
+
* Some R4-era core package resources bake a version into the canonical url itself:
|
|
928
|
+
* hl7.fhir.r4.core#4.0.1 publishes the multi-version v2 tables (0006, 0360, 0391)
|
|
929
|
+
* as e.g. url "http://terminology.hl7.org/CodeSystem/v2-0360|2.7" with version "0360"
|
|
930
|
+
* (the table number!). The base packages can never be republished, so normalize at
|
|
931
|
+
* load time: split the pipe into url + version, on the CodeSystems themselves and on
|
|
932
|
+
* ValueSet compose include/exclude systems that reference them. Without this,
|
|
933
|
+
* provider.system() never matches the system in a coding, so validation fails -
|
|
934
|
+
* silently, in the CodeSystem $validate-code case.
|
|
935
|
+
*/
|
|
936
|
+
function normalizeVersionedCanonicals(resource) {
|
|
937
|
+
if (!resource || typeof resource !== 'object') {
|
|
938
|
+
return resource;
|
|
939
|
+
}
|
|
940
|
+
if (resource.resourceType === 'CodeSystem' || resource.resourceType === 'ValueSet') {
|
|
941
|
+
if (typeof resource.url === 'string' && resource.url.includes('|')) {
|
|
942
|
+
const cut = resource.url.indexOf('|');
|
|
943
|
+
// the url-embedded version wins over the version element (which is "0360" etc)
|
|
944
|
+
resource.version = resource.url.substring(cut + 1);
|
|
945
|
+
resource.url = resource.url.substring(0, cut);
|
|
946
|
+
}
|
|
947
|
+
if (resource.resourceType === 'ValueSet' && resource.compose) {
|
|
948
|
+
for (const list of [resource.compose.include, resource.compose.exclude]) {
|
|
949
|
+
for (const inc of list || []) {
|
|
950
|
+
if (typeof inc.system === 'string' && inc.system.includes('|')) {
|
|
951
|
+
const cut = inc.system.indexOf('|');
|
|
952
|
+
if (!inc.version) {
|
|
953
|
+
inc.version = inc.system.substring(cut + 1);
|
|
954
|
+
}
|
|
955
|
+
inc.system = inc.system.substring(0, cut);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return resource;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
module.exports = { PackageManager, PackageContentLoader, normalizeVersionedCanonicals };
|
package/npmprojector/watcher.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fhirsmith",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"txVersion": "1.9.1",
|
|
5
5
|
"description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
|
|
6
6
|
"main": "server.js",
|
|
@@ -109,7 +109,8 @@
|
|
|
109
109
|
"fast-xml-parser": "^5.3.4",
|
|
110
110
|
"inquirer": {
|
|
111
111
|
"lodash": "4.18.1"
|
|
112
|
-
}
|
|
112
|
+
},
|
|
113
|
+
"brace-expansion": "^5.0.8"
|
|
113
114
|
},
|
|
114
115
|
"bin": {
|
|
115
116
|
"tx-import": "./tx/importers/tx-import.js",
|
package/packages/packages.js
CHANGED
|
@@ -605,10 +605,6 @@ class PackagesModule {
|
|
|
605
605
|
this.crawler = new PackageCrawler(this.config, this.db, this.stats);
|
|
606
606
|
}
|
|
607
607
|
|
|
608
|
-
// The feed uses the versioned package.tgz url as the (permalink) GUID, so reusing
|
|
609
|
-
// the link as the GUID keeps a later crawl from inserting a duplicate.
|
|
610
|
-
const guid = link;
|
|
611
|
-
|
|
612
608
|
const buffer = await this.crawler.fetchUrl(link);
|
|
613
609
|
const npm = await this.crawler.extractNpmPackage(buffer, link);
|
|
614
610
|
|
|
@@ -618,22 +614,37 @@ class PackagesModule {
|
|
|
618
614
|
}
|
|
619
615
|
|
|
620
616
|
const idver = npm.id + '#' + npm.version;
|
|
621
|
-
|
|
617
|
+
|
|
618
|
+
// Delete whatever is stored for this package version, matching on the id#version
|
|
619
|
+
// actually found in the fetched tarball. This used to match on GUID = link, but the
|
|
620
|
+
// stored GUID is whatever the feed said at crawl time (http vs https, or another
|
|
621
|
+
// permalink form), so an exact string match could silently delete nothing and leave
|
|
622
|
+
// the stale copy in place alongside the newly stored one.
|
|
623
|
+
const old = await this.deleteVersionsByIdVersion(npm.id, npm.version);
|
|
624
|
+
|
|
625
|
+
// Keep the old row's GUID when there was one - it is the feed's permalink, and
|
|
626
|
+
// reusing it stops the next crawl from re-inserting the package as a duplicate.
|
|
627
|
+
// When we never had the package, the link is the best available GUID (the feeds
|
|
628
|
+
// use the versioned package.tgz url as the permalink GUID anyway).
|
|
629
|
+
const guid = old.guids[0] || link;
|
|
622
630
|
|
|
623
631
|
const itemLog = { status: '??' };
|
|
624
632
|
await this.crawler.store(link, link, guid, new Date(), buffer, idver, itemLog);
|
|
625
633
|
|
|
626
|
-
pckLog.info('Force-updated ' + idver + ' from ' + link + ' (replaced ' +
|
|
627
|
-
return { status: 'updated', id: npm.id, version: npm.version, replaced };
|
|
634
|
+
pckLog.info('Force-updated ' + idver + ' from ' + link + ' (replaced ' + old.count + ' existing row(s), guid ' + guid + ')');
|
|
635
|
+
return { status: 'updated', id: npm.id, version: npm.version, replaced: old.count };
|
|
628
636
|
}
|
|
629
637
|
|
|
630
|
-
// Delete a stored package version and all of its child rows, by
|
|
631
|
-
|
|
638
|
+
// Delete a stored package version and all of its child rows, by package id and
|
|
639
|
+
// version. Resolves to { count, guids }: how many PackageVersions rows went, and
|
|
640
|
+
// the distinct GUIDs they were stored under (so a caller can reuse the permalink).
|
|
641
|
+
deleteVersionsByIdVersion(id, version) {
|
|
632
642
|
return new Promise((resolve, reject) => {
|
|
633
|
-
this.db.all('SELECT PackageVersionKey FROM PackageVersions WHERE
|
|
643
|
+
this.db.all('SELECT PackageVersionKey, GUID FROM PackageVersions WHERE Id = ? AND Version = ?', [id, version], (err, rows) => {
|
|
634
644
|
if (err) return reject(err);
|
|
635
645
|
const keys = (rows || []).map(r => r.PackageVersionKey);
|
|
636
|
-
|
|
646
|
+
const guids = [...new Set((rows || []).map(r => r.GUID))];
|
|
647
|
+
if (keys.length === 0) return resolve({ count: 0, guids: [] });
|
|
637
648
|
const ph = keys.map(() => '?').join(',');
|
|
638
649
|
const stmts = [
|
|
639
650
|
'DELETE FROM PackageFHIRVersions WHERE PackageVersionKey IN (' + ph + ')',
|
|
@@ -642,7 +653,7 @@ class PackagesModule {
|
|
|
642
653
|
'DELETE FROM PackageVersions WHERE PackageVersionKey IN (' + ph + ')'
|
|
643
654
|
];
|
|
644
655
|
const runNext = (i) => {
|
|
645
|
-
if (i >= stmts.length) return resolve(keys.length);
|
|
656
|
+
if (i >= stmts.length) return resolve({ count: keys.length, guids });
|
|
646
657
|
this.db.run(stmts[i], keys, (e) => e ? reject(e) : runNext(i + 1));
|
|
647
658
|
};
|
|
648
659
|
runNext(0);
|
|
@@ -1289,8 +1300,10 @@ class PackagesModule {
|
|
|
1289
1300
|
//
|
|
1290
1301
|
// POST /update-package { "links": ["http://hl7.org/fhir/uv/ips/2.0.1/package.tgz", ...] }
|
|
1291
1302
|
//
|
|
1292
|
-
// The link is the versioned package.tgz url
|
|
1293
|
-
//
|
|
1303
|
+
// The link is the versioned package.tgz url. The stored copy to replace is found by
|
|
1304
|
+
// the id#version inside the fetched tarball (not by matching the link against the
|
|
1305
|
+
// stored GUID, which can differ in scheme or form from what the feed published), and
|
|
1306
|
+
// the replacement keeps the old row's GUID so a later crawl won't create a duplicate.
|
|
1294
1307
|
// If config.updateToken is set, the request must carry it in the x-update-token header.
|
|
1295
1308
|
this.router.post('/update-package', async (req, res) => {
|
|
1296
1309
|
const start = Date.now();
|
|
@@ -1712,7 +1725,7 @@ class PackagesModule {
|
|
|
1712
1725
|
// Update download counts after successful response
|
|
1713
1726
|
// Do this asynchronously to not delay the response
|
|
1714
1727
|
setImmediate(() => {
|
|
1715
|
-
this.incrementDownloadCounts(packageData.PackageVersionKey, id);
|
|
1728
|
+
void this.incrementDownloadCounts(packageData.PackageVersionKey, id); // fire-and-forget; errors handled internally
|
|
1716
1729
|
});
|
|
1717
1730
|
|
|
1718
1731
|
} catch (error) {
|
package/publisher/publisher.js
CHANGED
|
@@ -2665,14 +2665,14 @@ class PublisherModule {
|
|
|
2665
2665
|
return colors[status] || 'secondary';
|
|
2666
2666
|
}
|
|
2667
2667
|
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2668
|
+
// Fire-and-forget audit logging: callers don't await this, and it must never fail a
|
|
2669
|
+
// request, so it returns void and swallows any DB error in the callback.
|
|
2670
|
+
logUserAction(userId, action, targetId, ipAddress) {
|
|
2671
|
+
this.db.run(
|
|
2672
|
+
'INSERT INTO user_actions (user_id, action, target_id, ip_address) VALUES (?, ?, ?, ?)',
|
|
2673
|
+
[userId, action, targetId, ipAddress],
|
|
2674
|
+
() => {} // don't fail (or block) the request if audit logging fails
|
|
2675
|
+
);
|
|
2676
2676
|
}
|
|
2677
2677
|
|
|
2678
2678
|
getStatus() {
|
package/registry/api.js
CHANGED
|
@@ -10,18 +10,20 @@ class RegistryAPI {
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Build rows for code system queries
|
|
13
|
-
*
|
|
13
|
+
* Build the discovery rows for a code system query
|
|
14
14
|
*/
|
|
15
15
|
buildRowsForCodeSystem(params = {}) {
|
|
16
16
|
const {
|
|
17
17
|
registryCode = '',
|
|
18
18
|
serverCode = '',
|
|
19
19
|
version = '',
|
|
20
|
-
codeSystem = ''
|
|
20
|
+
codeSystem = '',
|
|
21
|
+
language = ''
|
|
21
22
|
} = params;
|
|
22
23
|
|
|
23
24
|
const rows = [];
|
|
24
25
|
const data = this.crawler.getData();
|
|
26
|
+
const requestLangs = ServerRegistryUtilities.parseAcceptLanguage(language);
|
|
25
27
|
|
|
26
28
|
data.registries.forEach(registry => {
|
|
27
29
|
if (registryCode && registry.code !== registryCode) return;
|
|
@@ -29,13 +31,15 @@ class RegistryAPI {
|
|
|
29
31
|
registry.servers.forEach(server => {
|
|
30
32
|
if (serverCode && server.code !== serverCode) return;
|
|
31
33
|
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
// Excluded content is hidden from the ecosystem entirely for this server
|
|
35
|
+
if (codeSystem && server.isExcludedTarget(codeSystem)) return;
|
|
36
|
+
|
|
37
|
+
// Check if server is authoritative for this code system, taking language
|
|
38
|
+
// specific claims into account (maskBase matches hasMatchingCodeSystem semantics)
|
|
39
|
+
const authMatch = codeSystem
|
|
40
|
+
? server.matchAuthCS(codeSystem, requestLangs, true)
|
|
41
|
+
: { isAuth: false, scoped: false };
|
|
42
|
+
const isAuth = authMatch.isAuth;
|
|
39
43
|
|
|
40
44
|
server.versions.forEach(versionInfo => {
|
|
41
45
|
if (version && !ServerRegistryUtilities.versionMatches(version, versionInfo.version)) {
|
|
@@ -64,6 +68,9 @@ class RegistryAPI {
|
|
|
64
68
|
versionInfo,
|
|
65
69
|
isAuth
|
|
66
70
|
);
|
|
71
|
+
if (authMatch.scoped) {
|
|
72
|
+
row.languages = [authMatch.tag];
|
|
73
|
+
}
|
|
67
74
|
rows.push(row);
|
|
68
75
|
}
|
|
69
76
|
});
|
|
@@ -75,7 +82,7 @@ class RegistryAPI {
|
|
|
75
82
|
|
|
76
83
|
/**
|
|
77
84
|
* Build rows for value set queries
|
|
78
|
-
*
|
|
85
|
+
* Build the discovery rows for a value set query
|
|
79
86
|
*/
|
|
80
87
|
buildRowsForValueSet(params = {}) {
|
|
81
88
|
const {
|
|
@@ -94,7 +101,11 @@ class RegistryAPI {
|
|
|
94
101
|
registry.servers.forEach(server => {
|
|
95
102
|
if (serverCode && server.code !== serverCode) return;
|
|
96
103
|
|
|
104
|
+
// Excluded content is hidden from the ecosystem entirely for this server
|
|
105
|
+
if (valueSet && server.isExcludedTarget(valueSet)) return;
|
|
106
|
+
|
|
97
107
|
// Check if server is authoritative for this value set
|
|
108
|
+
// (value set claims have no language dimension at this time)
|
|
98
109
|
const isAuth = valueSet ? ServerRegistryUtilities.hasMatchingValueSet(
|
|
99
110
|
valueSet,
|
|
100
111
|
server.authVSList,
|
|
@@ -129,7 +140,7 @@ class RegistryAPI {
|
|
|
129
140
|
);
|
|
130
141
|
|
|
131
142
|
// Include if authoritative OR has the value set
|
|
132
|
-
//
|
|
143
|
+
// if the server is authoritative, all its versions are included
|
|
133
144
|
if (isAuth || hasValueSet) {
|
|
134
145
|
includeRow = true;
|
|
135
146
|
}
|
|
@@ -339,13 +350,13 @@ class RegistryAPI {
|
|
|
339
350
|
/**
|
|
340
351
|
* Find best server for a given code system/value set
|
|
341
352
|
*/
|
|
342
|
-
findBestServer(type, url, version) {
|
|
353
|
+
findBestServer(type, url, version, language = '') {
|
|
343
354
|
let rows;
|
|
344
355
|
|
|
345
356
|
if (type === 'codesystem') {
|
|
346
|
-
rows = this.buildRowsForCodeSystem({ codeSystem: url, version });
|
|
357
|
+
rows = this.buildRowsForCodeSystem({ codeSystem: url, version, language });
|
|
347
358
|
} else if (type === 'valueset') {
|
|
348
|
-
rows = this.buildRowsForValueSet({ valueSet: url, version });
|
|
359
|
+
rows = this.buildRowsForValueSet({ valueSet: url, version, language });
|
|
349
360
|
} else {
|
|
350
361
|
throw new Error(`Unknown type: ${type}`);
|
|
351
362
|
}
|
|
@@ -378,9 +389,9 @@ class RegistryAPI {
|
|
|
378
389
|
|
|
379
390
|
/**
|
|
380
391
|
* NEW FUNCTION: Resolve the best server for a code system
|
|
381
|
-
*
|
|
392
|
+
* Implements the ecosystem Resolution API for code systems
|
|
382
393
|
*/
|
|
383
|
-
resolveCodeSystem(fhirVersion, codeSystem, authoritativeOnly, usage = '') {
|
|
394
|
+
resolveCodeSystem(fhirVersion, codeSystem, authoritativeOnly, usage = '', language = '') {
|
|
384
395
|
if (!fhirVersion) {
|
|
385
396
|
throw new Error('A FHIR version is required');
|
|
386
397
|
}
|
|
@@ -388,6 +399,7 @@ class RegistryAPI {
|
|
|
388
399
|
throw new Error('A code system URL is required');
|
|
389
400
|
}
|
|
390
401
|
const normalizedVersion = this._normalizeFhirVersion(fhirVersion);
|
|
402
|
+
const requestLangs = ServerRegistryUtilities.parseAcceptLanguage(language);
|
|
391
403
|
|
|
392
404
|
const result = {
|
|
393
405
|
formatVersion: '1',
|
|
@@ -397,18 +409,26 @@ class RegistryAPI {
|
|
|
397
409
|
};
|
|
398
410
|
|
|
399
411
|
const matchedServers = [];
|
|
412
|
+
const authMatches = []; // {entry, score} - sorted by language match before returning
|
|
400
413
|
const data = this.crawler.getData();
|
|
401
414
|
|
|
402
415
|
data.registries.forEach(registry => {
|
|
403
416
|
registry.servers.forEach(server => {
|
|
404
417
|
let added = false;
|
|
405
418
|
|
|
419
|
+
// Excluded content is hidden from the ecosystem entirely for this server -
|
|
420
|
+
// not authoritative, not a candidate
|
|
421
|
+
if (server.isExcludedTarget(codeSystem)) return;
|
|
422
|
+
|
|
406
423
|
// Check if server supports the requested usage tag
|
|
407
424
|
if (server.usageList.length === 0 ||
|
|
408
425
|
(usage && server.usageList.includes(usage))) {
|
|
409
426
|
|
|
410
|
-
// Check if server is authoritative for this code system
|
|
411
|
-
|
|
427
|
+
// Check if server is authoritative for this code system, taking language
|
|
428
|
+
// specific claims into account (a language specific claim is invisible to
|
|
429
|
+
// requests without a matching language)
|
|
430
|
+
const authMatch = server.matchAuthCS(codeSystem, requestLangs);
|
|
431
|
+
const isAuth = authMatch.isAuth;
|
|
412
432
|
|
|
413
433
|
server.versions.forEach(version => {
|
|
414
434
|
if (ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) {
|
|
@@ -425,9 +445,19 @@ class RegistryAPI {
|
|
|
425
445
|
|
|
426
446
|
if (hasMatchingCS) {
|
|
427
447
|
if (isAuth) {
|
|
428
|
-
|
|
448
|
+
authMatches.push({
|
|
449
|
+
entry: this.createServerEntry(server, version, null, authMatch),
|
|
450
|
+
score: authMatch.score
|
|
451
|
+
});
|
|
429
452
|
} else if (!authoritativeOnly) {
|
|
430
|
-
|
|
453
|
+
const candidate = this.createServerEntry(server, version, content.content);
|
|
454
|
+
if (requestLangs) {
|
|
455
|
+
// the ecosystem has no knowledge of a candidate's language abilities -
|
|
456
|
+
// mark it so clients can apply their wildcard strictness policy
|
|
457
|
+
// (see 'Language Specific Claims' in the tx ecosystem IG)
|
|
458
|
+
candidate['language-support'] = 'unknown';
|
|
459
|
+
}
|
|
460
|
+
result.candidates.push(candidate);
|
|
431
461
|
}
|
|
432
462
|
added = true;
|
|
433
463
|
}
|
|
@@ -442,20 +472,27 @@ class RegistryAPI {
|
|
|
442
472
|
});
|
|
443
473
|
|
|
444
474
|
// NEW: Fallback - if no matches found, check for authoritative pattern matches
|
|
445
|
-
if (
|
|
475
|
+
if (authMatches.length === 0 && result.candidates.length === 0) {
|
|
446
476
|
data.registries.forEach(registry => {
|
|
447
477
|
registry.servers.forEach(server => {
|
|
478
|
+
// Excluded content stays hidden in the fallback path too
|
|
479
|
+
if (server.isExcludedTarget(codeSystem)) return;
|
|
480
|
+
|
|
448
481
|
// Check if server supports the requested usage tag
|
|
449
482
|
if (server.usageList.length === 0 ||
|
|
450
483
|
(usage && server.usageList.includes(usage))) {
|
|
451
484
|
|
|
452
485
|
// Check if server is authoritative for this code system
|
|
453
|
-
|
|
486
|
+
// (taking language specific claims into account)
|
|
487
|
+
const authMatch = server.matchAuthCS(codeSystem, requestLangs);
|
|
454
488
|
|
|
455
|
-
if (isAuth) {
|
|
489
|
+
if (authMatch.isAuth) {
|
|
456
490
|
server.versions.forEach(version => {
|
|
457
491
|
if (ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) {
|
|
458
|
-
|
|
492
|
+
authMatches.push({
|
|
493
|
+
entry: this.createServerEntry(server, version, null, authMatch),
|
|
494
|
+
score: authMatch.score
|
|
495
|
+
});
|
|
459
496
|
if (!matchedServers.includes(server.code)) {
|
|
460
497
|
matchedServers.push(server.code);
|
|
461
498
|
}
|
|
@@ -467,6 +504,12 @@ class RegistryAPI {
|
|
|
467
504
|
});
|
|
468
505
|
}
|
|
469
506
|
|
|
507
|
+
// Language specific matches rank before authoritative-list matches, most specific
|
|
508
|
+
// match first. Array.prototype.sort is stable, so entries with equal scores (e.g.
|
|
509
|
+
// all matched on the authoritative list) keep their existing (registration) order
|
|
510
|
+
authMatches.sort((a, b) => a.score - b.score);
|
|
511
|
+
result.authoritative = authMatches.map(m => m.entry);
|
|
512
|
+
|
|
470
513
|
return {
|
|
471
514
|
result: this._cleanEmptyArrays(result),
|
|
472
515
|
matches: matchedServers.length > 0 ? matchedServers.join(',') : '--'
|
|
@@ -475,9 +518,13 @@ class RegistryAPI {
|
|
|
475
518
|
|
|
476
519
|
/**
|
|
477
520
|
* NEW FUNCTION: Resolve the best server for a value set
|
|
478
|
-
*
|
|
521
|
+
* Implements the ecosystem Resolution API for value sets
|
|
479
522
|
*/
|
|
480
|
-
|
|
523
|
+
// eslint-disable-next-line no-unused-vars -- language is accepted for API symmetry (see note below)
|
|
524
|
+
resolveValueSet(fhirVersion, valueSet, authoritativeOnly, usage = '', language = '') {
|
|
525
|
+
// Note: the language parameter is accepted for API symmetry with resolveCodeSystem,
|
|
526
|
+
// but value set claims have no language dimension at this time (language specific
|
|
527
|
+
// claims - the languages property - apply to code systems only), so it has no effect
|
|
481
528
|
if (!fhirVersion) {
|
|
482
529
|
throw new Error('A FHIR version is required');
|
|
483
530
|
}
|
|
@@ -508,6 +555,10 @@ class RegistryAPI {
|
|
|
508
555
|
registry.servers.forEach(server => {
|
|
509
556
|
let added = false;
|
|
510
557
|
|
|
558
|
+
// Excluded content is hidden from the ecosystem entirely for this server -
|
|
559
|
+
// not authoritative, not a candidate
|
|
560
|
+
if (server.isExcludedTarget(valueSet)) return;
|
|
561
|
+
|
|
511
562
|
// Check if server supports the requested usage tag
|
|
512
563
|
if (server.usageList.length === 0 ||
|
|
513
564
|
(usage && server.usageList.includes(usage))) {
|
|
@@ -562,8 +613,11 @@ class RegistryAPI {
|
|
|
562
613
|
|
|
563
614
|
/**
|
|
564
615
|
* Helper function to create a server entry for resolve results
|
|
616
|
+
* @param {Object} authMatch - optional result of server.matchAuthCS(); when the entry
|
|
617
|
+
* was matched on a language specific claim, the claim's language tag is reported so
|
|
618
|
+
* the client can tell language routing applied (vs falling through to the default server)
|
|
565
619
|
*/
|
|
566
|
-
createServerEntry(server, version) {
|
|
620
|
+
createServerEntry(server, version, content = null, authMatch = null) {
|
|
567
621
|
const entry = {
|
|
568
622
|
'server-name': server.name,
|
|
569
623
|
url: version.address
|
|
@@ -575,8 +629,15 @@ class RegistryAPI {
|
|
|
575
629
|
if (server.accessInfo) {
|
|
576
630
|
entry.access_info = server.accessInfo;
|
|
577
631
|
}
|
|
578
|
-
|
|
579
|
-
|
|
632
|
+
// Report the content mode of the matched code system, if the server provided one
|
|
633
|
+
// (per the resolve API in the tx ecosystem IG, candidates report content when known).
|
|
634
|
+
// This was previously read from version.content, which doesn't exist - the value
|
|
635
|
+
// passed by resolveCodeSystem was silently dropped.
|
|
636
|
+
if (content) {
|
|
637
|
+
entry.content = content;
|
|
638
|
+
}
|
|
639
|
+
if (authMatch && authMatch.scoped && authMatch.isAuth) {
|
|
640
|
+
entry.languages = [authMatch.tag];
|
|
580
641
|
}
|
|
581
642
|
|
|
582
643
|
return entry;
|
|
@@ -584,7 +645,7 @@ class RegistryAPI {
|
|
|
584
645
|
|
|
585
646
|
/**
|
|
586
647
|
* NEW FUNCTION: Render a JSON result as an HTML table
|
|
587
|
-
*
|
|
648
|
+
* Renders a discovery result as an HTML table
|
|
588
649
|
*/
|
|
589
650
|
renderJsonToHtml(json, path, regCode = '', serverCode = '', versionCode = '') {
|
|
590
651
|
let html = '<table class="grid">\n';
|
|
@@ -648,7 +709,7 @@ class RegistryAPI {
|
|
|
648
709
|
|
|
649
710
|
/**
|
|
650
711
|
* NEW FUNCTION: Render registry info as HTML
|
|
651
|
-
*
|
|
712
|
+
* Renders the registry tree as HTML
|
|
652
713
|
*/
|
|
653
714
|
renderInfoToHtml() {
|
|
654
715
|
const data = this.crawler.getData();
|
package/registry/crawler.js
CHANGED
|
@@ -255,6 +255,20 @@ class RegistryCrawler {
|
|
|
255
255
|
server.authCSList = (serverConfig.authoritative || []).sort();
|
|
256
256
|
server.authVSList = (serverConfig['authoritative-valuesets'] || []).sort();
|
|
257
257
|
server.usageList = (serverConfig.usage || []).sort();
|
|
258
|
+
// Content hidden from the ecosystem entirely - also filtered out of the discovered
|
|
259
|
+
// content lists below, and checked during claim matching (see the tx ecosystem IG)
|
|
260
|
+
server.exclusions = (serverConfig.exclusions || []).sort();
|
|
261
|
+
// Language specific authoritative claims: BCP-47 tag -> list of CS masks.
|
|
262
|
+
// A backwards compatible addition to registry format 1 - see the tx ecosystem IG
|
|
263
|
+
server.languages = {};
|
|
264
|
+
if (serverConfig.languages && typeof serverConfig.languages === 'object' && !Array.isArray(serverConfig.languages)) {
|
|
265
|
+
for (const tag of Object.keys(serverConfig.languages)) {
|
|
266
|
+
const masks = serverConfig.languages[tag];
|
|
267
|
+
if (Array.isArray(masks)) {
|
|
268
|
+
server.languages[tag] = masks.filter(m => typeof m === 'string').sort();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
258
272
|
|
|
259
273
|
// Process each FHIR version
|
|
260
274
|
const fhirVersions = serverConfig.fhirVersions || [];
|
|
@@ -427,7 +441,7 @@ class RegistryCrawler {
|
|
|
427
441
|
async fetchValueSets(version, server, exclusions) {
|
|
428
442
|
// Initial search URL
|
|
429
443
|
let count = 0;
|
|
430
|
-
let searchUrl = `${version.address}/ValueSet?_elements=url,version
|
|
444
|
+
let searchUrl = `${version.address}/ValueSet?_elements=url,version&_count=1000`;
|
|
431
445
|
try {
|
|
432
446
|
// Set of URLs to avoid duplicates
|
|
433
447
|
const valueSetUrls = new Set();
|