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.
Files changed (43) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +40 -19
  3. package/config-template.json +25 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/library/html-server.js +16 -16
  7. package/library/html.js +9 -0
  8. package/library/narrative-check.js +203 -0
  9. package/library/npm-audit.js +42 -3
  10. package/package.json +2 -1
  11. package/packages/openapi.yaml +6 -3
  12. package/packages/package-crawler.js +189 -82
  13. package/packages/packages.js +33 -69
  14. package/packages/readme.md +1 -1
  15. package/packages/utilities/dedupe-versions.js +22 -8
  16. package/registry/crawler.js +8 -1
  17. package/registry/registry.js +18 -11
  18. package/release.sh +80 -0
  19. package/shl/shl.js +5 -3
  20. package/testing/readme.md +1 -1
  21. package/tx/cm/cm-database.js +62 -0
  22. package/tx/cm/cm-package.js +20 -3
  23. package/tx/cs/cs-cs.js +86 -18
  24. package/tx/cs/cs-loinc.js +32 -14
  25. package/tx/library/resource-ids.js +16 -1
  26. package/tx/library.js +9 -2
  27. package/tx/ocl/README.md +62 -6
  28. package/tx/ocl/cm-ocl.cjs +166 -44
  29. package/tx/ocl/cs-ocl.cjs +163 -13
  30. package/tx/ocl/resolve/public-access.js +143 -0
  31. package/tx/ocl/resolve/reference-resolver.js +458 -0
  32. package/tx/ocl/vs-ocl.cjs +167 -4
  33. package/tx/problems.js +7 -5
  34. package/tx/provider.js +5 -1
  35. package/tx/tx-html.js +46 -7
  36. package/tx/tx.js +22 -6
  37. package/tx/vs/vs-database.js +26 -0
  38. package/tx/vs/vs-package.js +42 -10
  39. package/tx/workers/expand.js +1 -1
  40. package/tx/workers/metadata.js +6 -1
  41. package/tx/workers/read.js +87 -5
  42. package/utilities/submit-test-report.js +1 -1
  43. 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
- const systemEsc = escape(system);
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
- versionRefs.push(`<a href="ValueSet?system=${systemEsc}|${escape(v)}&source=${source}&_elements=url%2Cversion%2Cname%2Ctitle%2Cstatus%2Ccontent%2Cdate">${v}</a>`);
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=${systemEsc}&_elements=url%2Cversion%2Cname%2Ctitle%2Cstatus%2Ccontent%2Cdate">${system}</a></td>`+
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
- cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion()));
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.renderResourceWithNarrative(json, json.text?.div);
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
- return `<li><a href="${this.path}/${rtype}/${id}?_format=${type}">${name}</a></li>`;
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.renderResourceWithNarrative(json, json.text?.div);
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.renderResourceWithNarrative(json, json.text?.div);
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 || 'N/A'}</p>`;
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
- valueSetsJson: JSON.stringify(json.valueSets || [])
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
- this.timers.push(setInterval(async () => {
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
- }, 60 * 1000));
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
- return res.status(415).json(buildOperationOutcome('error', 'invalid', `Unsupported Media Type: ${contentType}`));
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) {
@@ -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
@@ -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
- this.dbPath = path.join(packageLoader.packageFolder, '.valuesets.db');
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
- const dbExists = await this.database.exists();
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.database.create();
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.database.close();
64
- await fs.rm(this.dbPath, { force: true });
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}`);
@@ -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, false);
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
@@ -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
 
@@ -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 {Object} log - Logger instance
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 res.status(404).json(buildOperationOutcome('error', 'not-found', `CodeSystem/${id} not found`));
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 res.status(404).json(buildOperationOutcome('error', 'not-found', `ValueSet/${id} not found`));
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 res.status(404).json(buildOperationOutcome('error', 'not-found', `ConceptMap/${id} not found`));
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://testing.fhir.org/testing
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>")