fhirsmith 0.17.0 → 0.18.0
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 +83 -0
- package/README.md +80 -13
- package/config-template.json +106 -1
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +21 -2
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +7 -2
- package/packages/packages.js +34 -3
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +41 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +230 -44
- package/registry/uptime.js +154 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +56 -1
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +47 -25
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/provider.js +16 -4
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +15 -1
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +33 -6
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
package/tx/workers/validate.js
CHANGED
|
@@ -582,6 +582,7 @@ class ValueSetChecker {
|
|
|
582
582
|
messages.push(this.addNormalisationIssue(op, path, code, normalised, cs));
|
|
583
583
|
normalForm.value = normalised;
|
|
584
584
|
}
|
|
585
|
+
await this.noteClassificationCode(cs, ctxt.context, code, normalForm);
|
|
585
586
|
let msg = await cs.incompleteValidationMessage(ctxt.context, this.params.HTTPLanguages);
|
|
586
587
|
if (msg) {
|
|
587
588
|
op.addIssue(new Issue('information', 'informational', addToPath(path, 'code'), null, msg, 'process-note'));
|
|
@@ -1079,6 +1080,8 @@ class ValueSetChecker {
|
|
|
1079
1080
|
} else if (c.display && list.designations.length > 0) {
|
|
1080
1081
|
await this.checkDisplays(list, defLang, c, msg, op, path);
|
|
1081
1082
|
}
|
|
1083
|
+
await this.checkCodingExtensions(csd, c, path, op, msg);
|
|
1084
|
+
await this.checkCodeIssues(csd, c, path, op, msg);
|
|
1082
1085
|
// Report the FIRST matching coding, not the last: when a CodeableConcept
|
|
1083
1086
|
// has several valid codings, the code/system/version/display echoed back
|
|
1084
1087
|
// are those of the earliest coding that validated. Only capture once
|
|
@@ -1349,10 +1352,18 @@ class ValueSetChecker {
|
|
|
1349
1352
|
result.addParamCode('normalized-code', normalForm.value);
|
|
1350
1353
|
}
|
|
1351
1354
|
|
|
1355
|
+
let outCode = null;
|
|
1352
1356
|
if (pcode) {
|
|
1353
|
-
|
|
1357
|
+
outCode = pcode;
|
|
1354
1358
|
} else if (tcode && mode !== 'codeableConcept') {
|
|
1355
|
-
|
|
1359
|
+
outCode = tcode;
|
|
1360
|
+
}
|
|
1361
|
+
if (outCode) {
|
|
1362
|
+
result.addParamCode('code', outCode);
|
|
1363
|
+
const cc = normalForm.classificationCodes?.get(outCode);
|
|
1364
|
+
if (cc) {
|
|
1365
|
+
result.addParamCode('classificationCode', cc);
|
|
1366
|
+
}
|
|
1356
1367
|
}
|
|
1357
1368
|
if (pver) {
|
|
1358
1369
|
result.addParamStr('version', pver);
|
|
@@ -1416,6 +1427,46 @@ class ValueSetChecker {
|
|
|
1416
1427
|
return result;
|
|
1417
1428
|
}
|
|
1418
1429
|
|
|
1430
|
+
/**
|
|
1431
|
+
* Extensions on the coding that its code system defines and so can check (ICD-11's
|
|
1432
|
+
* classificationCode). Anything wrong is an error: the extension is there to be relied on.
|
|
1433
|
+
*/
|
|
1434
|
+
async checkCodingExtensions(cs, c, path, op, msg) {
|
|
1435
|
+
if (!cs || typeof cs.checkCodingExtensions !== 'function' || !c.extension || c.extension.length === 0) {
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
const loc = await cs.locate(c.code);
|
|
1439
|
+
if (!loc.context) {
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
for (const p of await cs.checkCodingExtensions(c, loc.context)) {
|
|
1443
|
+
const m = this.worker.i18n.translate(p.msgId, this.params.HTTPLanguages, p.params);
|
|
1444
|
+
msg(m);
|
|
1445
|
+
op.addIssue(new Issue('error', 'invalid', addToPath(path, 'extension[' + p.index + ']'), p.msgId, m, 'invalid-data'));
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* What the code system has to say about a code that is valid (an ICD-11 expression with two
|
|
1451
|
+
* values on an axis that takes one, say).
|
|
1452
|
+
*/
|
|
1453
|
+
async checkCodeIssues(cs, c, path, op, msg) {
|
|
1454
|
+
if (!cs || typeof cs.codeIssues !== 'function' || !c.code) {
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
const loc = await cs.locate(c.code);
|
|
1458
|
+
if (!loc.context) {
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
for (const p of await cs.codeIssues(loc.context)) {
|
|
1462
|
+
const m = this.worker.i18n.translate(p.msgId, this.params.HTTPLanguages, p.params);
|
|
1463
|
+
if (p.severity === 'error') {
|
|
1464
|
+
msg(m);
|
|
1465
|
+
}
|
|
1466
|
+
op.addIssue(new Issue(p.severity, 'business-rule', addToPath(path, 'code'), p.msgId, m, 'code-rule'));
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1419
1470
|
async checkDisplays(list, defLang, c, msg, op, path) {
|
|
1420
1471
|
let hd = list.hasDisplay(this.params.workingLanguages(), null, c.display, false, DisplayCheckingStyle.CASE_INSENSITIVE)
|
|
1421
1472
|
if (!hd.found) {
|
|
@@ -1522,6 +1573,60 @@ class ValueSetChecker {
|
|
|
1522
1573
|
*
|
|
1523
1574
|
* @returns {string} the message, for callers that also collect it into `messages`
|
|
1524
1575
|
*/
|
|
1576
|
+
/**
|
|
1577
|
+
* The code came in its normal form; if the code system also has a classification code for
|
|
1578
|
+
* it (ICD-11's short code), remember it against that code, so the output can report the
|
|
1579
|
+
* one that belongs to whatever ends up in the `code` parameter.
|
|
1580
|
+
*/
|
|
1581
|
+
/**
|
|
1582
|
+
* For value set membership found through an enumerated concept or a filter: the same
|
|
1583
|
+
* normal-form reporting the whole-code-system path does, but only for code systems that
|
|
1584
|
+
* have a classification code (ICD-11), so that other code systems' value set results are
|
|
1585
|
+
* unchanged.
|
|
1586
|
+
*/
|
|
1587
|
+
async noteValueSetNormalForm(op, path, cs, context, code, normalForm) {
|
|
1588
|
+
if (!(await cs.classificationCode(context))) {
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
const normalised = await cs.code(context);
|
|
1592
|
+
if (normalised && normalised !== code) {
|
|
1593
|
+
this.addNormalisationIssue(op, path, code, normalised, cs);
|
|
1594
|
+
normalForm.value = normalised;
|
|
1595
|
+
}
|
|
1596
|
+
await this.noteClassificationCode(cs, context, code, normalForm);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* True when an enumerated concept in a value set denotes the same concept as the code
|
|
1601
|
+
* asked about, though spelled differently - ICD-11's short code and entity id. Only
|
|
1602
|
+
* code systems with a classification code have two spellings, so for the rest this is
|
|
1603
|
+
* one call and false.
|
|
1604
|
+
*/
|
|
1605
|
+
async sameConceptDifferentForm(cs, code, enumerated) {
|
|
1606
|
+
if (code === enumerated) {
|
|
1607
|
+
return false;
|
|
1608
|
+
}
|
|
1609
|
+
const a = await cs.locate(code);
|
|
1610
|
+
if (!a.context || !(await cs.classificationCode(a.context))) {
|
|
1611
|
+
return false;
|
|
1612
|
+
}
|
|
1613
|
+
const b = await cs.locate(enumerated);
|
|
1614
|
+
return !!b.context && (await cs.code(a.context)) === (await cs.code(b.context));
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
async noteClassificationCode(cs, context, code, normalForm) {
|
|
1618
|
+
// the code system decides whether this spelling of the code earns a classificationCode
|
|
1619
|
+
// (ICD-11: when it was written with entity ids, in any order - not when it was written
|
|
1620
|
+
// with the classification codes themselves)
|
|
1621
|
+
const cc = await cs.reportedClassificationCode(context, code);
|
|
1622
|
+
if (cc && cc !== code) {
|
|
1623
|
+
if (!normalForm.classificationCodes) {
|
|
1624
|
+
normalForm.classificationCodes = new Map();
|
|
1625
|
+
}
|
|
1626
|
+
normalForm.classificationCodes.set(code, cc);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1525
1630
|
addNormalisationIssue(op, path, code, normalised, cs) {
|
|
1526
1631
|
const caseOnly = normalised.toLowerCase() === code.toLowerCase();
|
|
1527
1632
|
const id = caseOnly ? 'CODE_CASE_DIFFERENCE' : 'CODE_NOT_IN_NORMAL_FORM';
|
|
@@ -1558,6 +1663,7 @@ class ValueSetChecker {
|
|
|
1558
1663
|
this.addNormalisationIssue(op, path, code, normalised, cs);
|
|
1559
1664
|
normalForm.value = normalised;
|
|
1560
1665
|
}
|
|
1666
|
+
await this.noteClassificationCode(cs, loc.context, code, normalForm);
|
|
1561
1667
|
let msg = await cs.incompleteValidationMessage(loc.context, this.params.HTTPLanguages);
|
|
1562
1668
|
if (msg) {
|
|
1563
1669
|
op.addIssue(new Issue('information', 'informational', addToPath(path, 'code'), null, msg, 'process-note'));
|
|
@@ -1623,10 +1729,20 @@ class ValueSetChecker {
|
|
|
1623
1729
|
}
|
|
1624
1730
|
}
|
|
1625
1731
|
|
|
1732
|
+
// an enumerated concept may be written in the other form (ICD-11 short code vs uri)
|
|
1733
|
+
let altMatch = null;
|
|
1734
|
+
if (cset.concept && !cset.concept.some(cc => cc.code === code)) {
|
|
1735
|
+
for (const cc of cset.concept) {
|
|
1736
|
+
if (await this.sameConceptDifferentForm(cs, code, cc.code)) {
|
|
1737
|
+
altMatch = cc;
|
|
1738
|
+
break;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1626
1742
|
for (let cc of cset.concept || []) {
|
|
1627
1743
|
this.worker.deadCheck('checkConceptSet#1');
|
|
1628
1744
|
let c = cc.code;
|
|
1629
|
-
if (code === c) {
|
|
1745
|
+
if (code === c || cc === altMatch) {
|
|
1630
1746
|
let loc = await cs.locate(code);
|
|
1631
1747
|
if (loc.context !== null) {
|
|
1632
1748
|
loc = loc.context;
|
|
@@ -1662,6 +1778,9 @@ class ValueSetChecker {
|
|
|
1662
1778
|
inactive.code = code;
|
|
1663
1779
|
vstatus.value = await cs.getStatus(loc);
|
|
1664
1780
|
}
|
|
1781
|
+
if (role !== 'not in') {
|
|
1782
|
+
await this.noteValueSetNormalForm(op, path, cs, loc, code, normalForm);
|
|
1783
|
+
}
|
|
1665
1784
|
result = true;
|
|
1666
1785
|
return result;
|
|
1667
1786
|
}
|
|
@@ -1691,7 +1810,7 @@ class ValueSetChecker {
|
|
|
1691
1810
|
let loc = await cs.filterLocate(prep, filters[0], code);
|
|
1692
1811
|
if (loc != null && !(typeof loc === 'string') && filters.length > 1) {
|
|
1693
1812
|
for (let i = 1; i < filters.length; i++) {
|
|
1694
|
-
if (
|
|
1813
|
+
if ((await cs.filterCheck(prep, filters[i], loc)) != true) { // a failing check may return a message, which is truthy
|
|
1695
1814
|
loc = null;
|
|
1696
1815
|
break;
|
|
1697
1816
|
}
|
|
@@ -1733,6 +1852,9 @@ class ValueSetChecker {
|
|
|
1733
1852
|
}
|
|
1734
1853
|
}
|
|
1735
1854
|
}
|
|
1855
|
+
if (role !== 'not in') {
|
|
1856
|
+
await this.noteValueSetNormalForm(op, path, cs, loc, code, normalForm);
|
|
1857
|
+
}
|
|
1736
1858
|
result = true;
|
|
1737
1859
|
return result;
|
|
1738
1860
|
}
|
|
@@ -2035,7 +2157,7 @@ class ValidateWorker extends TerminologyWorker {
|
|
|
2035
2157
|
return res.status(422).json(this.operationOutcome('error', 'not-found',
|
|
2036
2158
|
`CodeSystem/${id} not found`));
|
|
2037
2159
|
}
|
|
2038
|
-
const csp =
|
|
2160
|
+
const csp = FhirCodeSystemProvider.create(this.opContext, new CodeSystem(codeSystem), []);
|
|
2039
2161
|
|
|
2040
2162
|
// Extract coded value
|
|
2041
2163
|
let mode = { mode : null }
|
|
@@ -2168,7 +2290,7 @@ class ValidateWorker extends TerminologyWorker {
|
|
|
2168
2290
|
// Check for codeSystem resource parameter
|
|
2169
2291
|
const csResource = this.getResourceParam(params, 'codeSystem');
|
|
2170
2292
|
if (csResource) {
|
|
2171
|
-
return
|
|
2293
|
+
return FhirCodeSystemProvider.create(this.opContext, new CodeSystem(csResource), []); // todo: supplements
|
|
2172
2294
|
}
|
|
2173
2295
|
let path = coded == null ? null : this.systemPath(mode);
|
|
2174
2296
|
let fromCoded = false;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generates the "national extension" content for the SNOMED CT test edition
|
|
4
|
+
* (module 31000003106, namespace 1000003) that the tx-ecosystem tests use.
|
|
5
|
+
*
|
|
6
|
+
* The test edition at tx/data/snomed is a subontology extract of the
|
|
7
|
+
* International Edition; everything in it except the module concept itself
|
|
8
|
+
* is International content. This script adds a handful of extension concepts
|
|
9
|
+
* whose definitions are built entirely from International concepts that are
|
|
10
|
+
* in the subset, so that national -> international translation can be tested:
|
|
11
|
+
*
|
|
12
|
+
* - one fully defined concept with a single ungrouped attribute (laterality)
|
|
13
|
+
* - one fully defined concept with role-grouped attributes, not equivalent
|
|
14
|
+
* to any International concept
|
|
15
|
+
* - one fully defined concept that is exactly equivalent to an existing
|
|
16
|
+
* International concept (same defining relationships)
|
|
17
|
+
* - one primitive concept with only an IS A (closest ancestor only)
|
|
18
|
+
* - one primitive concept directly under a top-level hierarchy (no useful ancestor)
|
|
19
|
+
* - one inactive concept with no historical association (nothing to translate to)
|
|
20
|
+
* - one inactive concept REPLACED BY an International concept
|
|
21
|
+
*
|
|
22
|
+
* Output is a set of RF2 snapshot files alongside the INT ones, with the
|
|
23
|
+
* TEST1000003 namespace tag in the filename. Rows are deterministic, so the
|
|
24
|
+
* script can be re-run safely; ids are allocated from item numbers >= 100001
|
|
25
|
+
* so they never collide with the extractor-generated ids.
|
|
26
|
+
*
|
|
27
|
+
* Usage: node utilities/make-sct-test-extension.js [tx/data/snomed]
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const crypto = require('crypto');
|
|
33
|
+
|
|
34
|
+
const root = process.argv[2] || path.join(__dirname, '..', 'tx', 'data', 'snomed');
|
|
35
|
+
const TAG = 'TEST1000003';
|
|
36
|
+
const ET = '20250909';
|
|
37
|
+
const MODULE = '31000003106';
|
|
38
|
+
const NAMESPACE = '1000003';
|
|
39
|
+
const EOL = '\r\n';
|
|
40
|
+
|
|
41
|
+
// --- SCTID construction -------------------------------------------------
|
|
42
|
+
|
|
43
|
+
// Verhoeff check digit
|
|
44
|
+
const d = [
|
|
45
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
|
|
46
|
+
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7], [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
|
|
47
|
+
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2], [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
|
|
48
|
+
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]];
|
|
49
|
+
const p = [
|
|
50
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
|
|
51
|
+
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7], [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
|
|
52
|
+
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5], [7, 0, 4, 6, 8, 1, 5, 3, 9, 2]];
|
|
53
|
+
const inv = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9];
|
|
54
|
+
|
|
55
|
+
function verhoeff(numStr) {
|
|
56
|
+
let c = 0;
|
|
57
|
+
const digits = numStr.split('').reverse().map(Number);
|
|
58
|
+
for (let i = 0; i < digits.length; i++) {
|
|
59
|
+
c = d[c][p[(i + 1) % 8][digits[i]]];
|
|
60
|
+
}
|
|
61
|
+
return inv[c];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sctid(item, partition) {
|
|
65
|
+
const body = `${item}${NAMESPACE}${partition}`;
|
|
66
|
+
return body + verhoeff(body);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let nextItem = 100001;
|
|
70
|
+
const conceptId = () => sctid(nextItem++, '10');
|
|
71
|
+
const descriptionId = () => sctid(nextItem++, '11');
|
|
72
|
+
const relationshipId = () => sctid(nextItem++, '12');
|
|
73
|
+
|
|
74
|
+
// Deterministic UUID for refset members (name-based, so re-runs are stable)
|
|
75
|
+
function memberId(...parts) {
|
|
76
|
+
const h = crypto.createHash('sha1').update(['sct-test-ext', ...parts].join('|')).digest('hex');
|
|
77
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-a${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- RF2 constants ------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
const FULLY_DEFINED = '900000000000073002';
|
|
83
|
+
const PRIMITIVE = '900000000000074008';
|
|
84
|
+
const FSN = '900000000000003001';
|
|
85
|
+
const SYNONYM = '900000000000013009';
|
|
86
|
+
const CASE_INSENSITIVE = '900000000000448009';
|
|
87
|
+
const IS_A = '116680003';
|
|
88
|
+
const INFERRED = '900000000000011006';
|
|
89
|
+
const EXISTENTIAL = '900000000000451002';
|
|
90
|
+
const EN_GB = '900000000000508004';
|
|
91
|
+
const EN_US = '900000000000509007';
|
|
92
|
+
const PREFERRED = '900000000000548007';
|
|
93
|
+
const ACCEPTABLE = '900000000000549004';
|
|
94
|
+
const OWL_AXIOM_REFSET = '733073007';
|
|
95
|
+
const ROLE_GROUP = '609096000';
|
|
96
|
+
const REPLACED_BY = '900000000000526001'; // the SAME AS refset concept is not in the subset
|
|
97
|
+
|
|
98
|
+
// --- The extension content ---------------------------------------------
|
|
99
|
+
|
|
100
|
+
// All destination/attribute ids below are International concepts present in
|
|
101
|
+
// the test subset. Each concept: fsn, synonyms (first is preferred),
|
|
102
|
+
// parents, groups (array of arrays of [typeId, destinationId]; group 0 =
|
|
103
|
+
// ungrouped), definition status, and the stated OWL axiom.
|
|
104
|
+
const concepts = [
|
|
105
|
+
{
|
|
106
|
+
key: 'right-kidney',
|
|
107
|
+
// shape 1: fully defined, one ungrouped attribute (Laterality is never grouped)
|
|
108
|
+
fsn: 'Structure of kidney adjacent to liver (body structure)',
|
|
109
|
+
synonyms: ['Right kidney (the one under the liver)', 'Liver-side kidney'],
|
|
110
|
+
parents: ['64033007'], // Kidney structure
|
|
111
|
+
ungrouped: [['272741003', '24028007']], // Laterality = Right
|
|
112
|
+
groups: [],
|
|
113
|
+
status: FULLY_DEFINED,
|
|
114
|
+
owl: (id) => `EquivalentClasses(:${id} ObjectIntersectionOf(:64033007 ObjectSomeValuesFrom(:272741003 :24028007)))`
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
key: 'pate-hepatitis',
|
|
118
|
+
// shape 2: fully defined, role-grouped, no International equivalent
|
|
119
|
+
fsn: 'Acute toxic hepatitis caused by liver pate (disorder)',
|
|
120
|
+
synonyms: ['Pate-induced hepatitis', 'Revenge of the liver'],
|
|
121
|
+
parents: ['4896000'], // Acute toxic hepatitis
|
|
122
|
+
ungrouped: [],
|
|
123
|
+
groups: [
|
|
124
|
+
[['116676008', '4532008'], // Associated morphology = Acute inflammation
|
|
125
|
+
['246075003', '227014008'], // Causative agent = Liver pate
|
|
126
|
+
['363698007', '10200004']], // Finding site = Liver structure
|
|
127
|
+
[['263502005', '424124008']] // Clinical course = Sudden onset AND/OR short duration
|
|
128
|
+
],
|
|
129
|
+
status: FULLY_DEFINED,
|
|
130
|
+
// Same shape as the International axiom for 4896000, with the causative agent refined
|
|
131
|
+
owl: (id) => `EquivalentClasses(:${id} ObjectIntersectionOf(:75478009 ObjectSomeValuesFrom(:${ROLE_GROUP} ObjectIntersectionOf(ObjectSomeValuesFrom(:116676008 :4532008) ObjectSomeValuesFrom(:246075003 :227014008) ObjectSomeValuesFrom(:363698007 :10200004))) ObjectSomeValuesFrom(:${ROLE_GROUP} ObjectSomeValuesFrom(:263502005 :424124008))))`
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
key: 'us-liver-biopsy',
|
|
135
|
+
// shape 3: fully defined, exactly equivalent to 438300009 |Biopsy of liver using ultrasound guidance|
|
|
136
|
+
fsn: 'Liver biopsy using the ultrasound thingy (procedure)',
|
|
137
|
+
synonyms: ['Ultrasound-guided liver biopsy (local dialect)'],
|
|
138
|
+
parents: ['105377009', '86259008'], // Ultrasonography of liver, Biopsy of liver
|
|
139
|
+
ungrouped: [],
|
|
140
|
+
groups: [
|
|
141
|
+
[['260686004', '129314006'], // Method = Biopsy - action
|
|
142
|
+
['405813007', '10200004']], // Procedure site - Direct = Liver structure
|
|
143
|
+
[['260686004', '278292003'], // Method = Ultrasound imaging - action
|
|
144
|
+
['363703001', '429892002'], // Has intent = Guidance intent
|
|
145
|
+
['405813007', '10200004']] // Procedure site - Direct = Liver structure
|
|
146
|
+
],
|
|
147
|
+
status: FULLY_DEFINED,
|
|
148
|
+
owl: (id) => `EquivalentClasses(:${id} :438300009)`
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
key: 'liverish',
|
|
152
|
+
// shape 4: primitive, IS A only
|
|
153
|
+
fsn: 'Feeling liverish (finding)',
|
|
154
|
+
synonyms: ['Liverish', 'Bit liverish this morning'],
|
|
155
|
+
parents: ['300338006'], // Liver problem
|
|
156
|
+
ungrouped: [],
|
|
157
|
+
groups: [],
|
|
158
|
+
status: PRIMITIVE,
|
|
159
|
+
owl: (id) => `SubClassOf(:${id} :300338006)`
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
key: 'vaguely-unwell',
|
|
163
|
+
// shape 5: primitive, directly under a top-level hierarchy - the only International
|
|
164
|
+
// ancestor carries no information, so there is no useful translation
|
|
165
|
+
fsn: 'Feeling vaguely unwell about the liver (finding)',
|
|
166
|
+
synonyms: ['Vaguely unwell about the liver'],
|
|
167
|
+
parents: ['404684003'], // Clinical finding
|
|
168
|
+
ungrouped: [],
|
|
169
|
+
groups: [],
|
|
170
|
+
status: PRIMITIVE,
|
|
171
|
+
owl: (id) => `SubClassOf(:${id} :404684003)`
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
key: 'dropsy',
|
|
175
|
+
// shape 6: inactive, no relationships, no historical association - nothing at all
|
|
176
|
+
fsn: 'Dropsy of the liver (disorder)',
|
|
177
|
+
synonyms: ['Hepatic dropsy'],
|
|
178
|
+
parents: [], ungrouped: [], groups: [],
|
|
179
|
+
status: PRIMITIVE,
|
|
180
|
+
active: false
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
key: 'hepatic-revenge',
|
|
184
|
+
// shape 7: inactive, REPLACED BY an International concept
|
|
185
|
+
fsn: 'Hepatic revenge syndrome (disorder)',
|
|
186
|
+
synonyms: ['Hepatic revenge'],
|
|
187
|
+
parents: [], ungrouped: [], groups: [],
|
|
188
|
+
status: PRIMITIVE,
|
|
189
|
+
active: false,
|
|
190
|
+
replacedBy: '4896000' // Acute toxic hepatitis
|
|
191
|
+
}
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
// --- Row builders -------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
const conceptRows = [];
|
|
197
|
+
const descriptionRows = [];
|
|
198
|
+
const relationshipRows = [];
|
|
199
|
+
const languageRows = [];
|
|
200
|
+
const owlRows = [];
|
|
201
|
+
const associationRows = [];
|
|
202
|
+
const summary = [];
|
|
203
|
+
|
|
204
|
+
for (const c of concepts) {
|
|
205
|
+
const id = conceptId();
|
|
206
|
+
const active = c.active === false ? '0' : '1';
|
|
207
|
+
conceptRows.push([id, ET, active, MODULE, c.status]);
|
|
208
|
+
|
|
209
|
+
const terms = [[FSN, c.fsn, PREFERRED], ...c.synonyms.map((t, i) => [SYNONYM, t, i === 0 ? PREFERRED : ACCEPTABLE])];
|
|
210
|
+
for (const [typeId, term, acceptability] of terms) {
|
|
211
|
+
const did = descriptionId();
|
|
212
|
+
descriptionRows.push([did, ET, '1', MODULE, id, 'en', typeId, term, CASE_INSENSITIVE]);
|
|
213
|
+
for (const refset of [EN_GB, EN_US]) {
|
|
214
|
+
languageRows.push([memberId('lang', refset, did), ET, '1', MODULE, refset, did, acceptability]);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const parent of c.parents) {
|
|
219
|
+
relationshipRows.push([relationshipId(), ET, '1', MODULE, id, parent, '0', IS_A, INFERRED, EXISTENTIAL]);
|
|
220
|
+
}
|
|
221
|
+
for (const [typeId, dest] of c.ungrouped) {
|
|
222
|
+
relationshipRows.push([relationshipId(), ET, '1', MODULE, id, dest, '0', typeId, INFERRED, EXISTENTIAL]);
|
|
223
|
+
}
|
|
224
|
+
c.groups.forEach((group, gi) => {
|
|
225
|
+
for (const [typeId, dest] of group) {
|
|
226
|
+
relationshipRows.push([relationshipId(), ET, '1', MODULE, id, dest, String(gi + 1), typeId, INFERRED, EXISTENTIAL]);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (c.owl) {
|
|
231
|
+
owlRows.push([memberId('owl', id), ET, '1', MODULE, OWL_AXIOM_REFSET, id, c.owl(id)]);
|
|
232
|
+
}
|
|
233
|
+
if (c.replacedBy) {
|
|
234
|
+
associationRows.push([memberId('assoc', REPLACED_BY, id), ET, '1', MODULE, REPLACED_BY, id, c.replacedBy]);
|
|
235
|
+
}
|
|
236
|
+
summary.push(`${id} ${c.fsn}${c.active === false ? ' (inactive)' : ''}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- Write files --------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
function write(rel, header, rows) {
|
|
242
|
+
const file = path.join(root, rel);
|
|
243
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
244
|
+
fs.writeFileSync(file, [header, ...rows.map(r => r.join('\t'))].join(EOL) + EOL, 'utf8');
|
|
245
|
+
console.log(`${rows.length.toString().padStart(3)} rows ${rel}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
write(`Terminology/sct2_Concept_Snapshot_${TAG}_${ET}.txt`,
|
|
249
|
+
'id\teffectiveTime\tactive\tmoduleId\tdefinitionStatusId', conceptRows);
|
|
250
|
+
write(`Terminology/sct2_Description_Snapshot-en_${TAG}_${ET}.txt`,
|
|
251
|
+
'id\teffectiveTime\tactive\tmoduleId\tconceptId\tlanguageCode\ttypeId\tterm\tcaseSignificanceId', descriptionRows);
|
|
252
|
+
write(`Terminology/sct2_Relationship_Snapshot_${TAG}_${ET}.txt`,
|
|
253
|
+
'id\teffectiveTime\tactive\tmoduleId\tsourceId\tdestinationId\trelationshipGroup\ttypeId\tcharacteristicTypeId\tmodifierId', relationshipRows);
|
|
254
|
+
write(`Terminology/sct2_sRefset_OWLExpressionSnapshot_${TAG}_${ET}.txt`,
|
|
255
|
+
'id\teffectiveTime\tactive\tmoduleId\trefsetId\treferencedComponentId\towlExpression', owlRows);
|
|
256
|
+
// the IG master keeps the content refsets under Refset/Content; FHIRsmith's copy has them in Refset/
|
|
257
|
+
const contentDir = fs.existsSync(path.join(root, 'Refset', 'Content')) ? 'Refset/Content' : 'Refset';
|
|
258
|
+
write(`${contentDir}/der2_cRefset_AssociationSnapshot_${TAG}_${ET}.txt`,
|
|
259
|
+
'id\teffectiveTime\tactive\tmoduleId\trefsetId\treferencedComponentId\ttargetComponentId', associationRows);
|
|
260
|
+
write(`Refset/Language/der2_cRefset_LanguageSnapshot-en_${TAG}_${ET}.txt`,
|
|
261
|
+
'id\teffectiveTime\tactive\tmoduleId\trefsetId\treferencedComponentId\tacceptabilityId', languageRows);
|
|
262
|
+
|
|
263
|
+
console.log('\nConcepts:');
|
|
264
|
+
for (const s of summary) console.log(' ' + s);
|
package/xig/xig.js
CHANGED
|
@@ -3251,6 +3251,37 @@ async function initializeXigModule(stats, xigConfig) {
|
|
|
3251
3251
|
}
|
|
3252
3252
|
|
|
3253
3253
|
// Graceful shutdown
|
|
3254
|
+
/**
|
|
3255
|
+
* The paragraph for the server's daily report: pages served, and whether the
|
|
3256
|
+
* database can be read (it's downloaded afresh each day, and a failed download
|
|
3257
|
+
* or an unreadable file is what this is for).
|
|
3258
|
+
*/
|
|
3259
|
+
async function dailyReport({since, now}) {
|
|
3260
|
+
const {plural} = require('../library/daily-report');
|
|
3261
|
+
const sentences = [];
|
|
3262
|
+
if (globalStats) {
|
|
3263
|
+
const counts = globalStats.countsSince(since, now);
|
|
3264
|
+
sentences.push(`${plural(counts.total, 'page')} ${counts.total === 1 ? 'was' : 'were'} served.`);
|
|
3265
|
+
}
|
|
3266
|
+
if (!xigDb) {
|
|
3267
|
+
sentences.push('The database is not open: it could not be accessed.');
|
|
3268
|
+
} else {
|
|
3269
|
+
try {
|
|
3270
|
+
const row = await new Promise((resolve, reject) =>
|
|
3271
|
+
xigDb.get('SELECT COUNT(*) AS n FROM Resources', [], (err, row) => err ? reject(err) : resolve(row)));
|
|
3272
|
+
sentences.push(`The database holds ${plural(row.n, 'resource')}.`);
|
|
3273
|
+
} catch (e) {
|
|
3274
|
+
sentences.push(`The database could not be accessed: ${e.message}.`);
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
const last = getLastUpdateAttempt();
|
|
3278
|
+
if (last && last.status === 'failed') {
|
|
3279
|
+
const when = last.timestamp instanceof Date ? last.timestamp.toISOString().substring(0, 16).replace('T', ' ') : String(last.timestamp);
|
|
3280
|
+
sentences.push(`The last database update (${when}) failed${last.error ? ': ' + last.error : ''}.`);
|
|
3281
|
+
}
|
|
3282
|
+
return sentences.join(' ');
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3254
3285
|
function shutdown() {
|
|
3255
3286
|
return new Promise((resolve) => {
|
|
3256
3287
|
if (xigDb) {
|
|
@@ -3275,6 +3306,7 @@ module.exports = {
|
|
|
3275
3306
|
updateXigDatabase,
|
|
3276
3307
|
getDatabaseInfo,
|
|
3277
3308
|
shutdown,
|
|
3309
|
+
dailyReport,
|
|
3278
3310
|
initializeXigModule,
|
|
3279
3311
|
|
|
3280
3312
|
// Cache functions
|
package/registry/readme.md
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
# Terminology Server Registry
|
|
2
|
-
|
|
3
|
-
The coordination server of a terminology server ecosystem, as described by the
|
|
4
|
-
[terminology ecosystem IG](https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig/ecosystem.html).
|
|
5
|
-
It regularly scans the servers listed in the ecosystem's master registration file, and tells
|
|
6
|
-
clients which terminology server to use for a code system or value set. It runs at
|
|
7
|
-
http://tx.fhir.org/tx-reg for the HL7 ecosystem.
|
|
8
|
-
|
|
9
|
-
Its main client is the HL7 Java tooling (`TerminologyClientManager` in org.hl7.fhir.r5),
|
|
10
|
-
which calls `/resolve` to route each terminology operation to the right server.
|
|
11
|
-
|
|
12
|
-
## API
|
|
13
|
-
|
|
14
|
-
The public API is described by an OpenAPI 3.1 spec:
|
|
15
|
-
|
|
16
|
-
| URL | |
|
|
17
|
-
|---|---|
|
|
18
|
-
| `/tx-reg/openapi` | Browsable reference (HTML), with a "try it" form for each GET operation |
|
|
19
|
-
| `/tx-reg/openapi.json` | The spec as JSON |
|
|
20
|
-
| `/tx-reg/openapi.yaml` | The spec as YAML (the source file, [openapi.yaml](openapi.yaml)) |
|
|
21
|
-
|
|
22
|
-
Every response carries a `Link: </tx-reg/openapi.json>; rel="service-desc"` header
|
|
23
|
-
(RFC 8631), and the HTML pages carry the matching `<link>` element.
|
|
24
|
-
|
|
25
|
-
| Endpoint | Purpose |
|
|
26
|
-
|---|---|
|
|
27
|
-
| `GET /tx-reg/` | **Discovery**: the server endpoints in the ecosystem. Filters: `registry`, `server`, `fhirVersion`, `url`, `authoritativeOnly`, `language` |
|
|
28
|
-
| `GET /tx-reg/resolve` | **Resolution**: which endpoints to use for a code system (`url`) or value set (`valueSet`) at a `fhirVersion`. Also `authoritativeOnly`, `language`, `usage`, `version` |
|
|
29
|
-
|
|
30
|
-
Both return an HTML page instead of JSON when the request's `Accept` header contains
|
|
31
|
-
`text/html`. `/tx-reg/resolve` without parameters is a form for trying it out.
|
|
32
|
-
`/tx-reg/log` (the crawler log) is operational, and not in the spec.
|
|
33
|
-
|
|
34
|
-
Parameters are read leniently: unknown parameters are ignored, and a repeated parameter
|
|
35
|
-
takes its first value.
|
|
36
|
-
|
|
37
|
-
### How resolution decides
|
|
38
|
-
|
|
39
|
-
* An endpoint is only returned for content it actually hosts, as reported by its
|
|
40
|
-
TerminologyCapabilities (code systems) and ValueSet search (value sets).
|
|
41
|
-
* A SNOMED CT edition (`http://snomed.info/sct|http://snomed.info/sct/{edition}`) is hosted
|
|
42
|
-
by any endpoint that hosts a version of that edition. Otherwise SNOMED CT versions match
|
|
43
|
-
exactly; for other code systems, a server that hosts the code system hosts all its
|
|
44
|
-
versions.
|
|
45
|
-
* Endpoints whose server claims authority (the `authoritative` masks in its registration)
|
|
46
|
-
are listed as `authoritative`; the others as `candidates`.
|
|
47
|
-
* With `language`, servers with a matching language specific claim (`languages` in the
|
|
48
|
-
registration) come first, most specific tag first; candidates are marked
|
|
49
|
-
`language-support: unknown`.
|
|
50
|
-
* A server with a `usage` list is only returned when the request's `usage` is in it.
|
|
51
|
-
* A server's `exclusions` hide the matching content from it entirely.
|
|
52
|
-
* FHIR versions can be given as release codes (`R4`, `R4B`, `R5`, `R6` ...) or numbers
|
|
53
|
-
(`4.0.1`, `4.0`).
|
|
54
|
-
|
|
55
|
-
### Differences from the ecosystem IG
|
|
56
|
-
|
|
57
|
-
* Discovery: without `url`, a row's `authoritative`/`authoritative-valuesets` are the
|
|
58
|
-
server's claim masks, and there are no candidate lists. With `url`, `candidate` is
|
|
59
|
-
`[url]` when the endpoint hosts it without claiming authority.
|
|
60
|
-
* Entries carry the IG's security flags (`open`, `token`...) and also a `security` string
|
|
61
|
-
(`open` or `api-key`). Note that this records how the *registry* reaches the server: an
|
|
62
|
-
endpoint is `api-key` when the registry is configured with a key for it (`apiKeys`), and
|
|
63
|
-
`open` otherwise.
|
|
64
|
-
* Resolve takes a `version` parameter, as an alternative to `url|version`.
|
|
65
|
-
* Resolve omits `authoritative` and `candidates` when they are empty.
|
|
66
|
-
|
|
67
|
-
### Keeping the spec honest
|
|
68
|
-
|
|
69
|
-
[openapi.yaml](openapi.yaml) is maintained by hand. `tests/registry/openapi.test.js` fails
|
|
70
|
-
if a route is added without being described (or listed in the test's `EXCLUDED` table), if
|
|
71
|
-
the spec describes a route the router doesn't have, or if the documented query parameters
|
|
72
|
-
differ from `DISCOVERY_PARAMS` and `RESOLVE_PARAMS` in `registry.js`. So when you change a
|
|
73
|
-
route or a parameter, change `openapi.yaml` with it.
|
|
74
|
-
|
|
75
|
-
## Configuration
|
|
76
|
-
|
|
77
|
-
In the server config, under `modules.registry`:
|
|
78
|
-
|
|
79
|
-
```json
|
|
80
|
-
"registry": {
|
|
81
|
-
"enabled": true,
|
|
82
|
-
"masterUrl": "https://fhir.github.io/ig-registry/tx-servers.json",
|
|
83
|
-
"crawlInterval": 30,
|
|
84
|
-
"timeout": 30000,
|
|
85
|
-
"userAgent": "YourServer/1.0",
|
|
86
|
-
"apiKeys": {}
|
|
87
|
-
}
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
| Setting | |
|
|
91
|
-
|---|---|
|
|
92
|
-
| `masterUrl` | The ecosystem's master registration file. Defaults to the HL7 one. |
|
|
93
|
-
| `crawlInterval` | Minutes between scans. 0 or absent: no scanning. |
|
|
94
|
-
| `timeout` | Per-request timeout for scanning, in milliseconds. |
|
|
95
|
-
| `userAgent` | The User-Agent the scanner sends. |
|
|
96
|
-
| `apiKeys` | API keys for servers that need one, by server code. |
|
|
97
|
-
|
|
98
|
-
## Scanning
|
|
99
|
-
|
|
100
|
-
Each scan reads the master registration file, each registry it lists, and then, for each
|
|
101
|
-
server endpoint:
|
|
102
|
-
|
|
103
|
-
* `/metadata` (the CapabilityStatement, for the software name and version)
|
|
104
|
-
* `/metadata?mode=terminology` (the TerminologyCapabilities, for the code systems and
|
|
105
|
-
versions it hosts)
|
|
106
|
-
* `/ValueSet?_elements=url,version` (the value sets it hosts)
|
|
107
|
-
|
|
108
|
-
An endpoint that fails keeps the content found by its last successful scan, and reports the
|
|
109
|
-
error. The scanner refuses to fetch from private or loopback addresses (SSRF protection).
|
|
110
|
-
|
|
111
|
-
The results are saved to `[data]/registry/registry-data.json` after each scan, and loaded at
|
|
112
|
-
startup, so the registry can answer immediately after a restart.
|
|
113
|
-
|
|
114
|
-
## Code
|
|
115
|
-
|
|
116
|
-
| File | |
|
|
117
|
-
|---|---|
|
|
118
|
-
| `registry.js` | The module: routes, HTML pages, scan scheduling |
|
|
119
|
-
| `api.js` | Discovery and resolution |
|
|
120
|
-
| `crawler.js` | Scanning |
|
|
121
|
-
| `model.js` | The data model, and mask and version matching |
|
|
122
|
-
| `openapi.yaml`, `openapi.js` | The API description |
|
|
123
|
-
|
|
124
|
-
Tests are in `tests/registry`.
|