fhirsmith 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/README.md +80 -13
- package/config-template.json +106 -1
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +21 -2
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +7 -2
- package/packages/packages.js +34 -3
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +41 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +230 -44
- package/registry/uptime.js +154 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +56 -1
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +47 -25
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/provider.js +16 -4
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +15 -1
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +33 -6
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The syndication store: the content that has flowed through the tx library, as the
|
|
3
|
+
* Terminology Syndication Feed IG (https://build.fhir.org/ig/FHIR/fhir-syndication-ig/)
|
|
4
|
+
* will publish it.
|
|
5
|
+
*
|
|
6
|
+
* The store is a folder (terminology-cache/syndication) holding syndication.json - the
|
|
7
|
+
* metadata for every entry - and files/, copies of the naked resource files the library
|
|
8
|
+
* loaded. Packages and binaries are not copied: packages are permanently available from
|
|
9
|
+
* npm.fhir.org, and the binaries are write-once in the external store they were downloaded
|
|
10
|
+
* from, so their entries link there.
|
|
11
|
+
*
|
|
12
|
+
* Rules (see the syndication design doc in the FHIRsmith project):
|
|
13
|
+
* - content is inducted as the library loads it. Nothing is back-filled: anything the store
|
|
14
|
+
* has not seen before is new, and gets the time it was inducted
|
|
15
|
+
* - the store only grows. Entries are never changed or removed, and there are no retract
|
|
16
|
+
* entries. A later update to the same content (a changed file for the same url|version)
|
|
17
|
+
* is a new entry, so contentItemVersion is deliberately not unique (the IG says it must
|
|
18
|
+
* be; the spec authors agree that rule is wrong)
|
|
19
|
+
* - one store serves every endpoint. Only the core packages are FHIR version specific; their
|
|
20
|
+
* entries carry fhirVersion, and every other entry is in every endpoint's feed
|
|
21
|
+
*
|
|
22
|
+
* Induction is queued and done in the background, one item at a time, so that downloading
|
|
23
|
+
* a package to hash it never holds up the server starting. A failed induction is logged and
|
|
24
|
+
* not recorded, so it is tried again the next time the content is loaded.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const fsp = fs.promises;
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const crypto = require('crypto');
|
|
31
|
+
|
|
32
|
+
const NCTS_SCHEME = 'http://ns.electronichealth.net.au/ncts/syndication/asf/scheme/1.0.0';
|
|
33
|
+
const FHIRSMITH_SCHEME = 'http://healthintersections.com.au/syndication/fhirsmith/1.0.0';
|
|
34
|
+
const PACKAGE_SERVER = 'https://npm.fhir.org/web/';
|
|
35
|
+
const STORE_FORMAT = 1;
|
|
36
|
+
// no fetch is allowed to hold induction (or server shutdown, which waits for it) for longer
|
|
37
|
+
const FETCH_TIMEOUT_MS = 120000;
|
|
38
|
+
|
|
39
|
+
/** library source type -> category term in the FHIRsmith scheme, for the binary sources */
|
|
40
|
+
const BINARY_TERMS = {
|
|
41
|
+
snomed: 'SNOMED_CACHE',
|
|
42
|
+
loinc: 'LOINC_DB',
|
|
43
|
+
rxnorm: 'RXNORM_DB',
|
|
44
|
+
ndc: 'NDC_DB',
|
|
45
|
+
unii: 'UNII_DB',
|
|
46
|
+
omop: 'OMOP_DB',
|
|
47
|
+
icd11: 'ICD11_DB',
|
|
48
|
+
cpt: 'CPT_DB'
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const RESOURCE_TYPES = ['CodeSystem', 'ValueSet', 'ConceptMap'];
|
|
52
|
+
|
|
53
|
+
const SNOMED_RIGHTS = 'Use of SNOMED CT is subject to use limitations: a licence from SNOMED International or a national release centre is required';
|
|
54
|
+
const OTHER_RIGHTS = 'Use of this content is subject to the applicable licence';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a package version is a build rather than a release - #current and its kin are not
|
|
58
|
+
* syndicated, since they change under the same version.
|
|
59
|
+
*/
|
|
60
|
+
function isBuildVersion(version) {
|
|
61
|
+
if (!version) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const v = String(version);
|
|
65
|
+
return /^(current|dev)/.test(v) || v.includes('cibuild');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** SHA-256 of a file, streamed (the binaries run to gigabytes) */
|
|
69
|
+
function hashFile(filePath) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const hash = crypto.createHash('sha256');
|
|
72
|
+
const stream = fs.createReadStream(filePath);
|
|
73
|
+
stream.on('error', reject);
|
|
74
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
75
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hashBuffer(buffer) {
|
|
80
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The SHA-256 and length of a response body, streamed rather than held in memory (some
|
|
85
|
+
* packages are hundreds of megabytes)
|
|
86
|
+
*/
|
|
87
|
+
async function hashResponse(response) {
|
|
88
|
+
const hash = crypto.createHash('sha256');
|
|
89
|
+
let length = 0;
|
|
90
|
+
if (response.body && typeof response.body[Symbol.asyncIterator] === 'function') {
|
|
91
|
+
for await (const chunk of response.body) {
|
|
92
|
+
hash.update(chunk);
|
|
93
|
+
length += chunk.length;
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
97
|
+
hash.update(buffer);
|
|
98
|
+
length = buffer.length;
|
|
99
|
+
}
|
|
100
|
+
return { sha256: hash.digest('hex'), length };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
class SyndicationStore {
|
|
104
|
+
/**
|
|
105
|
+
* @param {string} folder - the store folder (created if need be)
|
|
106
|
+
* @param {Object} log - logger
|
|
107
|
+
* @param {Object} [options]
|
|
108
|
+
* @param {Function} [options.fetch] - fetch implementation (tests)
|
|
109
|
+
* @param {Function} [options.now] - clock (tests): returns a Date
|
|
110
|
+
*/
|
|
111
|
+
constructor(folder, log, options = {}) {
|
|
112
|
+
this.folder = folder;
|
|
113
|
+
this.filesFolder = path.join(folder, 'files');
|
|
114
|
+
this.jsonPath = path.join(folder, 'syndication.json');
|
|
115
|
+
this.log = log;
|
|
116
|
+
this.fetch = options.fetch || globalThis.fetch;
|
|
117
|
+
this.now = options.now || (() => new Date());
|
|
118
|
+
this.doc = null;
|
|
119
|
+
this.queue = Promise.resolve();
|
|
120
|
+
this.pending = 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read the store, or start it if there isn't one: the store is defined from now on.
|
|
125
|
+
*/
|
|
126
|
+
open() {
|
|
127
|
+
fs.mkdirSync(this.filesFolder, { recursive: true });
|
|
128
|
+
if (fs.existsSync(this.jsonPath)) {
|
|
129
|
+
const doc = JSON.parse(fs.readFileSync(this.jsonPath, 'utf8'));
|
|
130
|
+
if (!doc || !Array.isArray(doc.entries) || !doc.id) {
|
|
131
|
+
throw new Error(`The syndication store ${this.jsonPath} is not valid`);
|
|
132
|
+
}
|
|
133
|
+
this.doc = doc;
|
|
134
|
+
} else {
|
|
135
|
+
this.doc = {
|
|
136
|
+
format: STORE_FORMAT,
|
|
137
|
+
id: crypto.randomUUID(),
|
|
138
|
+
created: this.now().toISOString(),
|
|
139
|
+
entries: []
|
|
140
|
+
};
|
|
141
|
+
this.#save();
|
|
142
|
+
this.log.info(`Syndication store started in ${this.folder}`);
|
|
143
|
+
}
|
|
144
|
+
this.#index();
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#index() {
|
|
149
|
+
// key -> the most recent entry with that key
|
|
150
|
+
this.byKey = new Map();
|
|
151
|
+
// name -> path, for each file copied into the store (the only files it serves)
|
|
152
|
+
this.files = new Map();
|
|
153
|
+
for (const entry of this.doc.entries) {
|
|
154
|
+
this.#indexEntry(entry);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#indexEntry(entry) {
|
|
159
|
+
this.byKey.set(entry.key, entry);
|
|
160
|
+
if (entry.link && entry.link.file) {
|
|
161
|
+
this.files.set(entry.link.file, path.join(this.filesFolder, path.basename(entry.link.file)));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#save() {
|
|
166
|
+
const tmp = this.jsonPath + '.tmp';
|
|
167
|
+
fs.writeFileSync(tmp, JSON.stringify(this.doc, null, 2));
|
|
168
|
+
fs.renameSync(tmp, this.jsonPath);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** {string} the store's own id, from which each endpoint's feed id is made */
|
|
172
|
+
get id() {
|
|
173
|
+
return this.doc.id;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
get created() {
|
|
177
|
+
return this.doc.created;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** {Object[]} every entry, in the order they were inducted */
|
|
181
|
+
get entries() {
|
|
182
|
+
return this.doc.entries;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The entries in the feed for an endpoint at the given FHIR version: everything that isn't
|
|
187
|
+
* version specific, plus that version's own.
|
|
188
|
+
* @param {string} fhirVersion - major.minor
|
|
189
|
+
*/
|
|
190
|
+
entriesFor(fhirVersion) {
|
|
191
|
+
return this.doc.entries.filter(e => !e.fhirVersion || e.fhirVersion === fhirVersion);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Resolves when everything queued so far has been inducted (or has failed) */
|
|
195
|
+
whenIdle() {
|
|
196
|
+
return this.queue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#enqueue(label, task) {
|
|
200
|
+
this.pending++;
|
|
201
|
+
this.queue = this.queue.then(async () => {
|
|
202
|
+
try {
|
|
203
|
+
await task();
|
|
204
|
+
} catch (error) {
|
|
205
|
+
this.log.warn(`Syndication: unable to induct ${label}: ${error.message}`);
|
|
206
|
+
} finally {
|
|
207
|
+
this.pending--;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
return this.queue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#add(entry) {
|
|
214
|
+
const now = this.now().toISOString();
|
|
215
|
+
const full = {
|
|
216
|
+
id: 'urn:uuid:' + crypto.randomUUID(),
|
|
217
|
+
inducted: now,
|
|
218
|
+
...entry
|
|
219
|
+
};
|
|
220
|
+
this.doc.entries.push(full);
|
|
221
|
+
this.#indexEntry(full);
|
|
222
|
+
this.#save();
|
|
223
|
+
this.log.info(`Syndication: inducted ${full.title} (${full.version})`);
|
|
224
|
+
return full;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** download a url, keeping only its SHA-256 and length */
|
|
228
|
+
async #downloadHash(url) {
|
|
229
|
+
const response = await this.fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
230
|
+
if (!response.ok) {
|
|
231
|
+
throw new Error(`HTTP ${response.status} fetching ${url}`);
|
|
232
|
+
}
|
|
233
|
+
return hashResponse(response);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A package the library loaded (npm: or url: source, or an endpoint's core package).
|
|
238
|
+
*
|
|
239
|
+
* @param {Object} pkg
|
|
240
|
+
* @param {string} pkg.id - package id
|
|
241
|
+
* @param {string} pkg.version - package version
|
|
242
|
+
* @param {string} [pkg.canonical] - canonical from package.json
|
|
243
|
+
* @param {string} [pkg.title] - title from package.json
|
|
244
|
+
* @param {string} [pkg.description] - description from package.json
|
|
245
|
+
* @param {string} [pkg.url] - where it came from, for a url: source; otherwise npm.fhir.org
|
|
246
|
+
* @param {string} [pkg.fhirVersion] - major.minor, for a version specific (core) package only
|
|
247
|
+
* @param {string} [pkg.requestedVersion] - the version the library asked for
|
|
248
|
+
*/
|
|
249
|
+
inductPackage(pkg) {
|
|
250
|
+
const label = `package ${pkg.id}#${pkg.version}`;
|
|
251
|
+
if (isBuildVersion(pkg.version) || isBuildVersion(pkg.requestedVersion)) {
|
|
252
|
+
return this.queue;
|
|
253
|
+
}
|
|
254
|
+
const key = `package:${pkg.id}#${pkg.version}`;
|
|
255
|
+
if (this.byKey.has(key)) {
|
|
256
|
+
return this.queue;
|
|
257
|
+
}
|
|
258
|
+
return this.#enqueue(label, async () => {
|
|
259
|
+
if (this.byKey.has(key)) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (!pkg.canonical) {
|
|
263
|
+
throw new Error('the package has no canonical in package.json');
|
|
264
|
+
}
|
|
265
|
+
const href = pkg.url || `${PACKAGE_SERVER}${encodeURIComponent(pkg.id)}-${encodeURIComponent(pkg.version)}.tgz`;
|
|
266
|
+
// packages never change, so the copy on the server is the one that is hashed
|
|
267
|
+
const { sha256, length } = await this.#downloadHash(href);
|
|
268
|
+
const entry = {
|
|
269
|
+
key,
|
|
270
|
+
kind: 'package',
|
|
271
|
+
category: { scheme: NCTS_SCHEME, term: 'FHIR_Package', label: 'FHIR Package' },
|
|
272
|
+
identifier: pkg.canonical,
|
|
273
|
+
version: `${pkg.canonical}|${pkg.version}`,
|
|
274
|
+
title: pkg.title ? `${pkg.title} (${pkg.id}#${pkg.version})` : `${pkg.id}#${pkg.version}`,
|
|
275
|
+
link: { href, type: 'application/gzip', length, sha256 }
|
|
276
|
+
};
|
|
277
|
+
if (pkg.description) {
|
|
278
|
+
entry.summary = pkg.description;
|
|
279
|
+
}
|
|
280
|
+
if (pkg.fhirVersion) {
|
|
281
|
+
entry.fhirVersion = pkg.fhirVersion;
|
|
282
|
+
}
|
|
283
|
+
this.#add(entry);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A resource the library loaded from a file: or folder: source. Resources without a version
|
|
289
|
+
* are not syndicated. The file is copied into the store, and a later file with different
|
|
290
|
+
* content for the same url|version is a new entry.
|
|
291
|
+
*
|
|
292
|
+
* @param {Object} res
|
|
293
|
+
* @param {string} res.filePath - the file it was loaded from
|
|
294
|
+
* @param {string} res.resourceType
|
|
295
|
+
* @param {string} res.url
|
|
296
|
+
* @param {string} res.version
|
|
297
|
+
* @param {string} [res.title]
|
|
298
|
+
* @param {string} [res.description]
|
|
299
|
+
*/
|
|
300
|
+
inductResource(res) {
|
|
301
|
+
if (!res.url || !res.version || !RESOURCE_TYPES.includes(res.resourceType)) {
|
|
302
|
+
return this.queue;
|
|
303
|
+
}
|
|
304
|
+
const label = `${res.resourceType} ${res.url}|${res.version}`;
|
|
305
|
+
const key = `resource:${res.url}|${res.version}`;
|
|
306
|
+
return this.#enqueue(label, async () => {
|
|
307
|
+
const bytes = await fsp.readFile(res.filePath);
|
|
308
|
+
const sha256 = hashBuffer(bytes);
|
|
309
|
+
const last = this.byKey.get(key);
|
|
310
|
+
if (last && last.link.sha256 === sha256) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const fileName = crypto.randomUUID() + '.json';
|
|
314
|
+
await fsp.writeFile(path.join(this.filesFolder, fileName), bytes);
|
|
315
|
+
const entry = {
|
|
316
|
+
key,
|
|
317
|
+
kind: 'resource',
|
|
318
|
+
category: { scheme: NCTS_SCHEME, term: 'FHIR_' + res.resourceType, label: 'FHIR ' + res.resourceType },
|
|
319
|
+
identifier: res.url,
|
|
320
|
+
version: `${res.url}|${res.version}`,
|
|
321
|
+
title: res.title || res.url,
|
|
322
|
+
// relative: the feed makes it absolute under the endpoint the feed is read from
|
|
323
|
+
link: { file: fileName, type: 'application/fhir+json', length: bytes.length, sha256 }
|
|
324
|
+
};
|
|
325
|
+
if (res.description) {
|
|
326
|
+
entry.summary = res.description;
|
|
327
|
+
}
|
|
328
|
+
this.#add(entry);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* A binary the library loaded (SNOMED cache, LOINC database etc). Only binaries that were
|
|
334
|
+
* published to the external store (baseUrl) are syndicated - a file that is only local is a
|
|
335
|
+
* development build. The binary is not copied: the entry links to the external store.
|
|
336
|
+
* Binary file names change whenever their content does, so the file name is the key.
|
|
337
|
+
*
|
|
338
|
+
* @param {Object} bin
|
|
339
|
+
* @param {string} bin.sourceType - library source type (snomed, loinc ...)
|
|
340
|
+
* @param {string} bin.filePath - the local copy
|
|
341
|
+
* @param {string} bin.baseUrl - the external store the library downloads binaries from
|
|
342
|
+
* @param {string} bin.system - code system uri
|
|
343
|
+
* @param {string} bin.version - code system version (for SNOMED, the version uri)
|
|
344
|
+
* @param {string} [bin.name] - code system name
|
|
345
|
+
*/
|
|
346
|
+
inductBinary(bin) {
|
|
347
|
+
const term = BINARY_TERMS[bin.sourceType];
|
|
348
|
+
const fileName = path.basename(bin.filePath);
|
|
349
|
+
if (!term || !bin.baseUrl) {
|
|
350
|
+
return this.queue;
|
|
351
|
+
}
|
|
352
|
+
// the full CPT database is licensed content, and is never distributed; the public
|
|
353
|
+
// fragment is
|
|
354
|
+
if (bin.sourceType === 'cpt' && !fileName.toLowerCase().includes('fragment')) {
|
|
355
|
+
return this.queue;
|
|
356
|
+
}
|
|
357
|
+
const key = `binary:${fileName}`;
|
|
358
|
+
if (this.byKey.has(key)) {
|
|
359
|
+
return this.queue;
|
|
360
|
+
}
|
|
361
|
+
return this.#enqueue(`${bin.sourceType} file ${fileName}`, async () => {
|
|
362
|
+
if (this.byKey.has(key)) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const href = bin.baseUrl.replace(/\/+$/, '') + '/' + fileName;
|
|
366
|
+
const stat = await fsp.stat(bin.filePath);
|
|
367
|
+
const head = await this.fetch(href, { method: 'HEAD', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
368
|
+
const length = Number(head.headers.get('content-length'));
|
|
369
|
+
if (!head.ok || length !== stat.size) {
|
|
370
|
+
this.log.info(`Syndication: not syndicating ${fileName}: it is not published at ${href} (a local build)`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const sha256 = await hashFile(bin.filePath);
|
|
374
|
+
let identifier = bin.system;
|
|
375
|
+
let version = bin.version ? `${bin.system}|${bin.version}` : bin.system;
|
|
376
|
+
const sct = /^(.*)\/version\/[^/]+$/.exec(bin.version || '');
|
|
377
|
+
if (bin.sourceType === 'snomed' && sct) {
|
|
378
|
+
// SNOMED editions are identified by their module uri, and versioned by the version uri
|
|
379
|
+
identifier = sct[1];
|
|
380
|
+
version = bin.version;
|
|
381
|
+
}
|
|
382
|
+
const name = bin.name || bin.system;
|
|
383
|
+
this.#add({
|
|
384
|
+
key,
|
|
385
|
+
kind: 'binary',
|
|
386
|
+
category: { scheme: FHIRSMITH_SCHEME, term, label: term },
|
|
387
|
+
identifier,
|
|
388
|
+
version,
|
|
389
|
+
title: `${name}${bin.version && !name.includes(bin.version) ? ' ' + bin.version : ''} (${fileName})`,
|
|
390
|
+
summary: `FHIRsmith ${term} file ${fileName}`,
|
|
391
|
+
rights: bin.sourceType === 'snomed' ? SNOMED_RIGHTS : OTHER_RIGHTS,
|
|
392
|
+
link: { href, type: 'application/octet-stream', length: stat.size, sha256 }
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The local path of a file copied into the store, or null if there isn't one by that name.
|
|
399
|
+
* The name is only ever looked up among the files the store's entries record - the path
|
|
400
|
+
* returned is the store's own, never one made from the name asked for.
|
|
401
|
+
* @param {string} name
|
|
402
|
+
*/
|
|
403
|
+
filePath(name) {
|
|
404
|
+
if (typeof name !== 'string' || !/^[0-9a-f-]{36}\.json$/.test(name)) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
const p = this.files.get(name);
|
|
408
|
+
return p && fs.existsSync(p) ? p : null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
module.exports = {
|
|
413
|
+
SyndicationStore, isBuildVersion, hashFile,
|
|
414
|
+
NCTS_SCHEME, FHIRSMITH_SCHEME, BINARY_TERMS
|
|
415
|
+
};
|
package/tx/tx-html.js
CHANGED
|
@@ -155,6 +155,14 @@ function publishesLibrarySource() {
|
|
|
155
155
|
return publishLibrarySource;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
// Whether this server has a syndication feed (modules.tx.syndication.enabled): gates the nav
|
|
159
|
+
// item and the feed autodiscovery link, as publishLibrarySource does for the library
|
|
160
|
+
let syndicationFeed = false;
|
|
161
|
+
|
|
162
|
+
function setSyndicationFeed(enabled) {
|
|
163
|
+
syndicationFeed = enabled === true;
|
|
164
|
+
}
|
|
165
|
+
|
|
158
166
|
/**
|
|
159
167
|
* Highlight a YAML document for display.
|
|
160
168
|
*
|
|
@@ -269,6 +277,11 @@ class TxHtmlRenderer {
|
|
|
269
277
|
options.apiLink = '<a href="' + escape(endpoint.path) + '/openapi" style="color: gold">API</a> | ';
|
|
270
278
|
options.apiHead = '<link rel="service-desc" type="application/json" href="' + escape(endpoint.path) + '/openapi.json"/>';
|
|
271
279
|
}
|
|
280
|
+
if (syndicationFeed) {
|
|
281
|
+
options.libraryLink += '<a href="' + escape(endpoint.path) + '/syndication" style="color: gold">Syndication</a> | ';
|
|
282
|
+
options.apiHead = (options.apiHead || '') + '<link rel="alternate" type="application/atom+xml" title="Syndication Feed" href="'
|
|
283
|
+
+ escape(endpoint.path) + '/syndication?_format=atom"/>';
|
|
284
|
+
}
|
|
272
285
|
|
|
273
286
|
return htmlServer.renderPage('tx', title, content, options);
|
|
274
287
|
}
|
|
@@ -1902,5 +1915,6 @@ class TxHtmlRenderer {
|
|
|
1902
1915
|
|
|
1903
1916
|
module.exports = {
|
|
1904
1917
|
TxHtmlRenderer, loadTemplate, acceptsHtml,
|
|
1905
|
-
setPublishLibrarySource, publishesLibrarySource, highlightYaml, buildLibrarySourcePage
|
|
1918
|
+
setPublishLibrarySource, publishesLibrarySource, highlightYaml, buildLibrarySourcePage,
|
|
1919
|
+
setSyndicationFeed
|
|
1906
1920
|
};
|
package/tx/tx.fhir.org.yml
CHANGED
|
@@ -26,15 +26,13 @@ sources:
|
|
|
26
26
|
- unii:unii_20240622.db
|
|
27
27
|
- snomed:sct_intl_20240201.cache
|
|
28
28
|
- snomed!:sct_intl_20250201.cache
|
|
29
|
-
- snomed:sct_ch_20230607.cache
|
|
30
|
-
- snomed:sct_dk_20260331.cache
|
|
31
29
|
- snomed:sct_ips_20241216.cache
|
|
32
30
|
- snomed:sct_nl_20240930.cache
|
|
33
31
|
- snomed:sct_uk_20230412.cache
|
|
34
32
|
- snomed:sct_us_20230301.cache
|
|
35
33
|
- snomed:sct_us_20240301.cache
|
|
36
34
|
- snomed:sct_us_20250901.cache
|
|
37
|
-
- snomed:
|
|
35
|
+
- snomed:sct_test_20250909c.cache
|
|
38
36
|
- cpt:CodeSystem-cpt.db|cpt-2023-fragment-0.1.db
|
|
39
37
|
- omop:omop_v20250227.db
|
|
40
38
|
- npm:hl7.terminology=tho
|
|
@@ -44,6 +42,7 @@ sources:
|
|
|
44
42
|
- npm:hl7.fhir.us.core=us
|
|
45
43
|
- npm:us.cdc.phinvads=cdc
|
|
46
44
|
- npm:hl7.fhir.uv.sdc=sdc
|
|
45
|
+
- npm:hl7.fhir.uv.ips=ips
|
|
47
46
|
- internal:vsac=vsac
|
|
48
47
|
- icd11:icd11-2026-01-b.db
|
|
49
48
|
|
package/tx/tx.js
CHANGED
|
@@ -59,6 +59,8 @@ const ConceptUsageTracker = require("./usage-tracker");
|
|
|
59
59
|
const ProblemFinder = require("./problems");
|
|
60
60
|
const { buildOperationOutcome, outcomeFromError } = require('./library/operation-outcome');
|
|
61
61
|
const txOpenApi = require('./openapi');
|
|
62
|
+
const { SyndicationStore } = require('./syndication/syndication-store');
|
|
63
|
+
const syndicationFeed = require('./syndication/syndication-feed');
|
|
62
64
|
// const {writeFileSync} = require("fs");
|
|
63
65
|
|
|
64
66
|
class TXModule {
|
|
@@ -140,9 +142,14 @@ class TXModule {
|
|
|
140
142
|
* Initialize the TX module
|
|
141
143
|
* @param {Object} config - Module configuration
|
|
142
144
|
* @param {express.Application} app - Express application for registering endpoints
|
|
145
|
+
* @param {Object} [server] - server level settings: {hostName} (config.hostName)
|
|
143
146
|
*/
|
|
144
|
-
async initialize(config, app) {
|
|
147
|
+
async initialize(config, app, server = {}) {
|
|
145
148
|
this.config = config;
|
|
149
|
+
// the server's public host name, when it is configured as one (it is also used as a
|
|
150
|
+
// display name, so anything that isn't a host name is ignored)
|
|
151
|
+
this.publicHost = typeof server.hostName === 'string' && /^[A-Za-z0-9.-]+(:[0-9]{1,5})?$/.test(server.hostName)
|
|
152
|
+
? server.hostName : null;
|
|
146
153
|
// Initialize logger with config settings
|
|
147
154
|
this.log = Logger.getInstance().child({
|
|
148
155
|
module: 'tx',
|
|
@@ -166,6 +173,7 @@ class TXModule {
|
|
|
166
173
|
// Off unless the operator opts in. This gates the nav item as well as the route, so a
|
|
167
174
|
// server that does not publish its library source shows no link to it.
|
|
168
175
|
txHtml.setPublishLibrarySource(config.publishLibrarySource === true);
|
|
176
|
+
txHtml.setSyndicationFeed(!!(config.syndication && config.syndication.enabled));
|
|
169
177
|
|
|
170
178
|
// Validate config
|
|
171
179
|
if (!config.librarySource) {
|
|
@@ -236,6 +244,17 @@ class TXModule {
|
|
|
236
244
|
// Load the library from YAML
|
|
237
245
|
this.log.info(`Loading library from: ${config.librarySource}`);
|
|
238
246
|
this.library = new Library(config.librarySource, config.vsacCfg, this.log, this.stats);
|
|
247
|
+
|
|
248
|
+
// The syndication feed: content is recorded in the store as the library loads it, from
|
|
249
|
+
// the time the administrator turns it on (see tx/syndication). One store serves every
|
|
250
|
+
// endpoint, since the library is shared. If it is turned on and the store can't be
|
|
251
|
+
// opened, that stops startup.
|
|
252
|
+
this.syndicationStore = null;
|
|
253
|
+
if (config.syndication && config.syndication.enabled) {
|
|
254
|
+
this.syndicationStore = new SyndicationStore(path.join(this.library.cacheFolder, 'syndication'), this.log).open();
|
|
255
|
+
this.library.syndication = this.syndicationStore;
|
|
256
|
+
this.log.info(`Syndication feed enabled: store in ${this.syndicationStore.folder}`);
|
|
257
|
+
}
|
|
239
258
|
this.log.info(`Load...`);
|
|
240
259
|
await this.library.load();
|
|
241
260
|
this.log.info('Library loaded successfully');
|
|
@@ -1133,6 +1152,10 @@ class TXModule {
|
|
|
1133
1152
|
});
|
|
1134
1153
|
}
|
|
1135
1154
|
|
|
1155
|
+
if (this.syndicationStore) {
|
|
1156
|
+
this.setupSyndicationRoutes(router, endpointPath);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1136
1159
|
// Metadata / CapabilityStatement
|
|
1137
1160
|
router.get('/metadata', async (req, res) => {
|
|
1138
1161
|
const start = Date.now();
|
|
@@ -1332,6 +1355,31 @@ class TXModule {
|
|
|
1332
1355
|
};
|
|
1333
1356
|
}
|
|
1334
1357
|
|
|
1358
|
+
/**
|
|
1359
|
+
* The paragraph for the server's daily report: how many of each operation
|
|
1360
|
+
* were performed, over all the endpoints.
|
|
1361
|
+
*/
|
|
1362
|
+
dailyReport({since, now}) {
|
|
1363
|
+
const {plural} = require('../library/daily-report');
|
|
1364
|
+
if (!this.stats) {
|
|
1365
|
+
return null;
|
|
1366
|
+
}
|
|
1367
|
+
const counts = this.stats.countsSince(since, now);
|
|
1368
|
+
if (counts.total === 0) {
|
|
1369
|
+
return 'No requests were served.';
|
|
1370
|
+
}
|
|
1371
|
+
// the FHIR operations first, busiest first, then the rest (pages, metadata, reads)
|
|
1372
|
+
const names = Object.keys(counts.operations).sort((a, b) => {
|
|
1373
|
+
const opA = a.startsWith('$') ? 0 : 1;
|
|
1374
|
+
const opB = b.startsWith('$') ? 0 : 1;
|
|
1375
|
+
return opA - opB || counts.operations[b] - counts.operations[a] || a.localeCompare(b);
|
|
1376
|
+
});
|
|
1377
|
+
const list = names.map(n => `${n} ${counts.operations[n].toLocaleString('en-US')}`).join(', ');
|
|
1378
|
+
const endpoints = Object.keys(counts.endpoints).filter(e => e).length;
|
|
1379
|
+
const where = endpoints > 1 ? ` over ${plural(endpoints, 'endpoint')}` : '';
|
|
1380
|
+
return `${counts.total.toLocaleString('en-US')} requests were served${where}: ${list}.`;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1335
1383
|
/**
|
|
1336
1384
|
* Shutdown the module
|
|
1337
1385
|
*/
|
|
@@ -1341,6 +1389,10 @@ class TXModule {
|
|
|
1341
1389
|
clearInterval(timer);
|
|
1342
1390
|
}
|
|
1343
1391
|
this.timers = [];
|
|
1392
|
+
if (this.syndicationStore) {
|
|
1393
|
+
// let an induction in progress finish, so the store isn't left with a half-copied file
|
|
1394
|
+
await this.syndicationStore.whenIdle();
|
|
1395
|
+
}
|
|
1344
1396
|
// Clean up any resources if needed
|
|
1345
1397
|
await this.library.close();
|
|
1346
1398
|
if (this.closureStore) {
|
|
@@ -1350,6 +1402,93 @@ class TXModule {
|
|
|
1350
1402
|
this.log.info('TX module shut down');
|
|
1351
1403
|
}
|
|
1352
1404
|
|
|
1405
|
+
/**
|
|
1406
|
+
* The syndication feed for an endpoint (Terminology Syndication Feed IG, RFC 5005
|
|
1407
|
+
* archived feeds) - see tx/syndication/syndication-feed.js. Atom unless HTML is asked for.
|
|
1408
|
+
*/
|
|
1409
|
+
/**
|
|
1410
|
+
* The public url of an endpoint, for the links in its feed: from the configured host name
|
|
1411
|
+
* (config.hostName), so the links don't depend on what a client puts in the Host header.
|
|
1412
|
+
* Only without one does it fall back to the request (as for a local test server).
|
|
1413
|
+
*/
|
|
1414
|
+
syndicationBase(req, endpointPath) {
|
|
1415
|
+
if (this.publicHost) {
|
|
1416
|
+
return `https://${this.publicHost}${endpointPath}`;
|
|
1417
|
+
}
|
|
1418
|
+
return `${req.protocol}://${req.get('host')}${endpointPath}`;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
setupSyndicationRoutes(router, endpointPath) {
|
|
1422
|
+
const sendFeed = async (req, res, page) => {
|
|
1423
|
+
const start = Date.now();
|
|
1424
|
+
try {
|
|
1425
|
+
const params = {
|
|
1426
|
+
store: this.syndicationStore,
|
|
1427
|
+
endpointPath,
|
|
1428
|
+
fhirVersion: req.txEndpoint.fhirVersion,
|
|
1429
|
+
base: this.syndicationBase(req, endpointPath),
|
|
1430
|
+
page,
|
|
1431
|
+
title: this.config.title,
|
|
1432
|
+
serverVersion: packageJson.version
|
|
1433
|
+
};
|
|
1434
|
+
if (txHtml.acceptsHtml(req)) {
|
|
1435
|
+
const content = syndicationFeed.renderHtml(params);
|
|
1436
|
+
if (content == null) {
|
|
1437
|
+
res.status(404).json(this.operationOutcome('error', 'not-found', 'There is no such syndication archive'));
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
let txhtml = new TxHtmlRenderer(new Renderer(req.txOpContext, req.txProvider), this.liquid, this.languages, this.i18n, endpointPath);
|
|
1441
|
+
const title = page == null ? 'Syndication Feed' : `Syndication Feed (archive ${page})`;
|
|
1442
|
+
const html = await txhtml.renderPage(title, content, req.txEndpoint, req.txStartTime);
|
|
1443
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1444
|
+
res.send(html);
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
const xml = syndicationFeed.renderAtom(params);
|
|
1448
|
+
if (xml == null) {
|
|
1449
|
+
res.status(404).json(this.operationOutcome('error', 'not-found', 'There is no such syndication archive'));
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
// an archive never changes; the current document changes whenever content is added.
|
|
1453
|
+
// An archive is only cacheable forever when its links come from the configured host
|
|
1454
|
+
// name - links made from the request's Host header must not be cached for others
|
|
1455
|
+
res.setHeader('Cache-Control', page == null || !this.publicHost ? 'no-cache' : 'public, max-age=31536000, immutable');
|
|
1456
|
+
res.setHeader('Content-Type', 'application/atom+xml; charset=utf-8');
|
|
1457
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
1458
|
+
res.send(xml);
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
this.log.error(`Error rendering syndication feed: ${error.message}`);
|
|
1461
|
+
res.status(500).json(this.operationOutcome('error', 'exception', 'Unable to render the syndication feed'));
|
|
1462
|
+
} finally {
|
|
1463
|
+
this.countRequest(endpointPath, 'syndication', Date.now() - start);
|
|
1464
|
+
}
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
router.get('/syndication', (req, res) => sendFeed(req, res, null));
|
|
1468
|
+
|
|
1469
|
+
router.get('/syndication/archive/:page', (req, res) => {
|
|
1470
|
+
const page = /^[1-9][0-9]{0,8}$/.test(req.params.page) ? Number(req.params.page) : -1;
|
|
1471
|
+
return sendFeed(req, res, page);
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
// not rate limited here: as for the rest of the tx module, nginx does that (see nginx.md)
|
|
1475
|
+
router.get('/syndication/files/:name', (req, res) => {
|
|
1476
|
+
const file = this.syndicationStore.filePath(req.params.name);
|
|
1477
|
+
if (!file) {
|
|
1478
|
+
res.status(404).json(this.operationOutcome('error', 'not-found', 'There is no such syndication file'));
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
// copies in the store never change
|
|
1482
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
1483
|
+
res.setHeader('Content-Type', 'application/fhir+json');
|
|
1484
|
+
// the file is whatever the source folder held: never let a browser sniff it as a page
|
|
1485
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
1486
|
+
// served from the store's folder by the store's own name for it (sendFile refuses
|
|
1487
|
+
// anything that would leave root)
|
|
1488
|
+
res.sendFile(path.basename(file), { root: this.syndicationStore.filesFolder });
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1353
1492
|
trimParameters(params) {
|
|
1354
1493
|
if (!params || !params.parameter) {
|
|
1355
1494
|
return params;
|
|
@@ -1515,8 +1654,11 @@ class TXModule {
|
|
|
1515
1654
|
if (hdr) {
|
|
1516
1655
|
return parseInt(hdr);
|
|
1517
1656
|
}
|
|
1657
|
+
// the tx-ecosystem test runner (Tools/Java) always gets the test limit, so every server
|
|
1658
|
+
// gives it the same answer. Otherwise the configured externalLimit - it used to be read
|
|
1659
|
+
// only when internalLimit was configured too, so setting it alone did nothing
|
|
1518
1660
|
let isTest = req.header("User-Agent") == 'Tools/Java';
|
|
1519
|
-
if (this.config.
|
|
1661
|
+
if (this.config.externalLimit && !isTest) return this.config.externalLimit; else return EXTERNAL_TEST_DEFAULT_LIMIT;
|
|
1520
1662
|
}
|
|
1521
1663
|
|
|
1522
1664
|
}
|