fhirsmith 0.13.4 → 0.14.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 (46) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +16 -3
  3. package/config-template.json +70 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/nginx.md +391 -0
  7. package/package.json +3 -2
  8. package/publisher/git-remote.js +141 -0
  9. package/publisher/github-release.js +195 -0
  10. package/publisher/publisher.js +146 -14
  11. package/server.js +51 -14
  12. package/stats.js +19 -13
  13. package/testing/readme.md +112 -0
  14. package/testing/render.js +482 -0
  15. package/testing/search.js +378 -0
  16. package/testing/store.js +266 -0
  17. package/testing/testing-template.html +126 -0
  18. package/testing/testing.js +486 -0
  19. package/translations/Messages.properties +32 -0
  20. package/tx/closure/closure-store.js +396 -0
  21. package/tx/cs/cs-api.js +6 -1
  22. package/tx/cs/cs-rxnorm.js +35 -8
  23. package/tx/cs/cs-snomed.js +22 -3
  24. package/tx/html/codesystem-operations.liquid +177 -0
  25. package/tx/html/ecl-panel.liquid +93 -0
  26. package/tx/html/home-metrics.liquid +39 -76
  27. package/tx/html/tx-template.html +1 -0
  28. package/tx/library/conceptmap.js +22 -6
  29. package/tx/library/designations.js +12 -3
  30. package/tx/library/operation-outcome.js +3 -1
  31. package/tx/library/renderer.js +42 -5
  32. package/tx/operation-context.js +80 -17
  33. package/tx/provider.js +85 -0
  34. package/tx/sct/expressions.js +128 -44
  35. package/tx/tx-html.js +426 -44
  36. package/tx/tx.js +117 -9
  37. package/tx/workers/cache-control.js +2 -2
  38. package/tx/workers/closure.js +483 -13
  39. package/tx/workers/metadata.js +8 -3
  40. package/tx/workers/read.js +18 -3
  41. package/tx/workers/subsumes.js +18 -2
  42. package/tx/workers/translate.js +167 -20
  43. package/tx/workers/validate.js +61 -182
  44. package/tx/workers/worker.js +9 -0
  45. package/tx/xversion/xv-conceptmap.js +17 -8
  46. package/utilities/submit-test-report.js +150 -0
@@ -1,27 +1,497 @@
1
1
  //
2
- // Closure Worker - Handles ConceptMap $closure operation
2
+ // Closure Worker - Handles the $closure operation
3
3
  //
4
- // GET /ConceptMap/$closure?{params}
5
- // POST /ConceptMap/$closure
4
+ // POST [base]/$closure
6
5
  //
