fhirsmith 0.18.2 → 1.0.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 (203) hide show
  1. package/ACCESSIBILITY.md +119 -0
  2. package/CHANGELOG.md +149 -0
  3. package/CONFIGURATION.md +305 -0
  4. package/LICENSE +29 -0
  5. package/README.md +29 -122
  6. package/cluster.js +1220 -0
  7. package/cluster.md +707 -0
  8. package/config-template.json +77 -8
  9. package/extension-tracker/extension-tracker-template.html +5 -8
  10. package/extension-tracker/extension-tracker.js +0 -2
  11. package/folder/folder.js +7 -6
  12. package/ips/analysis.js +560 -0
  13. package/ips/ips-template.html +5 -8
  14. package/ips/ips.js +132 -59
  15. package/ips/nationality.js +120 -0
  16. package/ips/pipeline.js +74 -21
  17. package/ips/presenter.js +1 -1
  18. package/ips/readme.md +132 -25
  19. package/ips/render.js +466 -77
  20. package/ips/store.js +79 -22
  21. package/ips/structure.js +247 -0
  22. package/ips/terminology.js +1 -1
  23. package/library/body-limit.js +22 -0
  24. package/library/cache-generation.js +15 -0
  25. package/library/cgroup-memory.js +133 -1
  26. package/library/cluster-counts.js +121 -0
  27. package/library/cluster-events.js +153 -0
  28. package/library/cluster-metrics.js +193 -0
  29. package/library/cluster-status.js +468 -0
  30. package/library/config-reader.js +96 -0
  31. package/library/daily-report.js +100 -2
  32. package/library/html-server.js +27 -1
  33. package/library/i18nsupport.js +23 -1
  34. package/library/keep-alive.js +18 -0
  35. package/library/languages.js +17 -3
  36. package/library/logger.js +31 -0
  37. package/library/memory-guard.js +428 -0
  38. package/library/native-memory.js +52 -0
  39. package/library/npm-audit.js +51 -25
  40. package/library/openapi-doc.js +7 -5
  41. package/library/package-manager.js +8 -0
  42. package/library/role.js +197 -0
  43. package/library/server-credentials.js +169 -0
  44. package/library/sqlite-utilities.js +59 -0
  45. package/library/ssrf-guard.js +100 -0
  46. package/library/static-response-stats.js +16 -0
  47. package/library/version-utilities.js +50 -0
  48. package/library/worker-count.js +192 -0
  49. package/nginx.md +30 -11
  50. package/npmprojector/indexer.js +18 -10
  51. package/package.json +11 -8
  52. package/packages/feed-check-page.js +117 -0
  53. package/packages/feed-check.js +279 -0
  54. package/packages/npm-check.js +57 -3
  55. package/packages/npm-signing.js +219 -0
  56. package/packages/openapi.yaml +207 -1
  57. package/packages/package-crawler.js +322 -41
  58. package/packages/packages-template.html +6 -8
  59. package/packages/packages.js +604 -57
  60. package/packages/readme.md +113 -1
  61. package/publisher/publisher-template.html +2 -5
  62. package/publisher/publisher.js +121 -110
  63. package/publisher/readme.md +75 -0
  64. package/registry/crawler.js +84 -108
  65. package/registry/documentation/configuration.md +34 -0
  66. package/registry/registry-template.html +5 -8
  67. package/registry/registry.js +5 -4
  68. package/root-bare-template.html +7 -4
  69. package/root-template.html +5 -8
  70. package/server.js +398 -198
  71. package/shl/shl.js +38 -37
  72. package/static/a11y/a11y-check.js +832 -0
  73. package/static/a11y/axe.min.js +12 -0
  74. package/static/assets/css/bootstrap-fhir.css +15 -15
  75. package/static/assets/images/flags/au.svg +18 -0
  76. package/static/assets/images/flags/unknown.svg +11 -0
  77. package/static/assets/images/flags/us.svg +63 -0
  78. package/static/assets/images/icon_audit.png +0 -0
  79. package/static/assets/images/icon_clinical.png +0 -0
  80. package/static/assets/images/icon_document.png +0 -0
  81. package/static/assets/images/icon_external.png +0 -0
  82. package/static/assets/images/icon_medication.png +0 -0
  83. package/static/assets/images/icon_organization.png +0 -0
  84. package/static/assets/images/icon_other.png +0 -0
  85. package/static/assets/images/icon_person.png +0 -0
  86. package/static/assets/images/icon_section.png +0 -0
  87. package/stats-db.js +45 -0
  88. package/stats.js +197 -10
  89. package/testing/testing-template.html +5 -8
  90. package/token/nginx_passport_setup.md +1 -1
  91. package/token/security_guide.md +1 -1
  92. package/token/token-template.html +2 -5
  93. package/token/token.js +40 -36
  94. package/translations/Messages.properties +19 -0
  95. package/tx/README.md +154 -4
  96. package/tx/closure/closure-store.js +126 -3
  97. package/tx/cm/cm-database.js +69 -57
  98. package/tx/cm/cm-package.js +29 -1
  99. package/tx/cs/cs-api.js +284 -19
  100. package/tx/cs/cs-api.md +41 -4
  101. package/tx/cs/cs-areacode.js +1 -1
  102. package/tx/cs/cs-country.js +17 -5
  103. package/tx/cs/cs-cpt.js +33 -16
  104. package/tx/cs/cs-cs.js +148 -14
  105. package/tx/cs/cs-currency.js +1 -1
  106. package/tx/cs/cs-icd11.js +21 -13
  107. package/tx/cs/cs-icf.js +22 -3
  108. package/tx/cs/cs-lang.js +2 -2
  109. package/tx/cs/cs-loinc.js +185 -66
  110. package/tx/cs/cs-mimetypes.js +12 -2
  111. package/tx/cs/cs-ndc.js +40 -25
  112. package/tx/cs/cs-omop.js +54 -29
  113. package/tx/cs/cs-rxnorm.js +300 -196
  114. package/tx/cs/cs-snomed.js +608 -64
  115. package/tx/cs/cs-ucum.js +14 -4
  116. package/tx/cs/cs-unii.js +18 -14
  117. package/tx/cs/cs-virtual.js +210 -0
  118. package/tx/ecosystem-resolver.js +413 -0
  119. package/tx/html/cluster-dash-metrics.liquid +26 -0
  120. package/tx/html/cluster-home-metrics.liquid +31 -0
  121. package/tx/html/cluster-metrics-script.liquid +251 -0
  122. package/tx/html/codesystem-operations.liquid +3 -3
  123. package/tx/html/ecl-panel.liquid +1 -1
  124. package/tx/html/external-metrics.liquid +17 -0
  125. package/tx/html/operations-form.liquid +17 -17
  126. package/tx/html/search-form.liquid +11 -11
  127. package/tx/html/status-line.liquid +58 -0
  128. package/tx/html/status-panel.liquid +140 -0
  129. package/tx/html/status-tasks.liquid +25 -0
  130. package/tx/html/tx-template.html +2 -5
  131. package/tx/importers/atc-to-fhir.js +17 -16
  132. package/tx/importers/export-icd11-codesystem.js +3 -2
  133. package/tx/importers/import-icd11.js +4 -3
  134. package/tx/importers/import-loinc.module.js +118 -56
  135. package/tx/importers/import-ndc.module.js +56 -44
  136. package/tx/importers/import-rxnorm.module.js +118 -79
  137. package/tx/importers/import-sct.module.js +57 -58
  138. package/tx/importers/import-unii.module.js +37 -36
  139. package/tx/importers/subset-loinc.module.js +49 -48
  140. package/tx/importers/subset-rxnorm.module.js +29 -28
  141. package/tx/importers/terminal.js +18 -0
  142. package/tx/importers/tx-import-base.js +13 -12
  143. package/tx/importers/tx-import-settings.js +5 -4
  144. package/tx/importers/tx-import.js +53 -52
  145. package/tx/library/canonical-resource.js +49 -0
  146. package/tx/library/codesystem.js +94 -5
  147. package/tx/library/designations.js +45 -2
  148. package/tx/library/extensions.js +13 -7
  149. package/tx/library/implicit-valuesets.js +66 -0
  150. package/tx/library/renderer.js +77 -21
  151. package/tx/library/sqlite-utilities.js +2 -0
  152. package/tx/library/ucum-parsers.js +11 -1
  153. package/tx/library/wait-for-cache.js +43 -0
  154. package/tx/library.js +321 -22
  155. package/tx/openapi-operations.js +6 -4
  156. package/tx/operation-context.js +460 -99
  157. package/tx/params.js +25 -0
  158. package/tx/problems.js +3 -5
  159. package/tx/provider.js +278 -42
  160. package/tx/resource-cache-sqlite.js +1050 -0
  161. package/tx/sct/ecl.js +47 -6
  162. package/tx/sct/editions.js +39 -0
  163. package/tx/sct/expressions.js +10 -1
  164. package/tx/sct/load-config.js +107 -0
  165. package/tx/sct/mmap.js +109 -0
  166. package/tx/sct/structures.js +166 -13
  167. package/tx/sct/text-index.js +5 -1
  168. package/tx/syndication/syndication-feed.js +2 -2
  169. package/tx/tx-html.js +44 -8
  170. package/tx/tx.fhir.org.yml +8 -8
  171. package/tx/tx.js +282 -80
  172. package/tx/usage-tracker.js +39 -11
  173. package/tx/vs/vs-api.js +19 -0
  174. package/tx/vs/vs-database.js +386 -62
  175. package/tx/vs/vs-package.js +27 -4
  176. package/tx/vs/vs-vsac.js +648 -100
  177. package/tx/workers/batch-validate.js +37 -10
  178. package/tx/workers/batch.js +6 -1
  179. package/tx/workers/cache-control.js +15 -4
  180. package/tx/workers/closure.js +156 -13
  181. package/tx/workers/compare.js +8 -5
  182. package/tx/workers/expand.js +1173 -289
  183. package/tx/workers/metadata.js +35 -0
  184. package/tx/workers/search.js +53 -1
  185. package/tx/workers/supplement-filters.js +145 -0
  186. package/tx/workers/translate.js +169 -4
  187. package/tx/workers/validate.js +223 -119
  188. package/tx/workers/worker.js +123 -31
  189. package/tx/xversion/xv-valueset.js +7 -4
  190. package/utilities/submit-test-report.js +3 -1
  191. package/utilities/tx-stress-test.js +482 -0
  192. package/vcl/readme.md +37 -0
  193. package/vcl/vcl-parser.js +15 -0
  194. package/xig/readme.md +52 -0
  195. package/xig/xig-template.html +5 -8
  196. package/xig/xig.js +238 -422
  197. package/changelog-template.md +0 -22
  198. package/registry/test-runner.js +0 -237
  199. package/release.sh +0 -80
  200. package/test-scripts/repro-re2-wasm-leak.js +0 -34
  201. package/tsconfig.eslint.json +0 -9
  202. package/tx/html/dash-metrics.liquid +0 -147
  203. package/tx/html/home-metrics.liquid +0 -225
