fhirsmith 0.15.1 → 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 +36 -0
- package/README.md +14 -1
- package/gr.sh +3 -0
- package/library/npm-audit.js +108 -2
- package/library/sqlite-session-store.js +133 -0
- package/package.json +18 -28
- 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/token/token.js +7 -10
- package/tx/importers/import-rxnorm.module.js +1 -1
- package/tx/importers/import-sct.module.js +1 -1
- package/tx/library/designations.js +1 -1
- package/tx/library/resource-ids.js +16 -1
- package/tx/sct/text-index.js +1 -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,42 @@ 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
|
+
|
|
26
|
+
## [0.16.0] - 2026-10-15
|
|
27
|
+
|
|
28
|
+
### Security
|
|
29
|
+
-
|
|
30
|
+
- Token: the login session store is now FHIRsmith's own (`library/sqlite-session-store.js`), in place of connect-sqlite3. connect-sqlite3 depends on sqlite3 5, which brought node-gyp 8, tar 6 and @tootallnate/once into every npm install of FHIRsmith (12 advisories, one critical). The `overrides` in package.json kept them out of a clone + `npm ci` (and so out of the Docker image and the repo's own `npm audit`), but npm ignores overrides when installing the published package, so `npm install -g fhirsmith` still got them. Sessions are kept in the same `sessions` table of the token database, in the same layout, so existing logins carry over
|
|
31
|
+
- Dependencies: fhirpath 5 (its ucum-lhc no longer brings csv-parse 4) and node-cron 4 (no longer depends on uuid), for the same reason: the csv-parse and uuid overrides didn't reach npm installs. The csv-parse override is gone
|
|
32
|
+
- Dependencies: natural 8 (uuid 13). Its stemmers are unchanged - every stemmer we use gives the same stem as natural 6 on ~1.6M words (RxNorm, LOINC's translations, and English, French, Spanish, Italian and Portuguese word lists), so existing SNOMED caches and RxNorm databases don't need rebuilding. FHIRsmith now loads only `natural/lib/natural/stemmers`: the package index also loads natural's storage backends, which run dotenv (reading any `.env` in the working directory into the environment, and in natural 8 printing a banner to stdout)
|
|
33
|
+
- Development: jest 30 and jest-junit 17, and `npm run dev` uses Node's own `node --watch` in place of nodemon. With these, `npm audit` is clean for the dev tooling too (jest 29 and nodemon brought braces, and jest's coverage tooling sprintf-js, neither of which has a fixed version). The package.json overrides are down to one, js-yaml 4 for @istanbuljs/load-nyc-config, which only affects the dev install; the rest were no longer doing anything
|
|
34
|
+
- The PR pipeline fails on high/critical advisories anywhere in the dependency tree, on moderate ones in what ships, and audits a fresh install of the packed package (installed the way users install it, so the repo's overrides don't apply)
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- The npm audit self-check looks up the latest FHIRsmith release, and only tells the administrator to upgrade when there is a newer release to upgrade to; a server already on the latest release says so instead
|
|
39
|
+
|
|
40
|
+
### Tx Conformance Statement
|
|
41
|
+
|
|
42
|
+
FHIRsmith v0.16.0 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.1)
|
|
43
|
+
|
|
8
44
|
## [0.15.1] - 2026-10-15
|
|
9
45
|
|
|
10
46
|
### 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
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
//
|
|
13
13
|
// Config (all optional), top level of config.json:
|
|
14
14
|
// "npmAudit": { "enabled": true, "intervalHours": 24 }
|
|
15
|
+
//
|
|
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:
|
|
19
|
+
// npm ignores our package.json "overrides" for anything but a clone + npm ci, so an
|
|
20
|
+
// npm-installed server can carry transitive versions the repo itself has overridden away.
|
|
15
21
|
|
|
16
22
|
const fs = require('fs');
|
|
17
23
|
const path = require('path');
|
|
@@ -19,6 +25,7 @@ const axios = require('axios');
|
|
|
19
25
|
const escape = require('escape-html');
|
|
20
26
|
|
|
21
27
|
const DEFAULT_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk';
|
|
28
|
+
const DEFAULT_LATEST_URL = 'https://registry.npmjs.org/fhirsmith/latest';
|
|
22
29
|
const SEVERITIES = ['critical', 'high', 'moderate', 'low', 'info'];
|
|
23
30
|
const HOUR_MS = 60 * 60 * 1000;
|
|
24
31
|
const FIRST_RUN_DELAY_MS = 60 * 1000; // let startup finish first
|
|
@@ -38,11 +45,34 @@ function describeAgo(time, now = Date.now()) {
|
|
|
38
45
|
return `${Math.floor(hours / 24)} days ago`;
|
|
39
46
|
}
|
|
40
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Compare two x.y.z versions numerically (pre-release and build suffixes ignored).
|
|
50
|
+
* Returns <0, 0 or >0, or null if either can't be read
|
|
51
|
+
*/
|
|
52
|
+
function compareVersions(a, b) {
|
|
53
|
+
const parse = v => {
|
|
54
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || '').trim());
|
|
55
|
+
return m ? m.slice(1).map(Number) : null;
|
|
56
|
+
};
|
|
57
|
+
const x = parse(a);
|
|
58
|
+
const y = parse(b);
|
|
59
|
+
if (!x || !y) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
for (let i = 0; i < 3; i++) {
|
|
63
|
+
if (x[i] !== y[i]) {
|
|
64
|
+
return x[i] - y[i];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
41
70
|
class NpmAudit {
|
|
42
71
|
constructor(config = {}, logger = null, appDir = path.join(__dirname, '..'), stats = null) {
|
|
43
72
|
this.enabled = config.enabled !== false;
|
|
44
73
|
this.intervalHours = config.intervalHours > 0 ? config.intervalHours : 24;
|
|
45
74
|
this.url = config.url || DEFAULT_URL;
|
|
75
|
+
this.latestUrl = config.latestUrl || DEFAULT_LATEST_URL;
|
|
46
76
|
this.timeout = config.timeout || 60000;
|
|
47
77
|
this.logger = logger;
|
|
48
78
|
this.appDir = appDir;
|
|
@@ -55,6 +85,8 @@ class NpmAudit {
|
|
|
55
85
|
this.findings = []; // [{name, installed: [versions], id, severity, title, url, range}]
|
|
56
86
|
this.lastError = null; // message from the last attempt, if it failed
|
|
57
87
|
this.lastAttempt = null;
|
|
88
|
+
this.installedVersion = null; // this server's fhirsmith version, from package.json
|
|
89
|
+
this.latestVersion = null; // the latest fhirsmith release on npm, if it could be found
|
|
58
90
|
}
|
|
59
91
|
|
|
60
92
|
/**
|
|
@@ -93,6 +125,7 @@ class NpmAudit {
|
|
|
93
125
|
const pkg = JSON.parse(fs.readFileSync(path.join(this.appDir, 'package.json'), 'utf8'));
|
|
94
126
|
if (pkg.name && pkg.version) {
|
|
95
127
|
add(pkg.name, pkg.version);
|
|
128
|
+
this.installedVersion = pkg.version;
|
|
96
129
|
}
|
|
97
130
|
} catch (e) {
|
|
98
131
|
// no package.json - nothing to add
|
|
@@ -153,6 +186,74 @@ class NpmAudit {
|
|
|
153
186
|
return true;
|
|
154
187
|
}
|
|
155
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
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Look up the latest fhirsmith release. A failure only means the advice is less specific,
|
|
226
|
+
* so it is logged and otherwise ignored (the previous answer is kept)
|
|
227
|
+
*/
|
|
228
|
+
async checkLatest() {
|
|
229
|
+
try {
|
|
230
|
+
const response = await axios.get(this.latestUrl, { timeout: this.timeout, headers: { 'Accept': 'application/json' } });
|
|
231
|
+
const version = response.data && response.data.version;
|
|
232
|
+
if (typeof version === 'string' && compareVersions(version, version) === 0) {
|
|
233
|
+
this.latestVersion = version;
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (this.logger) {
|
|
237
|
+
this.logger.info('npm audit: could not look up the latest FHIRsmith release: ' + error.message);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* What the administrator can do about the findings
|
|
244
|
+
*/
|
|
245
|
+
advice() {
|
|
246
|
+
const cmp = compareVersions(this.installedVersion, this.latestVersion);
|
|
247
|
+
if (cmp === null) {
|
|
248
|
+
return 'The server administrator should upgrade to the latest FHIRsmith release.';
|
|
249
|
+
}
|
|
250
|
+
if (cmp < 0) {
|
|
251
|
+
return `The server administrator should upgrade to FHIRsmith ${this.latestVersion} (this server is running ${this.installedVersion}).`;
|
|
252
|
+
}
|
|
253
|
+
return `This server is already running the latest FHIRsmith release (${this.installedVersion}), ` +
|
|
254
|
+
'so these will be fixed by a future FHIRsmith release.';
|
|
255
|
+
}
|
|
256
|
+
|
|
156
257
|
async run() {
|
|
157
258
|
if (this.running) {
|
|
158
259
|
return;
|
|
@@ -162,6 +263,9 @@ class NpmAudit {
|
|
|
162
263
|
if (this.stats) {
|
|
163
264
|
this.stats.task('npm audit', 'Checking');
|
|
164
265
|
}
|
|
266
|
+
// independent of the audit: failures here are logged and otherwise ignored
|
|
267
|
+
this.readInstalledVersion();
|
|
268
|
+
await this.checkLatest();
|
|
165
269
|
try {
|
|
166
270
|
const packages = this.collectPackages();
|
|
167
271
|
const response = await axios.post(this.url, packages, {
|
|
@@ -278,7 +382,7 @@ class NpmAudit {
|
|
|
278
382
|
return `<div class="alert ${cls}" role="alert"><strong>Security:</strong> npm audit found ` +
|
|
279
383
|
`${escape(this.summary())} in the packages this server uses ` +
|
|
280
384
|
`(checked ${escape(describeAgo(this.checkedAt.getTime(), now))}). ` +
|
|
281
|
-
|
|
385
|
+
`${escape(this.advice())} ` +
|
|
282
386
|
'<a href="/dashboard#npm-audit">Details</a></div>';
|
|
283
387
|
}
|
|
284
388
|
|
|
@@ -290,6 +394,7 @@ class NpmAudit {
|
|
|
290
394
|
if (!this.enabled) {
|
|
291
395
|
return html + '<p><strong>npm audit:</strong> disabled (npmAudit.enabled = false)</p></div>';
|
|
292
396
|
}
|
|
397
|
+
html += this.renderRelease();
|
|
293
398
|
if (!this.checkedAt) {
|
|
294
399
|
html += '<p><strong>npm audit:</strong> ';
|
|
295
400
|
html += this.lastError ? `<span style="color:#b00">could not be run: ${escape(this.lastError)}</span>` : 'not run yet';
|
|
@@ -303,6 +408,7 @@ class NpmAudit {
|
|
|
303
408
|
}
|
|
304
409
|
html += '</p>';
|
|
305
410
|
if (this.findings.length > 0) {
|
|
411
|
+
html += `<p>${escape(this.advice())}</p>`;
|
|
306
412
|
html += '<table class="grid"><tr><th>Severity</th><th>Package</th><th>Installed</th><th>Vulnerable</th><th>Advisory</th></tr>';
|
|
307
413
|
for (const f of this.findings) {
|
|
308
414
|
const link = /^https:\/\//.test(f.url) ? `<a href="${escape(f.url)}">${escape(f.title || f.url)}</a>` : escape(f.title);
|
|
@@ -315,4 +421,4 @@ class NpmAudit {
|
|
|
315
421
|
}
|
|
316
422
|
}
|
|
317
423
|
|
|
318
|
-
module.exports = { NpmAudit, describeAgo };
|
|
424
|
+
module.exports = { NpmAudit, describeAgo, compareVersions };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// library/sqlite-session-store.js
|
|
2
|
+
// An express-session store kept in a table of an already-open sqlite3 database.
|
|
3
|
+
//
|
|
4
|
+
// This replaces connect-sqlite3, which depends on sqlite3 ^5 and so brought a second, old
|
|
5
|
+
// copy of sqlite3 (and with it node-gyp 8 and tar 6) into every npm install of FHIRsmith.
|
|
6
|
+
// Our package.json "overrides" hid that from npm audit in the repo, but npm ignores
|
|
7
|
+
// overrides for anyone installing the published package.
|
|
8
|
+
//
|
|
9
|
+
// The table layout is the one connect-sqlite3 used - (sid PRIMARY KEY, expired, sess),
|
|
10
|
+
// with expired in epoch ms and sess as JSON - so sessions in an existing database carry over.
|
|
11
|
+
|
|
12
|
+
const session = require('express-session');
|
|
13
|
+
|
|
14
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
+
|
|
17
|
+
class SqliteSessionStore extends session.Store {
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} options
|
|
20
|
+
* @param {object} options.db - an open sqlite3 Database; the caller owns it and closes it
|
|
21
|
+
* @param {string} [options.table='sessions']
|
|
22
|
+
* @param {number} [options.cleanupIntervalMs=one day] - how often expired sessions are deleted
|
|
23
|
+
*/
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
super();
|
|
26
|
+
if (!options.db) {
|
|
27
|
+
throw new Error('SqliteSessionStore needs an open sqlite3 database');
|
|
28
|
+
}
|
|
29
|
+
const table = options.table || 'sessions';
|
|
30
|
+
if (!TABLE_NAME.test(table)) {
|
|
31
|
+
throw new Error('Invalid session table name: ' + table);
|
|
32
|
+
}
|
|
33
|
+
this.db = options.db;
|
|
34
|
+
this.table = table;
|
|
35
|
+
this.timer = null;
|
|
36
|
+
this.ready = this.run(`CREATE TABLE IF NOT EXISTS ${table} (sid PRIMARY KEY, expired, sess)`)
|
|
37
|
+
.then(() => this.cleanup());
|
|
38
|
+
this.ready.catch(() => { /* reported by the first operation that waits on it */ });
|
|
39
|
+
const interval = options.cleanupIntervalMs > 0 ? options.cleanupIntervalMs : ONE_DAY_MS;
|
|
40
|
+
this.timer = setInterval(() => void this.cleanup().catch(() => {}), interval);
|
|
41
|
+
if (this.timer.unref) {
|
|
42
|
+
this.timer.unref();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
run(sql, params = []) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
this.db.run(sql, params, function (err) {
|
|
49
|
+
if (err) {
|
|
50
|
+
reject(err);
|
|
51
|
+
} else {
|
|
52
|
+
resolve(this);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
query(method, sql, params = []) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
this.db[method](sql, params, (err, result) => err ? reject(err) : resolve(result));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// adapt a promise to express-session's callback style
|
|
65
|
+
done(promise, cb) {
|
|
66
|
+
promise.then(result => cb && cb(null, result), err => cb && cb(err));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
cleanup() {
|
|
70
|
+
return this.run(`DELETE FROM ${this.table} WHERE expired < ?`, [Date.now()]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static expiry(sess) {
|
|
74
|
+
if (sess && sess.cookie) {
|
|
75
|
+
if (sess.cookie.expires) {
|
|
76
|
+
const t = new Date(sess.cookie.expires).getTime();
|
|
77
|
+
if (!isNaN(t)) {
|
|
78
|
+
return t;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (sess.cookie.maxAge) {
|
|
82
|
+
return Date.now() + sess.cookie.maxAge;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return Date.now() + ONE_DAY_MS;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get(sid, cb) {
|
|
89
|
+
this.done(this.ready
|
|
90
|
+
.then(() => this.query('get', `SELECT sess FROM ${this.table} WHERE sid = ? AND expired >= ?`, [sid, Date.now()]))
|
|
91
|
+
.then(row => row ? JSON.parse(row.sess) : null), cb);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
set(sid, sess, cb) {
|
|
95
|
+
this.done(this.ready
|
|
96
|
+
.then(() => this.run(`INSERT OR REPLACE INTO ${this.table} (sid, expired, sess) VALUES (?, ?, ?)`,
|
|
97
|
+
[sid, SqliteSessionStore.expiry(sess), JSON.stringify(sess)]))
|
|
98
|
+
.then(() => undefined), cb);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
touch(sid, sess, cb) {
|
|
102
|
+
this.done(this.ready
|
|
103
|
+
.then(() => this.run(`UPDATE ${this.table} SET expired = ? WHERE sid = ? AND expired >= ?`,
|
|
104
|
+
[SqliteSessionStore.expiry(sess), sid, Date.now()]))
|
|
105
|
+
.then(() => undefined), cb);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
destroy(sid, cb) {
|
|
109
|
+
this.done(this.ready
|
|
110
|
+
.then(() => this.run(`DELETE FROM ${this.table} WHERE sid = ?`, [sid]))
|
|
111
|
+
.then(() => undefined), cb);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
length(cb) {
|
|
115
|
+
this.done(this.ready
|
|
116
|
+
.then(() => this.query('get', `SELECT COUNT(*) AS count FROM ${this.table} WHERE expired >= ?`, [Date.now()]))
|
|
117
|
+
.then(row => row.count), cb);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
clear(cb) {
|
|
121
|
+
this.done(this.ready.then(() => this.run(`DELETE FROM ${this.table}`)).then(() => undefined), cb);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Stop the cleanup timer. The database itself belongs to the caller. */
|
|
125
|
+
close() {
|
|
126
|
+
if (this.timer) {
|
|
127
|
+
clearInterval(this.timer);
|
|
128
|
+
this.timer = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { SqliteSessionStore };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fhirsmith",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"txVersion": "1.9.6",
|
|
5
5
|
"description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
|
|
6
6
|
"main": "server.js",
|
|
@@ -9,18 +9,18 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node server.js",
|
|
12
|
-
"dev": "
|
|
12
|
+
"dev": "node --watch server.js",
|
|
13
13
|
"test": "jest",
|
|
14
14
|
"test:watch": "jest --watch",
|
|
15
15
|
"test:coverage": "jest --coverage",
|
|
16
16
|
"test:ci": "jest --ci --coverage --watchAll=false --reporters=default --reporters=jest-junit --verbose",
|
|
17
|
-
"test:shl": "jest --
|
|
18
|
-
"test:vcl": "jest --
|
|
19
|
-
"test:xig": "jest --
|
|
20
|
-
"test:packages": "jest --
|
|
21
|
-
"test:token": "jest --
|
|
22
|
-
"test:integration": "jest --
|
|
23
|
-
"test:unit": "jest --
|
|
17
|
+
"test:shl": "jest --testPathPatterns=shl",
|
|
18
|
+
"test:vcl": "jest --testPathPatterns=vcl",
|
|
19
|
+
"test:xig": "jest --testPathPatterns=xig",
|
|
20
|
+
"test:packages": "jest --testPathPatterns=packages",
|
|
21
|
+
"test:token": "jest --testPathPatterns=token",
|
|
22
|
+
"test:integration": "jest --testPathPatterns=integration",
|
|
23
|
+
"test:unit": "jest --testPathPatterns=unit",
|
|
24
24
|
"lint": "eslint . --ext .js --ignore-path .gitignore",
|
|
25
25
|
"lint:fix": "eslint . --fix --ignore-pattern 'static/assets/js/html5shiv.js'",
|
|
26
26
|
"lint:relaxed": "eslint . --config .eslintrc.relaxed.js",
|
|
@@ -41,21 +41,20 @@
|
|
|
41
41
|
"cli-progress": "^3.12.0",
|
|
42
42
|
"commander": "^14.0.3",
|
|
43
43
|
"commonmark": "^0.31.2",
|
|
44
|
-
"connect-sqlite3": "^0.9.18",
|
|
45
44
|
"cors": "^2.8.6",
|
|
46
45
|
"express": "^5.2.1",
|
|
47
46
|
"express-rate-limit": "^7.4.1",
|
|
48
47
|
"express-session": "^1.19.0",
|
|
49
48
|
"fast-xml-parser": "^5.3.4",
|
|
50
49
|
"fhir-validator-wrapper": "1.4.0",
|
|
51
|
-
"fhirpath": "^
|
|
50
|
+
"fhirpath": "^5.2.1",
|
|
52
51
|
"fs-extra": "^11.3.3",
|
|
53
52
|
"ini": "^6.0.0",
|
|
54
53
|
"inquirer": "^8.2.5",
|
|
55
54
|
"liquidjs": "^10.25.5",
|
|
56
55
|
"lusca": "^1.7.0",
|
|
57
|
-
"natural": "^
|
|
58
|
-
"node-cron": "^
|
|
56
|
+
"natural": "^8.1.1",
|
|
57
|
+
"node-cron": "^4.6.0",
|
|
59
58
|
"pako": "^2.1.0",
|
|
60
59
|
"passport": "^0.7.0",
|
|
61
60
|
"passport-facebook": "^3.0.0",
|
|
@@ -71,18 +70,17 @@
|
|
|
71
70
|
"yaml": "^2.8.2"
|
|
72
71
|
},
|
|
73
72
|
"devDependencies": {
|
|
74
|
-
"@types/jest": "^
|
|
73
|
+
"@types/jest": "^30.0.0",
|
|
75
74
|
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
|
76
75
|
"@typescript-eslint/parser": "^8.54.0",
|
|
77
76
|
"ajv": "^8.20.0",
|
|
78
77
|
"eslint": "^8.57.1",
|
|
79
78
|
"eslint-plugin-promise": "^7.2.1",
|
|
80
|
-
"jest": "^
|
|
81
|
-
"jest-environment-node": "^
|
|
82
|
-
"jest-junit": "^
|
|
79
|
+
"jest": "^30.5.2",
|
|
80
|
+
"jest-environment-node": "^30.5.2",
|
|
81
|
+
"jest-junit": "^17.0.0",
|
|
83
82
|
"mock-fs": "^5.5.0",
|
|
84
83
|
"nock": "^13.4.0",
|
|
85
|
-
"nodemon": "^3.1.11",
|
|
86
84
|
"supertest": "^7.2.2",
|
|
87
85
|
"tmp": "^0.2.5"
|
|
88
86
|
},
|
|
@@ -107,16 +105,8 @@
|
|
|
107
105
|
"author": "Health Intersections Pty Ltd",
|
|
108
106
|
"license": "BSD-3",
|
|
109
107
|
"overrides": {
|
|
110
|
-
"
|
|
111
|
-
|
|
112
|
-
"inquirer": {
|
|
113
|
-
"lodash": "4.18.1"
|
|
114
|
-
},
|
|
115
|
-
"brace-expansion": "^5.0.8",
|
|
116
|
-
"csv-parse": "^7.0.3",
|
|
117
|
-
"uuid": "^11.1.1",
|
|
118
|
-
"connect-sqlite3": {
|
|
119
|
-
"sqlite3": "$sqlite3"
|
|
108
|
+
"@istanbuljs/load-nyc-config": {
|
|
109
|
+
"js-yaml": "^4.1.0"
|
|
120
110
|
}
|
|
121
111
|
},
|
|
122
112
|
"bin": {
|
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) {
|
package/token/token.js
CHANGED
|
@@ -11,7 +11,6 @@ const crypto = require('crypto');
|
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const session = require('express-session');
|
|
14
|
-
const SQLiteStore = require('connect-sqlite3')(session);
|
|
15
14
|
const passport = require('passport');
|
|
16
15
|
const GoogleStrategy = require('passport-google-oauth20').Strategy;
|
|
17
16
|
const FacebookStrategy = require('passport-facebook').Strategy;
|
|
@@ -19,6 +18,7 @@ const GitHubStrategy = require('passport-github2').Strategy;
|
|
|
19
18
|
const rateLimit = require('express-rate-limit');
|
|
20
19
|
const lusca = require('lusca');
|
|
21
20
|
const folders = require('../library/folder-setup');
|
|
21
|
+
const { SqliteSessionStore } = require('../library/sqlite-session-store');
|
|
22
22
|
const escape = require('escape-html');
|
|
23
23
|
|
|
24
24
|
|
|
@@ -155,17 +155,11 @@ class TokenModule {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
initializeSession() {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
? configDb
|
|
161
|
-
: folders.ensureFilePath('token', configDb);
|
|
158
|
+
// sessions live in the module's own database (initializeDatabase has opened it)
|
|
159
|
+
this.sessionStore = new SqliteSessionStore({ db: this.db, table: 'sessions' });
|
|
162
160
|
|
|
163
161
|
const sessionConfig = {
|
|
164
|
-
store:
|
|
165
|
-
dir: path.dirname(sessionDbPath),
|
|
166
|
-
db: path.basename(sessionDbPath),
|
|
167
|
-
table: 'sessions'
|
|
168
|
-
}),
|
|
162
|
+
store: this.sessionStore,
|
|
169
163
|
secret: this.config.sessionSecret || crypto.randomBytes(64).toString('hex'),
|
|
170
164
|
name: 'fhir.token.sid', // Don't use default session name
|
|
171
165
|
resave: false,
|
|
@@ -1287,6 +1281,9 @@ class TokenModule {
|
|
|
1287
1281
|
}
|
|
1288
1282
|
|
|
1289
1283
|
async shutdown() {
|
|
1284
|
+
if (this.sessionStore) {
|
|
1285
|
+
this.sessionStore.close();
|
|
1286
|
+
}
|
|
1290
1287
|
if (this.db) {
|
|
1291
1288
|
await new Promise((resolve) => {
|
|
1292
1289
|
this.db.close((err) => {
|
|
@@ -780,7 +780,7 @@ class RxNormImporter {
|
|
|
780
780
|
if (this.options.verbose) console.log('Generating word stems...');
|
|
781
781
|
|
|
782
782
|
// Simple English stemmer implementation
|
|
783
|
-
var natural = require('natural');
|
|
783
|
+
var natural = require('natural/lib/natural/stemmers'); // just the stemmers: the package index also loads its storage backends, and dotenv with them
|
|
784
784
|
const stems = new Map();
|
|
785
785
|
|
|
786
786
|
// Get all RXNORM concepts
|
|
@@ -4,7 +4,7 @@ const inquirer = require('inquirer');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const readline = require('readline');
|
|
6
6
|
const chalk = require('chalk');
|
|
7
|
-
const natural = require('natural');
|
|
7
|
+
const natural = require('natural/lib/natural/stemmers'); // just the stemmers: the package index also loads its storage backends, and dotenv with them
|
|
8
8
|
|
|
9
9
|
const {
|
|
10
10
|
SnomedStrings, SnomedWords, SnomedStems, SnomedReferences,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { LanguagePartType, Languages, Language, LanguageDefinitions} = require('../../library/languages');
|
|
2
2
|
const {validateParameter, validateOptionalParameter, validateArrayParameter} = require("../../library/utilities");
|
|
3
|
-
const natural = require('natural');
|
|
3
|
+
const natural = require('natural/lib/natural/stemmers'); // just the stemmers: the package index also loads its storage backends, and dotenv with them
|
|
4
4
|
/**
|
|
5
5
|
* Display checking modes for concept designations
|
|
6
6
|
*/
|
|
@@ -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/sct/text-index.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* event loop as it goes, and a search takes milliseconds once it exists.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
const natural = require('natural');
|
|
30
|
+
const natural = require('natural/lib/natural/stemmers'); // just the stemmers: the package index also loads its storage backends, and dotenv with them
|
|
31
31
|
|
|
32
32
|
const stemmer = natural.PorterStemmer;
|
|
33
33
|
|
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
|
|