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.
- package/CHANGELOG.md +42 -0
- package/package.json +1 -1
- package/packages/package-crawler.js +123 -6
- package/packages/packages.js +104 -0
- package/publisher/publisher.js +191 -5
- package/registry/crawler.js +85 -6
- package/registry/registry.js +18 -1
- package/server.js +5 -0
- package/stats.js +26 -18
- package/translations/Messages.properties +1 -0
- package/tx/html/home-metrics.liquid +10 -10
- package/tx/library/codesystem.js +31 -0
- package/tx/library/conceptmap.js +24 -0
- package/tx/library/renderer.js +8 -8
- package/tx/library/valueset.js +46 -0
- package/tx/operation-context.js +109 -4
- package/tx/provider.js +3 -3
- package/tx/tx-html.js +27 -0
- package/tx/tx.js +80 -17
- package/tx/vs/vs-database.js +41 -8
- package/tx/vs/vs-package.js +3 -0
- package/tx/vs/vs-vsac.js +4 -0
- package/tx/workers/cache-control.js +186 -0
- package/tx/workers/{related.js → compare.js} +27 -27
- package/tx/workers/metadata.js +11 -6
- package/tx/workers/worker.js +133 -46
- package/tx/data/OperationDefinition-ValueSet-related.json +0 -133
package/registry/crawler.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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) {
|
package/registry/registry.js
CHANGED
|
@@ -976,7 +976,15 @@ class RegistryModule {
|
|
|
976
976
|
|
|
977
977
|
try {
|
|
978
978
|
const params = this._normalizeQueryParams(req.query);
|
|
979
|
-
const {fhirVersion,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
let
|
|
148
|
-
for (
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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>
|
|
22
|
-
<canvas id="
|
|
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
|
-
//
|
|
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.
|
|
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: '#
|
|
124
|
+
text: 'Expansion cache # items'
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
}
|
|
@@ -158,13 +158,13 @@
|
|
|
158
158
|
}
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
-
//
|
|
162
|
-
new Chart(document.getElementById('
|
|
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 =>
|
|
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
|
}
|
package/tx/library/codesystem.js
CHANGED
|
@@ -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
|
/**
|
package/tx/library/conceptmap.js
CHANGED
|
@@ -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
|
/**
|
package/tx/library/renderer.js
CHANGED
|
@@ -340,12 +340,12 @@ class Renderer {
|
|
|
340
340
|
}
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
-
renderLinkComma(x, uri) {
|
|
344
|
-
let {
|
|
345
|
-
if (
|
|
346
|
-
x.commaItem(
|
|
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(
|
|
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, '
|
|
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 {
|
package/tx/library/valueset.js
CHANGED
|
@@ -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
|
/**
|