fhirsmith 0.9.7 → 0.10.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.
@@ -2,15 +2,34 @@
2
2
  // Crawler for gathering server information from terminology servers
3
3
 
4
4
  const axios = require('axios');
5
- const {
6
- ServerRegistries,
7
- ServerRegistry,
8
- ServerInformation,
5
+ const dns = require('dns');
6
+ const http = require('http');
7
+ const https = require('https');
8
+ const ipaddr = require('ipaddr.js');
9
+ const {
10
+ ServerRegistries,
11
+ ServerRegistry,
12
+ ServerInformation,
9
13
  ServerVersionInformation,
10
14
  } = require('./model');
11
15
  const {Extensions} = require("../tx/library/extensions");
12
16
  const {debugLog} = require("../tx/operation-context");
13
17
 
18
+ // True if an IP literal is anything other than a normal public (unicast) address:
19
+ // loopback, private, link-local (incl. 169.254.169.254 cloud metadata), unique-local,
20
+ // CGNAT, multicast, reserved, etc. IPv4-mapped IPv6 addresses are unwrapped first.
21
+ function isNonPublicAddress(ip) {
22
+ try {
23
+ let addr = ipaddr.parse(ip);
24
+ if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
25
+ addr = addr.toIPv4Address();
26
+ }
27
+ return addr.range() !== 'unicast';
28
+ } catch (e) {
29
+ return true; // unparseable - treat as unsafe
30
+ }
31
+ }
32
+
14
33
  const MASTER_URL = 'https://fhir.github.io/ig-registry/tx-servers.json';
15
34
 