6
+ // $closure is defined on ConceptMap (OperationDefinition ConceptMap-closure) but is a
7
+ // SYSTEM-level operation: system=true, type=false, instance=false in R3, R4 and R5.
8
+ // So it lives at [base]/$closure, not [base]/ConceptMap/$closure (issue #100).
9
+ //
10
+ // A closure table is state held on the server between calls, and between client
11
+ // sessions, so it only exists when the administrator has turned it on and said where
12
+ // it is kept (modules.tx.closure - see ClosureStore). Without that, every request gets
13
+ // a not-supported OperationOutcome.
14
+ //
15
+ // The calls:
16
+ // name (+ reset, + configuration) initialise the table. It is an error if the table
17
+ // already exists, unless reset = true, which wipes it
18
+ // name + concept(s) add concepts; returns the entries they produce
19
+ // name + version resync; returns every entry added after version
20
+ //
21
+ // The configuration is fixed when the table is initialised, and replayed on every later
22
+ // call: tx-resource (the code systems it is built over, which the client does not send
23
+ // again), useSupplement, system-version, check-system-version, force-system-version.
24
+ //
25
+ // Closure is stable across sessions, so it never uses a client's terminology cache: the
26
+ // X-Cache-Id header is ignored here.
27
+ //
28
+ // Access: the table name is the only thing that identifies a table, so anyone who knows a
29
+ // name can read, add to, or reset that table. On a public server, require UUID names
30
+ // (requireUuidNames) so that a name is effectively a secret held by the client that made it.
31
+ //
32
+ // Cost: everything a client can make this do is bounded (see LIMITS) - the tables, the
33
+ // concepts and entries in a table, what one call can add, what a table's configuration
34
+ // can store, and how many calls can queue for one table and for how long. The work itself
35
+ // yields (checkAndYield) and runs against the operation's compute deadline; the storage
36
+ // is synchronous, so reads that can be large are paged and writes are capped.
37
+ //
38
+
39
+ const { TerminologyWorker, Unknown_Code_in_VersionSCT, SCTVersion } = require('./worker');
40
+ const { TxParameters } = require('../params');
41
+ const { Issue, OperationOutcome, buildOperationOutcome, outcomeFromError } = require('../library/operation-outcome');
42
+ const { debugLog } = require('../operation-context');
43
+ const { NARROWER, EQUIVALENT } = require('../closure/closure-store');
44
+ const { VersionUtilities } = require('../../library/version-utilities');
45
+
46
+ // parameters that configure a table, and so are only accepted when it is initialised
47
+ const CONFIG_PARAMS = ['tx-resource', 'useSupplement', 'system-version', 'check-system-version', 'force-system-version'];
48
+
49
+ const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
50
+
51
+ // Defaults for modules.tx.closure.<name>. Absent = the default; 0 = no limit.
52
+ const LIMITS = {
53
+ maxTables: 1000,
54
+ maxConceptsPerTable: 10000,
55
+ maxConceptsPerRequest: 1000,
56
+ maxEntriesPerTable: 250000,
57
+ maxEntriesPerRequest: 50000,
58
+ maxConfigSize: 5 * 1024 * 1024, // bytes of stored configuration (mostly tx-resource)
59
+ maxQueue: 10, // calls queued on one table, counting the running one
60
+ lockTimeout: 60 // seconds a call will wait for its table
61
+ };
62
+
63
+ const RESYNC_PAGE = 2000; // rows read from the store at a time
64
+
65
+ class ClosureWorker extends TerminologyWorker {
66
+ /**
67
+ * @param {OperationContext} opContext - Operation context
68
+ * @param {Logger} log - Logger instance
69
+ * @param {Provider} provider - Provider for code systems and resources
70
+ * @param {LanguageDefinitions} languages - Language definitions
71
+ * @param {I18nSupport} i18n - Internationalization support
72
+ * @param {ClosureStore|null} store - where closure tables are kept; null = not supported
73
+ * @param {Object} [config] - modules.tx.closure
74
+ */
75
+ constructor(opContext, log, provider, languages, i18n, store = null, config = {}) {
76
+ super(opContext, log, provider, languages, i18n);
77
+ this.store = store;
78
+ this.closureConfig = config || {};
79
+ }
7
80
 
8
- const { buildOperationOutcome } = require('../library/operation-outcome');
81
+ opName() {
82
+ return 'closure';
83
+ }
84
+
85
+ // Not a value-set operation; the base class requires this to be implemented.
86
+ vsHandle() {
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * The configured value of a limit, or its default. 0 means no limit (Infinity).
92
+ */
93
+ limit(name) {
94
+ const v = this.closureConfig[name];
95
+ const n = (v === undefined || v === null) ? LIMITS[name] : Number(v);
96
+ return (!Number.isFinite(n) || n <= 0) ? Infinity : n;
97
+ }
9
98
 
10
- class ClosureWorker {
11
99
  /**
12
- * Handle a $closure request
13
- * @param {express.Request} req - Express request (with txProvider attached)
100
+ * Express entry point for [base]/$closure (GET and POST).
101
+ * @param {express.Request} req - Express request
14
102
  * @param {express.Response} res - Express response
15
- * @param {Object} log - Logger instance
16
103
  */
17
- static handle(req, res) {
18
- const params = req.method === 'POST' ? req.body : req.query;
104
+ async handle(req, res) {
105
+ try {
106
+ if (!this.store) {
107
+ return res.status(501).json(buildOperationOutcome('error', 'not-supported',
108
+ this.i18n.translate('CLOSURE_NOT_SUPPORTED', this.opContext.langs, []), 'not-supported'));
109
+ }
110
+ // $closure changes state on the server (affectsState = true), so GET isn't allowed
111
+ if (req.method !== 'POST') {
112
+ throw this.closureIssue('invalid', 'CLOSURE_POST_ONLY', [], 'invalid-data', 405);
113
+ }
114
+ const params = this.buildParameters(req);
115
+ const fhirVersion = req.txEndpoint ? req.txEndpoint.fhirVersion : '5.0';
116
+ const cm = await this.closure(params, fhirVersion);
117
+ return res.status(200).json(cm);
118
+ } catch (error) {
119
+ this.log.error(error);
120
+ debugLog(error);
121
+ req.logInfo = this.usedSources.join('|') + ' - error' + (error.msgId ? ' ' + error.msgId : '');
122
+ if (error instanceof Issue) {
123
+ const oo = new OperationOutcome();
124
+ oo.addIssue(error);
125
+ return res.status(error.statusCode || 500).json(oo.jsonObj);
126
+ }
127
+ return res.status(error.statusCode || 500).json(outcomeFromError(error));
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Do the operation. Everything after the request checks happens with the table locked,
133
+ * so two calls on the same table can't interleave - each one reads the table, works out
134
+ * what's new (which awaits, and yields), and writes, before the next starts.
135
+ *
136
+ * @param {Object} params - Parameters resource
137
+ * @param {string} fhirVersion - the endpoint's FHIR version, for the response
138
+ * @returns {Object} ConceptMap
139
+ */
140
+ async closure(params, fhirVersion) {
141
+ // never the client's terminology cache - see the header comment
142
+ this.opContext.cacheId = null;
143
+ const list = (params.parameter || []).filter(p => p.name !== 'cache-id');
144
+
145
+ const name = this.readName(list);
146
+ const concepts = [];
147
+ const config = [];
148
+ let versionParam = null;
149
+ let reset = false;
150
+ const ignored = [];
151
+ for (const p of list) {
152
+ if (p.name === 'concept') {
153
+ concepts.push(p);
154
+ } else if (CONFIG_PARAMS.includes(p.name)) {
155
+ config.push(p);
156
+ } else if (p.name === 'version') {
157
+ versionParam = p;
158
+ } else if (p.name === 'reset') {
159
+ reset = p.valueBoolean === true || p.valueString === 'true';
160
+ } else if (p.name !== 'name') {
161
+ // Ignored, as everywhere else in the server: a request carries parameters that
162
+ // are not for the operation in hand. The tx-ecosystem runner, for one, adds a
163
+ // 'uuid' to every request from its default profile
164
+ ignored.push(p.name);
165
+ }
166
+ }
167
+ if (ignored.length > 0) {
168
+ this.log.debug(`$closure: ignored parameter(s) ${ignored.map(safeText).join(', ')}`);
169
+ }
170
+ if (concepts.length > 0 && versionParam) {
171
+ throw this.closureIssue('invalid', 'CLOSURE_CONCEPT_AND_VERSION', [], 'invalid-data', 400);
172
+ }
173
+ const maxRequest = this.limit('maxConceptsPerRequest');
174
+ if (concepts.length > maxRequest) {
175
+ throw this.closureIssue('too-costly', 'CLOSURE_TOO_MANY_REQUEST_CONCEPTS', [String(maxRequest)], 'too-costly', 422);
176
+ }
177
+ // checked here, before anything waits for the table
178
+ let configSize = 0;
179
+ for (const p of config) {
180
+ configSize += JSON.stringify(p).length;
181
+ }
182
+ const maxConfig = this.limit('maxConfigSize');
183
+ if (configSize > maxConfig) {
184
+ throw this.closureIssue('too-costly', 'CLOSURE_CONFIG_TOO_LARGE', [String(maxConfig)], 'too-costly', 422);
185
+ }
19
186
 
20
- this.log.debug('ConceptMap $closure with params:', params);
187
+ return await this.lockTable(name, async () => {
188
+ const initialise = reset || (concepts.length === 0 && !versionParam);
189
+ let table = this.store.getTable(name);
21
190
 
22
- // TODO: Implement closure logic using provider
23
- res.status(501).json(buildOperationOutcome('error', 'not-supported', 'ConceptMap $closure operation not yet implemented'));
191
+ if (initialise) {
192
+ if (versionParam) {
193
+ throw this.closureIssue('invalid', 'CLOSURE_RESET_AND_VERSION', [], 'invalid-data', 400);
194
+ }
195
+ if (table && !reset) {
196
+ throw this.closureIssue('duplicate', 'CLOSURE_TABLE_EXISTS', [name], 'business-rule', 409);
197
+ }
198
+ const max = this.limit('maxTables');
199
+ if (!table && this.store.tableCount() >= max) {
200
+ throw this.closureIssue('too-costly', 'CLOSURE_TOO_MANY_TABLES', [String(max)], 'too-costly', 422);
201
+ }
202
+ table = this.store.createTable(name, config);
203
+ this.log.info(`closure table '${name}' ${reset ? 'reset' : 'created'}`);
204
+ if (concepts.length === 0) {
205
+ return await this.buildConceptMap(name, table.version, [], fhirVersion);
206
+ }
207
+ } else {
208
+ if (!table) {
209
+ throw this.closureIssue('not-found', 'CLOSURE_TABLE_UNKNOWN', [name], 'not-found', 404);
210
+ }
211
+ if (config.length > 0) {
212
+ throw this.closureIssue('invalid', 'CLOSURE_CONFIG_NOT_ALLOWED', [config[0].name], 'invalid-data', 400);
213
+ }
214
+ }
215
+
216
+ if (versionParam) {
217
+ return await this.resync(table, name, versionParam, fhirVersion);
218
+ }
219
+ return await this.add(table, name, concepts, fhirVersion);
220
+ });
24
221
  }
222
+
223
+ /**
224
+ * Take the table's lock, with a bounded queue and a bounded wait. The wait goes through
225
+ * OperationContext.waitFor: it isn't charged to the compute deadline (the call isn't
226
+ * computing), and a client that has gone away stops waiting.
227
+ */
228
+ async lockTable(name, fn) {
229
+ const maxQueue = this.limit('maxQueue');
230
+ const timeout = this.limit('lockTimeout');
231
+ try {
232
+ return await this.store.withLock(name, fn, {
233
+ maxQueue: maxQueue === Infinity ? 0 : maxQueue,
234
+ timeoutMs: timeout === Infinity ? 0 : timeout * 1000,
235
+ waitFor: p => this.opContext.waitFor(p, 'closure-lock')
236
+ });
237
+ } catch (error) {
238
+ if (error.closureBusy) {
239
+ throw this.closureIssue('too-costly', 'CLOSURE_TABLE_BUSY', [name], 'too-costly', 429);
240
+ }
241
+ throw error;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * The table name: required, no whitespace or control characters, at most 64 characters
247
+ * - and a UUID, if the administrator has said so (tx.fhir.org does, so names chosen by
248
+ * different clients can't collide, and can't be guessed).
249
+ */
250
+ readName(list) {
251
+ const p = list.find(x => x.name === 'name');
252
+ const name = p ? this.getParameterValue(p) : null;
253
+ if (typeof name !== 'string' || name.length === 0) {
254
+ throw this.closureIssue('required', 'CLOSURE_NAME_REQUIRED', [], 'invalid-data', 400);
255
+ }
256
+ if (/[\s\p{Cc}]/u.test(name) || name.length > 64) {
257
+ throw this.closureIssue('invalid', 'CLOSURE_NAME_INVALID', [safeText(name)], 'invalid-data', 400);
258
+ }
259
+ if (this.closureConfig.requireUuidNames && !UUID.test(name)) {
260
+ throw this.closureIssue('invalid', 'CLOSURE_NAME_NOT_UUID', [name], 'invalid-data', 400);
261
+ }
262
+ return name;
263
+ }
264
+
265
+ /**
266
+ * Resync: every entry added after the nominated version, at the table's current version.
267
+ * The table can be large, so it is read a page at a time, yielding in between.
268
+ */
269
+ async resync(table, name, versionParam, fhirVersion) {
270
+ const raw = this.getParameterValue(versionParam);
271
+ const v = typeof raw === 'number' ? raw : (/^\d{1,15}$/.test(String(raw)) ? parseInt(raw, 10) : NaN);
272
+ if (!Number.isInteger(v) || v < 0 || v > table.version) {
273
+ throw this.closureIssue('invalid', 'CLOSURE_VERSION_INVALID', [safeText(raw), name, String(table.version)], 'invalid-data', 400);
274
+ }
275
+ this.store.touch(table.id);
276
+ const entries = [];
277
+ let after = 0;
278
+ for (;;) {
279
+ await this.checkAndYield('closure-resync');
280
+ const page = this.store.entriesPage(table.id, v, after, RESYNC_PAGE);
281
+ for (const r of page) {
282
+ entries.push({
283
+ source: { system: r.source_system, code: r.source_code, display: r.source_display },
284
+ target: { system: r.target_system, code: r.target_code, display: r.target_display },
285
+ relationship: r.relationship
286
+ });
287
+ }
288
+ if (page.length < RESYNC_PAGE) {
289
+ break;
290
+ }
291
+ after = page[page.length - 1].rowid;
292
+ }
293
+ return await this.buildConceptMap(name, table.version, entries, fhirVersion);
294
+ }
295
+
296
+ /**
297
+ * Add concepts. Each new concept is compared with every concept of the same code system
298
+ * already in the table (and with the ones added before it in this call), in both
299
+ * directions. Nothing is written until every concept has been checked: an unknown code
300
+ * system or code, or going over a limit, fails the whole call.
301
+ */
302
+ async add(table, name, conceptParams, fhirVersion) {
303
+ // replay the table's configuration: its code systems, and its version rules
304
+ const stored = { resourceType: 'Parameters', parameter: this.store.getParams(table.id) };
305
+ this.setupAdditionalResources(stored);
306
+ const txp = new TxParameters(this.opContext.i18n.languageDefinitions, this.opContext.i18n);
307
+ txp.readParams(stored);
308
+
309
+ const existing = this.store.getConcepts(table.id);
310
+ const known = new Set();
311
+ for (const c of existing) {
312
+ known.add(c.system + '|' + c.code);
313
+ }
314
+ const providers = new Map();
315
+ const fresh = [];
316
+
317
+ for (const p of conceptParams) {
318
+ await this.checkAndYield('closure-add');
319
+ const coding = p.valueCoding;
320
+ if (!coding || typeof coding.system !== 'string' || typeof coding.code !== 'string' || !coding.system || !coding.code) {
321
+ throw this.closureIssue('invalid', 'CLOSURE_CONCEPT_INVALID', [], 'invalid-data', 400);
322
+ }
323
+ let cs = providers.get(coding.system);
324
+ if (!cs) {
325
+ cs = await this.findCodeSystem(coding.system, coding.version || '', txp, ['complete'], null, false, true);
326
+ this.seeSourceProvider(cs, coding.system);
327
+ providers.set(coding.system, cs);
328
+ }
329
+ const loc = await cs.locate(coding.code);
330
+ if (!loc || !loc.context) {
331
+ throw this.unknownCodeIssue(cs, coding.code, loc ? loc.message : null, txp);
332
+ }
333
+ // the code system's own spelling of the code, so a case-insensitive code system
334
+ // doesn't end up with the same concept in the table twice
335
+ const code = (await cs.code(loc.context)) || coding.code;
336
+ const key = coding.system + '|' + code;
337
+ if (known.has(key)) {
338
+ continue;
339
+ }
340
+ known.add(key);
341
+ // ref: how the store refers to it until it has an id (see ClosureStore.recordAdd)
342
+ fresh.push({ key, ref: key, system: coding.system, code, display: await cs.display(loc.context), cs });
343
+ }
344
+
345
+ const maxConcepts = this.limit('maxConceptsPerTable');
346
+ if (fresh.length > 0 && existing.length + fresh.length > maxConcepts) {
347
+ throw this.closureIssue('too-costly', 'CLOSURE_TOO_MANY_CONCEPTS', [name, String(maxConcepts)], 'too-costly', 422);
348
+ }
349
+
350
+ // work out the entries: each new concept against everything before it. The number of
351
+ // entries is capped as it grows - a client-supplied code system can be shaped so that
352
+ // nearly every pair is related - so the write below is always bounded
353
+ const maxEntries = Math.min(this.limit('maxEntriesPerRequest'),
354
+ this.limit('maxEntriesPerTable') - (fresh.length > 0 ? this.store.entryCount(table.id) : 0));
355
+ const entries = [];
356
+ const out = [];
357
+ const before = existing.map(c => ({ ref: c.id, system: c.system, code: c.code, display: c.display }));
358
+ for (const n of fresh) {
359
+ for (const o of before) {
360
+ if (o.system !== n.system) {
361
+ continue;
362
+ }
363
+ await this.checkAndYield('closure-subsumes');
364
+ const outcome = await n.cs.subsumesTest(n.code, o.code);
365
+ let source, target, relationship;
366
+ if (outcome === 'subsumes') { // the new concept is the broader one
367
+ source = o; target = n; relationship = NARROWER;
368
+ } else if (outcome === 'subsumed-by') { // the new concept is the narrower one
369
+ source = n; target = o; relationship = NARROWER;
370
+ } else if (outcome === 'equivalent') {
371
+ source = n; target = o; relationship = EQUIVALENT;
372
+ } else {
373
+ continue;
374
+ }
375
+ if (entries.length >= maxEntries) {
376
+ throw this.closureIssue('too-costly', 'CLOSURE_TOO_MANY_ENTRIES', [name], 'too-costly', 422);
377
+ }
378
+ entries.push({ source: source.ref, target: target.ref, relationship });
379
+ out.push({ source, target, relationship });
380
+ }
381
+ before.push(n);
382
+ }
383
+
384
+ const version = this.store.recordAdd(table, fresh, entries);
385
+ if (fresh.length > 0) {
386
+ this.log.info(`closure table '${name}': added ${fresh.length} concept(s), ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}, now version ${version}`);
387
+ }
388
+ return await this.buildConceptMap(name, version, out, fhirVersion);
389
+ }
390
+
391
+ /**
392
+ * The response: a ConceptMap of the entries, one group per source/target system, sorted
393
+ * so the same table always reads the same way. For R3/R4 the relationship is also given
394
+ * as the equivalence the R3/R4 OperationDefinition names ('subsumes' - the target
395
+ * subsumes the source), which the version transform then keeps in preference to the one
396
+ * it would work out itself ('wider').
397
+ *
398
+ * A resync can hand this a whole table, so it yields as it goes, and never sorts the
399
+ * entries as one list: elements are sorted within their group (at most one per concept)
400
+ * and targets within their element.
401
+ */
402
+ async buildConceptMap(name, version, entries, fhirVersion) {
403
+ const legacy = VersionUtilities.isR3Ver(fhirVersion) || VersionUtilities.isR4Ver(fhirVersion);
404
+ const cm = {
405
+ resourceType: 'ConceptMap',
406
+ version: String(version),
407
+ title: `Closure table ${name}`,
408
+ status: 'active',
409
+ experimental: true,
410
+ date: new Date().toISOString()
411
+ };
412
+ const groups = new Map(); // source system -> target system -> source code -> element
413
+ for (const e of entries) {
414
+ await this.checkAndYield('closure-response');
415
+ let bySource = groups.get(e.source.system);
416
+ if (!bySource) {
417
+ bySource = new Map();
418
+ groups.set(e.source.system, bySource);
419
+ }
420
+ let g = bySource.get(e.target.system);
421
+ if (!g) {
422
+ g = new Map();
423
+ bySource.set(e.target.system, g);
424
+ }
425
+ let el = g.get(e.source.code);
426
+ if (!el) {
427
+ el = { code: e.source.code };
428
+ if (e.source.display) {
429
+ el.display = e.source.display;
430
+ }
431
+ el.target = [];
432
+ g.set(e.source.code, el);
433
+ }
434
+ const t = { code: e.target.code };
435
+ if (e.target.display) {
436
+ t.display = e.target.display;
437
+ }
438
+ t.relationship = e.relationship;
439
+ if (legacy) {
440
+ t.equivalence = e.relationship === EQUIVALENT ? 'equal' : 'subsumes';
441
+ }
442
+ el.target.push(t);
443
+ }
444
+
445
+ const byKey = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
446
+ const group = [];
447
+ for (const src of [...groups.keys()].sort(byKey)) {
448
+ const bySource = groups.get(src);
449
+ for (const tgt of [...bySource.keys()].sort(byKey)) {
450
+ const g = bySource.get(tgt);
451
+ const element = [];
452
+ for (const code of [...g.keys()].sort(byKey)) {
453
+ await this.checkAndYield('closure-response');
454
+ const el = g.get(code);
455
+ el.target.sort((a, b) => byKey(a.code, b.code));
456
+ element.push(el);
457
+ }
458
+ group.push({ source: src, target: tgt, element });
459
+ }
460
+ }
461
+ if (group.length > 0) {
462
+ cm.group = group;
463
+ }
464
+ return cm;
465
+ }
466
+
467
+ closureIssue(cause, msgId, params, txIssueType, status) {
468
+ return new Issue('error', cause, null, msgId, this.i18n.translate(msgId, this.opContext.langs, params), txIssueType, status);
469
+ }
470
+
471
+ /**
472
+ * An unknown code - the same message id and tx-issue-type as $validate-code and $subsumes.
473
+ */
474
+ unknownCodeIssue(cs, code, message, txp) {
475
+ const system = cs.system();
476
+ const version = cs.version();
477
+ const msgId = Unknown_Code_in_VersionSCT(system, version);
478
+ const msg = this.i18n.translate(msgId, txp ? txp.HTTPLanguages : this.opContext.langs, [safeText(code), system, version, SCTVersion(system, version)]);
479
+ const issue = new Issue('error', 'code-invalid', 'concept', msgId, msg, 'invalid-code', 404);
480
+ if (message) {
481
+ issue.withDiagnostics(message);
482
+ }
483
+ return issue;
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Client-supplied text going into a message (and so into the log): no control characters
489
+ * - a newline would let a client write lines of its own into the log - and not too long.
490
+ */
491
+ function safeText(v) {
492
+ const s = String(v).replace(/\p{Cc}/gu, '?');
493
+ return s.length > 100 ? s.substring(0, 100) + '...' : s;
25
494
  }
26
495
 
27
496
  module.exports = ClosureWorker;
497
+ module.exports.LIMITS = LIMITS;
@@ -272,8 +272,8 @@ class MetadataHandler {
272
272
  { name: '_id', type: 'token' }
273
273
  ],
274
274
  operation: [
275
- { name: 'translate', definition: 'http://hl7.org/fhir/OperationDefinition/ConceptMap-translate' },
276
- { name: 'closure', definition: 'http://hl7.org/fhir/OperationDefinition/ConceptMap-closure' }
275
+ { name: 'translate', definition: 'http://hl7.org/fhir/OperationDefinition/ConceptMap-translate' }
276
+ // closure is system level, not ConceptMap level - see rest.operation below
277
277
  ]
278
278
  }
279
279
  ],
@@ -286,7 +286,8 @@ class MetadataHandler {
286
286
  { name: 'subsumes', definition: 'http://hl7.org/fhir/OperationDefinition/CodeSystem-subsumes' },
287
287
  { name: 'validate-code', definition: 'http://hl7.org/fhir/OperationDefinition/Resource-validate-code' },
288
288
  { name: 'translate', definition: 'http://hl7.org/fhir/OperationDefinition/ConceptMap-translate' },
289
- { name: 'closure', definition: 'http://hl7.org/fhir/OperationDefinition/ConceptMap-closure' },
289
+ // only when the administrator has turned it on (modules.tx.closure)
290
+ ...(this.config.closure ? [{ name: 'closure', definition: 'http://hl7.org/fhir/OperationDefinition/ConceptMap-closure' }] : []),
290
291
  { name: 'compare', definition: 'http://hl7.org/fhir/tools/OperationDefinition/ValueSet-compare' },
291
292
  { name: 'cache-control', definition: 'http://hl7.org/fhir/tools/OperationDefinition/cache-control' },
292
293
  { name: 'versions', definition: 'http://hl7.org/fhir/OperationDefinition/fhir-versions' }
@@ -333,6 +334,10 @@ class MetadataHandler {
333
334
  validateCode: this.buildValidateCodeCapabilities(),
334
335
  translation: this.buildTranslationCapabilities()
335
336
  };
337
+ if (this.config.closure) {
338
+ // closure tables relate concepts within one code system, never across them
339
+ tc.closure = { translation: false };
340
+ }
336
341
 
337
342
  return tc;
338
343
  }
@@ -61,6 +61,21 @@ class ReadWorker extends TerminologyWorker {
61
61
  }
62
62
  }
63
63
 
64
+ /**
65
+ * The resource as served at [type]/[id] must carry that id. Providers that
66
+ * share the server with others prefix their ids with a space id ("1-foo") but
67
+ * may keep the resource itself under its original id ("foo") - package
68
+ * ConceptMaps do - and then the HTML tabs, which link to [type]/[resource.id],
69
+ * point at a resource that doesn't exist (issue #256). Copy rather than
70
+ * mutate: the provider's object is shared and keyed on its original id.
71
+ */
72
+ withId(json, id) {
73
+ if (!json || json.id === id) {
74
+ return json;
75
+ }
76
+ return { ...json, id: id };
77
+ }
78
+
64
79
  /**
65
80
  * Handle CodeSystem read
66
81
  */
@@ -68,7 +83,7 @@ class ReadWorker extends TerminologyWorker {
68
83
  let cs = this.provider.getCodeSystemById(this.opContext, id);
69
84
  if (cs != null) {
70
85
  req.sourcePackage = cs.sourcePackage;
71
- return res.json(cs.jsonObj);
86
+ return res.json(this.withId(cs.jsonObj, id));
72
87
  }
73
88
 
74
89
  if (id.startsWith("x-")) {
@@ -127,7 +142,7 @@ class ReadWorker extends TerminologyWorker {
127
142
  const vs = await vsp.fetchValueSetById(id);
128
143
  if (vs) {
129
144
  req.sourcePackage = vs.sourcePackage;
130
- return res.json(vs.jsonObj);
145
+ return res.json(this.withId(vs.jsonObj, id));
131
146
  }
132
147
  }
133
148
 
@@ -143,7 +158,7 @@ class ReadWorker extends TerminologyWorker {
143
158
  const cm = await cmsp.fetchConceptMapById(id);
144
159
  if (cm) {
145
160
  req.sourcePackage = cm.sourcePackage;
146
- return res.json(cm.jsonObj);
161
+ return res.json(this.withId(cm.jsonObj, id));
147
162
  }
148
163
  }
149
164
 
@@ -107,9 +107,16 @@ class SubsumesWorker extends TerminologyWorker {
107
107
  codingA = params.get('codingA');
108
108
  codingB = params.get('codingB');
109
109
 
110
+ // A Coding with no system has no defined meaning, so there is nothing to test. This is a
111
+ // problem with the request (400/invalid), not a code system the server can't find
112
+ for (const [name, coding] of [['codingA', codingA], ['codingB', codingB]]) {
113
+ if (!coding.system) {
114
+ throw this.parameterIssue(txp, 'SUBSUMPTION_NO_SYSTEM', [name], name);
115
+ }
116
+ }
110
117
  // Codings must have the same system
111
118
  if (codingA.system !== codingB.system) {
112
- throw new Issue('error', 'not-found', null, null, 'codingA and codingB must have the same system', null, 400);
119
+ throw this.parameterIssue(txp, 'SUBSUMPTION_SYSTEM_MISMATCH', [codingA.system, codingB.system], 'codingB');
113
120
  }
114
121
  // Get the code system provider from the coding's system
115
122
  csProvider = await this.findCodeSystem(codingA.system, codingA.version || '', txp, ['complete'], null, false);
@@ -117,7 +124,7 @@ class SubsumesWorker extends TerminologyWorker {
117
124
  } else if (params.has('codeA') && params.has('codeB')) {
118
125
  // Using codeA, codeB - system is required
119
126
  if (!params.has('system')) {
120
- throw new Issue('error', 'not-found', null, null, 'system parameter is required when using codeA and codeB', null, 404);
127
+ throw this.parameterIssue(txp, 'SUBSUMPTION_NO_SYSTEM', ['codeA'], 'codeA');
121
128
  }
122
129
 
123
130
  csProvider = await this.findCodeSystem(params.get('system'), params.get('version') || '', txp, ['complete'], null, false);
@@ -318,6 +325,15 @@ class SubsumesWorker extends TerminologyWorker {
318
325
  };
319
326
  }
320
327
 
328
+ /**
329
+ * Build the Issue for a request that is malformed - a missing or unusable parameter. These
330
+ * are 400 / invalid: the request can't be processed as given, whatever the server knows
331
+ */
332
+ parameterIssue(txp, msgId, args, path) {
333
+ const msg = this.i18n.translate(msgId, txp.HTTPLanguages, args);
334
+ return new Issue('error', 'invalid', path, msgId, msg, 'invalid-data', 400);
335
+ }
336
+
321
337
  /**
322
338
  * Build the Issue for a code that isn't in the code system. Uses the same message ids
323
339
  * and tx-issue-type detail code as $validate-code, so clients get consistent errors