fhirsmith 0.16.0 → 0.17.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 +65 -0
- package/README.md +40 -19
- package/config-template.json +25 -4
- package/extension-tracker/extension-tracker.js +111 -3
- package/extension-tracker/readme.md +3 -0
- package/library/html-server.js +16 -16
- package/library/html.js +9 -0
- package/library/narrative-check.js +203 -0
- package/library/npm-audit.js +42 -3
- package/package.json +2 -1
- package/packages/openapi.yaml +6 -3
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +33 -69
- package/packages/readme.md +1 -1
- package/packages/utilities/dedupe-versions.js +22 -8
- package/registry/crawler.js +8 -1
- package/registry/registry.js +18 -11
- package/release.sh +80 -0
- package/shl/shl.js +5 -3
- package/testing/readme.md +1 -1
- package/tx/cm/cm-database.js +62 -0
- package/tx/cm/cm-package.js +20 -3
- package/tx/cs/cs-cs.js +86 -18
- package/tx/cs/cs-loinc.js +32 -14
- package/tx/library/resource-ids.js +16 -1
- package/tx/library.js +9 -2
- package/tx/ocl/README.md +62 -6
- package/tx/ocl/cm-ocl.cjs +166 -44
- package/tx/ocl/cs-ocl.cjs +163 -13
- package/tx/ocl/resolve/public-access.js +143 -0
- package/tx/ocl/resolve/reference-resolver.js +458 -0
- package/tx/ocl/vs-ocl.cjs +167 -4
- package/tx/problems.js +7 -5
- package/tx/provider.js +5 -1
- package/tx/tx-html.js +46 -7
- package/tx/tx.js +22 -6
- package/tx/vs/vs-database.js +26 -0
- package/tx/vs/vs-package.js +42 -10
- package/tx/workers/expand.js +1 -1
- package/tx/workers/metadata.js +6 -1
- package/tx/workers/read.js +87 -5
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +88 -12
package/tx/problems.js
CHANGED
|
@@ -36,7 +36,7 @@ class ProblemFinder {
|
|
|
36
36
|
}
|
|
37
37
|
let list = await this.unknownVersionsHtml(sourceUnknownVersions, provider, sp);
|
|
38
38
|
if (list) {
|
|
39
|
-
result = result + `<h4>${sp}</h4>` + list;
|
|
39
|
+
result = result + `<h4>${escape(sp)}</h4>` + list;
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
}
|
|
@@ -51,14 +51,16 @@ class ProblemFinder {
|
|
|
51
51
|
entries.sort((a, b) => a[0].localeCompare(b[0]));
|
|
52
52
|
let html = '<table class="grid"><thead><tr><th>System</th><th>Unknown Versions</th><th>Known Versions</th></tr></thead><tbody>';
|
|
53
53
|
for (const [system, vset] of entries) {
|
|
54
|
-
|
|
54
|
+
// values from the loaded packages: URL-encoded in links, HTML-escaped everywhere
|
|
55
|
+
const elements = '&_elements=url%2Cversion%2Cname%2Ctitle%2Cstatus%2Ccontent%2Cdate';
|
|
55
56
|
const versions = [...vset].sort((a, b) => a.localeCompare(b));
|
|
56
57
|
const versionRefs = [];
|
|
57
58
|
for (const v of versions) {
|
|
58
|
-
|
|
59
|
+
const href = `ValueSet?system=${encodeURIComponent(system + '|' + v)}&source=${encodeURIComponent(source)}${elements}`;
|
|
60
|
+
versionRefs.push(`<a href="${escape(href)}">${escape(v)}</a>`);
|
|
59
61
|
}
|
|
60
|
-
const knownVersions = [...await provider.listCodeSystemVersions(system)].join('<br/>');
|
|
61
|
-
html += `<tr><td><a href="CodeSystem?url=${
|
|
62
|
+
const knownVersions = [...await provider.listCodeSystemVersions(system)].map(v => escape(v)).join('<br/>');
|
|
63
|
+
html += `<tr><td><a href="${escape(`CodeSystem?url=${encodeURIComponent(system)}${elements}`)}">${escape(system)}</a></td>`+
|
|
62
64
|
`<td>${versionRefs.join('<br/>')}</td><td>${knownVersions}</td></tr>`;
|
|
63
65
|
}
|
|
64
66
|
html += '</tbody></table>';
|
package/tx/provider.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { CodeSystem } = require("./library/codesystem");
|
|
2
|
+
const { assertNoActiveContent } = require('../library/narrative-check');
|
|
2
3
|
const {VersionUtilities} = require("../library/version-utilities");
|
|
3
4
|
const { FhirCodeSystemProvider} = require("./cs/cs-cs");
|
|
4
5
|
const {OperationContext} = require("./operation-context");
|
|
@@ -184,7 +185,10 @@ class Provider {
|
|
|
184
185
|
for (const resource of resources) {
|
|
185
186
|
let cs;
|
|
186
187
|
try {
|
|
187
|
-
|
|
188
|
+
const json = await contentLoader.loadFile(resource, contentLoader.fhirVersion());
|
|
189
|
+
// narrative is shown as-is in a page, so it must not carry script (see narrative-check.js)
|
|
190
|
+
assertNoActiveContent(json);
|
|
191
|
+
cs = new CodeSystem(json);
|
|
188
192
|
} catch (e) {
|
|
189
193
|
// one unacceptable resource (e.g. contained resources the server doesn't support)
|
|
190
194
|
// must not stop the rest of the package loading
|
package/tx/tx-html.js
CHANGED
|
@@ -9,6 +9,7 @@ const htmlServer = require('../library/html-server');
|
|
|
9
9
|
const Logger = require('../library/logger');
|
|
10
10
|
const packageJson = require("../package.json");
|
|
11
11
|
const escape = require('escape-html');
|
|
12
|
+
const { checkResourceNarratives } = require('../library/narrative-check');
|
|
12
13
|
const {ExpandWorker} = require("./workers/expand");
|
|
13
14
|
const ValueSet = require("./library/valueset");
|
|
14
15
|
const {CodeSystemXML} = require("./xml/codesystem-xml");
|
|
@@ -1017,7 +1018,7 @@ class TxHtmlRenderer {
|
|
|
1017
1018
|
} else if (_fmt == "html/xml") {
|
|
1018
1019
|
html += await this.renderResourceXml(json);
|
|
1019
1020
|
} else if (_fmt == "html/narrative") {
|
|
1020
|
-
html += await this.
|
|
1021
|
+
html += await this.renderOriginalNarrative(json);
|
|
1021
1022
|
} else if (_fmt == "html/ops") {
|
|
1022
1023
|
html += await this.liquid.renderFile('codesystem-operations', {
|
|
1023
1024
|
opsId: this.generateResourceId(),
|
|
@@ -1040,9 +1041,10 @@ class TxHtmlRenderer {
|
|
|
1040
1041
|
|
|
1041
1042
|
tab(b, name, rtype, type, id) {
|
|
1042
1043
|
if (b) {
|
|
1043
|
-
return `<li class="active"><a href="#">${name}</a></li>`;
|
|
1044
|
+
return `<li class="active"><a href="#">${escape(name)}</a></li>`;
|
|
1044
1045
|
} else {
|
|
1045
|
-
|
|
1046
|
+
const href = `${this.path}/${encodeURIComponent(rtype)}/${encodeURIComponent(id)}?_format=${encodeURIComponent(type)}`;
|
|
1047
|
+
return `<li><a href="${escape(href)}">${escape(name)}</a></li>`;
|
|
1046
1048
|
}
|
|
1047
1049
|
}
|
|
1048
1050
|
/**
|
|
@@ -1078,7 +1080,7 @@ class TxHtmlRenderer {
|
|
|
1078
1080
|
} else if (_fmt == "html/xml") {
|
|
1079
1081
|
html += await this.renderResourceXml(json);
|
|
1080
1082
|
} else if (_fmt == "html/narrative") {
|
|
1081
|
-
html += await this.
|
|
1083
|
+
html += await this.renderOriginalNarrative(json);
|
|
1082
1084
|
} else if (_fmt == "html/ops") {
|
|
1083
1085
|
html += await this.liquid.renderFile('valueset-operations', {
|
|
1084
1086
|
opsId: this.generateResourceId(),
|
|
@@ -1218,7 +1220,7 @@ class TxHtmlRenderer {
|
|
|
1218
1220
|
} else if (_fmt == "html/json") {
|
|
1219
1221
|
html += await this.renderResourceJson(json);
|
|
1220
1222
|
} else if (_fmt == "html/narrative") {
|
|
1221
|
-
html += await this.
|
|
1223
|
+
html += await this.renderOriginalNarrative(json);
|
|
1222
1224
|
} else if (_fmt == "html/ops") {
|
|
1223
1225
|
const sourceSet = new Set();
|
|
1224
1226
|
const codeSet = new Set();
|
|
@@ -1329,6 +1331,30 @@ class TxHtmlRenderer {
|
|
|
1329
1331
|
return html;
|
|
1330
1332
|
}
|
|
1331
1333
|
|
|
1334
|
+
/**
|
|
1335
|
+
* The page for a read of an id that nothing has, but that several resources had in their
|
|
1336
|
+
* source (see ReadWorker.notFound): a list of them, to pick from.
|
|
1337
|
+
*
|
|
1338
|
+
* @param {string} resourceType
|
|
1339
|
+
* @param {string} id - the id that was asked for
|
|
1340
|
+
* @param {Array<{id, url, version, title, source}>} matches
|
|
1341
|
+
* @returns {string} html
|
|
1342
|
+
*/
|
|
1343
|
+
renderIdChoices(resourceType, id, matches) {
|
|
1344
|
+
let html = `<p>There is no ${escape(resourceType)} with the id <code>${escape(id)}</code> on this server. ` +
|
|
1345
|
+
`Every resource here has an id that starts with a prefix for the source it came from, and ` +
|
|
1346
|
+
`${matches.length} ${escape(resourceType)} resources had the id <code>${escape(id)}</code> in their source:</p>`;
|
|
1347
|
+
html += '<table class="grid"><tr><th>Id</th><th>URL</th><th>Version</th><th>Title</th><th>Source</th></tr>';
|
|
1348
|
+
for (const m of matches) {
|
|
1349
|
+
// relative to /{type}/{id}, like the redirect when there is only one
|
|
1350
|
+
html += `<tr><td><a href="${escape(encodeURIComponent(m.id))}">${escape(m.id)}</a></td>` +
|
|
1351
|
+
`<td>${escape(m.url || '')}</td><td>${escape(m.version || '')}</td>` +
|
|
1352
|
+
`<td>${escape(m.title || '')}</td><td>${escape(m.source || '')}</td></tr>`;
|
|
1353
|
+
}
|
|
1354
|
+
html += '</table>';
|
|
1355
|
+
return html;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1332
1358
|
/**
|
|
1333
1359
|
* Render Bundle resource
|
|
1334
1360
|
*/
|
|
@@ -1701,7 +1727,7 @@ class TxHtmlRenderer {
|
|
|
1701
1727
|
html += '<div class="card-header">Bundle</div>';
|
|
1702
1728
|
html += '<div class="card-body">';
|
|
1703
1729
|
html += `<p><strong>Type:</strong> ${escape(json.type)}</p>`;
|
|
1704
|
-
html += `<p><strong>Total:</strong> ${json.total
|
|
1730
|
+
html += `<p><strong>Total:</strong> ${escape(String(json.total ?? 'N/A'))}</p>`;
|
|
1705
1731
|
html += '</div>';
|
|
1706
1732
|
html += '</div>';
|
|
1707
1733
|
|
|
@@ -1802,6 +1828,18 @@ class TxHtmlRenderer {
|
|
|
1802
1828
|
return html;
|
|
1803
1829
|
}
|
|
1804
1830
|
|
|
1831
|
+
/**
|
|
1832
|
+
* The resource's own narrative, as it is - unless it carries active content, which is
|
|
1833
|
+
* refused at load time but may reach here from a source that isn't loaded that way
|
|
1834
|
+
*/
|
|
1835
|
+
async renderOriginalNarrative(json) {
|
|
1836
|
+
const found = checkResourceNarratives({ resourceType: json.resourceType, id: json.id, text: json.text });
|
|
1837
|
+
if (found) {
|
|
1838
|
+
return `<div class="alert alert-danger">This narrative is not shown: ${escape(found)}</div>`;
|
|
1839
|
+
}
|
|
1840
|
+
return this.renderResourceWithNarrative(json, json.text?.div);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1805
1843
|
async renderResourceJson(json) {
|
|
1806
1844
|
let html = "";
|
|
1807
1845
|
html += `<div class="json-content" style="margin-top: 10px;">`;
|
|
@@ -1840,7 +1878,8 @@ class TxHtmlRenderer {
|
|
|
1840
1878
|
return await this.liquid.renderFile('operations-form', {
|
|
1841
1879
|
vcSystemId,
|
|
1842
1880
|
inferSystemId,
|
|
1843
|
-
|
|
1881
|
+
// inside a <script>: no '<', so no value can close it
|
|
1882
|
+
valueSetsJson: JSON.stringify(json.valueSets || []).replace(/</g, '\\u003c')
|
|
1844
1883
|
});
|
|
1845
1884
|
}
|
|
1846
1885
|
|
package/tx/tx.js
CHANGED
|
@@ -211,7 +211,7 @@ class TXModule {
|
|
|
211
211
|
}
|
|
212
212
|
};
|
|
213
213
|
void prune(); // errors are logged inside
|
|
214
|
-
this.timers.push(setInterval(prune, 24 * 60 * 60 * 1000));
|
|
214
|
+
this.timers.push(setInterval(() => void prune(), 24 * 60 * 60 * 1000));
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
|
|
@@ -305,13 +305,14 @@ class TXModule {
|
|
|
305
305
|
if (this.stats) {
|
|
306
306
|
this.stats.addTask("Client Cache", "5 min");
|
|
307
307
|
}
|
|
308
|
-
|
|
308
|
+
const updateCodeSystemList = async () => {
|
|
309
309
|
try {
|
|
310
310
|
await endpointInfo.provider.updateCodeSystemList();
|
|
311
311
|
} catch (error) {
|
|
312
312
|
this.log.error(`Error updating CodeSystem list for ${endpointPath}: ${error.message}`);
|
|
313
313
|
}
|
|
314
|
-
}
|
|
314
|
+
};
|
|
315
|
+
this.timers.push(setInterval(() => void updateCodeSystemList(), 60 * 1000)); // errors are logged inside
|
|
315
316
|
this.log.info(`CodeSystem list update scheduled for ${endpointPath}`);
|
|
316
317
|
this.timers.push(setInterval(() => {
|
|
317
318
|
endpointInfo.resourceCache.prune(cacheTimeoutMs);
|
|
@@ -516,8 +517,12 @@ class TXModule {
|
|
|
516
517
|
return res.status(400).json(buildOperationOutcome('error', 'invalid', `Invalid XML: ${e.message}`));
|
|
517
518
|
}
|
|
518
519
|
}
|
|
519
|
-
} else if (contentType != 'application/x-www-form-urlencoded') {
|
|
520
|
-
|
|
520
|
+
} else if (contentType.split(';')[0].trim().toLowerCase() != 'application/x-www-form-urlencoded') {
|
|
521
|
+
// compare the media type alone: a form post may carry a charset parameter
|
|
522
|
+
const message = contentType
|
|
523
|
+
? `Unsupported Media Type: ${contentType}`
|
|
524
|
+
: `No Content-Type was given for this ${req.method}; use application/fhir+json or application/fhir+xml`;
|
|
525
|
+
return res.status(415).json(buildOperationOutcome('error', 'invalid', message));
|
|
521
526
|
}
|
|
522
527
|
|
|
523
528
|
if (req.body) {
|
|
@@ -994,7 +999,7 @@ class TXModule {
|
|
|
994
999
|
));
|
|
995
1000
|
}
|
|
996
1001
|
let worker = new ReadWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
|
|
997
|
-
await worker.handle(req, res, resourceType);
|
|
1002
|
+
await worker.handle(req, res, resourceType, (req, res, resourceType, id, matches) => this.renderIdChoices(req, res, resourceType, id, matches));
|
|
998
1003
|
} finally {
|
|
999
1004
|
this.countRequest(endpointPath, 'read', Date.now() - start);
|
|
1000
1005
|
}
|
|
@@ -1432,6 +1437,17 @@ class TXModule {
|
|
|
1432
1437
|
return count;
|
|
1433
1438
|
}
|
|
1434
1439
|
|
|
1440
|
+
// A read of an unprefixed id that several resources had in their source: the page
|
|
1441
|
+
// that lists them (status 300, Multiple Choices). See ReadWorker.notFound.
|
|
1442
|
+
async renderIdChoices(req, res, resourceType, id, matches) {
|
|
1443
|
+
const txhtml = new TxHtmlRenderer(new Renderer(req.txOpContext, req.txEndpoint.provider), this.liquid, this.languages, this.i18n, req.txEndpoint.path);
|
|
1444
|
+
const content = txhtml.renderIdChoices(resourceType, id, matches);
|
|
1445
|
+
const html = await txhtml.renderPage(`${resourceType}/${id}`, content, req.txEndpoint, req.txStartTime);
|
|
1446
|
+
res.status(300);
|
|
1447
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1448
|
+
return res.send(html);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1435
1451
|
// $closure usage for the home page: null when closure isn't turned on
|
|
1436
1452
|
closureStats() {
|
|
1437
1453
|
if (!this.closureStore) {
|
package/tx/vs/vs-database.js
CHANGED
|
@@ -4,6 +4,9 @@ const sqlite3 = require('sqlite3').verbose();
|
|
|
4
4
|
const { VersionUtilities } = require('../../library/version-utilities');
|
|
5
5
|
const ValueSet = require("../library/valueset");
|
|
6
6
|
|
|
7
|
+
// Settings key written once a package cache has been fully populated (see isComplete)
|
|
8
|
+
const CACHE_COMPLETE_KEY = 'cache-complete';
|
|
9
|
+
|
|
7
10
|
// Columns that can be returned directly without parsing JSON
|
|
8
11
|
const INDEXED_COLUMNS = ['id', 'url', 'version', 'date', 'description', 'name', 'publisher', 'status', 'title'];
|
|
9
12
|
|
|
@@ -485,6 +488,29 @@ class ValueSetDatabase {
|
|
|
485
488
|
});
|
|
486
489
|
}
|
|
487
490
|
|
|
491
|
+
/**
|
|
492
|
+
* True if this cache was fully populated: markComplete() was reached. A cache
|
|
493
|
+
* whose population was cut short (the process killed part way through) has no
|
|
494
|
+
* marker - and so does a file too damaged to open or read - so it reports false.
|
|
495
|
+
* @returns {Promise<boolean>}
|
|
496
|
+
*/
|
|
497
|
+
async isComplete() {
|
|
498
|
+
try {
|
|
499
|
+
return (await this.getSetting(CACHE_COMPLETE_KEY)) != null;
|
|
500
|
+
} catch (e) {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Record that this cache has been fully populated. Called once, after the last
|
|
507
|
+
* value set has been written
|
|
508
|
+
* @returns {Promise<void>}
|
|
509
|
+
*/
|
|
510
|
+
async markComplete() {
|
|
511
|
+
await this.setSetting(CACHE_COMPLETE_KEY, new Date().toISOString());
|
|
512
|
+
}
|
|
513
|
+
|
|
488
514
|
/**
|
|
489
515
|
* Insert or update a single ValueSet in the database
|
|
490
516
|
* @param {Object} valueSet - The ValueSet resource
|
package/tx/vs/vs-package.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
|
+
const { assertNoActiveContent } = require('../../library/narrative-check');
|
|
2
3
|
const fs = require('fs').promises;
|
|
3
4
|
const { AbstractValueSetProvider } = require('./vs-api');
|
|
4
5
|
const { PackageContentLoader } = require('../../library/package-manager');
|
|
@@ -21,7 +22,9 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
|
|
|
21
22
|
super();
|
|
22
23
|
validateParameter(packageLoader, "packageLoader", PackageContentLoader);
|
|
23
24
|
this.packageLoader = packageLoader;
|
|
24
|
-
|
|
25
|
+
// -2: caches from before value sets were checked for active content in their narrative
|
|
26
|
+
// are rebuilt (see initialize)
|
|
27
|
+
this.dbPath = path.join(packageLoader.packageFolder, '.valuesets-2.db');
|
|
25
28
|
this.database = new ValueSetDatabase(this.dbPath);
|
|
26
29
|
this.valueSetMap = new Map();
|
|
27
30
|
this.initialized = false;
|
|
@@ -45,12 +48,21 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
|
|
|
45
48
|
return;
|
|
46
49
|
}
|
|
47
50
|
await this.packageLoader.initialize();
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
await fs.rm(path.join(this.packageLoader.packageFolder, '.valuesets.db'), { force: true }); // the old cache
|
|
52
|
+
|
|
53
|
+
// A cache is only used once its population finished. One that was cut short
|
|
54
|
+
// (the process killed part way through) would otherwise be trusted forever,
|
|
55
|
+
// silently missing whatever hadn't been written yet
|
|
56
|
+
let dbExists = await this.database.exists();
|
|
57
|
+
if (dbExists && !(await this.database.isComplete())) {
|
|
58
|
+
require('../../library/logger').getInstance().child({ module: 'tx' })
|
|
59
|
+
.warn(`Package ${this.packageLoader.pid ? this.packageLoader.pid() : ''}: value set cache ${this.dbPath} is incomplete - rebuilding it`);
|
|
60
|
+
await this.#discardDatabase();
|
|
61
|
+
dbExists = false;
|
|
62
|
+
}
|
|
50
63
|
|
|
51
64
|
if (!dbExists) {
|
|
52
|
-
await this
|
|
53
|
-
await this._populateDatabase();
|
|
65
|
+
await this.#buildDatabase();
|
|
54
66
|
}
|
|
55
67
|
|
|
56
68
|
this.valueSetMap = await this.database.loadAllValueSets(this.sourcePackage());
|
|
@@ -60,11 +72,8 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
|
|
|
60
72
|
// package-manager.js): if any cached value set still carries a "system|version"
|
|
61
73
|
// include or a pipe in its url, rebuild this cache from the package.
|
|
62
74
|
if (dbExists && this.#hasPipeVersionedCanonicals()) {
|
|
63
|
-
await this
|
|
64
|
-
await
|
|
65
|
-
this.database = new ValueSetDatabase(this.dbPath);
|
|
66
|
-
await this.database.create();
|
|
67
|
-
await this._populateDatabase();
|
|
75
|
+
await this.#discardDatabase();
|
|
76
|
+
await this.#buildDatabase();
|
|
68
77
|
this.valueSetMap = await this.database.loadAllValueSets(this.sourcePackage());
|
|
69
78
|
}
|
|
70
79
|
|
|
@@ -84,6 +93,27 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
|
|
|
84
93
|
this.initialized = true;
|
|
85
94
|
}
|
|
86
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Create the cache and fill it from the package. The complete marker is written
|
|
98
|
+
* last, so a build that doesn't finish is detected (and redone) next time
|
|
99
|
+
* @private
|
|
100
|
+
*/
|
|
101
|
+
async #buildDatabase() {
|
|
102
|
+
await this.database.create();
|
|
103
|
+
await this._populateDatabase();
|
|
104
|
+
await this.database.markComplete();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Close and delete the cache file, leaving a fresh (not yet created) database
|
|
109
|
+
* @private
|
|
110
|
+
*/
|
|
111
|
+
async #discardDatabase() {
|
|
112
|
+
await this.database.close();
|
|
113
|
+
await fs.rm(this.dbPath, { force: true });
|
|
114
|
+
this.database = new ValueSetDatabase(this.dbPath);
|
|
115
|
+
}
|
|
116
|
+
|
|
87
117
|
/**
|
|
88
118
|
* True if any loaded value set still has a pipe-versioned canonical (url or
|
|
89
119
|
* compose include/exclude system) - the signature of a cache written before
|
|
@@ -134,6 +164,8 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
|
|
|
134
164
|
// don't store a value set the server can't use (e.g. one that contains a
|
|
135
165
|
// CodeSystem): it would be listed by search, and fail when read
|
|
136
166
|
checkContained(valueSet);
|
|
167
|
+
// its narrative is shown as-is in a page, so it must not carry script
|
|
168
|
+
assertNoActiveContent(valueSet);
|
|
137
169
|
} catch (e) {
|
|
138
170
|
require('../../library/logger').getInstance().child({ module: 'tx' })
|
|
139
171
|
.warn(`Package ${this.packageLoader.pid ? this.packageLoader.pid() : ''}: skipping ValueSet ${valueSet.url}: ${e.message}`);
|
package/tx/workers/expand.js
CHANGED
|
@@ -836,7 +836,7 @@ class ValueSetExpander {
|
|
|
836
836
|
notClosed.value = true;
|
|
837
837
|
}
|
|
838
838
|
const prep = await cs.getPrepContext(true);
|
|
839
|
-
const ctxt = await cs.searchFilter(prep, filter,
|
|
839
|
+
const ctxt = await cs.searchFilter(prep, filter, true);
|
|
840
840
|
let set = await cs.executeFilters(prep);
|
|
841
841
|
if (this.count === 0 && valueSets.length === 0) {
|
|
842
842
|
// count=0 (count only): report the filtered size directly without
|
package/tx/workers/metadata.js
CHANGED
|
@@ -472,7 +472,12 @@ class MetadataHandler {
|
|
|
472
472
|
{ name: 'useSupplement' },
|
|
473
473
|
{ name: 'valueSet' },
|
|
474
474
|
{ name: 'valueSetVersion' }
|
|
475
|
-
]
|
|
475
|
+
],
|
|
476
|
+
textFilter: 'On code systems loaded from files and packages, a text filter ignores case, and accents on Latin, Greek and Cyrillic letters. ' +
|
|
477
|
+
'It matches concepts whose code, display, definition or a designation contains the text. ' +
|
|
478
|
+
'A filter of several words also matches a display or designation in which each word starts a word, ' +
|
|
479
|
+
'in any order; for this, punctuation and spaces only separate words. The closest matches come first. ' +
|
|
480
|
+
'Other code systems, such as SNOMED CT, LOINC and RxNorm, match text in their own way.'
|
|
476
481
|
};
|
|
477
482
|
}
|
|
478
483
|
|
package/tx/workers/read.js
CHANGED
|
@@ -8,6 +8,9 @@ const { TerminologyWorker } = require('./worker');
|
|
|
8
8
|
const {debugLog} = require("../operation-context");
|
|
9
9
|
|
|
10
10
|
const { buildOperationOutcome } = require('../library/operation-outcome');
|
|
11
|
+
const { originalId } = require('../library/resource-ids');
|
|
12
|
+
const { acceptsHtml } = require('../tx-html');
|
|
13
|
+
|
|
11
14
|
class ReadWorker extends TerminologyWorker {
|
|
12
15
|
/**
|
|
13
16
|
* @param {OperationContext} opContext - Operation context
|
|
@@ -32,10 +35,12 @@ class ReadWorker extends TerminologyWorker {
|
|
|
32
35
|
* @param {express.Request} req - Express request (with txProvider attached)
|
|
33
36
|
* @param {express.Response} res - Express response
|
|
34
37
|
* @param {string} resourceType - The resource type (CodeSystem, ValueSet, ConceptMap)
|
|
35
|
-
* @param {
|
|
38
|
+
* @param {Function} [renderChoices] - (req, res, resourceType, id, matches) renders the
|
|
39
|
+
* page that lists the resources an unprefixed id could mean (see notFound)
|
|
36
40
|
*/
|
|
37
|
-
async handle(req, res, resourceType) {
|
|
41
|
+
async handle(req, res, resourceType, renderChoices = null) {
|
|
38
42
|
const { id } = req.params;
|
|
43
|
+
this.renderChoices = renderChoices;
|
|
39
44
|
|
|
40
45
|
this.log.debug(`Read ${resourceType}/${id}`);
|
|
41
46
|
|
|
@@ -114,7 +119,7 @@ class ReadWorker extends TerminologyWorker {
|
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
121
|
|
|
117
|
-
return
|
|
122
|
+
return await this.notFound(req, res, 'CodeSystem', id);
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
/**
|
|
@@ -131,7 +136,7 @@ class ReadWorker extends TerminologyWorker {
|
|
|
131
136
|
}
|
|
132
137
|
}
|
|
133
138
|
|
|
134
|
-
return
|
|
139
|
+
return await this.notFound(req, res, 'ValueSet', id);
|
|
135
140
|
}
|
|
136
141
|
/**
|
|
137
142
|
* Handle ConceptMap read
|
|
@@ -147,7 +152,84 @@ class ReadWorker extends TerminologyWorker {
|
|
|
147
152
|
}
|
|
148
153
|
}
|
|
149
154
|
|
|
150
|
-
return
|
|
155
|
+
return await this.notFound(req, res, 'ConceptMap', id);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Nothing has the id asked for. Published IGs link to resources by the id they had in
|
|
160
|
+
* their source (ValueSet/2.16.840.1.113762.1.4.1), from before every id was put in its
|
|
161
|
+
* provider's id space (ValueSet/vsac-2.16.840.1.113762.1.4.1). So for a browser, look
|
|
162
|
+
* for resources that had that id in their source: if there's one, redirect to it, and
|
|
163
|
+
* if there are several, list them. Anything other than a browser still gets a 404 -
|
|
164
|
+
* an unprefixed id can't say which resource it means (see library/resource-ids.js).
|
|
165
|
+
*/
|
|
166
|
+
async notFound(req, res, resourceType, id) {
|
|
167
|
+
if (this.renderChoices && acceptsHtml(req)) {
|
|
168
|
+
const matches = await this.findByOriginalId(resourceType, id);
|
|
169
|
+
if (matches.length === 1) {
|
|
170
|
+
req.logInfo = `redirected to ${matches[0].id}`;
|
|
171
|
+
// relative, so that it works whatever path the server is behind; keeps the query
|
|
172
|
+
const q = req.originalUrl.indexOf('?');
|
|
173
|
+
return res.redirect(302, encodeURIComponent(matches[0].id) + (q >= 0 ? req.originalUrl.substring(q) : ''));
|
|
174
|
+
}
|
|
175
|
+
if (matches.length > 1) {
|
|
176
|
+
req.logInfo = `${matches.length} matches`;
|
|
177
|
+
return await this.renderChoices(req, res, resourceType, id, matches);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return res.status(404).json(buildOperationOutcome('error', 'not-found', `${resourceType}/${id} not found`));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The resources of a type that had this id in their source, across all the providers.
|
|
185
|
+
*
|
|
186
|
+
* @returns {Promise<Array<{id, url, version, title, source}>>} in provider order
|
|
187
|
+
*/
|
|
188
|
+
async findByOriginalId(resourceType, id) {
|
|
189
|
+
const found = [];
|
|
190
|
+
const add = (resource) => {
|
|
191
|
+
const json = resource.jsonObj || resource;
|
|
192
|
+
if (json.id && !found.some(m => m.id === json.id)) {
|
|
193
|
+
found.push({
|
|
194
|
+
id: json.id,
|
|
195
|
+
url: json.url || null,
|
|
196
|
+
version: json.version || null,
|
|
197
|
+
title: json.title || json.name || null,
|
|
198
|
+
source: typeof resource.sourcePackage === 'string' ? resource.sourcePackage : null
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
switch (resourceType) {
|
|
204
|
+
case 'CodeSystem':
|
|
205
|
+
// the map holds a code system under more than one key (url, url|version)
|
|
206
|
+
for (const cs of new Set(this.provider.codeSystems.values())) {
|
|
207
|
+
this.deadCheck('findByOriginalId-cs');
|
|
208
|
+
if (originalId(cs.jsonObj.id) === id) {
|
|
209
|
+
add(cs);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
case 'ValueSet':
|
|
214
|
+
for (const vsp of this.provider.valueSetProviders) {
|
|
215
|
+
this.deadCheck('findByOriginalId-vs');
|
|
216
|
+
const vs = vsp.spaceId ? await vsp.fetchValueSetById(`${vsp.spaceId}-${id}`) : null;
|
|
217
|
+
if (vs) {
|
|
218
|
+
add(vs);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
case 'ConceptMap':
|
|
223
|
+
for (const cmp of this.provider.conceptMapProviders) {
|
|
224
|
+
this.deadCheck('findByOriginalId-cm');
|
|
225
|
+
const cm = cmp.spaceId ? await cmp.fetchConceptMapById(`${cmp.spaceId}-${id}`) : null;
|
|
226
|
+
if (cm) {
|
|
227
|
+
add(cm);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
return found;
|
|
151
233
|
}
|
|
152
234
|
}
|
|
153
235
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* node utilities/submit-test-report.js [-server url] [-file path] [-token token] [-header name]
|
|
13
13
|
*
|
|
14
|
-
* -server the /testing base; default https://
|
|
14
|
+
* -server the /testing base; default https://npm.fhir.org/testing
|
|
15
15
|
* -file default test-cases-report.json in the FHIRsmith root
|
|
16
16
|
* -token if the server requires one; or set FHIRSMITH_TESTING_TOKEN
|
|
17
17
|
* -header the header the token goes in; default Authorization (sent as "Bearer <token>")
|