16
35
  class RegistryCrawler {
@@ -22,7 +41,8 @@ class RegistryCrawler {
22
41
  masterUrl: config.masterUrl || MASTER_URL,
23
42
  userAgent: config.userAgent || 'HealthIntersections/FhirServer',
24
43
  crawlInterval: config.crawlInterval || 5 * 60 * 1000, // 5 minutes default
25
- apiKeys: config.apiKeys || {} // Map of server URL or code to API key
44
+ apiKeys: config.apiKeys || {}, // Map of server URL or code to API key
45
+ allowPrivateAddresses: config.allowPrivateAddresses || false // SSRF opt-out for local test servers
26
46
  };
27
47
  this.stats = stats;
28
48
 
@@ -39,6 +59,63 @@ class RegistryCrawler {
39
59
  this.log = logv;
40
60
  }
41
61
 
62
+ // A DNS lookup wrapper that rejects any host resolving to a non-public address.
63
+ // Enforced at connection time (so it also covers redirect targets and defeats
64
+ // DNS-rebinding), this is the SSRF guard for outbound fetches. The registry is
65
+ // crawled from server-supplied "next" links, so the target host is not trusted.
66
+ // Set config.allowPrivateAddresses = true to disable (e.g. for local test servers).
67
+ ssrfLookup() {
68
+ const allowPrivate = !!(this.config && this.config.allowPrivateAddresses);
69
+ return (hostname, options, callback) => {
70
+ if (typeof options === 'function') {
71
+ callback = options;
72
+ options = {};
73
+ }
74
+ const wantAll = !!(options && options.all);
75
+ dns.lookup(hostname, Object.assign({}, options, { all: true }), (err, addresses) => {
76
+ if (err) {
77
+ callback(err);
78
+ return;
79
+ }
80
+ if (!allowPrivate) {
81
+ for (const a of addresses) {
82
+ if (isNonPublicAddress(a.address)) {
83
+ callback(new Error('Blocked request to non-public address ' + a.address + ' (host ' + hostname + ')'));
84
+ return;
85
+ }
86
+ }
87
+ }
88
+ if (wantAll) {
89
+ callback(null, addresses);
90
+ } else {
91
+ callback(null, addresses[0].address, addresses[0].family);
92
+ }
93
+ });
94
+ };
95
+ }
96
+
97
+ // http/https agents that route every connection through the SSRF lookup.
98
+ guardedAgents() {
99
+ if (!this._guardedAgents) {
100
+ const lookup = this.ssrfLookup();
101
+ class GuardedHttpAgent extends http.Agent {
102
+ createConnection(options, cb) {
103
+ return super.createConnection(Object.assign({}, options, { lookup }), cb);
104
+ }
105
+ }
106
+ class GuardedHttpsAgent extends https.Agent {
107
+ createConnection(options, cb) {
108
+ return super.createConnection(Object.assign({}, options, { lookup }), cb);
109
+ }
110
+ }
111
+ this._guardedAgents = {
112
+ httpAgent: new GuardedHttpAgent({ keepAlive: true }),
113
+ httpsAgent: new GuardedHttpsAgent({ keepAlive: true })
114
+ };
115
+ }
116
+ return this._guardedAgents;
117
+ }
118
+
42
119
 
43
120
  /**
44
121
  * Main entry point - crawl the registry starting from the master URL
@@ -455,7 +532,9 @@ class RegistryCrawler {
455
532
  timeout: this.config.timeout,
456
533
  headers: headers,
457
534
  signal: this.abortController?.signal,
458
- validateStatus: (status) => status < 500 // Don't throw on 4xx
535
+ validateStatus: (status) => status < 500, // Don't throw on 4xx
536
+ httpAgent: this.guardedAgents().httpAgent,
537
+ httpsAgent: this.guardedAgents().httpsAgent
459
538
  });
460
539
 
461
540
  if (response.status >= 400) {
@@ -976,7 +976,15 @@ class RegistryModule {
976
976
 
977
977
  try {
978
978
  const params = this._normalizeQueryParams(req.query);
979
- const {fhirVersion, url, valueSet, usage} = params;
979
+ const {fhirVersion, valueSet, usage, version} = params;
980
+ let {url} = params;
981
+
982
+ // If a version was supplied separately, fold it into the code system
983
+ // URL using the canonical url|version syntax that the resolver expects.
984
+ // Don't double up if the caller already encoded a version in the url.
985
+ if (version && url && !url.includes('|')) {
986
+ url = `${url}|${version}`;
987
+ }
980
988
 
981
989
  // Convert authoritativeOnly to boolean
982
990
  const authoritativeOnly = params.authoritativeOnly === 'true';
@@ -1219,6 +1227,7 @@ class RegistryModule {
1219
1227
  buildResolveFormContent(queryParams = {}) {
1220
1228
  const fhirVersion = queryParams.fhirVersion || '';
1221
1229
  const url = queryParams.url || '';
1230
+ const version = queryParams.version || '';
1222
1231
  const valueSet = queryParams.valueSet || '';
1223
1232
  const authoritativeOnly = queryParams.authoritativeOnly === 'true';
1224
1233
 
@@ -1246,6 +1255,14 @@ class RegistryModule {
1246
1255
  html += '</p>';
1247
1256
  html += '<p class="text-muted small">Example: http://loinc.org</p>';
1248
1257
 
1258
+ // Code System Version field (optional)
1259
+ html += '<p>';
1260
+ html += '<label for="version" class="form-label fw-bold">Code System Version</label>';
1261
+ html += `<input type="text" class="form-control" id="version" name="version"
1262
+ value="${escape(version)}">`;
1263
+ html += '</p>';
1264
+ html += '<p class="text-muted small">Optional. Example: 2.74 (combined with the Code System URL as url|version)</p>';
1265
+
1249
1266
  // ValueSet URL field - now vertical
1250
1267
  html += '<p>';
1251
1268
  html += '<label for="valueSet" class="form-label fw-bold">Value Set URL</label>';
package/server.js CHANGED
@@ -430,6 +430,11 @@ async function buildRootPageContent() {
430
430
  content += `<td style="background-color:${pctColor(processPCT)}"><strong>Process Memory:</strong> ${rssMB} MB of ${memLimitMB} MB (${processPCT.toFixed(0)}%)</td>`;
431
431
  content += `<td style="background-color:${pctColor(sysMemPCT)}"><strong>System Memory:</strong> ${usedMemMB} MB of ${totalMemMB} MB (${sysMemPCT.toFixed(0)}%)</td>`;
432
432
  content += '</tr>';
433
+ content += '<tr>';
434
+ content += `<td><strong>Expansion Cache:</strong> ${stats.expansionItems()} items</td>`;
435
+ content += `<td><strong>Client Cache:</strong> ${stats.clientCaches()} caches / ${stats.clientConcepts()} concepts</td>`;
436
+ content += `<td><strong>Max Client Cache:</strong> ${stats.maxClientCaches()} caches / ${stats.maxClientConcepts()} concepts</td>`;
437
+ content += '</tr>';
433
438
  content += getLogStats();
434
439
  content += '</table>';
435
440
 
package/stats.js CHANGED
@@ -36,24 +36,26 @@ class ServerStats {
36
36
  : (now - this.startTime) / 60000;
37
37
  const requestsPerMin = minutesSinceStart > 0 ? requestsDelta / minutesSinceStart : 0;
38
38
 
39
- const currentCpu = this.readSystemCpu();
40
- const idleDelta = currentCpu.idle - this.lastUsage.idle;
41
- const totalDelta = currentCpu.total - this.lastUsage.total;
42
- const percent = totalDelta > 0 ? 100 * (1 - idleDelta / totalDelta) : 0;
43
-
44
39
  const loopDelay = this.eventLoopMonitor.mean / 1e6;
45
- let cacheCount = 0;
40
+ // Two distinct caches: the expansion cache (count entries) and the client
41
+ // (resource) cache (count concepts held - a sense of how much it's carrying).
42
+ let expansionItems = 0;
43
+ let clientConcepts = 0;
46
44
  for (let m of this.cachingModules) {
47
- cacheCount = cacheCount + m.cacheCount();
45
+ if (typeof m.expansionItemCount === 'function') {
46
+ expansionItems = expansionItems + m.expansionItemCount();
47
+ }
48
+ if (typeof m.clientConceptCount === 'function') {
49
+ clientConcepts = clientConcepts + m.clientConceptCount();
50
+ }
48
51
  }
49
52
 
50
- this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, cpu: percent, block: loopDelay, cache : cacheCount});
53
+ this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, block: loopDelay, expansion: expansionItems, clientConcepts: clientConcepts});
51
54
 
52
55
  this.eventLoopMonitor.reset();
53
56
  this.requestCountSnapshot = combinedCount;
54
57
  this.requestTime = 0;
55
58
  this.lastTime = now;
56
- this.lastUsage = currentCpu;
57
59
 
58
60
  // Prune old data (keep 24 hours)
59
61
  const cutoff = now - (24 * 60 * 60 * 1000); // 24 hours ago
@@ -65,7 +67,6 @@ class ServerStats {
65
67
  this.started = true;
66
68
  this.startMem = process.memoryUsage().heapUsed;
67
69
  this.startTime = Date.now();
68
- this.lastUsage = this.readSystemCpu();
69
70
  this.lastTime = this.startTime;
70
71
  this.eventLoopMonitor = monitorEventLoopDelay({ resolution: 20 });
71
72
  this.eventLoopMonitor.enable();
@@ -141,17 +142,24 @@ class ServerStats {
141
142
  clearInterval(this.timer);
142
143
  }
143
144
 
144
- readSystemCpu() {
145
- const os = require('os');
146
- const cpus = os.cpus();
147
- let idle = 0, total = 0;
148
- for (const cpu of cpus) {
149
- idle += cpu.times.idle;
150
- total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
145
+ // Live cache aggregates across all registered caching modules (defensive: a
146
+ // module that doesn't expose a given metric contributes 0).
147
+ _sumCachingModules(method) {
148
+ let total = 0;
149
+ for (let m of this.cachingModules) {
150
+ if (typeof m[method] === 'function') {
151
+ total = total + m[method]();
152
+ }
151
153
  }
152
- return { idle, total };
154
+ return total;
153
155
  }
154
156
 
157
+ expansionItems() { return this._sumCachingModules('expansionItemCount'); }
158
+ clientCaches() { return this._sumCachingModules('clientCacheCount'); }
159
+ clientConcepts() { return this._sumCachingModules('clientConceptCount'); }
160
+ maxClientCaches() { return this._sumCachingModules('maxClientCacheCount'); }
161
+ maxClientConcepts() { return this._sumCachingModules('maxClientConceptCount'); }
162
+
155
163
  getTaskColor(status) {
156
164
  switch (status) {
157
165
  case "started": return "LightGrey";
@@ -1100,6 +1100,7 @@ Unable_to_resolve_system__value_set_has_multiple_matches = The System URI could
1100
1100
  Unable_to_resolve_system__value_set_has_no_includes_or_expansion = The System URI could not be determined for the code ''{0}'' in the ValueSet ''{1}'': value set has no includes or expansion
1101
1101
  UNABLE_TO_RESOLVE_SYSTEM_SYSTEM_IS_INDETERMINATE = The code system {1} referred to from value set {0} has a grammar, and the code might be valid in it
1102
1102
  Unable_to_resolve_value_Set_ = A definition for the value Set ''{0}'' could not be found
1103
+ CACHE_ID_UNKNOWN = The cache ''{0}'' is not known to this server. Caches are created with $cache-control?mode=start; this one was never created, or has expired or been released
1103
1104
  Unable_to_validate_code_without_using_server = Unable to validate code without using server because: {0}
1104
1105
  Undefined_attribute__on__for_type__properties__ = Undefined attribute ''@{0}'' on {1} for type {2}
1105
1106
  Undefined_element_ = Undefined element ''{0}'' at {1}
@@ -4,7 +4,7 @@
4
4
  <canvas id="memoryChart" height="200"></canvas>
5
5
  </div>
6
6
  <div style="flex: 1;">
7
- <h6>Cache</h6>
7
+ <h6>Expansion Cache</h6>
8
8
  <canvas id="cacheChart" height="200"></canvas>
9
9
  </div>
10
10
  <div style="flex: 1;">
@@ -18,8 +18,8 @@
18
18
  <canvas id="requestChart" height="200"></canvas>
19
19
  </div>
20
20
  <div style="flex: 1;">
21
- <h6>CPU Usage (%)</h6>
22
- <canvas id="cpuChart" height="200"></canvas>
21
+ <h6>Client Cache</h6>
22
+ <canvas id="clientCacheChart" height="200"></canvas>
23
23
  </div>
24
24
  <div style="flex: 1;">
25
25
  <h6>Node Blocking</h6>
@@ -99,13 +99,13 @@
99
99
  }
100
100
  });
101
101
 
102
- // Cache chart
102
+ // Expansion cache chart
103
103
  new Chart(document.getElementById('cacheChart'), {
104
104
  type: 'line',
105
105
  data: {
106
106
  labels: historyData.map(d => formatTime(d.time)),
107
107
  datasets: [{
108
- data: historyData.map(d => d.cache),
108
+ data: historyData.map(d => d.expansion),
109
109
  borderColor: '#7b7b7b',
110
110
  backgroundColor: 'rgba(123, 123, 123, 0.1)',
111
111
  fill: true,
@@ -121,7 +121,7 @@
121
121
  ...chartOptions.scales.y,
122
122
  title: {
123
123
  display: true,
124
- text: '# cached items'
124
+ text: 'Expansion cache # items'
125
125
  }
126
126
  }
127
127
  }
@@ -158,13 +158,13 @@
158
158
  }
159
159
  });
160
160
 
161
- // CPU chart
162
- new Chart(document.getElementById('cpuChart'), {
161
+ // Client cache concepts chart
162
+ new Chart(document.getElementById('clientCacheChart'), {
163
163
  type: 'line',
164
164
  data: {
165
165
  labels: historyData.map(d => formatTime(d.time)),
166
166
  datasets: [{
167
- data: historyData.map(d => parseFloat(formatMB(d.cpu))),
167
+ data: historyData.map(d => d.clientConcepts),
168
168
  borderColor: '#ff7b00',
169
169
  backgroundColor: 'rgba(255, 123, 0, 0.1)',
170
170
  fill: true,
@@ -180,7 +180,7 @@
180
180
  ...chartOptions.scales.y,
181
181
  title: {
182
182
  display: true,
183
- text: '%'
183
+ text: 'Client Cache # concepts'
184
184
  }
185
185
  }
186
186
  }
@@ -42,6 +42,37 @@ class CodeSystem extends CanonicalResource {
42
42
  }
43
43
  this.buildMaps();
44
44
  }
45
+ // Precalculated at construction so callers (e.g. the resource cache) have a
46
+ // cheap O(1) sense of how large this resource is.
47
+ this._conceptCount = CodeSystem._countConcepts(this.jsonObj.concept);
48
+ }
49
+
50
+ /**
51
+ * Number of concepts defined in this CodeSystem, counted recursively (nested
52
+ * concepts included). Precalculated at construction time.
53
+ * @returns {number}
54
+ */
55
+ conceptCount() {
56
+ return this._conceptCount;
57
+ }
58
+
59
+ /**
60
+ * Recursively count concepts (including nested `concept` children).
61
+ * @param {Array} concepts
62
+ * @returns {number}
63
+ */
64
+ static _countConcepts(concepts) {
65
+ if (!Array.isArray(concepts)) {
66
+ return 0;
67
+ }
68
+ let n = 0;
69
+ for (const c of concepts) {
70
+ n++;
71
+ if (c && c.concept) {
72
+ n += CodeSystem._countConcepts(c.concept);
73
+ }
74
+ }
75
+ return n;
45
76
  }
46
77
 
47
78
  /**
@@ -20,6 +20,30 @@ class ConceptMap extends CanonicalResource {
20
20
  this.jsonObj = conceptMapToR5(jsonObj, fhirVersion);
21
21
  this.validate();
22
22
  this.id = this.jsonObj.id;
23
+ // Precalculated at construction so callers (e.g. the resource cache) have a
24
+ // cheap O(1) sense of how large this resource is.
25
+ this._conceptCount = ConceptMap._countConcepts(this.jsonObj);
26
+ }
27
+
28
+ /**
29
+ * Number of concept nodes this ConceptMap carries: every source `element` plus
30
+ * every `target` across all groups (each is a coded node, so this approximates
31
+ * the resource's size). Precalculated at construction time.
32
+ * @returns {number}
33
+ */
34
+ conceptCount() {
35
+ return this._conceptCount;
36
+ }
37
+
38
+ static _countConcepts(jsonObj) {
39
+ let n = 0;
40
+ for (const g of ((jsonObj && jsonObj.group) || [])) {
41
+ for (const e of (g.element || [])) {
42
+ n++;
43
+ n += Array.isArray(e.target) ? e.target.length : 0;
44
+ }
45
+ }
46
+ return n;
23
47
  }
24
48
 
25
49
  /**
@@ -340,12 +340,12 @@ class Renderer {
340
340
  }
341
341
  }
342
342
 
343
- renderLinkComma(x, uri) {
344
- let {desc, url} = (this.linkResolver ? this.linkResolver.resolveURL(this.opContext, uri) : null) || {};
345
- if (url) {
346
- x.commaItem(desc, url);
343
+ async renderLinkComma(x, uri) {
344
+ let {description, link} = await (this.linkResolver ? this.linkResolver.resolveURL(this.opContext, uri) : null) || {};
345
+ if (link) {
346
+ x.commaItem(description, link);
347
347
  } else {
348
- x.commaItem(uri);
348
+ x.commaItem(link);
349
349
  }
350
350
  }
351
351
 
@@ -673,7 +673,7 @@ class Renderer {
673
673
  p.tx(" ");
674
674
  p.startCommaList("and");
675
675
  for (let ext of supplements) {
676
- this.renderLinkComma(p, getValuePrimitive(ext));
676
+ await this.renderLinkComma(p, getValuePrimitive(ext));
677
677
  }
678
678
  p.stopCommaList();
679
679
  p.tx(".");
@@ -762,10 +762,10 @@ class Renderer {
762
762
  li.stopCommaList();
763
763
  }
764
764
  } else if (inc.valueSet && inc.valueSet.length > 0) {
765
- li.tx(this.translatePlural(inc.valueSet.length, 'VALUE_SET_RULES_INC'));
765
+ li.tx(this.translatePlural(inc.valueSet.length, 'VALUE_SET_IMPORT')+" ");
766
766
  li.startCommaList("and");
767
767
  for (let vs of inc.valueSet) {
768
- this.renderLinkComma(li, vs);
768
+ await this.renderLinkComma(li, vs);
769
769
  }
770
770
  li.stopCommaList();
771
771
  } else {
@@ -25,6 +25,52 @@ class ValueSet extends CanonicalResource {
25
25
  this.jsonObj = valueSetToR5(jsonObj, fhirVersion);
26
26
  this.validate();
27
27
  this.buildMaps();
28
+ // Precalculated at construction so callers (e.g. the resource cache) have a
29
+ // cheap O(1) sense of how large this resource is.
30
+ this._conceptCount = ValueSet._countConcepts(this.jsonObj);
31
+ }
32
+
33
+ /**
34
+ * Number of concepts this ValueSet carries: the enumerated concepts in its
35
+ * compose (include + exclude `concept` lists) plus any concepts in an inline
36
+ * expansion (`expansion.contains`, counted recursively). Note this counts the
37
+ * concepts *present in the resource*, not the (possibly unbounded) size of the
38
+ * value set's full expansion. Precalculated at construction time.
39
+ * @returns {number}
40
+ */
41
+ conceptCount() {
42
+ return this._conceptCount;
43
+ }
44
+
45
+ static _countConcepts(jsonObj) {
46
+ let n = 0;
47
+ const compose = jsonObj && jsonObj.compose;
48
+ if (compose) {
49
+ for (const inc of (compose.include || [])) {
50
+ n += Array.isArray(inc.concept) ? inc.concept.length : 0;
51
+ }
52
+ for (const exc of (compose.exclude || [])) {
53
+ n += Array.isArray(exc.concept) ? exc.concept.length : 0;
54
+ }
55
+ }
56
+ if (jsonObj && jsonObj.expansion) {
57
+ n += ValueSet._countContains(jsonObj.expansion.contains);
58
+ }
59
+ return n;
60
+ }
61
+
62
+ static _countContains(items) {
63
+ if (!Array.isArray(items)) {
64
+ return 0;
65
+ }
66
+ let n = 0;
67
+ for (const it of items) {
68
+ n++;
69
+ if (it && it.contains) {
70
+ n += ValueSet._countContains(it.contains);
71
+ }
72
+ }
73
+ return n;
28
74
  }
29
75
 
30
76
  /**