fhirsmith 0.16.0 → 0.16.1
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 +18 -0
- package/README.md +14 -1
- package/gr.sh +3 -0
- package/library/npm-audit.js +42 -3
- package/package.json +1 -1
- package/packages/openapi.yaml +6 -3
- package/packages/packages.js +29 -7
- package/packages/readme.md +1 -1
- package/shl/shl.js +5 -3
- package/tx/library/resource-ids.js +16 -1
- package/tx/tx-html.js +24 -0
- package/tx/tx.js +18 -3
- package/tx/workers/read.js +87 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ All notable changes to Health Intersections FHIRsmith will be documented in this
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.16.1] - 2026-10-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- The dashboard always says which FHIRsmith release the server runs, and whether a newer one is available (looked up once a day with the npm audit check; before, the latest release was only looked up, and only mentioned, when the audit found something)
|
|
13
|
+
- README: how to follow releases (GitHub release emails, the releases Atom feed, and the dashboard)
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Packages: SUSHI (and anything else using fhir-package-loader) couldn't download packages from this server: `GET /packages/{id}/{version}` returned the version's JSON document instead of the tarball (since 0.16.0). The rule was "JSON when Accept includes `application/json`", but fhir-package-loader downloads with axios's default `Accept: application/json, text/plain, */*`, and plenty of other HTTP clients send JSON alongside `*/*` by default without wanting it. The version document is now returned only when the request prefers JSON: a JSON type ranked above any `*/*`, `application/*` or tarball type by q-value (`application/json` alone, or `application/json, */*;q=0.1`). Everything else gets the tarball, and `FHIR-Package-Download` still forces it
|
|
18
|
+
- A POST with no Content-Type now says so (it gave "Unsupported Media Type: " with nothing after it)
|
|
19
|
+
- A form post whose Content-Type has a charset (`application/x-www-form-urlencoded; charset=UTF-8`) was refused as an unsupported media type
|
|
20
|
+
- Links in published IGs to a resource by the id it had in its source (`ValueSet/2.16.840.1.113762.1.4.1`, from before ids were prefixed, as in `ValueSet/vsac-2.16.840.1.113762.1.4.1`) work again in a browser: a read of an id nothing has redirects to the one resource that had that id in its source, or lists them when there are several. Other requests still get a 404
|
|
21
|
+
|
|
22
|
+
### Tx Conformance Statement
|
|
23
|
+
|
|
24
|
+
FHIRsmith v0.16.1 passed all 3630 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11+closure, tests v1.9.6, runner v7.0.2)
|
|
25
|
+
|
|
8
26
|
## [0.16.0] - 2026-10-15
|
|
9
27
|
|
|
10
28
|
### Security
|
package/README.md
CHANGED
|
@@ -259,7 +259,7 @@ Each GitHub Release includes:
|
|
|
259
259
|
- `ghcr.io/healthintersections/fhirsmith:latest`
|
|
260
260
|
- `ghcr.io/healthintersections/fhirsmith:vX.Y.Z`
|
|
261
261
|
- `ghcr.io/healthintersections/fhirsmith:X.Y.Z`
|
|
262
|
-
- **npm package** published to npmjs.org as `fhirsmith`
|
|
262
|
+
- **npm package** published to npmjs.org as [`fhirsmith`](https://www.npmjs.com/package/fhirsmith)
|
|
263
263
|
|
|
264
264
|
Release images carry OCI labels, so an image says which release it is without
|
|
265
265
|
having to trust the tag it was pulled under:
|
|
@@ -271,6 +271,19 @@ docker inspect --format '{{json .Config.Labels}}' ghcr.io/healthintersections/fh
|
|
|
271
271
|
`org.opencontainers.image.version` is the release, `.revision` the commit it was
|
|
272
272
|
built from, and `.source` is what links the package to this repository on GHCR.
|
|
273
273
|
|
|
274
|
+
### Tracking Releases
|
|
275
|
+
|
|
276
|
+
Server administrators should follow releases: a server is only as safe as the
|
|
277
|
+
release it runs.
|
|
278
|
+
|
|
279
|
+
- **Email**: on the [GitHub repository](https://github.com/HealthIntersections/FHIRsmith),
|
|
280
|
+
choose **Watch → Custom → Releases**. Each release is emailed with its notes (needs a GitHub account)
|
|
281
|
+
- **Atom feed**: `https://github.com/HealthIntersections/FHIRsmith/releases.atom` - each
|
|
282
|
+
entry carries the release notes, for a feed reader or a Slack/Teams feed integration
|
|
283
|
+
- **The server itself**: once a day the server looks up the latest release on npm, and the
|
|
284
|
+
dashboard says whether a newer one is available (this is part of the npm audit check, so
|
|
285
|
+
`npmAudit.enabled = false` turns it off too)
|
|
286
|
+
|
|
274
287
|
### CI Images
|
|
275
288
|
|
|
276
289
|
Every push to main also publishes `:cibuild` and `:cibuild-<sha>` to the same
|
package/gr.sh
ADDED
package/library/npm-audit.js
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
// Config (all optional), top level of config.json:
|
|
14
14
|
// "npmAudit": { "enabled": true, "intervalHours": 24 }
|
|
15
15
|
//
|
|
16
|
-
// Each check also looks up the latest fhirsmith release on npm
|
|
17
|
-
// whether
|
|
16
|
+
// Each check also looks up the latest fhirsmith release on npm. The dashboard always says
|
|
17
|
+
// whether a newer release is available, and the advice about any findings says whether
|
|
18
|
+
// upgrading will help. When this server is already on the latest release it won't:
|
|
18
19
|
// npm ignores our package.json "overrides" for anything but a clone + npm ci, so an
|
|
19
20
|
// npm-installed server can carry transitive versions the repo itself has overridden away.
|
|
20
21
|
|
|
@@ -185,6 +186,41 @@ class NpmAudit {
|
|
|
185
186
|
return true;
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
/**
|
|
190
|
+
* This server's own version, from package.json (collectPackages reads it too, but that
|
|
191
|
+
* can fail for reasons that have nothing to do with the version)
|
|
192
|
+
*/
|
|
193
|
+
readInstalledVersion() {
|
|
194
|
+
try {
|
|
195
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(this.appDir, 'package.json'), 'utf8'));
|
|
196
|
+
if (pkg.version) {
|
|
197
|
+
this.installedVersion = pkg.version;
|
|
198
|
+
}
|
|
199
|
+
} catch (e) {
|
|
200
|
+
// leave it as it was
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Dashboard line: this server's version, and whether there is a newer release
|
|
206
|
+
*/
|
|
207
|
+
renderRelease() {
|
|
208
|
+
if (!this.installedVersion) {
|
|
209
|
+
return '';
|
|
210
|
+
}
|
|
211
|
+
let html = `<p><strong>FHIRsmith release:</strong> this server runs ${escape(this.installedVersion)}`;
|
|
212
|
+
const cmp = compareVersions(this.installedVersion, this.latestVersion);
|
|
213
|
+
if (cmp === null) {
|
|
214
|
+
html += ' (the latest release could not be checked)';
|
|
215
|
+
} else if (cmp < 0) {
|
|
216
|
+
html += ` - <span style="color:#b60">FHIRsmith ${escape(this.latestVersion)} is available</span>` +
|
|
217
|
+
' (<a href="https://github.com/HealthIntersections/FHIRsmith/releases">release notes</a>)';
|
|
218
|
+
} else {
|
|
219
|
+
html += ', the latest release';
|
|
220
|
+
}
|
|
221
|
+
return html + '</p>';
|
|
222
|
+
}
|
|
223
|
+
|
|
188
224
|
/**
|
|
189
225
|
* Look up the latest fhirsmith release. A failure only means the advice is less specific,
|
|
190
226
|
* so it is logged and otherwise ignored (the previous answer is kept)
|
|
@@ -227,6 +263,9 @@ class NpmAudit {
|
|
|
227
263
|
if (this.stats) {
|
|
228
264
|
this.stats.task('npm audit', 'Checking');
|
|
229
265
|
}
|
|
266
|
+
// independent of the audit: failures here are logged and otherwise ignored
|
|
267
|
+
this.readInstalledVersion();
|
|
268
|
+
await this.checkLatest();
|
|
230
269
|
try {
|
|
231
270
|
const packages = this.collectPackages();
|
|
232
271
|
const response = await axios.post(this.url, packages, {
|
|
@@ -255,7 +294,6 @@ class NpmAudit {
|
|
|
255
294
|
this.lastError = null;
|
|
256
295
|
const summary = this.summary();
|
|
257
296
|
if (findings.length > 0) {
|
|
258
|
-
await this.checkLatest();
|
|
259
297
|
if (this.logger) {
|
|
260
298
|
this.logger.warn(`npm audit: ${summary} in ${this.packageCount} packages: ` +
|
|
261
299
|
findings.map(f => `${f.name} (${f.severity}) ${f.url}`).join('; '));
|
|
@@ -356,6 +394,7 @@ class NpmAudit {
|
|
|
356
394
|
if (!this.enabled) {
|
|
357
395
|
return html + '<p><strong>npm audit:</strong> disabled (npmAudit.enabled = false)</p></div>';
|
|
358
396
|
}
|
|
397
|
+
html += this.renderRelease();
|
|
359
398
|
if (!this.checkedAt) {
|
|
360
399
|
html += '<p><strong>npm audit:</strong> ';
|
|
361
400
|
html += this.lastError ? `<span style="color:#b00">could not be run: ${escape(this.lastError)}</span>` : 'not run yet';
|
package/package.json
CHANGED
package/packages/openapi.yaml
CHANGED
|
@@ -350,9 +350,12 @@ paths:
|
|
|
350
350
|
summary: Download a package tarball, or get a version's document
|
|
351
351
|
description: |
|
|
352
352
|
Returns the package tarball (`.tgz`) - or, when the request's `Accept` header
|
|
353
|
-
|
|
354
|
-
the
|
|
355
|
-
|
|
353
|
+
prefers JSON, the version's npm document: the same object as that version's entry
|
|
354
|
+
in the package document. Preferring JSON means ranking `application/json` (or npm's
|
|
355
|
+
`application/vnd.npm.install-v1+json`) above any `*/*`, `application/*` or tarball
|
|
356
|
+
type by q-value: `application/json` alone does, but `application/json, text/plain, */*`
|
|
357
|
+
(axios's default, which SUSHI sends) does not. The response varies on `Accept` and
|
|
358
|
+
`FHIR-Package-Download`.
|
|
356
359
|
|
|
357
360
|
A request with a `FHIR-Package-Download` header always gets the tarball. The
|
|
358
361
|
HL7 Java tools send it; a client that downloads tarballs from this URL while
|
package/packages/packages.js
CHANGED
|
@@ -944,11 +944,13 @@ class PackagesModule {
|
|
|
944
944
|
}
|
|
945
945
|
}
|
|
946
946
|
|
|
947
|
-
|
|
947
|
+
// node-cron 4: stop() may return a promise
|
|
948
|
+
async stopCrawlerJob() {
|
|
948
949
|
if (this.crawlerJob) {
|
|
949
950
|
this.crawler.shutdown();
|
|
950
|
-
this.crawlerJob
|
|
951
|
+
const job = this.crawlerJob;
|
|
951
952
|
this.crawlerJob = null;
|
|
953
|
+
await job.stop();
|
|
952
954
|
pckLog.info('Package crawler job stopped');
|
|
953
955
|
}
|
|
954
956
|
}
|
|
@@ -1831,14 +1833,34 @@ class PackagesModule {
|
|
|
1831
1833
|
res.send(htmlServer.renderPage('packages', 'npm Registry Check', content, stats));
|
|
1832
1834
|
}
|
|
1833
1835
|
|
|
1834
|
-
// GET /{id}/{version} is npm's version document when the request
|
|
1835
|
-
// tarball otherwise - which is what FHIR clients have always got there
|
|
1836
|
-
//
|
|
1836
|
+
// GET /{id}/{version} is npm's version document when the request prefers JSON, and the
|
|
1837
|
+
// tarball otherwise - which is what FHIR clients have always got there. Preferring JSON
|
|
1838
|
+
// means ranking it above anything else the request accepts: a JSON type with a higher q
|
|
1839
|
+
// than any */*, application/* or tarball type. Many clients send JSON together with */*
|
|
1840
|
+
// at the same q - axios's default, application/json, text/plain, */*, which is what
|
|
1841
|
+
// SUSHI (fhir-package-loader) sends when it downloads a tarball from here - and they get
|
|
1842
|
+
// the tarball. A request carrying the TARBALL_HEADER always gets the tarball.
|
|
1837
1843
|
wantsVersionDocument(req) {
|
|
1838
1844
|
if (req.get(TARBALL_HEADER) !== undefined) {
|
|
1839
1845
|
return false;
|
|
1840
1846
|
}
|
|
1841
|
-
|
|
1847
|
+
let json = 0;
|
|
1848
|
+
let other = 0;
|
|
1849
|
+
for (const part of String(req.get('accept') || '').split(',')) {
|
|
1850
|
+
const [type, ...params] = part.split(';').map(s => s.trim().toLowerCase());
|
|
1851
|
+
const qParam = params.find(p => p.startsWith('q='));
|
|
1852
|
+
const q = qParam ? parseFloat(qParam.substring(2)) : 1;
|
|
1853
|
+
if (!type || Number.isNaN(q)) {
|
|
1854
|
+
continue;
|
|
1855
|
+
}
|
|
1856
|
+
if (type === 'application/json' || type === 'application/vnd.npm.install-v1+json') {
|
|
1857
|
+
json = Math.max(json, q);
|
|
1858
|
+
} else if (['*/*', 'application/*', 'application/tar+gzip', 'application/gzip', 'application/x-gzip',
|
|
1859
|
+
'application/x-tar', 'application/octet-stream'].includes(type)) {
|
|
1860
|
+
other = Math.max(other, q);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
return json > 0 && json > other;
|
|
1842
1864
|
}
|
|
1843
1865
|
|
|
1844
1866
|
async serveVersionDocument(secure, id, version, req, res) {
|
|
@@ -2844,7 +2866,7 @@ class PackagesModule {
|
|
|
2844
2866
|
async shutdown() {
|
|
2845
2867
|
pckLog.info('Shutting down Packages module...');
|
|
2846
2868
|
|
|
2847
|
-
this.stopCrawlerJob();
|
|
2869
|
+
await this.stopCrawlerJob();
|
|
2848
2870
|
|
|
2849
2871
|
// Close database connection
|
|
2850
2872
|
if (this.db) {
|
package/packages/readme.md
CHANGED
|
@@ -31,7 +31,7 @@ In summary:
|
|
|
31
31
|
| `GET /packages/catalog` | Search packages: `name`, `canonical`, `pkgcanonical`, `fhirversion`, `dependency`, `dependson`, `sort` |
|
|
32
32
|
| `GET /packages/-/v1/search` | npm search API: `text`, `size`, `from`, plus the `/catalog` parameters |
|
|
33
33
|
| `GET /packages/{id}` | npm package document: all versions, dependencies, tarball URLs |
|
|
34
|
-
| `GET /packages/{id}/{version}` | Download a package tarball; or, when the request's `Accept`
|
|
34
|
+
| `GET /packages/{id}/{version}` | Download a package tarball; or, when the request's `Accept` prefers JSON (ranks it above `*/*`), npm's document for that version. A `FHIR-Package-Download` header always gets the tarball (the HL7 Java tools send it) |
|
|
35
35
|
| `GET /packages/updates` | Versions published since a date |
|
|
36
36
|
| `GET /packages/broken` | Package versions whose dependencies this registry doesn't hold |
|
|
37
37
|
| `GET /packages/npm-check` | Check any registry against the npm registry protocol (a form, in a browser) |
|
package/shl/shl.js
CHANGED
|
@@ -418,10 +418,12 @@ class SHLModule {
|
|
|
418
418
|
}
|
|
419
419
|
}
|
|
420
420
|
|
|
421
|
-
|
|
421
|
+
// node-cron 4: stop() may return a promise
|
|
422
|
+
async stopCleanupJob() {
|
|
422
423
|
if (this.cleanupJob) {
|
|
423
|
-
this.cleanupJob
|
|
424
|
+
const job = this.cleanupJob;
|
|
424
425
|
this.cleanupJob = null;
|
|
426
|
+
await job.stop();
|
|
425
427
|
}
|
|
426
428
|
}
|
|
427
429
|
|
|
@@ -1060,7 +1062,7 @@ class SHLModule {
|
|
|
1060
1062
|
async shutdown() {
|
|
1061
1063
|
shlLog.info('Shutting down SHL module...');
|
|
1062
1064
|
|
|
1063
|
-
this.stopCleanupJob();
|
|
1065
|
+
await this.stopCleanupJob();
|
|
1064
1066
|
|
|
1065
1067
|
// Stop FHIR validator
|
|
1066
1068
|
if (this.fhirValidator) {
|
|
@@ -58,6 +58,21 @@ function stripIdPrefix(id, spaceId) {
|
|
|
58
58
|
return id.startsWith(prefix) && id.length > prefix.length ? id.substring(prefix.length) : null;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The id a resource had in its source, for an id in any id space: everything after the
|
|
63
|
+
* prefix, which never contains a '-' (see checkIdPrefix). Null if the id has no prefix.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} id
|
|
66
|
+
* @returns {string|null}
|
|
67
|
+
*/
|
|
68
|
+
function originalId(id) {
|
|
69
|
+
if (typeof id !== 'string') {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const i = id.indexOf('-');
|
|
73
|
+
return i > 0 && i < id.length - 1 ? id.substring(i + 1) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
61
76
|
/**
|
|
62
77
|
* Whether a prefix given in the library YAML is acceptable. It's the start of an id, so it
|
|
63
78
|
* may only use id characters, and no '-', so that the prefix of any id is unambiguous.
|
|
@@ -104,4 +119,4 @@ function assignIdSpace(provider, spaceId) {
|
|
|
104
119
|
|
|
105
120
|
const CORE_PREFIX = 'core';
|
|
106
121
|
|
|
107
|
-
module.exports = { applyIdPrefix, stripIdPrefix, checkIdPrefix, assignIdSpace, CORE_PREFIX };
|
|
122
|
+
module.exports = { applyIdPrefix, stripIdPrefix, originalId, checkIdPrefix, assignIdSpace, CORE_PREFIX };
|
package/tx/tx-html.js
CHANGED
|
@@ -1329,6 +1329,30 @@ class TxHtmlRenderer {
|
|
|
1329
1329
|
return html;
|
|
1330
1330
|
}
|
|
1331
1331
|
|
|
1332
|
+
/**
|
|
1333
|
+
* The page for a read of an id that nothing has, but that several resources had in their
|
|
1334
|
+
* source (see ReadWorker.notFound): a list of them, to pick from.
|
|
1335
|
+
*
|
|
1336
|
+
* @param {string} resourceType
|
|
1337
|
+
* @param {string} id - the id that was asked for
|
|
1338
|
+
* @param {Array<{id, url, version, title, source}>} matches
|
|
1339
|
+
* @returns {string} html
|
|
1340
|
+
*/
|
|
1341
|
+
renderIdChoices(resourceType, id, matches) {
|
|
1342
|
+
let html = `<p>There is no ${escape(resourceType)} with the id <code>${escape(id)}</code> on this server. ` +
|
|
1343
|
+
`Every resource here has an id that starts with a prefix for the source it came from, and ` +
|
|
1344
|
+
`${matches.length} ${escape(resourceType)} resources had the id <code>${escape(id)}</code> in their source:</p>`;
|
|
1345
|
+
html += '<table class="grid"><tr><th>Id</th><th>URL</th><th>Version</th><th>Title</th><th>Source</th></tr>';
|
|
1346
|
+
for (const m of matches) {
|
|
1347
|
+
// relative to /{type}/{id}, like the redirect when there is only one
|
|
1348
|
+
html += `<tr><td><a href="${escape(encodeURIComponent(m.id))}">${escape(m.id)}</a></td>` +
|
|
1349
|
+
`<td>${escape(m.url || '')}</td><td>${escape(m.version || '')}</td>` +
|
|
1350
|
+
`<td>${escape(m.title || '')}</td><td>${escape(m.source || '')}</td></tr>`;
|
|
1351
|
+
}
|
|
1352
|
+
html += '</table>';
|
|
1353
|
+
return html;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1332
1356
|
/**
|
|
1333
1357
|
* Render Bundle resource
|
|
1334
1358
|
*/
|
package/tx/tx.js
CHANGED
|
@@ -516,8 +516,12 @@ class TXModule {
|
|
|
516
516
|
return res.status(400).json(buildOperationOutcome('error', 'invalid', `Invalid XML: ${e.message}`));
|
|
517
517
|
}
|
|
518
518
|
}
|
|
519
|
-
} else if (contentType != 'application/x-www-form-urlencoded') {
|
|
520
|
-
|
|
519
|
+
} else if (contentType.split(';')[0].trim().toLowerCase() != 'application/x-www-form-urlencoded') {
|
|
520
|
+
// compare the media type alone: a form post may carry a charset parameter
|
|
521
|
+
const message = contentType
|
|
522
|
+
? `Unsupported Media Type: ${contentType}`
|
|
523
|
+
: `No Content-Type was given for this ${req.method}; use application/fhir+json or application/fhir+xml`;
|
|
524
|
+
return res.status(415).json(buildOperationOutcome('error', 'invalid', message));
|
|
521
525
|
}
|
|
522
526
|
|
|
523
527
|
if (req.body) {
|
|
@@ -994,7 +998,7 @@ class TXModule {
|
|
|
994
998
|
));
|
|
995
999
|
}
|
|
996
1000
|
let worker = new ReadWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
|
|
997
|
-
await worker.handle(req, res, resourceType);
|
|
1001
|
+
await worker.handle(req, res, resourceType, (req, res, resourceType, id, matches) => this.renderIdChoices(req, res, resourceType, id, matches));
|
|
998
1002
|
} finally {
|
|
999
1003
|
this.countRequest(endpointPath, 'read', Date.now() - start);
|
|
1000
1004
|
}
|
|
@@ -1432,6 +1436,17 @@ class TXModule {
|
|
|
1432
1436
|
return count;
|
|
1433
1437
|
}
|
|
1434
1438
|
|
|
1439
|
+
// A read of an unprefixed id that several resources had in their source: the page
|
|
1440
|
+
// that lists them (status 300, Multiple Choices). See ReadWorker.notFound.
|
|
1441
|
+
async renderIdChoices(req, res, resourceType, id, matches) {
|
|
1442
|
+
const txhtml = new TxHtmlRenderer(new Renderer(req.txOpContext, req.txEndpoint.provider), this.liquid, this.languages, this.i18n, req.txEndpoint.path);
|
|
1443
|
+
const content = txhtml.renderIdChoices(resourceType, id, matches);
|
|
1444
|
+
const html = await txhtml.renderPage(`${resourceType}/${id}`, content, req.txEndpoint, req.txStartTime);
|
|
1445
|
+
res.status(300);
|
|
1446
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1447
|
+
return res.send(html);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1435
1450
|
// $closure usage for the home page: null when closure isn't turned on
|
|
1436
1451
|
closureStats() {
|
|
1437
1452
|
if (!this.closureStore) {
|
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
|
|