fhirsmith 0.16.1 → 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 +47 -0
- package/README.md +26 -18
- 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/package.json +2 -1
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +4 -62
- 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/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.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 +22 -7
- package/tx/tx.js +4 -3
- 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/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +88 -12
- package/gr.sh +0 -3
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();
|
|
@@ -1725,7 +1727,7 @@ class TxHtmlRenderer {
|
|
|
1725
1727
|
html += '<div class="card-header">Bundle</div>';
|
|
1726
1728
|
html += '<div class="card-body">';
|
|
1727
1729
|
html += `<p><strong>Type:</strong> ${escape(json.type)}</p>`;
|
|
1728
|
-
html += `<p><strong>Total:</strong> ${json.total
|
|
1730
|
+
html += `<p><strong>Total:</strong> ${escape(String(json.total ?? 'N/A'))}</p>`;
|
|
1729
1731
|
html += '</div>';
|
|
1730
1732
|
html += '</div>';
|
|
1731
1733
|
|
|
@@ -1826,6 +1828,18 @@ class TxHtmlRenderer {
|
|
|
1826
1828
|
return html;
|
|
1827
1829
|
}
|
|
1828
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
|
+
|
|
1829
1843
|
async renderResourceJson(json) {
|
|
1830
1844
|
let html = "";
|
|
1831
1845
|
html += `<div class="json-content" style="margin-top: 10px;">`;
|
|
@@ -1864,7 +1878,8 @@ class TxHtmlRenderer {
|
|
|
1864
1878
|
return await this.liquid.renderFile('operations-form', {
|
|
1865
1879
|
vcSystemId,
|
|
1866
1880
|
inferSystemId,
|
|
1867
|
-
|
|
1881
|
+
// inside a <script>: no '<', so no value can close it
|
|
1882
|
+
valueSetsJson: JSON.stringify(json.valueSets || []).replace(/</g, '\\u003c')
|
|
1868
1883
|
});
|
|
1869
1884
|
}
|
|
1870
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);
|
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
|
|
|
@@ -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>")
|
package/xig/xig.js
CHANGED
|
@@ -19,12 +19,14 @@ const escape = require('escape-html');
|
|
|
19
19
|
|
|
20
20
|
const Logger = require('../library/logger');
|
|
21
21
|
const {describeCron} = require("../library/cron-utilities");
|
|
22
|
+
const {tokenMatches, tokenConfigured} = require('../library/request-token');
|
|
22
23
|
const xigLog = Logger.getInstance().child({ module: 'xig' });
|
|
23
24
|
|
|
24
25
|
const router = express.Router();
|
|
25
26
|
|
|
26
27
|
// Configuration
|
|
27
|
-
|
|
28
|
+
// https: the database is trusted content (its HTML is shown as is), so it must come from fhir.org
|
|
29
|
+
const XIG_DB_URL = 'https://fhir.org/guides/stats/xig.db';
|
|
28
30
|
const XIG_DB_PATH = folders.filePath('xig', 'xig.db');
|
|
29
31
|
const TEMPLATE_PATH = path.join(__dirname, 'xig-template.html');
|
|
30
32
|
|
|
@@ -1718,6 +1720,11 @@ function downloadFile(url, destination, maxRedirects = 5) {
|
|
|
1718
1720
|
}
|
|
1719
1721
|
}
|
|
1720
1722
|
|
|
1723
|
+
// never be redirected off https
|
|
1724
|
+
if (new URL(redirectUrl).protocol !== 'https:') {
|
|
1725
|
+
reject(Object.assign(new Error(`Refused a redirect to ${redirectUrl}: the database is only downloaded over https`), { downloadMeta }));
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1721
1728
|
attemptDownload(redirectUrl, redirectCount + 1);
|
|
1722
1729
|
return;
|
|
1723
1730
|
}
|
|
@@ -2203,6 +2210,23 @@ function getDatabaseAgeInfo() {
|
|
|
2203
2210
|
};
|
|
2204
2211
|
}
|
|
2205
2212
|
|
|
2213
|
+
// The form on /xig/stats that starts an update (POST /xig/update), when an updateToken is configured
|
|
2214
|
+
function buildUpdateForm() {
|
|
2215
|
+
if (!tokenConfigured(xigSettings.updateToken)) {
|
|
2216
|
+
return '<p class="text-muted">An update can be started from here when an updateToken is set in the xig configuration.</p>';
|
|
2217
|
+
}
|
|
2218
|
+
let html = '<h4>Update the database now</h4>';
|
|
2219
|
+
html += '<form method="POST" action="/xig/update" class="mb-4">';
|
|
2220
|
+
html += '<p>Downloads the current database from fhir.org (about 3 GB) and switches to it. ';
|
|
2221
|
+
html += updateInProgress ? '<strong>An update is running now.</strong>' : 'It takes several minutes.';
|
|
2222
|
+
html += '</p>';
|
|
2223
|
+
html += '<label for="xig-update-token">Update token</label> ';
|
|
2224
|
+
html += '<input type="password" id="xig-update-token" name="token" required autocomplete="off" maxlength="200"> ';
|
|
2225
|
+
html += '<button type="submit" class="btn btn-primary">Start the update</button>';
|
|
2226
|
+
html += '</form>';
|
|
2227
|
+
return html;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2206
2230
|
function buildStatsTable(statsData) {
|
|
2207
2231
|
let html = '<table class="table table-striped table-bordered">';
|
|
2208
2232
|
html += '<thead class="table-dark">';
|
|
@@ -2429,16 +2453,25 @@ router.get('/', async (req, res) => {
|
|
|
2429
2453
|
const title = 'FHIR Resources';
|
|
2430
2454
|
|
|
2431
2455
|
// Parse query parameters
|
|
2456
|
+
// Each a string: a repeated parameter (?text=a&text=b) arrives as an array, which
|
|
2457
|
+
// the filters and SQL builders would fail on. Capped in length
|
|
2458
|
+
const qp = (name, dflt = '') => {
|
|
2459
|
+
let v = req.query[name];
|
|
2460
|
+
if (Array.isArray(v)) {
|
|
2461
|
+
v = v[0];
|
|
2462
|
+
}
|
|
2463
|
+
return typeof v === 'string' && v !== '' ? v.substring(0, 200) : dflt;
|
|
2464
|
+
};
|
|
2432
2465
|
const queryParams = {
|
|
2433
|
-
ver:
|
|
2434
|
-
auth:
|
|
2435
|
-
realm:
|
|
2436
|
-
type:
|
|
2437
|
-
rt:
|
|
2438
|
-
text:
|
|
2439
|
-
pkg:
|
|
2440
|
-
onlyUsed:
|
|
2441
|
-
offset:
|
|
2466
|
+
ver: qp('ver'),
|
|
2467
|
+
auth: qp('auth'),
|
|
2468
|
+
realm: qp('realm'),
|
|
2469
|
+
type: qp('type'),
|
|
2470
|
+
rt: qp('rt'),
|
|
2471
|
+
text: qp('text'),
|
|
2472
|
+
pkg: qp('pkg'),
|
|
2473
|
+
onlyUsed: qp('onlyUsed'),
|
|
2474
|
+
offset: qp('offset', '0')
|
|
2442
2475
|
};
|
|
2443
2476
|
|
|
2444
2477
|
// Parse offset for pagination
|
|
@@ -2600,7 +2633,7 @@ router.get('/stats', async (req, res) => {
|
|
|
2600
2633
|
introContent += `</div>`;
|
|
2601
2634
|
}
|
|
2602
2635
|
|
|
2603
|
-
const fullContent = introContent + content;
|
|
2636
|
+
const fullContent = introContent + content + buildUpdateForm();
|
|
2604
2637
|
|
|
2605
2638
|
const stats = await gatherPageStatistics();
|
|
2606
2639
|
stats.processingTime = Date.now() - startTime;
|
|
@@ -3125,8 +3158,44 @@ router.get('/cache', async (req, res) => {
|
|
|
3125
3158
|
}
|
|
3126
3159
|
});
|
|
3127
3160
|
|
|
3128
|
-
|
|
3161
|
+
// POST /xig/update re-downloads the database (about 3 GB), so it needs the updateToken from
|
|
3162
|
+
// the xig config: in the x-update-token header, or (from the form on /xig/stats) in a
|
|
3163
|
+
// "token" form field. With no updateToken it is disabled.
|
|
3164
|
+
//
|
|
3165
|
+
// The form gets a page back straight away, while the update runs on (it takes minutes);
|
|
3166
|
+
// /xig/stats shows how it went. Other callers wait for the update and get JSON.
|
|
3167
|
+
router.post('/update', express.urlencoded({ extended: false, limit: '2kb' }), async (req, res) => {
|
|
3168
|
+
const fromForm = !!req.is('application/x-www-form-urlencoded');
|
|
3169
|
+
const page = (status, alert, message) => {
|
|
3170
|
+
const content = `<div class="alert alert-${alert}">${escape(message)}</div><p><a href="/xig/stats">Back to the status page</a></p>`;
|
|
3171
|
+
res.status(status).setHeader('Content-Type', 'text/html');
|
|
3172
|
+
res.send(renderPage('Update the XIG database', content));
|
|
3173
|
+
};
|
|
3174
|
+
const refuse = (message) => fromForm ? page(403, 'danger', message) : res.status(403).json({ status: 'ERROR', error: 'forbidden: ' + message });
|
|
3175
|
+
|
|
3129
3176
|
try {
|
|
3177
|
+
if (!tokenConfigured(xigSettings.updateToken)) {
|
|
3178
|
+
refuse('no updateToken is configured, so this endpoint is disabled');
|
|
3179
|
+
return;
|
|
3180
|
+
}
|
|
3181
|
+
const supplied = req.headers['x-update-token'] ?? req.body?.token;
|
|
3182
|
+
if (!tokenMatches(xigSettings.updateToken, supplied)) {
|
|
3183
|
+
xigLog.warn('Rejected /xig/update: missing or invalid token (from ' + req.ip + ')');
|
|
3184
|
+
refuse('missing or invalid x-update-token');
|
|
3185
|
+
return;
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
if (fromForm) {
|
|
3189
|
+
if (updateInProgress) {
|
|
3190
|
+
page(200, 'info', 'An update is already running. The status page shows how it is going.');
|
|
3191
|
+
return;
|
|
3192
|
+
}
|
|
3193
|
+
xigLog.info('Manual update triggered from the status page');
|
|
3194
|
+
updateXigDatabase().catch((err) => xigLog.error('XIG database update failed: ' + err.message));
|
|
3195
|
+
page(202, 'success', 'The update has started. It takes several minutes; the status page shows how it is going.');
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3130
3199
|
xigLog.info('Manual update triggered via API');
|
|
3131
3200
|
await updateXigDatabase();
|
|
3132
3201
|
res.json({
|
|
@@ -3143,10 +3212,16 @@ router.post('/update', async (req, res) => {
|
|
|
3143
3212
|
});
|
|
3144
3213
|
|
|
3145
3214
|
let globalStats;
|
|
3215
|
+
let xigSettings = {};
|
|
3216
|
+
// The xig section of the config (initializeXigModule; tests set it directly)
|
|
3217
|
+
function setXigSettings(xigConfig) {
|
|
3218
|
+
xigSettings = xigConfig || {};
|
|
3219
|
+
}
|
|
3146
3220
|
// Initialize the XIG module
|
|
3147
3221
|
async function initializeXigModule(stats, xigConfig) {
|
|
3148
3222
|
try {
|
|
3149
3223
|
globalStats = stats;
|
|
3224
|
+
setXigSettings(xigConfig);
|
|
3150
3225
|
loadTemplate();
|
|
3151
3226
|
|
|
3152
3227
|
await initializeDatabase();
|
|
@@ -3196,6 +3271,7 @@ function shutdown() {
|
|
|
3196
3271
|
// Export everything
|
|
3197
3272
|
module.exports = {
|
|
3198
3273
|
router,
|
|
3274
|
+
setXigSettings,
|
|
3199
3275
|
updateXigDatabase,
|
|
3200
3276
|
getDatabaseInfo,
|
|
3201
3277
|
shutdown,
|
package/gr.sh
DELETED