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.
Files changed (121) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +422 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +7 -2
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +9 -0
  89. package/tx/README.md +52 -0
  90. package/tx/cs/cs-api.js +90 -14
  91. package/tx/cs/cs-cs.js +56 -1
  92. package/tx/cs/cs-icd11.js +366 -112
  93. package/tx/cs/cs-icf.js +418 -0
  94. package/tx/cs/cs-loinc.js +47 -25
  95. package/tx/cs/cs-omop.js +10 -3
  96. package/tx/cs/cs-snomed.js +918 -55
  97. package/tx/cs/cs-ucum.js +6 -0
  98. package/tx/cs/icd11-expressions.js +191 -78
  99. package/tx/importers/export-icd11-codesystem.js +58 -12
  100. package/tx/importers/icd11-schema.md +9 -5
  101. package/tx/library.js +98 -4
  102. package/tx/openapi-operations.js +4 -0
  103. package/tx/params.js +8 -1
  104. package/tx/provider.js +16 -4
  105. package/tx/sct/documentation.md +251 -0
  106. package/tx/sct/ecl.js +758 -46
  107. package/tx/sct/expressions.js +113 -52
  108. package/tx/syndication/syndication-feed.js +218 -0
  109. package/tx/syndication/syndication-store.js +415 -0
  110. package/tx/tx-html.js +15 -1
  111. package/tx/tx.fhir.org.yml +2 -3
  112. package/tx/tx.js +144 -2
  113. package/tx/vs/vs-vsac.js +25 -6
  114. package/tx/workers/expand.js +33 -6
  115. package/tx/workers/lookup.js +61 -6
  116. package/tx/workers/subsumes.js +1 -1
  117. package/tx/workers/translate.js +166 -15
  118. package/tx/workers/validate.js +128 -6
  119. package/utilities/make-sct-test-extension.js +264 -0
  120. package/xig/xig.js +32 -0
  121. 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 PREFIXES = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'sa', 'eb', 'ap'];
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 ? dateClause(def, prefix, range) : null;
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
  *
@@ -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:</p>
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/{api_key}</div>
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/{api_key}</div>
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/{api_key}?days=30</div>
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
- this.router.get('/api/validate/:key', this.validateApiKey.bind(this));
440
- this.router.post('/api/usage/:key', this.recordUsage.bind(this));
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/:key', this.getUsageStats.bind(this));
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.params.key;
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.params.key;
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.params.key;
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, last_request_ip
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}, '${escape(key.name)}')">Delete</button>` :
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 &copy; 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&reg; is a registered United States trademark of Regenstrief Institute, Inc.
378
381
  HTA_SCT_DESC = SNOMED Clinical Terms&reg; (SNOMED CT&reg;)
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
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