@@ -0,0 +1,413 @@
1
+ const axios = require('axios');
2
+ const Logger = require('../library/logger');
3
+ const { ServerCredentials } = require('../library/server-credentials');
4
+ const { createGuardedAgents } = require('../library/ssrf-guard');
5
+
6
+ /**
7
+ * Finds another server in the terminology ecosystem for a code system (and version) that this
8
+ * server doesn't hold, by asking the ecosystem registry, and talks to it (modules.tx.ecosystem,
9
+ * see README.md). Provider.getCodeSystemProvider() uses it when it can't resolve a code system
10
+ * itself, and hands the result to a VirtualCodeSystemProvider (cs/cs-virtual.js), which sends its
11
+ * requests through here.
12
+ *
13
+ * A server is used if it's open, or if there are credentials for it (an API key or OAuth client
14
+ * credentials, under its code in the registry, as the registry module has them)
15
+ *
16
+ * The operation context passed in (opContext) has every request this sends for it counted
17
+ * (opContext.externalRequests), so the operation is counted as an external request: what is
18
+ * already known (cached) costs nothing, and isn't counted.
19
+ *
20
+ * What the registry and the servers say about themselves is kept for an hour, and then refreshed
21
+ * in the background: the request that finds it due uses what's known, and doesn't wait (only the
22
+ * first request for something waits for it). The servers' answers to $lookup and $validate-code
23
+ * are kept for an hour too (sendCached), and the same request asked twice at once is only sent
24
+ * once.
25
+ *
26
+ * Every request (to the registry, to the servers it names, and for OAuth tokens) goes through the
27
+ * SSRF guard (library/ssrf-guard.js): only public addresses, unless allowPrivateAddresses is set
28
+ */
29
+
30
+ /**
31
+ * The header on every request made to another server on behalf of an operation here. A
32
+ * request that arrives with it is never resolved through the ecosystem again, so two
33
+ * servers can't hand a request back and forth
34
+ */
35
+ const FORWARDED_HEADER = 'X-FHIRsmith-Ecosystem-Forwarded';
36
+
37
+ const DEFAULT_REGISTRY = 'https://ecosystem.hl7.org/tx-reg';
38
+ const TIMEOUT_MS = 30000;
39
+ const CACHE_MS = 60 * 60 * 1000; // what the registry and the servers say is kept for an hour
40
+ const RETRY_MS = 5 * 60 * 1000; // a refresh that failed is tried again after this (the old answer is kept)
41
+ const MAX_ANSWERS = 10000; // the most answers (to $lookup and $validate-code) kept
42
+
43
+ class EcosystemResolver {
44
+ /**
45
+ * @param {Object} config - modules.tx.ecosystem, as checked by EcosystemResolver.checkConfig
46
+ */
47
+ constructor(config) {
48
+ const registry = config.registry || DEFAULT_REGISTRY;
49
+ this.registry = registry.endsWith('/') ? registry.substring(0, registry.length - 1) : registry;
50
+ this.excludedServers = (config.excludedServers || []).map(e => EcosystemResolver.normalise(e));
51
+ this.excludedPatterns = (config.excludedPatterns || []).map(p => EcosystemResolver.patternToRegex(p));
52
+ this.agents = createGuardedAgents({ allowPrivate: config.allowPrivateAddresses === true });
53
+ this.credentials = new ServerCredentials({
54
+ apiKeys: config.apiKeys,
55
+ oauth: config.oauth,
56
+ timeout: TIMEOUT_MS,
57
+ userAgent: 'FHIRsmith (ecosystem resolution)',
58
+ agents: () => this.agents
59
+ });
60
+ this.log = Logger.getInstance().child({module: 'tx-ecosystem'});
61
+ // see #known: system|version -> server address (or null); server -> its code systems; and
62
+ // the registry's list of servers (normalised address -> server code)
63
+ this.servers = new Map();
64
+ this.versions = new Map();
65
+ this.codes = new Map();
66
+ // see sendCached: request -> {at, value} or {pending}, oldest first
67
+ this.answers = new Map();
68
+ }
69
+
70
+ /**
71
+ * Check modules.tx.ecosystem, and say what's wrong with it, if anything
72
+ *
73
+ * @returns {Object|null} the configuration, if it's in use
74
+ */
75
+ static checkConfig(cfg) {
76
+ if (cfg === undefined || cfg === null) {
77
+ return null;
78
+ }
79
+ const fail = (why) => { throw new Error(`modules.tx.ecosystem: ${why}`); };
80
+ if (typeof cfg !== 'object' || Array.isArray(cfg)) {
81
+ fail(`must be an object, not ${JSON.stringify(cfg)}`);
82
+ }
83
+ for (const name of ['use', 'allowPrivateAddresses']) {
84
+ if (cfg[name] !== undefined && typeof cfg[name] !== 'boolean') {
85
+ fail(`${name} must be true or false, not ${JSON.stringify(cfg[name])}`);
86
+ }
87
+ }
88
+ if (cfg.registry !== undefined && (typeof cfg.registry !== 'string' || !/^https?:\/\/\S+$/.test(cfg.registry))) {
89
+ fail(`registry must be the address of an ecosystem registry (e.g. ${DEFAULT_REGISTRY}), not ${JSON.stringify(cfg.registry)}`);
90
+ }
91
+ for (const name of ['excludedServers', 'excludedPatterns']) {
92
+ const v = cfg[name];
93
+ if (v !== undefined && (!Array.isArray(v) || v.some(e => typeof e !== 'string' || !e.trim()))) {
94
+ fail(`${name} must be a list of strings, not ${JSON.stringify(v)}`);
95
+ }
96
+ }
97
+ if (cfg.apiKeys !== undefined && (typeof cfg.apiKeys !== 'object' || Array.isArray(cfg.apiKeys) || Object.values(cfg.apiKeys).some(v => typeof v !== 'string'))) {
98
+ fail('apiKeys must be an object: server code -> API key');
99
+ }
100
+ if (cfg.oauth !== undefined) {
101
+ if (typeof cfg.oauth !== 'object' || Array.isArray(cfg.oauth)) {
102
+ fail('oauth must be an object: server code -> { tokenEndpoint, clientId, clientSecret, scope }');
103
+ }
104
+ for (const [code, o] of Object.entries(cfg.oauth)) {
105
+ if (!o || typeof o !== 'object' || !o.tokenEndpoint || !o.clientId || !o.clientSecret) {
106
+ fail(`oauth.${code} needs a tokenEndpoint, clientId and clientSecret`);
107
+ }
108
+ if (!/^https:\/\//i.test(o.tokenEndpoint)) {
109
+ fail(`oauth.${code}.tokenEndpoint must be an https: URL`);
110
+ }
111
+ }
112
+ }
113
+ return cfg.use === true ? cfg : null;
114
+ }
115
+
116
+ /**
117
+ * an address without its scheme or a trailing /, in lower case
118
+ */
119
+ static normalise(address) {
120
+ let s = address.trim().toLowerCase().replace(/^[a-z]+:\/\//, '');
121
+ while (s.endsWith('/')) {
122
+ s = s.substring(0, s.length - 1);
123
+ }
124
+ return s;
125
+ }
126
+
127
+ /**
128
+ * a pattern as the registry has them: * matches anything
129
+ */
130
+ static patternToRegex(p) {
131
+ return new RegExp('^' + p.trim().split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$');
132
+ }
133
+
134
+ isExcludedServer(address) {
135
+ const a = EcosystemResolver.normalise(address);
136
+ return this.excludedServers.some(e => a === e || a.startsWith(e + '/') || a.startsWith(e + ':'));
137
+ }
138
+
139
+ /**
140
+ * True if the code system (or version) is never looked for in the ecosystem: it matches one of
141
+ * modules.tx.ecosystem.excludedPatterns. Checked before the registry is asked anything
142
+ */
143
+ isExcludedSystem(system, version) {
144
+ return this.excludedPatterns.some(r => r.test(system) || (version && r.test(system + '|' + version)));
145
+ }
146
+
147
+ static fhirVersionFor(v) {
148
+ return v === 3 ? '3.0' : v === 4 ? '4.0' : '5.0';
149
+ }
150
+
151
+ /**
152
+ * The code a server has in the registry (what its credentials are under), from the registry's
153
+ * list of servers
154
+ */
155
+ async serverCode(address, opContext = null) {
156
+ const map = await this.#known(this.codes, 'listing', async (ctxt) => {
157
+ const json = await this.getRegistryJson(this.registry, ctxt);
158
+ const m = new Map();
159
+ for (const r of json.results || []) {
160
+ if (r.url && r['server-code']) {
161
+ m.set(EcosystemResolver.normalise(r.url), r['server-code']);
162
+ }
163
+ }
164
+ return m;
165
+ }, opContext, (e) => `Unable to read the list of servers from ${this.registry}: ${e.message}`);
166
+ return (map && map.get(EcosystemResolver.normalise(address))) || null;
167
+ }
168
+
169
+ /**
170
+ * What the registry or a server says, kept in cache under key (an hour), loaded by loader if
171
+ * it isn't known. When it's due for a refresh, what's known is returned, and loaded again in the
172
+ * background (not counted against the operation: it doesn't wait for it). A loader that fails
173
+ * (throws) gives null, the first time; on a refresh, the old answer is kept, and the refresh is
174
+ * tried again in a few minutes. The same thing asked for twice at once is only loaded once
175
+ *
176
+ * @param {Map} cache - key -> {at, value, pending, refreshing}
177
+ * @param {function(OperationContext)} loader
178
+ * @param {function(Error): string} why - the warning when it fails
179
+ */
180
+ async #known(cache, key, loader, opContext, why) {
181
+ const now = Date.now();
182
+ const entry = cache.get(key);
183
+ if (entry && entry.pending) {
184
+ // waiting for it is waiting on another server
185
+ if (opContext && opContext.countExternalRequest) {
186
+ opContext.countExternalRequest();
187
+ }
188
+ return entry.pending;
189
+ }
190
+ if (entry && 'value' in entry) {
191
+ if (now - entry.at >= CACHE_MS && !entry.refreshing) {
192
+ entry.refreshing = true;
193
+ loader(null).then(value => {
194
+ cache.set(key, { at: Date.now(), value });
195
+ }).catch(e => {
196
+ this.log.warn(why(e) + ' (still using what it said before)');
197
+ entry.at = Date.now() - CACHE_MS + RETRY_MS;
198
+ }).finally(() => {
199
+ entry.refreshing = false;
200
+ });
201
+ }
202
+ return entry.value;
203
+ }
204
+ const pending = loader(opContext).then(value => {
205
+ cache.set(key, { at: Date.now(), value });
206
+ return value;
207
+ }, e => {
208
+ // not known: null for now, and asked again (in the background) in a few minutes
209
+ this.log.warn(why(e));
210
+ cache.set(key, { at: Date.now() - CACHE_MS + RETRY_MS, value: null });
211
+ return null;
212
+ });
213
+ cache.set(key, { pending });
214
+ return pending;
215
+ }
216
+
217
+ /**
218
+ * Every request to another server goes through here: it's counted against the operation
219
+ */
220
+ async #request(opContext, config) {
221
+ if (opContext && opContext.countExternalRequest) {
222
+ opContext.countExternalRequest();
223
+ }
224
+ return axios.request(config);
225
+ }
226
+
227
+ async getRegistryJson(url, opContext = null) {
228
+ const response = await this.#request(opContext, {
229
+ url,
230
+ method: 'GET',
231
+ headers: { 'Accept': 'application/json', [FORWARDED_HEADER]: 'true' },
232
+ timeout: TIMEOUT_MS,
233
+ validateStatus: () => true,
234
+ httpAgent: this.agents.httpAgent,
235
+ httpsAgent: this.agents.httpsAgent
236
+ });
237
+ if (response.status !== 200 || !response.data || typeof response.data !== 'object') {
238
+ throw new Error(`${url} returned ${response.status}`);
239
+ }
240
+ return response.data;
241
+ }
242
+
243
+ /**
244
+ * Send a request to another server, with its credentials, if it has any. An OAuth token that
245
+ * the server refuses (401) is renewed, and the request sent once more
246
+ *
247
+ * @param {string} server - the server's address
248
+ * @param {string} url - the request
249
+ * @param {Object} [body] - a JSON body (POST), or none (GET)
250
+ * @param {OperationContext} [opContext] - the operation it's for
251
+ * @returns {Promise<{status: number, json: Object|null}>}
252
+ */
253
+ async send(server, url, body = null, opContext = null) {
254
+ const code = await this.serverCode(server, opContext);
255
+ const attempt = async (renew) => {
256
+ const headers = Object.assign({ 'Accept': 'application/fhir+json', [FORWARDED_HEADER]: 'true' },
257
+ code ? await this.credentials.headers(code, renew) : {});
258
+ if (body) {
259
+ headers['Content-Type'] = 'application/fhir+json';
260
+ }
261
+ return this.#request(opContext, {
262
+ url,
263
+ method: body ? 'POST' : 'GET',
264
+ headers,
265
+ data: body ? JSON.stringify(body) : undefined,
266
+ timeout: TIMEOUT_MS,
267
+ validateStatus: () => true,
268
+ maxRedirects: 0, // the credentials go where they were configured to, or nowhere
269
+ httpAgent: this.agents.httpAgent,
270
+ httpsAgent: this.agents.httpsAgent
271
+ });
272
+ };
273
+ let response = await attempt(false);
274
+ if (response.status === 401 && code && this.credentials.getOAuthConfig(code)) {
275
+ response = await attempt(true);
276
+ }
277
+ const json = response.data && typeof response.data === 'object' ? response.data : null;
278
+ return { status: response.status, json };
279
+ }
280
+
281
+ /**
282
+ * The address of a server that the registry says can provide system|version, and that can be
283
+ * used (open, or there are credentials for it, and not excluded), or null. Authoritative
284
+ * servers come first. The registry is asked about the FHIR version of the endpoint first, then
285
+ * the other versions: the operations used on the other server don't depend on its FHIR version
286
+ *
287
+ * @param {string} system
288
+ * @param {string} version - may be null
289
+ * @param {number} fhirVersion - the endpoint's (3, 4, 5 or 6)
290
+ * @param {OperationContext} [opContext] - the operation it's for
291
+ * @returns {Promise<string|null>}
292
+ */
293
+ async findServer(system, version, fhirVersion, opContext = null) {
294
+ if (this.isExcludedSystem(system, version)) {
295
+ return null;
296
+ }
297
+ const key = system + (version ? '|' + version : '');
298
+ return this.#known(this.servers, key + '#' + fhirVersion, async (ctxt) => {
299
+ const first = EcosystemResolver.fhirVersionFor(fhirVersion);
300
+ let failure = null;
301
+ for (const fv of [first, ...['5.0', '4.0'].filter(v => v !== first)]) {
302
+ try {
303
+ const json = await this.getRegistryJson(this.registry + '/resolve?fhirVersion=' + fv + '&url=' + encodeURIComponent(key), ctxt);
304
+ const server = (await this.chooseServer(json.authoritative, ctxt)) || (await this.chooseServer(json.candidates, ctxt));
305
+ if (server) {
306
+ return server;
307
+ }
308
+ } catch (e) {
309
+ this.log.warn(`Ecosystem resolution of ${key} for FHIR ${fv} failed: ${e.message}`);
310
+ failure = e;
311
+ }
312
+ }
313
+ if (failure) {
314
+ throw failure; // not known: not the same as no server
315
+ }
316
+ return null;
317
+ }, opContext, (e) => `Ecosystem resolution of ${key} failed: ${e.message}`);
318
+ }
319
+
320
+ async chooseServer(list, opContext = null) {
321
+ for (const s of list || []) {
322
+ // a candidate that says it doesn't have the content can't answer
323
+ if (!s.url || s.content === 'not-present' || this.isExcludedServer(s.url)) {
324
+ continue;
325
+ }
326
+ const open = (s.open || s.security === 'open') && !s.token;
327
+ if (open || this.credentials.hasCredentials(await this.serverCode(s.url, opContext))) {
328
+ return s.url;
329
+ }
330
+ }
331
+ return null;
332
+ }
333
+
334
+ /**
335
+ * The versions of system that a server says it has (from its TerminologyCapabilities), or
336
+ * null if it doesn't list the code system at all (or couldn't be asked)
337
+ *
338
+ * @param {string} server
339
+ * @param {string} system
340
+ * @param {OperationContext} [opContext] - the operation it's for
341
+ * @returns {Promise<{code: string, isDefault: boolean}[]|null>}
342
+ */
343
+ async listVersions(server, system, opContext = null) {
344
+ const caps = await this.codeSystemsOf(server, opContext);
345
+ return caps ? (caps.get(system) || null) : null;
346
+ }
347
+
348
+ /**
349
+ * What a server says it has (its TerminologyCapabilities): code system -> versions, or null if
350
+ * it couldn't be asked. Read once (an hour) for all the code systems
351
+ */
352
+ async codeSystemsOf(server, opContext = null) {
353
+ return this.#known(this.versions, server, async (ctxt) => {
354
+ const { status, json } = await this.send(server, server.replace(/\/$/, '') + '/metadata?mode=terminology', null, ctxt);
355
+ if (status >= 300 || !json) {
356
+ throw new Error(`it returned status ${status}`);
357
+ }
358
+ const map = new Map();
359
+ for (const cs of json.codeSystem || []) {
360
+ if (cs.uri) {
361
+ const list = map.get(cs.uri) || [];
362
+ map.set(cs.uri, list.concat((cs.version || []).filter(v => v.code).map(v => ({ code: v.code, isDefault: v.isDefault === true }))));
363
+ }
364
+ }
365
+ return map;
366
+ }, opContext, (e) => `Unable to read the code systems that ${server} has: ${e.message}`);
367
+ }
368
+
369
+ /**
370
+ * send(), for requests whose answer doesn't change from one minute to the next ($lookup and
371
+ * $validate-code): the answer is kept for an hour (the most recent MAX_ANSWERS of them), and the
372
+ * same request asked for again while it's on its way waits for that answer. Only what the
373
+ * server answered is kept: an answer that says something is wrong with the request (4xx) is
374
+ * kept, but not one about authorisation, throttling or the server itself (401, 403, 429, 5xx),
375
+ * and not a failure to get an answer at all
376
+ */
377
+ async sendCached(server, url, body = null, opContext = null) {
378
+ const key = url + (body ? ' ' + JSON.stringify(body) : '');
379
+ const entry = this.answers.get(key);
380
+ if (entry && entry.pending) {
381
+ if (opContext && opContext.countExternalRequest) {
382
+ opContext.countExternalRequest();
383
+ }
384
+ return entry.pending;
385
+ }
386
+ if (entry && Date.now() - entry.at < CACHE_MS) {
387
+ // most recently used goes to the end, so the oldest are dropped first
388
+ this.answers.delete(key);
389
+ this.answers.set(key, entry);
390
+ return entry.value;
391
+ }
392
+ const pending = this.send(server, url, body, opContext).then(value => {
393
+ const s = value.status;
394
+ if (s < 500 && s !== 401 && s !== 403 && s !== 429) {
395
+ this.answers.set(key, { at: Date.now(), value });
396
+ while (this.answers.size > MAX_ANSWERS) {
397
+ this.answers.delete(this.answers.keys().next().value);
398
+ }
399
+ } else {
400
+ this.answers.delete(key);
401
+ }
402
+ return value;
403
+ }, e => {
404
+ this.answers.delete(key);
405
+ throw e;
406
+ });
407
+ this.answers.delete(key);
408
+ this.answers.set(key, { pending });
409
+ return pending;
410
+ }
411
+ }
412
+
413
+ module.exports = { EcosystemResolver, FORWARDED_HEADER, DEFAULT_REGISTRY };
@@ -0,0 +1,26 @@
1
+ <style>
2
+ /* The dashboard wraps its content in the bare template's .row/.col-12: the row's -15px margins
3
+ and the column's padding outside its 100% width put a full-width chart row 45px past the page.
4
+ Here only, keep the row at the page's width and count the padding inside the column. */
5
+ .row:has(> .col-12 > .server-metrics-grid) { margin-left:0; margin-right:0; }
6
+ .col-12:has(> .server-metrics-grid) { box-sizing:border-box; }
7
+ </style>
8
+ {%- unless legend == false %}
9
+ <div id="serverMetricsLegend" class="server-metrics-legend"></div>
10
+ {%- endunless %}
11
+ <div id="serverMetricsShedding" class="server-metrics-shedding" hidden></div>
12
+ <div class="server-metrics-grid">
13
+ <div class="server-metrics-chart">
14
+ <h{{ level | default: 4 }} id="memoryChartTitle">Private memory</h{{ level | default: 4 }}>
15
+ <div class="server-metrics-canvas"><canvas id="memoryChart" aria-label="Private memory per process, the cluster's total and the refusal threshold"></canvas></div>
16
+ </div>
17
+ <div class="server-metrics-chart">
18
+ <h{{ level | default: 4 }}>Turnaround Time</h{{ level | default: 4 }}>
19
+ <div class="server-metrics-canvas"><canvas id="tatChart" aria-label="Turnaround Time"></canvas></div>
20
+ </div>
21
+ <div class="server-metrics-chart">
22
+ <h{{ level | default: 4 }}>Requests per Minute</h{{ level | default: 4 }}>
23
+ <div class="server-metrics-canvas"><canvas id="requestChart" aria-label="Requests per Minute"></canvas></div>
24
+ </div>
25
+ </div>
26
+ {% render 'cluster-metrics-script', metricsJson: metricsJson %}
@@ -0,0 +1,31 @@
1
+ {%- unless legend == false %}
2
+ <div id="serverMetricsLegend" class="server-metrics-legend"></div>
3
+ {%- endunless %}
4
+ <div id="serverMetricsShedding" class="server-metrics-shedding" hidden></div>
5
+ <div class="server-metrics-grid">
6
+ <div class="server-metrics-chart">
7
+ <h{{ level | default: 4 }} id="memoryChartTitle">Private memory</h{{ level | default: 4 }}>
8
+ <div class="server-metrics-canvas"><canvas id="memoryChart" aria-label="Private memory per process, the cluster's total and the refusal threshold"></canvas></div>
9
+ </div>
10
+ <div class="server-metrics-chart">
11
+ <h{{ level | default: 4 }}>Requests per Minute</h{{ level | default: 4 }}>
12
+ <div class="server-metrics-canvas"><canvas id="requestChart" aria-label="Requests per Minute"></canvas></div>
13
+ </div>
14
+ <div class="server-metrics-chart">
15
+ <h{{ level | default: 4 }}>Turnaround Time</h{{ level | default: 4 }}>
16
+ <div class="server-metrics-canvas"><canvas id="tatChart" aria-label="Turnaround Time"></canvas></div>
17
+ </div>
18
+ <div class="server-metrics-chart">
19
+ <h{{ level | default: 4 }}>Client Cache</h{{ level | default: 4 }}>
20
+ <div class="server-metrics-canvas"><canvas id="clientCacheChart" aria-label="Client Cache"></canvas></div>
21
+ </div>
22
+ <div class="server-metrics-chart">
23
+ <h{{ level | default: 4 }}>Node Blocking (mean)</h{{ level | default: 4 }}>
24
+ <div class="server-metrics-canvas"><canvas id="blockChart" aria-label="Node Blocking (mean)"></canvas></div>
25
+ </div>
26
+ <div class="server-metrics-chart">
27
+ <h{{ level | default: 4 }}>Node Blocking (max)</h{{ level | default: 4 }}>
28
+ <div class="server-metrics-canvas"><canvas id="blockMaxChart" aria-label="Node Blocking (max)"></canvas></div>
29
+ </div>
30
+ </div>
31
+ {% render 'cluster-metrics-script', metricsJson: metricsJson %}