fhirsmith 0.9.6 → 0.10.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 +51 -0
- package/library/folder-content-loader.js +91 -0
- package/npmprojector/npmprojector.js +2 -6
- package/package.json +1 -1
- package/packages/package-crawler.js +123 -6
- package/packages/packages.js +104 -0
- package/publisher/publisher.js +290 -12
- package/registry/crawler.js +85 -6
- package/registry/registry.js +24 -7
- package/server.js +11 -2
- package/stats.js +26 -18
- package/translations/Messages.properties +2 -1
- package/tx/cs/cs-cs.js +8 -0
- package/tx/cs/cs-loinc.js +1 -0
- package/tx/cs/cs-provider-list.js +2 -1
- package/tx/cs/cs-snomed.js +142 -59
- package/tx/data/snomed-testing.cache +0 -0
- package/tx/html/home-metrics.liquid +10 -10
- package/tx/library/canonical-resource.js +4 -2
- package/tx/library/codesystem.js +31 -0
- package/tx/library/conceptmap.js +24 -0
- package/tx/library/designations.js +27 -20
- package/tx/library/renderer.js +303 -22
- package/tx/library/ucum-types.js +4 -1
- package/tx/library/valueset.js +46 -0
- package/tx/library.js +65 -21
- package/tx/operation-context.js +122 -27
- package/tx/params.js +36 -8
- package/tx/provider.js +6 -3
- package/tx/tx-html.js +34 -0
- package/tx/tx.js +92 -30
- package/tx/vs/vs-vsac.js +157 -9
- package/tx/workers/cache-control.js +186 -0
- package/tx/workers/{related.js → compare.js} +27 -27
- package/tx/workers/expand.js +100 -96
- package/tx/workers/lookup.js +6 -0
- package/tx/workers/metadata.js +11 -6
- package/tx/workers/read.js +1 -1
- package/tx/workers/translate.js +20 -29
- package/tx/workers/validate.js +18 -10
- package/tx/workers/worker.js +134 -47
- package/tx/xversion/xv-bundle.js +1 -2
- package/tx/xversion/xv-codesystem.js +5 -2
- package/tx/xversion/xv-parameters.js +4 -4
- package/tx/xversion/xv-resource.js +2 -2
- package/tx/xversion/xv-terminologyCapabilities.js +11 -6
- package/tx/xversion/xv-valueset.js +7 -7
- package/publisher/task-draft.js +0 -463
- package/tx/data/OperationDefinition-ValueSet-related.json +0 -133
package/tx/tx.js
CHANGED
|
@@ -27,6 +27,7 @@ const LookupWorker = require('./workers/lookup');
|
|
|
27
27
|
const SubsumesWorker = require('./workers/subsumes');
|
|
28
28
|
const { MetadataHandler } = require('./workers/metadata');
|
|
29
29
|
const { BatchValidateWorker } = require('./workers/batch-validate');
|
|
30
|
+
const { CacheControlWorker } = require('./workers/cache-control');
|
|
30
31
|
const {CapabilityStatementXML} = require("./xml/capabilitystatement-xml");
|
|
31
32
|
const {TerminologyCapabilitiesXML} = require("./xml/terminologycapabilities-xml");
|
|
32
33
|
const {ParametersXML} = require("./xml/parameters-xml");
|
|
@@ -36,7 +37,7 @@ const {ConceptMapXML} = require("./xml/conceptmap-xml");
|
|
|
36
37
|
const {TxHtmlRenderer} = require("./tx-html");
|
|
37
38
|
const {Renderer} = require("./library/renderer");
|
|
38
39
|
const {OperationsWorker} = require("./workers/operations");
|
|
39
|
-
const {
|
|
40
|
+
const {CompareWorker} = require("./workers/compare");
|
|
40
41
|
const {codeSystemFromR5} = require("./xversion/xv-codesystem");
|
|
41
42
|
const {operationOutcomeFromR5} = require("./xversion/xv-operationoutcome");
|
|
42
43
|
const {parametersFromR5} = require("./xversion/xv-parameters");
|
|
@@ -296,6 +297,12 @@ class TXModule {
|
|
|
296
297
|
endpointInfo.resourceCache, endpointInfo.expansionCache
|
|
297
298
|
);
|
|
298
299
|
opContext.usageTracker = this.usageTracker;
|
|
300
|
+
// Normalise the cache-id from the X-Cache-Id header onto the operation
|
|
301
|
+
// context once, here, so every worker honours a front-loaded cache no
|
|
302
|
+
// matter how it later assembles its Parameters (some use buildParameters,
|
|
303
|
+
// others read req.body / query directly). An explicit cache-id parameter
|
|
304
|
+
// on the request still wins (see setupAdditionalResources).
|
|
305
|
+
opContext.cacheId = req.get('X-Cache-Id') || null;
|
|
299
306
|
|
|
300
307
|
// Attach everything to request
|
|
301
308
|
req.txProvider = endpointInfo.provider;
|
|
@@ -382,16 +389,10 @@ class TXModule {
|
|
|
382
389
|
next();
|
|
383
390
|
});
|
|
384
391
|
|
|
385
|
-
// CORS
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
390
|
-
if (req.method === 'OPTIONS') {
|
|
391
|
-
return res.sendStatus(200);
|
|
392
|
-
}
|
|
393
|
-
next();
|
|
394
|
-
});
|
|
392
|
+
// CORS is handled once at the app level (server.js, from config.server.cors).
|
|
393
|
+
// Do not set Access-Control-* headers here - doing so stacks a second
|
|
394
|
+
// CORS layer and produces duplicate, conflicting headers that browsers
|
|
395
|
+
// reject.
|
|
395
396
|
|
|
396
397
|
// JSON body parsing - accept both application/json and application/fhir+json
|
|
397
398
|
// Handle body that may already be read as a Buffer by app-level middleware
|
|
@@ -601,23 +602,23 @@ class TXModule {
|
|
|
601
602
|
}
|
|
602
603
|
});
|
|
603
604
|
|
|
604
|
-
// ValueSet/$
|
|
605
|
-
router.get('/ValueSet/\\$
|
|
605
|
+
// ValueSet/$compare(GET and POST)
|
|
606
|
+
router.get('/ValueSet/\\$compare', async (req, res) => {
|
|
606
607
|
const start = Date.now();
|
|
607
608
|
try {
|
|
608
|
-
let worker = new
|
|
609
|
+
let worker = new CompareWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
|
|
609
610
|
await worker.handle(req, res);
|
|
610
611
|
} finally {
|
|
611
|
-
this.countRequest('$
|
|
612
|
+
this.countRequest('$compare', Date.now() - start);
|
|
612
613
|
}
|
|
613
614
|
});
|
|
614
|
-
router.post('/ValueSet/\\$
|
|
615
|
+
router.post('/ValueSet/\\$compare', async (req, res) => {
|
|
615
616
|
const start = Date.now();
|
|
616
617
|
try {
|
|
617
|
-
let worker = new
|
|
618
|
+
let worker = new CompareWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
|
|
618
619
|
await worker.handle(req, res);
|
|
619
620
|
} finally {
|
|
620
|
-
this.countRequest('$
|
|
621
|
+
this.countRequest('$compare', Date.now() - start);
|
|
621
622
|
}
|
|
622
623
|
});
|
|
623
624
|
|
|
@@ -661,6 +662,26 @@ class TXModule {
|
|
|
661
662
|
}
|
|
662
663
|
});
|
|
663
664
|
|
|
665
|
+
// $cache-control (GET and POST) - base-level operation: mode=start|end
|
|
666
|
+
router.get('/\\$cache-control', async (req, res) => {
|
|
667
|
+
const start = Date.now();
|
|
668
|
+
try {
|
|
669
|
+
let worker = new CacheControlWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
|
|
670
|
+
await worker.handle(req, res, this.log);
|
|
671
|
+
} finally {
|
|
672
|
+
this.countRequest('$cache-control', Date.now() - start);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
router.post('/\\$cache-control', async (req, res) => {
|
|
676
|
+
const start = Date.now();
|
|
677
|
+
try {
|
|
678
|
+
let worker = new CacheControlWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
|
|
679
|
+
await worker.handle(req, res, this.log);
|
|
680
|
+
} finally {
|
|
681
|
+
this.countRequest('$cache-control', Date.now() - start);
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
|
|
664
685
|
// ConceptMap/$translate (GET and POST)
|
|
665
686
|
router.get('/ConceptMap/\\$translate', async (req, res) => {
|
|
666
687
|
const start = Date.now();
|
|
@@ -785,23 +806,23 @@ class TXModule {
|
|
|
785
806
|
});
|
|
786
807
|
|
|
787
808
|
|
|
788
|
-
// ValueSet/[id]/$
|
|
789
|
-
router.get('/ValueSet/:id/\\$
|
|
809
|
+
// ValueSet/[id]/$compare
|
|
810
|
+
router.get('/ValueSet/:id/\\$compare', async (req, res) => {
|
|
790
811
|
const start = Date.now();
|
|
791
812
|
try {
|
|
792
|
-
let worker = new
|
|
813
|
+
let worker = new CompareWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
|
|
793
814
|
await worker.handleInstance(req, res, this.log);
|
|
794
815
|
} finally {
|
|
795
|
-
this.countRequest('$
|
|
816
|
+
this.countRequest('$compare', Date.now() - start);
|
|
796
817
|
}
|
|
797
818
|
});
|
|
798
|
-
router.post('/ValueSet/:id/\\$
|
|
819
|
+
router.post('/ValueSet/:id/\\$compare', async (req, res) => {
|
|
799
820
|
const start = Date.now();
|
|
800
821
|
try {
|
|
801
|
-
let worker = new
|
|
822
|
+
let worker = new CompareWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
|
|
802
823
|
await worker.handleInstance(req, res, this.log);
|
|
803
824
|
} finally {
|
|
804
|
-
this.countRequest('$
|
|
825
|
+
this.countRequest('$compare', Date.now() - start);
|
|
805
826
|
}
|
|
806
827
|
});
|
|
807
828
|
|
|
@@ -980,7 +1001,9 @@ class TXModule {
|
|
|
980
1001
|
});
|
|
981
1002
|
|
|
982
1003
|
// External source info pages
|
|
983
|
-
|
|
1004
|
+
// GET renders the info page; POST lets a source's info() handle a form
|
|
1005
|
+
// submission (e.g. VSAC on-demand resync) and re-renders the same page.
|
|
1006
|
+
const infoHandler = async (req, res) => {
|
|
984
1007
|
const start = Date.now();
|
|
985
1008
|
try {
|
|
986
1009
|
const source = req.txEndpoint.provider.externalSources.find(s => s.id() === req.params.id);
|
|
@@ -1000,7 +1023,9 @@ class TXModule {
|
|
|
1000
1023
|
} finally {
|
|
1001
1024
|
this.countRequest('info', Date.now() - start);
|
|
1002
1025
|
}
|
|
1003
|
-
}
|
|
1026
|
+
};
|
|
1027
|
+
router.get('/info/:id', infoHandler);
|
|
1028
|
+
router.post('/info/:id', infoHandler);
|
|
1004
1029
|
}
|
|
1005
1030
|
|
|
1006
1031
|
/**
|
|
@@ -1131,8 +1156,9 @@ class TXModule {
|
|
|
1131
1156
|
|
|
1132
1157
|
convertResourceToXml(res) {
|
|
1133
1158
|
switch (res.resourceType) {
|
|
1134
|
-
case "CodeSystem" : return CodeSystemXML.
|
|
1159
|
+
case "CodeSystem" : return CodeSystemXML.toXml(res);
|
|
1135
1160
|
case "ValueSet" : return ValueSetXML.toXml(res);
|
|
1161
|
+
case "ConceptMap" : return ConceptMapXML.toXml(res);
|
|
1136
1162
|
case "Bundle" : return BundleXML.toXml(res, this.fhirVersion);
|
|
1137
1163
|
case "CapabilityStatement" : return CapabilityStatementXML.toXml(res, "R5");
|
|
1138
1164
|
case "TerminologyCapabilities" : return TerminologyCapabilitiesXML.toXml(res, "R5");
|
|
@@ -1178,10 +1204,46 @@ class TXModule {
|
|
|
1178
1204
|
}
|
|
1179
1205
|
}
|
|
1180
1206
|
|
|
1181
|
-
|
|
1207
|
+
// Number of entries in the expansion cache, summed across endpoints.
|
|
1208
|
+
expansionItemCount() {
|
|
1209
|
+
let count = 0;
|
|
1210
|
+
for (let ep of this.endpoints) {
|
|
1211
|
+
count = count + ep.expansionCache.size();
|
|
1212
|
+
}
|
|
1213
|
+
return count;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Number of concepts held in the client (resource) cache, summed across endpoints.
|
|
1217
|
+
clientConceptCount() {
|
|
1218
|
+
let count = 0;
|
|
1219
|
+
for (let ep of this.endpoints) {
|
|
1220
|
+
count = count + ep.resourceCache.conceptCount();
|
|
1221
|
+
}
|
|
1222
|
+
return count;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Number of caches (cache-ids) currently held in the client cache, across endpoints.
|
|
1226
|
+
clientCacheCount() {
|
|
1227
|
+
let count = 0;
|
|
1228
|
+
for (let ep of this.endpoints) {
|
|
1229
|
+
count = count + ep.resourceCache.size();
|
|
1230
|
+
}
|
|
1231
|
+
return count;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// High-water marks for the client cache, summed across endpoints.
|
|
1235
|
+
maxClientCacheCount() {
|
|
1236
|
+
let count = 0;
|
|
1237
|
+
for (let ep of this.endpoints) {
|
|
1238
|
+
count = count + ep.resourceCache.maxSize();
|
|
1239
|
+
}
|
|
1240
|
+
return count;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
maxClientConceptCount() {
|
|
1182
1244
|
let count = 0;
|
|
1183
1245
|
for (let ep of this.endpoints) {
|
|
1184
|
-
count = count + ep.resourceCache.
|
|
1246
|
+
count = count + ep.resourceCache.maxConceptCount();
|
|
1185
1247
|
}
|
|
1186
1248
|
return count;
|
|
1187
1249
|
}
|
package/tx/vs/vs-vsac.js
CHANGED
|
@@ -7,6 +7,12 @@ const { VersionUtilities } = require('../../library/version-utilities');
|
|
|
7
7
|
const folders = require('../../library/folder-setup');
|
|
8
8
|
const {debugLog} = require("../operation-context");
|
|
9
9
|
|
|
10
|
+
// Persisted watermark for the phase-1b _lastUpdated scan.
|
|
11
|
+
const VSAC_LAST_UPDATED_KEY = 'vsac_last_updated_date';
|
|
12
|
+
|
|
13
|
+
// Canonical URL prefix for VSAC value sets, so operators can enter a bare OID.
|
|
14
|
+
const VSAC_VALUESET_URL_PREFIX = 'http://cts.nlm.nih.gov/fhir/ValueSet/';
|
|
15
|
+
|
|
10
16
|
/**
|
|
11
17
|
* VSAC (Value Set Authority Center) ValueSet provider
|
|
12
18
|
* Fetches and caches ValueSets from the NLM VSAC FHIR server
|
|
@@ -21,6 +27,8 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
21
27
|
* @param {number} [config.refreshIntervalHours=24] - Hours between refresh scans
|
|
22
28
|
* @param {string} [config.baseUrl='http://cts.nlm.nih.gov/fhir'] - Base URL for VSAC FHIR server
|
|
23
29
|
* @param {number} [config.timeoutMs=120000] - HTTP request timeout in milliseconds
|
|
30
|
+
* @param {string} [config.resyncPassword] - If set, enables the operator "resync a ValueSet"
|
|
31
|
+
* form on the /info page, gated by this password. If unset, the form is not offered.
|
|
24
32
|
*/
|
|
25
33
|
constructor(config, stats) {
|
|
26
34
|
super();
|
|
@@ -31,6 +39,8 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
this.apiKey = config.apiKey;
|
|
42
|
+
// Optional operator password for the on-demand resync form (see info()).
|
|
43
|
+
this.resyncPassword = config.resyncPassword || null;
|
|
34
44
|
this.cacheFolder = folders.ensureFilePath("terminology-cache/vsac");
|
|
35
45
|
this.baseUrl = config.baseUrl || 'http://cts.nlm.nih.gov/fhir';
|
|
36
46
|
this.refreshIntervalHours = config.refreshIntervalHours || 24;
|
|
@@ -127,6 +137,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
127
137
|
return;
|
|
128
138
|
}
|
|
129
139
|
this.queue = [];
|
|
140
|
+
this._pendingLastUpdated = null;
|
|
130
141
|
|
|
131
142
|
this.isRefreshing = true;
|
|
132
143
|
const runId = await this.database.startRun();
|
|
@@ -185,6 +196,9 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
185
196
|
this.queue = [...new Set(this.queue)];
|
|
186
197
|
|
|
187
198
|
let tracking = { totalFetched: 0, totalNew: 0, totalUpdated: 0, count: 0, newCount : 0 };
|
|
199
|
+
// URLs that fail even after a requeue are permanently dropped this run; we
|
|
200
|
+
// hold the watermark back when this is non-zero so they get re-scanned.
|
|
201
|
+
let permanentFailures = 0;
|
|
188
202
|
// phase 2: query for history & content
|
|
189
203
|
this.requeue = [];
|
|
190
204
|
for (let q of this.queue) {
|
|
@@ -204,6 +218,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
204
218
|
try {
|
|
205
219
|
await this.processContentAndHistory(q, tracking, this.requeue.length);
|
|
206
220
|
} catch (error) {
|
|
221
|
+
permanentFailures++;
|
|
207
222
|
debugLog(error);
|
|
208
223
|
this.stats.task('VSAC Sync', error.message);
|
|
209
224
|
}
|
|
@@ -212,6 +227,20 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
212
227
|
|
|
213
228
|
// Reload map with fresh data
|
|
214
229
|
await this._reloadMap();
|
|
230
|
+
|
|
231
|
+
// Commit the _lastUpdated watermark only now that phase 2 has durably
|
|
232
|
+
// written everything. Hold it back if any URL was permanently dropped, so
|
|
233
|
+
// those URLs are re-scanned next run (never advance past un-applied
|
|
234
|
+
// changes). If the run threw or the process restarted before this point,
|
|
235
|
+
// the watermark is left untouched and the window is re-scanned.
|
|
236
|
+
if (this._pendingLastUpdated && permanentFailures === 0) {
|
|
237
|
+
await this.database.setSetting(VSAC_LAST_UPDATED_KEY, this._pendingLastUpdated);
|
|
238
|
+
} else if (permanentFailures > 0) {
|
|
239
|
+
const holdMsg = `Holding _lastUpdated watermark: ${permanentFailures} URL(s) failed after retry; will re-scan next run`;
|
|
240
|
+
console.log(holdMsg);
|
|
241
|
+
this.stats.task('VSAC Sync', holdMsg);
|
|
242
|
+
}
|
|
243
|
+
|
|
215
244
|
let msg = `VSAC refresh completed. Total: ${tracking.totalFetched} ValueSets, New: ${tracking.totalNew}, Updated: ${tracking.totalUpdated}`;
|
|
216
245
|
this.stats.taskDone('VSAC Sync', msg);
|
|
217
246
|
console.log(msg);
|
|
@@ -579,9 +608,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
579
608
|
* @private
|
|
580
609
|
*/
|
|
581
610
|
async _scanLastUpdated() {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
let sinceDate = await this.database.getSetting(SETTING_KEY);
|
|
611
|
+
let sinceDate = await this.database.getSetting(VSAC_LAST_UPDATED_KEY);
|
|
585
612
|
if (!sinceDate) {
|
|
586
613
|
// No stored date — default to 10 days ago
|
|
587
614
|
const d = new Date();
|
|
@@ -615,10 +642,13 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
615
642
|
url = this._getNextUrl(bundle);
|
|
616
643
|
}
|
|
617
644
|
|
|
618
|
-
//
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
645
|
+
// Capture the server's date but do NOT commit the watermark here. It must
|
|
646
|
+
// only advance once phase 2 has durably upserted the queued URLs; committing
|
|
647
|
+
// it now (before phase 2) means a phase-2 failure or a process restart would
|
|
648
|
+
// move the watermark past URLs that were never written, stranding them
|
|
649
|
+
// permanently. refreshValueSets() commits this._pendingLastUpdated after
|
|
650
|
+
// phase 2 completes with no dropped URLs.
|
|
651
|
+
this._pendingLastUpdated = serverDate;
|
|
622
652
|
|
|
623
653
|
return count;
|
|
624
654
|
}
|
|
@@ -631,8 +661,123 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
631
661
|
return "history";
|
|
632
662
|
}
|
|
633
663
|
|
|
634
|
-
|
|
664
|
+
/**
|
|
665
|
+
* Whether the on-demand resync form is enabled (a resyncPassword is configured).
|
|
666
|
+
*/
|
|
667
|
+
_resyncEnabled() {
|
|
668
|
+
return !!this.resyncPassword;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Timing-safe comparison of a provided password against the configured one.
|
|
673
|
+
* @private
|
|
674
|
+
*/
|
|
675
|
+
_passwordMatches(provided) {
|
|
676
|
+
if (!this.resyncPassword) {
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
const a = Buffer.from(String(provided == null ? '' : provided));
|
|
680
|
+
const b = Buffer.from(String(this.resyncPassword));
|
|
681
|
+
if (a.length !== b.length) {
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
return crypto.timingSafeEqual(a, b);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Immediately resync a single ValueSet URL (all versions): fetch from VSAC,
|
|
689
|
+
* upsert, and reload the in-memory map.
|
|
690
|
+
* @param {string} url - the ValueSet canonical url
|
|
691
|
+
* @returns {Promise<number>} number of versions fetched
|
|
692
|
+
*/
|
|
693
|
+
async resyncValueSet(url) {
|
|
694
|
+
const tracking = { totalFetched: 0, totalNew: 0, totalUpdated: 0, count: 0, newCount: 0 };
|
|
695
|
+
await this.processContentAndHistory(url, tracking, 1);
|
|
696
|
+
await this._reloadMap();
|
|
697
|
+
return tracking.totalFetched;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Handle a POST from the resync form. Validates the password (timing-safe) and,
|
|
702
|
+
* if a full sync isn't already running, resyncs the requested URL. Returns an
|
|
703
|
+
* HTML notice. Never echoes or logs the password.
|
|
704
|
+
* @private
|
|
705
|
+
*/
|
|
706
|
+
async _handleResyncRequest(req) {
|
|
707
|
+
const escape = require('escape-html');
|
|
708
|
+
if (!this._resyncEnabled()) {
|
|
709
|
+
return '';
|
|
710
|
+
}
|
|
711
|
+
const body = req.body || {};
|
|
712
|
+
const url = this._expandOidOrUrl(body.url);
|
|
713
|
+
|
|
714
|
+
if (!this._passwordMatches(body.password)) {
|
|
715
|
+
// Small delay to blunt brute-forcing; reveal nothing else.
|
|
716
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
717
|
+
return '<p style="color:#a00"><strong>Incorrect password — no action taken.</strong></p>';
|
|
718
|
+
}
|
|
719
|
+
if (!url) {
|
|
720
|
+
return '<p style="color:#a00">Enter a ValueSet URL to resync.</p>';
|
|
721
|
+
}
|
|
722
|
+
if (this.isRefreshing) {
|
|
723
|
+
return '<p style="color:#a60">A full sync is currently running; please retry in a few minutes.</p>';
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
const n = await this.resyncValueSet(url);
|
|
727
|
+
console.log(`Manual resync of ${url}: ${n} version(s)`);
|
|
728
|
+
this.stats.task('VSAC Sync', `Manual resync of ${url}: ${n} version(s)`);
|
|
729
|
+
return `<p style="color:#070"><strong>Resynced ${escape(url)}: ${n} version(s).</strong></p>`;
|
|
730
|
+
} catch (error) {
|
|
731
|
+
debugLog(error);
|
|
732
|
+
return `<p style="color:#a00">Resync of ${escape(url)} failed: ${escape(error.message)}</p>`;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Accept either a full canonical URL or a bare VSAC OID. A dotted-decimal OID
|
|
738
|
+
* (optionally with a urn:oid: prefix) is expanded to the VSAC ValueSet URL.
|
|
739
|
+
* @private
|
|
740
|
+
*/
|
|
741
|
+
_expandOidOrUrl(input) {
|
|
742
|
+
let s = (input == null ? '' : String(input)).trim();
|
|
743
|
+
if (s.toLowerCase().startsWith('urn:oid:')) {
|
|
744
|
+
s = s.substring('urn:oid:'.length);
|
|
745
|
+
}
|
|
746
|
+
if (/^[0-9]+(\.[0-9]+)+$/.test(s)) {
|
|
747
|
+
return VSAC_VALUESET_URL_PREFIX + s;
|
|
748
|
+
}
|
|
749
|
+
return s;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* The resync form HTML, or '' when the feature is disabled (no password set).
|
|
754
|
+
* @private
|
|
755
|
+
*/
|
|
756
|
+
_resyncFormHtml() {
|
|
757
|
+
if (!this._resyncEnabled()) {
|
|
758
|
+
return '';
|
|
759
|
+
}
|
|
760
|
+
return `<form method="post" autocomplete="off" style="margin:0 0 1em 0; padding:0.75em; border:1px solid #ccc; background:#f7f7f7">
|
|
761
|
+
<strong>Resync a ValueSet</strong>
|
|
762
|
+
<div style="margin-top:0.5em">
|
|
763
|
+
<label>ValueSet URL or OID: <input type="text" name="url" size="70" autocomplete="off"></label>
|
|
764
|
+
</div>
|
|
765
|
+
<div style="margin-top:0.5em">
|
|
766
|
+
<label>Password: <input type="password" name="password" autocomplete="off"></label>
|
|
767
|
+
<button type="submit">Resync</button>
|
|
768
|
+
</div>
|
|
769
|
+
</form>`;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async info(req) {
|
|
635
773
|
const escape = require('escape-html');
|
|
774
|
+
|
|
775
|
+
// Operator action: resync a specific ValueSet (POST from the form below).
|
|
776
|
+
let resyncNotice = '';
|
|
777
|
+
if (req && req.method === 'POST') {
|
|
778
|
+
resyncNotice = await this._handleResyncRequest(req);
|
|
779
|
+
}
|
|
780
|
+
|
|
636
781
|
const db = await this.database._getReadConnection();
|
|
637
782
|
|
|
638
783
|
const rows = await new Promise((resolve, reject) => {
|
|
@@ -689,7 +834,10 @@ class VSACValueSetProvider extends AbstractValueSetProvider {
|
|
|
689
834
|
? new Date(ts * 1000).toISOString().replace('T', ' ').substring(0, 19) + ' UTC'
|
|
690
835
|
: '—';
|
|
691
836
|
|
|
692
|
-
let html = '
|
|
837
|
+
let html = '';
|
|
838
|
+
html += this._resyncFormHtml();
|
|
839
|
+
html += resyncNotice;
|
|
840
|
+
html += '<h3>VSAC Sync History</h3>';
|
|
693
841
|
html += '<table class="grid">';
|
|
694
842
|
html += '<thead><tr><th>Time</th><th>Event</th><th>Detail</th></tr></thead>';
|
|
695
843
|
html += '<tbody>';
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Cache Control Worker - Handles the $cache-control operation
|
|
3
|
+
//
|
|
4
|
+
// GET /$cache-control?mode=start - create a cache, return its (server-issued) id
|
|
5
|
+
// POST /$cache-control?mode=start - as above, optionally front-loading resources
|
|
6
|
+
// GET /$cache-control?mode=end - tell the server it can release the cache now
|
|
7
|
+
// POST /$cache-control?mode=end
|
|
8
|
+
// (mode=check is reserved for later - report whether a cache is still valid + stats)
|
|
9
|
+
//
|
|
10
|
+
// This is the explicit replacement for the implicit `cache-id` parameter protocol:
|
|
11
|
+
// the server owns the cache-id namespace, so it can authoritatively reject an
|
|
12
|
+
// unknown/expired cache later instead of failing obscurely deep inside a validation.
|
|
13
|
+
//
|
|
14
|
+
// NOTE: this is scaffolding. start()/end() currently parse the request into a
|
|
15
|
+
// Parameters resource (the same way validate.js does) but do not yet create or
|
|
16
|
+
// release anything. The behaviour is filled in by later steps.
|
|
17
|
+
//
|
|
18
|
+
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const { TerminologyWorker, CACHE_ID_HEADER } = require('./worker');
|
|
21
|
+
const { Parameters } = require('../library/parameters');
|
|
22
|
+
const { debugLog } = require('../operation-context');
|
|
23
|
+
|
|
24
|
+
class CacheControlWorker extends TerminologyWorker {
|
|
25
|
+
/**
|
|
26
|
+
* @param {OperationContext} opContext - Operation context
|
|
27
|
+
* @param {Logger} log - Logger instance
|
|
28
|
+
* @param {Provider} provider - Provider for code systems and resources
|
|
29
|
+
* @param {LanguageDefinitions} languages - Language definitions
|
|
30
|
+
* @param {I18nSupport} i18n - Internationalization support
|
|
31
|
+
*/
|
|
32
|
+
constructor(opContext, log, provider, languages, i18n) {
|
|
33
|
+
super(opContext, log, provider, languages, i18n);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
opName() {
|
|
37
|
+
return 'cache-control';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Not a value-set operation; the base class requires this to be implemented.
|
|
41
|
+
vsHandle() {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Express entry point for /$cache-control (GET and POST).
|
|
47
|
+
* Dispatches on the `mode` query parameter.
|
|
48
|
+
* @param {express.Request} req - Express request
|
|
49
|
+
* @param {express.Response} res - Express response
|
|
50
|
+
*/
|
|
51
|
+
async handle(req, res) {
|
|
52
|
+
try {
|
|
53
|
+
const mode = this.getMode(req);
|
|
54
|
+
// GET is accepted as well as POST, for consistency with the server's other
|
|
55
|
+
// operations (which are all browsable) and the convenience of poking
|
|
56
|
+
// $cache-control from a browser. The operation is declared affectsState=true
|
|
57
|
+
// in its OperationDefinition, so conformant clients POST; an accidental
|
|
58
|
+
// GET-created cache is an empty entry that self-expires.
|
|
59
|
+
switch (mode) {
|
|
60
|
+
case 'start':
|
|
61
|
+
return await this.start(req, res);
|
|
62
|
+
case 'end':
|
|
63
|
+
return await this.end(req, res);
|
|
64
|
+
default:
|
|
65
|
+
return res.status(400).json(this.operationOutcome('error', 'invalid',
|
|
66
|
+
`$cache-control requires a 'mode' of 'start' or 'end'` +
|
|
67
|
+
(mode ? ` (got '${mode}')` : ` (none supplied)`)));
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
this.log.error(error);
|
|
71
|
+
debugLog(error);
|
|
72
|
+
req.logInfo = this.usedSources.join('|') + ' - error' + (error.msgId ? ' ' + error.msgId : '');
|
|
73
|
+
const statusCode = error.statusCode || 500;
|
|
74
|
+
const issueCode = error.issueCode || 'exception';
|
|
75
|
+
return res.status(statusCode).json(this.operationOutcome('error', issueCode, error.message));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Determine the requested mode. The cache-control mode travels in the query
|
|
81
|
+
* string (?mode=start) so it survives even when a Parameters resource is POSTed
|
|
82
|
+
* as the body; a `mode` parameter in the body is accepted as a fallback.
|
|
83
|
+
* @param {express.Request} req
|
|
84
|
+
* @returns {string|null}
|
|
85
|
+
*/
|
|
86
|
+
getMode(req) {
|
|
87
|
+
if (req.query && req.query.mode) {
|
|
88
|
+
return String(req.query.mode);
|
|
89
|
+
}
|
|
90
|
+
if (req.body && req.body.resourceType === 'Parameters') {
|
|
91
|
+
const p = this.findParameter(req.body, 'mode');
|
|
92
|
+
if (p) {
|
|
93
|
+
return this.getParameterValue(p);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* mode=start: mint a server-issued cache-id, create the (per-endpoint) cache,
|
|
101
|
+
* front-load any supplied resources into it, and return the id in the body.
|
|
102
|
+
*
|
|
103
|
+
* The id is returned in the body rather than a header to keep it readable by
|
|
104
|
+
* browser clients without CORS expose-header configuration; subsequent calls
|
|
105
|
+
* carry it back as the `${CACHE_ID_HEADER}` request header.
|
|
106
|
+
*
|
|
107
|
+
* The cache is created even when no resources are front-loaded: an explicitly
|
|
108
|
+
* empty cache must still *exist* so the server can later tell "cache I issued,
|
|
109
|
+
* currently empty" from "cache-id I never issued". That's why this uses
|
|
110
|
+
* ResourceCache.set (which always creates the entry) rather than add (which
|
|
111
|
+
* ignores empty resource lists).
|
|
112
|
+
*
|
|
113
|
+
* @param {express.Request} req
|
|
114
|
+
* @param {express.Response} res
|
|
115
|
+
*/
|
|
116
|
+
async start(req, res) {
|
|
117
|
+
const cache = this.opContext.resourceCache;
|
|
118
|
+
if (!cache) {
|
|
119
|
+
return res.status(500).json(this.operationOutcome('error', 'exception',
|
|
120
|
+
'No resource cache is available on this endpoint'));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const params = new Parameters(this.buildParameters(req));
|
|
124
|
+
const { txResources, primaryResources } = this.collectSuppliedResources(params.jsonObj);
|
|
125
|
+
const resources = txResources.concat(primaryResources);
|
|
126
|
+
|
|
127
|
+
const cacheId = crypto.randomUUID();
|
|
128
|
+
cache.set(cacheId, resources);
|
|
129
|
+
|
|
130
|
+
return res.status(200).json({
|
|
131
|
+
resourceType: 'Parameters',
|
|
132
|
+
parameter: [
|
|
133
|
+
{ name: 'cache-id', valueId: cacheId }
|
|
134
|
+
]
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* mode=end: release the cache named by the `${CACHE_ID_HEADER}` header so the
|
|
140
|
+
* server can reclaim it now rather than waiting for the idle timeout.
|
|
141
|
+
*
|
|
142
|
+
* Releasing is idempotent: ending an id the server doesn't have is not an error
|
|
143
|
+
* here (the authoritative "unknown cache" signal belongs on the validation path,
|
|
144
|
+
* a later step). A missing header is a client error, though.
|
|
145
|
+
*
|
|
146
|
+
* @param {express.Request} req
|
|
147
|
+
* @param {express.Response} res
|
|
148
|
+
*/
|
|
149
|
+
async end(req, res) {
|
|
150
|
+
const cache = this.opContext.resourceCache;
|
|
151
|
+
if (!cache) {
|
|
152
|
+
return res.status(500).json(this.operationOutcome('error', 'exception',
|
|
153
|
+
'No resource cache is available on this endpoint'));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cacheId = req.headers[CACHE_ID_HEADER];
|
|
157
|
+
if (!cacheId) {
|
|
158
|
+
return res.status(400).json(this.operationOutcome('error', 'invalid',
|
|
159
|
+
`$cache-control mode=end requires the cache-id in the '${CACHE_ID_HEADER}' header`));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
cache.clear(cacheId);
|
|
163
|
+
|
|
164
|
+
return res.status(200).json({ resourceType: 'Parameters', parameter: [] });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build an OperationOutcome.
|
|
169
|
+
* @param {string} severity - error, warning, information
|
|
170
|
+
* @param {string} code - Issue code
|
|
171
|
+
* @param {string} message - Diagnostic message
|
|
172
|
+
* @returns {Object} OperationOutcome resource
|
|
173
|
+
*/
|
|
174
|
+
operationOutcome(severity, code, message) {
|
|
175
|
+
return {
|
|
176
|
+
resourceType: 'OperationOutcome',
|
|
177
|
+
issue: [{
|
|
178
|
+
severity,
|
|
179
|
+
code,
|
|
180
|
+
details: { text: message }
|
|
181
|
+
}]
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = { CacheControlWorker, CACHE_ID_HEADER };
|