fhirsmith 0.17.0 → 0.18.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 +113 -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 +432 -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 +8 -3
- 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 +10 -0
- package/tx/README.md +52 -0
- package/tx/closure/closure-store.js +14 -0
- package/tx/cs/cs-api.js +92 -14
- package/tx/cs/cs-cs.js +64 -3
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +431 -0
- package/tx/cs/cs-loinc.js +296 -27
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +1210 -80
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/csv-records.js +35 -0
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/importers/import-loinc.module.js +177 -41
- package/tx/importers/import-sct.module.js +66 -1
- package/tx/importers/subset-loinc.module.js +25 -6
- package/tx/library/codesystem.js +4 -1
- package/tx/library/designations.js +23 -30
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +6 -1
- package/tx/params.js +8 -1
- package/tx/provider.js +24 -6
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/sct/structures.js +8 -2
- 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 +3 -4
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/closure.js +20 -0
- package/tx/workers/expand.js +35 -8
- 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 +138 -6
- package/utilities/make-sct-loinc-subset.js +250 -0
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
package/stats.js
CHANGED
|
@@ -43,6 +43,15 @@ class ModuleStats {
|
|
|
43
43
|
get staticRequestCount() { return this.parent.staticRequestCount; }
|
|
44
44
|
get history() { return this.parent.history; }
|
|
45
45
|
get startTime() { return this.parent.startTime; }
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What this module served in (since, now]: {total, operations: {name: count},
|
|
49
|
+
* endpoints: {endpoint: {name: count}}} - see ServerStats.countsSince(). This
|
|
50
|
+
* is what a module's dailyReport() builds its numbers from.
|
|
51
|
+
*/
|
|
52
|
+
countsSince(since, now = Date.now()) {
|
|
53
|
+
return this.parent.countsSince(since, now, this.module);
|
|
54
|
+
}
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
class ServerStats {
|
|
@@ -62,6 +71,13 @@ class ServerStats {
|
|
|
62
71
|
// key (module/endpoint/operation) -> counter. See countRequest().
|
|
63
72
|
counters = new Map();
|
|
64
73
|
db = null;
|
|
74
|
+
// this run's row in server_runs, and what the earlier runs add up to - see overallUptime()
|
|
75
|
+
runId = null;
|
|
76
|
+
earlierRuns = {firstStart: null, runningMs: 0};
|
|
77
|
+
// how often this run says "still alive"; a crash loses at most this much
|
|
78
|
+
// from the overall uptime figure
|
|
79
|
+
heartbeatMs = 60 * 1000;
|
|
80
|
+
heartbeat = null;
|
|
65
81
|
|
|
66
82
|
constructor(config, log) {
|
|
67
83
|
this.config = config || {};
|
|
@@ -146,16 +162,94 @@ class ServerStats {
|
|
|
146
162
|
}
|
|
147
163
|
}
|
|
148
164
|
|
|
149
|
-
markStarted() {
|
|
165
|
+
markStarted(version = null) {
|
|
150
166
|
this.started = true;
|
|
167
|
+
this.version = version;
|
|
151
168
|
this.startMem = process.memoryUsage().heapUsed;
|
|
152
169
|
this.startTime = Date.now();
|
|
153
170
|
this.lastTime = this.startTime;
|
|
154
171
|
this.eventLoopMonitor = monitorEventLoopDelay({ resolution: 20 });
|
|
155
172
|
this.eventLoopMonitor.enable();
|
|
173
|
+
this.startRun();
|
|
156
174
|
this.recordMetrics();
|
|
157
175
|
}
|
|
158
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Record this run in the database and start the heartbeat that keeps its
|
|
179
|
+
* last_seen current. As with everything else here, a failure is logged and
|
|
180
|
+
* the overall uptime just isn't available for this run.
|
|
181
|
+
*/
|
|
182
|
+
startRun() {
|
|
183
|
+
if (!this.db) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
this.runId = this.db.startRun(this.startTime, this.version);
|
|
188
|
+
this.earlierRuns = this.db.loadRunHistory(this.runId);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
this.log.error(`Unable to record the server run: ${e.message}. Overall uptime will not be available`);
|
|
191
|
+
this.runId = null;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this.heartbeat = setInterval(() => this.touchRun(), this.heartbeatMs);
|
|
195
|
+
if (typeof this.heartbeat.unref === 'function') {
|
|
196
|
+
this.heartbeat.unref();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The server's runs since `since`, oldest first, each {started, lastSeen,
|
|
202
|
+
* version, current}, preceded by the run before that (if any) - see
|
|
203
|
+
* StatsDatabase.loadRuns(). The current run is reported as seen now, not as
|
|
204
|
+
* of its last heartbeat. null if the history isn't being kept.
|
|
205
|
+
*/
|
|
206
|
+
runHistory(since, now = Date.now()) {
|
|
207
|
+
if (this.runId == null) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
let runs;
|
|
211
|
+
try {
|
|
212
|
+
runs = this.db.loadRuns(since);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
this.log.error(`Unable to read the server run history: ${e.message}`);
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
return runs.map(r => r.id === this.runId
|
|
218
|
+
? {started: r.started, lastSeen: now, version: r.version, current: true}
|
|
219
|
+
: {started: r.started, lastSeen: r.lastSeen, version: r.version, current: false});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
touchRun() {
|
|
223
|
+
if (this.db && this.runId != null) {
|
|
224
|
+
try {
|
|
225
|
+
this.db.touchRun(this.runId, Date.now());
|
|
226
|
+
} catch (e) {
|
|
227
|
+
this.log.error(`Unable to record the server heartbeat: ${e.message}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The proportion of time the server has been up, over every run recorded
|
|
234
|
+
* in the statistics database: time it was known to be running (each run,
|
|
235
|
+
* from its start to the last heartbeat it wrote, plus this run up to now)
|
|
236
|
+
* as a percentage of the time since the first recorded run started.
|
|
237
|
+
*
|
|
238
|
+
* Returns {percent, since} or null if the history isn't being kept.
|
|
239
|
+
*/
|
|
240
|
+
overallUptime(now = Date.now()) {
|
|
241
|
+
if (this.runId == null) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
const since = this.earlierRuns.firstStart == null ? this.startTime : Math.min(this.earlierRuns.firstStart, this.startTime);
|
|
245
|
+
const elapsed = now - since;
|
|
246
|
+
if (elapsed <= 0) {
|
|
247
|
+
return {percent: 100, since: since};
|
|
248
|
+
}
|
|
249
|
+
const running = this.earlierRuns.runningMs + (now - this.startTime);
|
|
250
|
+
return {percent: Math.min(100, (running * 100) / elapsed), since: since};
|
|
251
|
+
}
|
|
252
|
+
|
|
159
253
|
/**
|
|
160
254
|
* The counter for one (module, endpoint, operation), created on first use.
|
|
161
255
|
*
|
|
@@ -215,6 +309,7 @@ class ServerStats {
|
|
|
215
309
|
}
|
|
216
310
|
}
|
|
217
311
|
if (rows.length === 0) {
|
|
312
|
+
this.lastFlush = now;
|
|
218
313
|
return 0;
|
|
219
314
|
}
|
|
220
315
|
try {
|
|
@@ -227,9 +322,53 @@ class ServerStats {
|
|
|
227
322
|
counter.countDelta = 0;
|
|
228
323
|
counter.timeDelta = 0;
|
|
229
324
|
}
|
|
325
|
+
this.lastFlush = now;
|
|
230
326
|
return rows.length;
|
|
231
327
|
}
|
|
232
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Write out whatever has been counted since the last flush, so that a
|
|
331
|
+
* countsSince() up to `now` sees everything. Used by the daily report.
|
|
332
|
+
*/
|
|
333
|
+
flushNow(now = Date.now()) {
|
|
334
|
+
this.flushCounters(now, now - (this.lastFlush || this.lastTime || this.startTime));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The requests a module served in (since, now], from the persisted rows plus
|
|
339
|
+
* anything counted since the last flush. Without the database only the
|
|
340
|
+
* unflushed counts are known, so persisted is false to say the numbers are
|
|
341
|
+
* partial.
|
|
342
|
+
*/
|
|
343
|
+
countsSince(since, now, module) {
|
|
344
|
+
const result = {total: 0, operations: {}, endpoints: {}, persisted: Boolean(this.db)};
|
|
345
|
+
const add = (endpoint, operation, count) => {
|
|
346
|
+
if (!count) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
result.total += count;
|
|
350
|
+
result.operations[operation] = (result.operations[operation] || 0) + count;
|
|
351
|
+
const ep = result.endpoints[endpoint] || (result.endpoints[endpoint] = {});
|
|
352
|
+
ep[operation] = (ep[operation] || 0) + count;
|
|
353
|
+
};
|
|
354
|
+
if (this.db) {
|
|
355
|
+
try {
|
|
356
|
+
for (const row of this.db.countsSince(since, now, module)) {
|
|
357
|
+
add(row.endpoint, row.operation, row.count);
|
|
358
|
+
}
|
|
359
|
+
} catch (e) {
|
|
360
|
+
this.log.error(`Unable to read request counts: ${e.message}`);
|
|
361
|
+
result.persisted = false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const counter of this.counters.values()) {
|
|
365
|
+
if (counter.module === module) {
|
|
366
|
+
add(counter.endpoint, counter.operation, counter.countDelta);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
|
|
233
372
|
addTask(name, frequency) {
|
|
234
373
|
let info = {};
|
|
235
374
|
this.taskMap.set(name, info);
|
|
@@ -265,6 +404,19 @@ class ServerStats {
|
|
|
265
404
|
}
|
|
266
405
|
}
|
|
267
406
|
|
|
407
|
+
/**
|
|
408
|
+
* The background tasks whose last run failed: [{name, state, frequency, date}]
|
|
409
|
+
*/
|
|
410
|
+
failedTasks() {
|
|
411
|
+
const failed = [];
|
|
412
|
+
for (const [name, info] of this.taskMap) {
|
|
413
|
+
if (info.status === 'error') {
|
|
414
|
+
failed.push({name: name, state: info.state, frequency: info.frequency, date: info.date});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return failed;
|
|
418
|
+
}
|
|
419
|
+
|
|
268
420
|
taskDetails() {
|
|
269
421
|
if (this.taskMap.size == 0) {
|
|
270
422
|
return "";
|
|
@@ -290,6 +442,11 @@ class ServerStats {
|
|
|
290
442
|
|
|
291
443
|
finishStats() {
|
|
292
444
|
clearInterval(this.timer);
|
|
445
|
+
if (this.heartbeat) {
|
|
446
|
+
clearInterval(this.heartbeat);
|
|
447
|
+
this.heartbeat = null;
|
|
448
|
+
}
|
|
449
|
+
this.touchRun();
|
|
293
450
|
// Synchronous, so the last partial interval is on disk before server.js
|
|
294
451
|
// calls process.exit().
|
|
295
452
|
this.flushCounters(Date.now(), Date.now() - (this.lastTime || this.startTime));
|
package/testing/search.js
CHANGED
|
@@ -60,113 +60,7 @@ const MODIFIERS = {
|
|
|
60
60
|
// parameters that control the search rather than filter it
|
|
61
61
|
const CONTROL = new Set(['_sort', '_count', '_offset', '_summary', '_format', '_total', '_pretty']);
|
|
62
62
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const DATE_RE = /^(\d{4})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$/;
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* The range of instants a FHIR date, dateTime or instant covers, as [lo, hi) in epoch
|
|
69
|
-
* milliseconds. "2026" covers the whole year; "2026-09-24T10:00:00.5Z" covers 100ms.
|
|
70
|
-
* A time with no zone is taken as UTC (FHIR requires a zone, but search values often
|
|
71
|
-
* leave it off).
|
|
72
|
-
*
|
|
73
|
-
* @param {string} value
|
|
74
|
-
* @returns {{lo: number, hi: number}|null} null if the value isn't a date
|
|
75
|
-
*/
|
|
76
|
-
function dateRange(value) {
|
|
77
|
-
if (typeof value !== 'string') {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
const m = DATE_RE.exec(value.trim());
|
|
81
|
-
if (!m) {
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
const [, y, mo, d, h, mi, s, frac, tz] = m;
|
|
85
|
-
const year = parseInt(y, 10);
|
|
86
|
-
if (!mo) {
|
|
87
|
-
return { lo: Date.UTC(year, 0, 1), hi: Date.UTC(year + 1, 0, 1) };
|
|
88
|
-
}
|
|
89
|
-
const month = parseInt(mo, 10) - 1;
|
|
90
|
-
if (month < 0 || month > 11) {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
if (!d) {
|
|
94
|
-
return { lo: Date.UTC(year, month, 1), hi: Date.UTC(year, month + 1, 1) };
|
|
95
|
-
}
|
|
96
|
-
const day = parseInt(d, 10);
|
|
97
|
-
if (day < 1 || day > new Date(Date.UTC(year, month + 1, 0)).getUTCDate()) {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
if (!h) {
|
|
101
|
-
return { lo: Date.UTC(year, month, day), hi: Date.UTC(year, month, day + 1) };
|
|
102
|
-
}
|
|
103
|
-
const zone = tz || 'Z';
|
|
104
|
-
const text = `${y}-${mo}-${d}T${h}:${mi}:${s || '00'}${frac || ''}${zone}`;
|
|
105
|
-
const lo = Date.parse(text);
|
|
106
|
-
if (Number.isNaN(lo) || parseInt(h, 10) > 23 || parseInt(mi, 10) > 59 || (s && parseInt(s, 10) > 59)) {
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
let width;
|
|
110
|
-
if (!s) {
|
|
111
|
-
width = 60000;
|
|
112
|
-
} else if (!frac) {
|
|
113
|
-
width = 1000;
|
|
114
|
-
} else {
|
|
115
|
-
// .5 is 100ms wide, .50 10ms, .500 and beyond 1ms
|
|
116
|
-
width = Math.max(1, Math.pow(10, 3 - (frac.length - 1)));
|
|
117
|
-
}
|
|
118
|
-
return { lo, hi: lo + width };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function splitPrefix(value) {
|
|
122
|
-
const p = value.substring(0, 2);
|
|
123
|
-
if (PREFIXES.includes(p) && value.length > 2 && !/^[a-z]/.test(value.substring(2))) {
|
|
124
|
-
return { prefix: p, rest: value.substring(2) };
|
|
125
|
-
}
|
|
126
|
-
return { prefix: 'eq', rest: value };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function escapeLike(value) {
|
|
130
|
-
return value.replace(/[\\%_]/g, (c) => '\\' + c);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Split a parameter value on commas, honouring \, as an escaped comma.
|
|
135
|
-
*/
|
|
136
|
-
function splitValues(value) {
|
|
137
|
-
const out = [];
|
|
138
|
-
let cur = '';
|
|
139
|
-
for (let i = 0; i < value.length; i++) {
|
|
140
|
-
const c = value[i];
|
|
141
|
-
if (c === '\\' && i + 1 < value.length) {
|
|
142
|
-
cur += value[i + 1];
|
|
143
|
-
i++;
|
|
144
|
-
} else if (c === ',') {
|
|
145
|
-
out.push(cur);
|
|
146
|
-
cur = '';
|
|
147
|
-
} else {
|
|
148
|
-
cur += c;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
out.push(cur);
|
|
152
|
-
return out.filter(v => v.length > 0);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function dateClause(def, prefix, range) {
|
|
156
|
-
const { lo, hi } = def;
|
|
157
|
-
switch (prefix) {
|
|
158
|
-
case 'eq': return { sql: `(${lo} >= ? AND ${hi} <= ?)`, args: [range.lo, range.hi] };
|
|
159
|
-
case 'ne': return { sql: `NOT (${lo} >= ? AND ${hi} <= ?)`, args: [range.lo, range.hi] };
|
|
160
|
-
case 'gt': return { sql: `${hi} > ?`, args: [range.hi] };
|
|
161
|
-
case 'lt': return { sql: `${lo} < ?`, args: [range.lo] };
|
|
162
|
-
case 'ge': return { sql: `${hi} > ?`, args: [range.lo] };
|
|
163
|
-
case 'le': return { sql: `${lo} < ?`, args: [range.hi] };
|
|
164
|
-
case 'sa': return { sql: `${lo} >= ?`, args: [range.hi] };
|
|
165
|
-
case 'eb': return { sql: `${hi} <= ?`, args: [range.lo] };
|
|
166
|
-
case 'ap': return { sql: `(${lo} < ? AND ${hi} > ?)`, args: [range.hi, range.lo] };
|
|
167
|
-
}
|
|
168
|
-
return null;
|
|
169
|
-
}
|
|
63
|
+
const { dateRange, splitPrefix, escapeLike, splitValues, dateClause: dateRangeClause } = require('../library/fhir-search-values');
|
|
170
64
|
|
|
171
65
|
function numberClause(col, prefix, text) {
|
|
172
66
|
if (!/^[+-]?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(text)) {
|
|
@@ -230,7 +124,7 @@ function valueClause(def, modifier, value) {
|
|
|
230
124
|
case 'date': {
|
|
231
125
|
const { prefix, rest } = splitPrefix(value);
|
|
232
126
|
const range = dateRange(rest);
|
|
233
|
-
return range ?
|
|
127
|
+
return range ? dateRangeClause(def, prefix, range) : null;
|
|
234
128
|
}
|
|
235
129
|
}
|
|
236
130
|
return null;
|
package/testing/store.js
CHANGED
|
@@ -256,6 +256,11 @@ class TestReportStore {
|
|
|
256
256
|
return this.stmts.count.get().n;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/** How many reports were received in (sinceMs, nowMs] */
|
|
260
|
+
countReceived(sinceMs, nowMs) {
|
|
261
|
+
return this.db.prepare('SELECT COUNT(*) AS n FROM reports WHERE received_ms > ? AND received_ms <= ?').get(sinceMs, nowMs).n;
|
|
262
|
+
}
|
|
263
|
+
|
|
259
264
|
/**
|
|
260
265
|
* Run a parsed search (see search.js).
|
|
261
266
|
*
|
package/testing/testing.js
CHANGED
|
@@ -286,6 +286,26 @@ class TestingModule {
|
|
|
286
286
|
this.retentionTimer.unref();
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* The paragraph for the server's daily report: pages served, reports received
|
|
291
|
+
* in the period, and how many are held.
|
|
292
|
+
*/
|
|
293
|
+
dailyReport({since, now}) {
|
|
294
|
+
const {plural} = require('../library/daily-report');
|
|
295
|
+
const sentences = [];
|
|
296
|
+
if (this.stats) {
|
|
297
|
+
const counts = this.stats.countsSince(since, now);
|
|
298
|
+
sentences.push(`${plural(counts.total, 'page')} ${counts.total === 1 ? 'was' : 'were'} served.`);
|
|
299
|
+
}
|
|
300
|
+
if (this.store) {
|
|
301
|
+
const received = this.store.countReceived(since, now);
|
|
302
|
+
sentences.push(`${plural(received, 'test report')} ${received === 1 ? 'was' : 'were'} received; ${plural(this.store.count(), 'report')} ${this.store.count() === 1 ? 'is' : 'are'} held.`);
|
|
303
|
+
} else {
|
|
304
|
+
sentences.push('The report store is not open.');
|
|
305
|
+
}
|
|
306
|
+
return sentences.join(' ');
|
|
307
|
+
}
|
|
308
|
+
|
|
289
309
|
async shutdown() {
|
|
290
310
|
if (this.retentionTimer) {
|
|
291
311
|
clearInterval(this.retentionTimer);
|
|
@@ -150,23 +150,24 @@
|
|
|
150
150
|
|
|
151
151
|
<div class="api-docs">
|
|
152
152
|
<h4>API Documentation</h4>
|
|
153
|
-
<p>Use these endpoints to validate and track API key usage
|
|
153
|
+
<p>Use these endpoints to validate and track API key usage. Send the key in a header:
|
|
154
|
+
<code>Authorization: Bearer {api_key}</code> (or <code>X-API-Key: {api_key}</code>).</p>
|
|
154
155
|
|
|
155
156
|
<div class="mb-3">
|
|
156
157
|
<strong>Validate API Key:</strong>
|
|
157
|
-
<div class="api-endpoint">GET /token/api/validate
|
|
158
|
+
<div class="api-endpoint">GET /token/api/validate</div>
|
|
158
159
|
<p>Returns user information and allowed request count.</p>
|
|
159
160
|
</div>
|
|
160
161
|
|
|
161
162
|
<div class="mb-3">
|
|
162
163
|
<strong>Record Usage:</strong>
|
|
163
|
-
<div class="api-endpoint">POST /token/api/usage
|
|
164
|
+
<div class="api-endpoint">POST /token/api/usage</div>
|
|
164
165
|
<p>Records API usage. Send <code>{"count": 1}</code> in request body.</p>
|
|
165
166
|
</div>
|
|
166
167
|
|
|
167
168
|
<div class="mb-3">
|
|
168
169
|
<strong>Get Usage Statistics:</strong>
|
|
169
|
-
<div class="api-endpoint">GET /token/api/stats
|
|
170
|
+
<div class="api-endpoint">GET /token/api/stats?days=30</div>
|
|
170
171
|
<p>Returns usage statistics for the specified number of days.</p>
|
|
171
172
|
</div>
|
|
172
173
|
</div>
|
package/token/token.js
CHANGED
|
@@ -435,12 +435,19 @@ class TokenModule {
|
|
|
435
435
|
this.router.post('/keys', this.requireAuth.bind(this), this.createApiKey.bind(this));
|
|
436
436
|
this.router.delete('/keys/:id', this.requireAuth.bind(this), this.deleteApiKey.bind(this));
|
|
437
437
|
|
|
438
|
-
// JSON API routes for other servers
|
|
439
|
-
|
|
440
|
-
this.router.
|
|
438
|
+
// JSON API routes for other servers. The key goes in a header (Authorization: Bearer, or
|
|
439
|
+
// X-API-Key), not the path, so that it doesn't end up in access logs
|
|
440
|
+
this.router.get('/api/validate', this.validateApiKey.bind(this));
|
|
441
|
+
this.router.post('/api/usage', this.recordUsage.bind(this));
|
|
441
442
|
|
|
442
443
|
// Statistics
|
|
443
|
-
this.router.get('/api/stats
|
|
444
|
+
this.router.get('/api/stats', this.getUsageStats.bind(this));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// The API key a request carries: Authorization: Bearer <key>, or X-API-Key: <key>
|
|
448
|
+
apiKeyFrom(req) {
|
|
449
|
+
const auth = /^Bearer\s+(\S+)$/i.exec(req.get('Authorization') || '');
|
|
450
|
+
return auth ? auth[1] : (req.get('X-API-Key') || null);
|
|
444
451
|
}
|
|
445
452
|
|
|
446
453
|
// Middleware
|
|
@@ -627,7 +634,7 @@ class TokenModule {
|
|
|
627
634
|
try {
|
|
628
635
|
|
|
629
636
|
try {
|
|
630
|
-
const apiKey = req
|
|
637
|
+
const apiKey = this.apiKeyFrom(req);
|
|
631
638
|
|
|
632
639
|
if (!apiKey || !apiKey.startsWith('tk_')) {
|
|
633
640
|
return res.status(400).json({
|
|
@@ -700,7 +707,7 @@ class TokenModule {
|
|
|
700
707
|
try {
|
|
701
708
|
|
|
702
709
|
try {
|
|
703
|
-
const apiKey = req
|
|
710
|
+
const apiKey = this.apiKeyFrom(req);
|
|
704
711
|
const {count = 1} = req.body;
|
|
705
712
|
|
|
706
713
|
if (!apiKey || !apiKey.startsWith('tk_')) {
|
|
@@ -737,8 +744,8 @@ class TokenModule {
|
|
|
737
744
|
try {
|
|
738
745
|
|
|
739
746
|
try {
|
|
740
|
-
const apiKey = req
|
|
741
|
-
const days = Math.min(parseInt(req.query.days) || 30, 365); // Max 1 year
|
|
747
|
+
const apiKey = this.apiKeyFrom(req);
|
|
748
|
+
const days = Math.max(1, Math.min(parseInt(req.query.days) || 30, 365)); // Max 1 year
|
|
742
749
|
|
|
743
750
|
if (!apiKey || !apiKey.startsWith('tk_')) {
|
|
744
751
|
return res.status(400).json({error: 'Invalid key format'});
|
|
@@ -989,7 +996,7 @@ class TokenModule {
|
|
|
989
996
|
async getApiKeyUsageStats(keyId, days = 30) {
|
|
990
997
|
return new Promise((resolve, reject) => {
|
|
991
998
|
this.db.all(`
|
|
992
|
-
SELECT date, request_count
|
|
999
|
+
SELECT date, request_count
|
|
993
1000
|
FROM usage_stats
|
|
994
1001
|
WHERE api_key_id = ? AND date >= date('now', '-${days} days')
|
|
995
1002
|
ORDER BY date DESC
|
|
@@ -1134,7 +1141,7 @@ class TokenModule {
|
|
|
1134
1141
|
<td><span class="${statusClass}">${status}</span></td>
|
|
1135
1142
|
<td>
|
|
1136
1143
|
${key.is_active ?
|
|
1137
|
-
`<button class="btn btn-sm btn-danger" onclick="deleteKey(${key.id
|
|
1144
|
+
`<button class="btn btn-sm btn-danger" onclick="deleteKey(${Number(key.id)})">Delete</button>` :
|
|
1138
1145
|
'<span class="text-muted">-</span>'
|
|
1139
1146
|
}
|
|
1140
1147
|
</td>
|
|
@@ -181,6 +181,9 @@ CONCEPTMAP_GROUP_TARGET_PROPERTY_TYPE_NO_SYSTEM = Since no system has been provi
|
|
|
181
181
|
CONCEPTMAP_GROUP_TARGET_PROPERTY_TYPE_UNKNOWN_SYSTEM = The code system {0} could not be found, so code values can''t be checked
|
|
182
182
|
CONCEPTMAP_GROUP_TARGET_SERVER_SIDE = Target Code System {0} is only supported on the terminology server, so the target codes are not validated for performance reasons
|
|
183
183
|
CONCEPTMAP_GROUP_TARGET_UNKNOWN = The Target Code System {0} is not fully defined and populated, and no targetScope is specified, so the target code checking will not be performed
|
|
184
|
+
TRANSLATE_TARGET_CS_UNKNOWN = The code system {0} is not known, and the code ''{1}'' has not been validated
|
|
185
|
+
TRANSLATE_SCT_EDITION_TOP_LEVEL_ONE = No translation: the only {0} ancestor of {1} is the top-level hierarchy concept {2}
|
|
186
|
+
TRANSLATE_SCT_EDITION_TOP_LEVEL_MANY = No translation: the only {0} ancestors of {1} are the top-level hierarchy concepts {2}
|
|
184
187
|
CONCEPTMAP_SHAREABLE_EXTRA_MISSING = Published concept maps SHOULD conform to the ShareableConceptMap profile, which says that the element ConceptMap.{0} should be present, but it is not
|
|
185
188
|
CONCEPTMAP_SHAREABLE_EXTRA_MISSING_HL7 = Concept maps published by HL7 SHALL conform to the ShareableConceptMap profile, which says that the element ConceptMap.{0} should be present, but it is not
|
|
186
189
|
CONCEPTMAP_SHAREABLE_MISSING = Published concept maps SHOULD conform to the ShareableConceptMap profile, which says that the element ConceptMap.{0} is mandatory, but it is not present
|
|
@@ -377,6 +380,11 @@ HTA_LOINC_DESC = LOINC
|
|
|
377
380
|
HTA_LOINC_MESSAGE = This material contains content from <a href="http://loinc.org">LOINC</a>. LOINC is copyright © 1995-2020, Regenstrief Institute, Inc. and the Logical Observation Identifiers Names and Codes (LOINC) Committee and is available at no cost under the <a href="http://loinc.org/license">license</a>. LOINC® is a registered United States trademark of Regenstrief Institute, Inc.
|
|
378
381
|
HTA_SCT_DESC = SNOMED Clinical Terms® (SNOMED CT®)
|
|
379
382
|
HTA_SCT_MESSAGE = This material contains content that is copyright of SNOMED International. Implementers of these specifications must have the appropriate SNOMED CT Affiliate license - for more information contact <a href="https://www.snomed.org/get-snomed">https://www.snomed.org/get-snomed</a> or <a href="mailto:info@snomed.org">info@snomed.org</a>.
|
|
383
|
+
ICD11_CLASSIFICATION_CODE_NONE = The classificationCode extension says ''{0}'', but ''{1}'' has no classification code
|
|
384
|
+
ICD11_CLASSIFICATION_CODE_NOT_ID = The classificationCode extension is only used when the code is an entity id, and ''{0}'' is not one (the entity id is ''{1}'')
|
|
385
|
+
ICD11_CLASSIFICATION_CODE_SYSTEM = The classificationCode extension is only defined for ICD-11 MMS and ICF codings, not {0}
|
|
386
|
+
ICD11_CLASSIFICATION_CODE_WRONG = The classificationCode extension says ''{0}'', but the classification code of ''{1}'' is ''{2}''
|
|
387
|
+
ICD11_PC_AXIS_NOT_MULTIPLE = ''{0}'' takes one value on its {1} axis, but has several: {2}
|
|
380
388
|
ICD11_PC_NO_AXES = ''{0}'' cannot be postcoordinated onto ''{1}'': that concept declares no postcoordination axes
|
|
381
389
|
ICD11_PC_VALUE_NOT_ON_AXIS = ''{0}'' is not a permitted value on any postcoordination axis of ''{1}''. That stem declares: {2}
|
|
382
390
|
IG_DEPENDENCY_CAN_VERSION_ALONE = The canonical URL specifies the version ''{0}'', while the dependency does not. If a version is specified, it must be in the explicit version property
|
|
@@ -412,6 +420,7 @@ Internal_INT_Bad_Type = Unhandled fixed value type {0}
|
|
|
412
420
|
INV_FAILED = Constraint failed: {0}: ''{1}''
|
|
413
421
|
INV_FAILED_SOURCE = Constraint failed: {0}: ''{1}'' (defined in {2})
|
|
414
422
|
INVALID_DISPLAY_NAME = Invalid displayLanguage: ''{0}''
|
|
423
|
+
LOOKUP_DISPLAY_LANGUAGE_FALLBACK = There is no display in the language ''{0}'' for the code ''{1}'' in {2}, so the display is in the default language ''{3}''
|
|
415
424
|
Invalid_slicing__there_is_more_than_one_type_slice_at__but_one_of_them__has_min__1_so_the_other_slices_cannot_exist=Invalid slicing: there is more than one type slice at {0}, but one of them ({1}) has min = 1, so the other slices cannot exist
|
|
416
425
|
JSON_COMMA_EXTRA = There is an extra comma at the end of the {0} in the JSON
|
|
417
426
|
JSON_COMMA_MISSING = A Comma is missing in the JSON
|
|
@@ -1120,6 +1129,7 @@ CLOSURE_NAME_NOT_UUID = The closure table name ''{0}'' is not valid: this server
|
|
|
1120
1129
|
CLOSURE_PARAM_UNKNOWN = The parameter ''{0}'' is not supported by $closure on this server
|
|
1121
1130
|
CLOSURE_CONCEPT_AND_VERSION = A $closure request can add concepts or resynchronise from a version, but not both at once
|
|
1122
1131
|
CLOSURE_RESET_AND_VERSION = A $closure request can reset the closure table or resynchronise from a version, but not both at once
|
|
1132
|
+
CLOSURE_DISPOSE_NOT_ALONE = A $closure request that disposes of the closure table can only name the table: it cannot also add concepts, resynchronise, reset or configure it
|
|
1123
1133
|
CLOSURE_TABLE_EXISTS = The closure table ''{0}'' already exists. To discard its content and start again, include the parameter reset = true
|
|
1124
1134
|
CLOSURE_TABLE_UNKNOWN = The closure table ''{0}'' does not exist on this server. Initialise it first, with a $closure request that names it and has no concepts
|
|
1125
1135
|
CLOSURE_CONFIG_NOT_ALLOWED = The parameter ''{0}'' configures a closure table, so it can only be supplied when the table is initialised (or reset)
|
package/tx/README.md
CHANGED
|
@@ -148,6 +148,58 @@ The file is read from disk per request, so editing it is reflected immediately -
|
|
|
148
148
|
this means the published file can be ahead of what the running server actually loaded, if it
|
|
149
149
|
has been edited since startup.
|
|
150
150
|
|
|
151
|
+
### Syndication feed
|
|
152
|
+
|
|
153
|
+
`syndication.enabled` publishes the content the library loads as a
|
|
154
|
+
[Terminology Syndication Feed](https://build.fhir.org/ig/FHIR/fhir-syndication-ig/en/)
|
|
155
|
+
(Atom, NCTS ASF profile), so that Ontoserver, other FHIRsmith servers and people can follow
|
|
156
|
+
what the server loads. It is off by default.
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
"syndication": { "enabled": true }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**The store.** As the library loads its sources, each one is recorded in the syndication store,
|
|
163
|
+
`<data>/terminology-cache/syndication` (`syndication.json` plus `files/`). The store is defined
|
|
164
|
+
from the moment syndication is turned on: content loaded before then is not back-filled, but
|
|
165
|
+
since induction happens as content loads, everything the server loads after that is recorded
|
|
166
|
+
the first time the store sees it, with the time it was recorded. The store only grows -
|
|
167
|
+
entries are never changed or removed, there are no retract entries, and a later update to
|
|
168
|
+
the same content is a new entry (so `contentItemVersion` is not unique - a deliberate departure
|
|
169
|
+
from the IG). One store serves all the endpoints.
|
|
170
|
+
|
|
171
|
+
| Source | Entry | Link | Category |
|
|
172
|
+
|--------|-------|------|----------|
|
|
173
|
+
| `npm:` / `url:` packages, and each endpoint's core package | the package | `https://npm.fhir.org/web/{id}-{version}.tgz` (a `url:` source: its url) | `FHIR_Package` |
|
|
174
|
+
| `file:` / `folder:` | each resource that has a version | a copy in the store | `FHIR_CodeSystem` / `FHIR_ValueSet` / `FHIR_ConceptMap` |
|
|
175
|
+
| `snomed`, `loinc`, `rxnorm`, `ndc`, `unii`, `omop`, `icd11`, `cpt` (fragment only) | the file | `base.url` + file name | `SNOMED_CACHE`, `LOINC_DB` ... in `http://healthintersections.com.au/syndication/fhirsmith/1.0.0` |
|
|
176
|
+
|
|
177
|
+
Not syndicated: `internal:` sources (including VSAC), OCL, `#current`/build packages, resources
|
|
178
|
+
without a version, the full CPT database, and binaries that are not published at `base.url`
|
|
179
|
+
(checked with a HEAD request - a binary that is only local is a development build). Binary
|
|
180
|
+
entries carry `<rights>`. Every link carries `ncts:sha256Hash`: a package is downloaded once to
|
|
181
|
+
hash it, then discarded. Only the core packages are FHIR version specific; everything else
|
|
182
|
+
appears in every endpoint's feed, with that endpoint's `ncts:fhirVersion`.
|
|
183
|
+
|
|
184
|
+
Induction runs in the background, one item at a time, so it never holds up startup; a failure
|
|
185
|
+
is logged and tried again the next time the content loads.
|
|
186
|
+
|
|
187
|
+
**The feed** uses RFC 5005 archived feeds, 30 entries a page:
|
|
188
|
+
|
|
189
|
+
| URL | Content |
|
|
190
|
+
|-----|---------|
|
|
191
|
+
| `/{path}/syndication` | the newest entries (fewer than 30), linked to the newest archive |
|
|
192
|
+
| `/{path}/syndication/archive/{n}` | archive *n* (1 = oldest), linked back to *n*-1 - never changes once it exists |
|
|
193
|
+
| `/{path}/syndication/files/{name}` | a resource file copied into the store |
|
|
194
|
+
|
|
195
|
+
The links in the feed are built from the server's `hostName` (top level of config.json) as
|
|
196
|
+
`https://{hostName}{path}`, never from the request's Host header; only archives built that way
|
|
197
|
+
are served as cacheable forever. Without a `hostName` the links follow the request, and
|
|
198
|
+
archives are `no-cache`.
|
|
199
|
+
|
|
200
|
+
A browser (`Accept: text/html`) gets the feed as a page; `?_format=atom` forces Atom. The IG's
|
|
201
|
+
query-string filters are not supported. A **Syndication** item appears in the tx navigation bar.
|
|
202
|
+
|
|
151
203
|
## OpenAPI description
|
|
152
204
|
|
|
153
205
|
Each R5 endpoint is described by an OpenAPI 3.1 spec (the R4 and R3 endpoints work the same
|
|
@@ -286,6 +286,20 @@ class ClosureStore {
|
|
|
286
286
|
return tx();
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Delete a table, and everything in it. The caller holds the table's lock.
|
|
291
|
+
* @param {string} name
|
|
292
|
+
* @returns {boolean} whether there was a table to delete
|
|
293
|
+
*/
|
|
294
|
+
dropTable(name) {
|
|
295
|
+
const existing = this.stmt.getTable.get(name);
|
|
296
|
+
if (!existing) {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
this.stmt.deleteTable.run(existing.id);
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
289
303
|
/**
|
|
290
304
|
* The parameters the table was initialised with, as Parameters.parameter entries.
|
|
291
305
|
*/
|