fhirsmith 0.11.2 → 0.13.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 (68) hide show
  1. package/CHANGELOG.md +377 -2
  2. package/README.md +1 -1
  3. package/config-template.json +31 -1
  4. package/library/languages.js +191 -34
  5. package/library/path-safety.js +51 -0
  6. package/library/request-token.js +56 -0
  7. package/package.json +2 -2
  8. package/packages/packages.js +17 -118
  9. package/publisher/publisher.js +208 -30
  10. package/publisher/validation.js +210 -0
  11. package/server.js +24 -15
  12. package/stats-db.js +127 -0
  13. package/stats.js +172 -6
  14. package/translations/Messages.properties +5 -1
  15. package/tx/README.md +21 -0
  16. package/tx/cs/cs-api.js +30 -1
  17. package/tx/cs/cs-api.md +1 -1
  18. package/tx/cs/cs-cs.js +1 -1
  19. package/tx/cs/cs-icd11.js +805 -0
  20. package/tx/cs/cs-lang.js +331 -85
  21. package/tx/cs/cs-loinc.js +45 -4
  22. package/tx/cs/cs-mimetypes.js +492 -21
  23. package/tx/cs/cs-snomed.js +141 -58
  24. package/tx/cs/cs-ucum.js +33 -3
  25. package/tx/cs/icd11-expressions.js +243 -0
  26. package/tx/importers/export-icd11-codesystem.js +823 -0
  27. package/tx/importers/icd11-schema.md +285 -0
  28. package/tx/importers/import-icd11.js +861 -0
  29. package/tx/importers/import-loinc.module.js +14 -7
  30. package/tx/importers/import-sct.module.js +10 -1
  31. package/tx/library/codesystem.js +21 -27
  32. package/tx/library/conceptmap.js +39 -0
  33. package/tx/library/nesting-limit.js +79 -0
  34. package/tx/library/operation-outcome.js +97 -3
  35. package/tx/library/parameter-check.js +139 -0
  36. package/tx/library/valueset.js +16 -20
  37. package/tx/library.js +23 -1
  38. package/tx/ocl/cache/cache-utils.cjs +5 -3
  39. package/tx/ocl/cs-ocl.cjs +73 -17
  40. package/tx/ocl/jobs/background-queue.cjs +6 -3
  41. package/tx/ocl/mappers/concept-mapper.cjs +9 -1
  42. package/tx/ocl/shared/patches.cjs +48 -1
  43. package/tx/ocl/vs-ocl.cjs +15 -13
  44. package/tx/operation-context.js +220 -10
  45. package/tx/params.js +226 -175
  46. package/tx/sct/expressions.js +677 -15
  47. package/tx/tx.fhir.org.yml +3 -2
  48. package/tx/tx.js +72 -92
  49. package/tx/workers/batch-validate.js +11 -18
  50. package/tx/workers/batch.js +16 -28
  51. package/tx/workers/cache-control.js +87 -17
  52. package/tx/workers/closure.js +3 -8
  53. package/tx/workers/compare.js +6 -34
  54. package/tx/workers/expand.js +108 -60
  55. package/tx/workers/lookup.js +35 -40
  56. package/tx/workers/read.js +6 -40
  57. package/tx/workers/search.js +2 -8
  58. package/tx/workers/subsumes.js +58 -41
  59. package/tx/workers/translate.js +472 -47
  60. package/tx/workers/validate.js +83 -78
  61. package/tx/workers/worker.js +92 -11
  62. package/tx/xml/xml-base.js +264 -77
  63. package/tx/xversion/xv-conceptmap.js +81 -0
  64. package/tx/xversion/xv-parameters.js +8 -0
  65. package/tx/xversion/xv-valueset.js +39 -1
  66. package/utilities/merge-audit.py +178 -0
  67. package/utilities/why-merge-audit.md +39 -0
  68. package/tx/data/snomed-subset.txt +0 -84
@@ -10,10 +10,21 @@
10
10
  const { TerminologyWorker } = require('./worker');
11
11
  const { TxParameters } = require('../params');
12
12
  const { Parameters } = require('../library/parameters');
13
- const { Issue, OperationOutcome } = require('../library/operation-outcome');
13
+ const { Issue, OperationOutcome, buildOperationOutcome, outcomeFromError } = require('../library/operation-outcome');
14
14
  const {ConceptMap} = require("../library/conceptmap");
15
+ const {Extensions} = require("../library/extensions");
16
+ const {VersionUtilities} = require("../../library/version-utilities");
15
17
  const {debugLog} = require("../operation-context");
16
18
 
19
+ // ConceptMap.group.element.comment is R6; on an R5 ConceptMap it is carried by the
20
+ // cross-version extension. Read both, so a preadopted R5 resource and a native R6 one
21
+ // behave the same.
22
+ const EXT_ELEMENT_COMMENT = 'http://hl7.org/fhir/6.0/StructureDefinition/extension-ConceptMap.group.element.comment';
23
+
24
+ function elementComment(em) {
25
+ return em.comment || Extensions.readString(em, EXT_ELEMENT_COMMENT);
26
+ }
27
+
17
28
  class TranslateWorker extends TerminologyWorker {
18
29
  /**
19
30
  * @param {OperationContext} opContext - Operation context
@@ -51,8 +62,7 @@ class TranslateWorker extends TerminologyWorker {
51
62
  oo.addIssue(error);
52
63
  return res.status(error.statusCode || 500).json(oo.jsonObj);
53
64
  } else {
54
- return res.status(error.statusCode || 500).json(this.operationOutcome(
55
- 'error', error.issueCode || 'exception', error.message));
65
+ return res.status(error.statusCode || 500).json(outcomeFromError(error));
56
66
  }
57
67
  }
58
68
  }
@@ -74,8 +84,7 @@ class TranslateWorker extends TerminologyWorker {
74
84
  oo.addIssue(error);
75
85
  return res.status(error.statusCode || 500).json(oo.jsonObj);
76
86
  } else {
77
- return res.status(error.statusCode || 500).json(this.operationOutcome(
78
- 'error', error.issueCode || 'exception', error.message));
87
+ return res.status(error.statusCode || 500).json(outcomeFromError(error));
79
88
  }
80
89
  }
81
90
  }
@@ -114,6 +123,7 @@ class TranslateWorker extends TerminologyWorker {
114
123
  let sourceScope = null;
115
124
  let targetSystem = null;
116
125
  let reverse = false;
126
+ let reverseFromTargetConcept = false; // R5 style: the target concept was named directly
117
127
 
118
128
  // Get the source coding
119
129
  // Accept both R5 names (sourceCoding, sourceCodeableConcept, sourceCode/sourceSystem)
@@ -149,9 +159,11 @@ class TranslateWorker extends TerminologyWorker {
149
159
  coding = {system, version, code};
150
160
  } else if (params.has('targetCoding')) {
151
161
  reverse = true;
162
+ reverseFromTargetConcept = true;
152
163
  coding = params.get('targetCoding');
153
164
  } else if (params.has('targetCodeableConcept')) {
154
165
  reverse = true;
166
+ reverseFromTargetConcept = true;
155
167
  const cc = params.get('targetCodeableConcept');
156
168
  if (cc.coding && cc.coding.length > 0) {
157
169
  coding = cc.coding[0]; // Use first coding
@@ -159,10 +171,12 @@ class TranslateWorker extends TerminologyWorker {
159
171
  throw new Issue('error', 'invalid', null, null,
160
172
  'sourceCodeableConcept must contain at least one coding', null, 400);
161
173
  }
162
- } else if (params.has('targetCode')) {
174
+ } else if (params.has('targetCode') || params.has('targetcode')) {
175
+ // targetcode is accepted alongside targetCode, as targetsystem is alongside targetSystem
163
176
  reverse = true;
164
- const code = params.get('targetCode');
165
- const system = params.get('targetSystem');
177
+ reverseFromTargetConcept = true;
178
+ const code = params.has('targetCode') ? params.get('targetCode') : params.get('targetcode');
179
+ const system = params.has('targetSystem') ? params.get('targetSystem') : params.get('targetsystem');
166
180
  if (!system) {
167
181
  throw new Issue('error', 'invalid', null, null,
168
182
  'targetSystem parameter is required when using targetCode', null, 400);
@@ -178,7 +192,7 @@ class TranslateWorker extends TerminologyWorker {
178
192
  if (params.has('url')) {
179
193
  const url = params.get('url');
180
194
  const cmVersion = params.get('conceptMapVersion');
181
- let conceptMap = await this.provider.findConceptMap(this.opContext, url, cmVersion);
195
+ let conceptMap = await this.findConceptMapByUrl(url, cmVersion);
182
196
  if (!conceptMap) {
183
197
  const msg = cmVersion
184
198
  ? `ConceptMap not found: ${url} version ${cmVersion}`
@@ -189,20 +203,61 @@ class TranslateWorker extends TerminologyWorker {
189
203
  }
190
204
  }
191
205
 
192
- // Get scope parameters
206
+ // Get scope parameters. As with the source concept above, the R4 names are
207
+ // accepted as aliases for the R5 ones: R4 calls the scopes 'source' and
208
+ // 'target', and spells the target system 'targetsystem' (all lower case).
193
209
  if (params.has('sourceScope')) {
194
210
  sourceScope = params.get('sourceScope');
211
+ } else if (params.has('source')) {
212
+ sourceScope = params.get('source');
195
213
  }
196
214
  if (params.has('targetScope')) {
197
215
  targetScope = params.get('targetScope');
216
+ } else if (params.has('target')) {
217
+ targetScope = params.get('target');
218
+ }
219
+ if (params.has('targetSystem')) {
220
+ targetSystem = params.get('targetSystem');
221
+ } else if (params.has('targetsystem')) {
222
+ targetSystem = params.get('targetsystem');
223
+ }
224
+
225
+ // R4's 'reverse' parameter: "this parameter reverses the meaning of the source and
226
+ // target parameters". So do literally that - swap the two sides here, once the
227
+ // parameters have all been read, and let the rest of the operation run as usual.
228
+ // R5+ dropped it in favour of naming the target concept directly (targetCode,
229
+ // targetCoding, targetCodeableConcept), so there it is an error rather than
230
+ // something to interpret.
231
+ if (params.has('reverse')) {
232
+ const fhirVersion = this.provider.getFhirVersion();
233
+ if (!VersionUtilities.isR4Ver(fhirVersion) && !VersionUtilities.isR3Ver(fhirVersion)) {
234
+ throw new Issue('error', 'not-supported', null, null,
235
+ `The 'reverse' parameter is not defined in ${fhirVersion}: name the target concept with targetCode, targetCoding or targetCodeableConcept instead`, null, 400);
236
+ }
237
+ const rev = params.get('reverse');
238
+ if (rev === true || rev === 'true') {
239
+ if (reverseFromTargetConcept) {
240
+ throw new Issue('error', 'invalid', null, null,
241
+ "The 'reverse' parameter cannot be combined with targetCode, targetCoding or targetCodeableConcept", null, 400);
242
+ }
243
+ reverse = true;
244
+ // the concept named by system/code is now the target concept, the two value set
245
+ // scopes swap roles, and targetsystem names the system the answers come from
246
+ const scope = sourceScope;
247
+ sourceScope = targetScope;
248
+ targetScope = scope;
249
+ }
198
250
  }
199
- if (reverse) {
251
+
252
+ // The R5-shaped reverse names the target concept directly; the source system then
253
+ // says which system the answers should come from. R5 spells that 'system' (it is
254
+ // the system of the source concept); 'sourceSystem' is accepted as an alias, as it
255
+ // is for the forward direction.
256
+ if (reverseFromTargetConcept) {
200
257
  if (params.has('sourceSystem')) {
201
258
  targetSystem = params.get('sourceSystem');
202
- }
203
- } else {
204
- if (params.has('targetSystem')) {
205
- targetSystem = params.get('targetSystem');
259
+ } else if (params.has('system')) {
260
+ targetSystem = params.get('system');
206
261
  }
207
262
  }
208
263
  // If no explicit concept map, we need to find one based on source/target
@@ -215,7 +270,23 @@ class TranslateWorker extends TerminologyWorker {
215
270
  await this.provider.findConceptMapForTranslation(this.opContext, conceptMaps, coding.system, sourceScope, targetScope, targetSystem, coding.code);
216
271
  }
217
272
  if (conceptMaps.length == 0) {
218
- throw new Issue('error', 'not-found', null, null, 'No suitable ConceptMaps found for the specified source and target', null, 404);
273
+ // The client did not nominate a map, and this server knows none that covers this
274
+ // source and target. That is an answer - there is no translation - not an error;
275
+ // a nominated map that cannot be found is the error case, and is reported when
276
+ // the url parameter is resolved above.
277
+ const from = reverse ? (targetSystem || sourceScope) : coding.system;
278
+ const to = reverse ? coding.system : (targetSystem || targetScope);
279
+ return res.status(200).json({
280
+ resourceType: 'Parameters',
281
+ parameter: [{
282
+ name: 'message',
283
+ valueString: `No ConceptMap is available to translate from '${from || '(unspecified)'}' to '${to || '(unspecified)'}'`
284
+ },
285
+ {
286
+ name: 'result',
287
+ valueBoolean: false
288
+ }]
289
+ });
219
290
  }
220
291
  }
221
292
 
@@ -320,22 +391,64 @@ class TranslateWorker extends TerminologyWorker {
320
391
  return result;
321
392
  }
322
393
 
323
- translateUsingGroupsForwards(cm, coding, targetScope, targetSystem, params, output) {
394
+ async translateUsingGroupsForwards(cm, coding, targetScope, targetSystem, params, output, origin, visited) {
324
395
  let result = false;
325
- const matches = cm.listTranslations(coding, targetScope, targetSystem);
326
- if (matches.length > 0) {
327
- for (let match of matches) {
328
- const g = match.group;
329
- const em = match.match;
396
+ // originMap names where the chain of maps started, not the map that happened to
397
+ // supply the answer; any other map consulted along the way is a used-conceptmap.
398
+ const originMap = origin || cm.vurl;
399
+ // Work group by group. group.unmapped says what this group does with a concept it
400
+ // has no element for, so it is decided per group: a mapping in one group does not
401
+ // speak for another group, and does not suppress another group's fallback.
402
+ for (const g of cm.listGroupsInScope(coding, targetScope, targetSystem)) {
403
+ let groupResult = false;
404
+ for (const em of (g.element || []).filter(e => e.code === coding.code)) {
405
+ // ConceptMap.group.element.noMap says, positively, that this concept has no
406
+ // mapping in this map - which is an answer, not the absence of one. Report it
407
+ // as a match that states the source concept and the map it came from, with no
408
+ // concept and no relationship, and nothing to say about a target.
409
+ if (em.noMap === true) {
410
+ groupResult = true;
411
+ if (this.hasNoMap(output, g.source, em.code)) {
412
+ continue; // another map has already said this; saying it twice adds nothing
413
+ }
414
+ const noMapParts = [];
415
+ noMapParts.push({
416
+ name: 'noMap',
417
+ valueBoolean: true
418
+ });
419
+ const noMapComment = elementComment(em);
420
+ if (noMapComment) {
421
+ noMapParts.push({
422
+ name: 'sourceComment',
423
+ valueString: noMapComment
424
+ });
425
+ }
426
+ noMapParts.push({
427
+ name: 'sourceConcept',
428
+ valueCoding: {
429
+ system: g.source,
430
+ code: em.code
431
+ }
432
+ });
433
+ noMapParts.push({
434
+ name: 'originMap',
435
+ valueCanonical: originMap
436
+ });
437
+ output.push({
438
+ name: 'match',
439
+ part: noMapParts
440
+ });
441
+ continue;
442
+ }
330
443
  for (const map of em.target || []) {
331
444
  let ok = false;
332
445
  if (map.equivalence) { // R4 mode
333
- ok = ['null', 'relatedto', 'equivalent', 'equal', 'wider', 'subsumes', 'narrower', 'specializes', 'inexact'].includes(map.equivalence);
446
+ ok = ['null', 'relatedto', 'equivalent', 'equal', 'wider', 'subsumes', 'narrower', 'specializes', 'inexact', 'unmatched', 'disjoint'].includes(map.equivalence);
334
447
  } else {
335
- ok = ['null', 'related-to', 'equivalent', 'source-is-narrower-than-target', 'source-is-broader-than-target'].includes(map.relationship);
448
+ ok = ['null', 'related-to', 'equivalent', 'source-is-narrower-than-target', 'source-is-broader-than-target', 'not-related-to'].includes(map.relationship);
336
449
  }
337
450
  if (ok) {
338
- result = true;
451
+ groupResult = true;
339
452
 
340
453
  const outcome = {
341
454
  system: g.target,
@@ -348,6 +461,13 @@ class TranslateWorker extends TerminologyWorker {
348
461
  name: 'concept',
349
462
  valueCoding: outcome
350
463
  });
464
+ matchParts.push({
465
+ name: 'sourceConcept',
466
+ valueCoding: {
467
+ system: g.source,
468
+ code: em.code
469
+ }
470
+ });
351
471
  matchParts.push({
352
472
  name: 'relationship',
353
473
  valueCode: map.relationship
@@ -359,9 +479,18 @@ class TranslateWorker extends TerminologyWorker {
359
479
  valueCode: map.equivalence
360
480
  });
361
481
  }
482
+ // ConceptMap.group.element.comment is about the source concept,
483
+ // ConceptMap.group.element.target.comment is about this particular map
484
+ const srcComment = elementComment(em);
485
+ if (srcComment) {
486
+ matchParts.push({
487
+ name: 'sourceComment',
488
+ valueString: srcComment
489
+ });
490
+ }
362
491
  if (map.comment) {
363
492
  matchParts.push({
364
- name: 'message',
493
+ name: 'targetComment',
365
494
  valueString: map.comment
366
495
  });
367
496
  }
@@ -385,7 +514,7 @@ class TranslateWorker extends TerminologyWorker {
385
514
  }
386
515
  matchParts.push({
387
516
  name: 'originMap',
388
- valueCanonical: cm.vurl
517
+ valueCanonical: originMap
389
518
  });
390
519
  output.push({
391
520
  name: 'match',
@@ -395,6 +524,11 @@ class TranslateWorker extends TerminologyWorker {
395
524
  }
396
525
  }
397
526
  }
527
+ if (!groupResult) {
528
+ groupResult = await this.translateUsingUnmapped(cm, g, coding, targetScope, targetSystem, params, output,
529
+ visited || new Set([cm.vurl]), originMap);
530
+ }
531
+ result = groupResult || result;
398
532
  }
399
533
  return result;
400
534
  }
@@ -409,9 +543,9 @@ class TranslateWorker extends TerminologyWorker {
409
543
  const map = match.target;
410
544
  let ok = false;
411
545
  if (map.equivalence) { // R4 mode
412
- ok = ['null', 'relatedto', 'equivalent', 'equal', 'wider', 'subsumes', 'narrower', 'specializes', 'inexact'].includes(map.equivalence);
546
+ ok = ['null', 'relatedto', 'equivalent', 'equal', 'wider', 'subsumes', 'narrower', 'specializes', 'inexact', 'unmatched', 'disjoint'].includes(map.equivalence);
413
547
  } else {
414
- ok = ['null', 'related-to', 'equivalent', 'source-is-narrower-than-target', 'source-is-broader-than-target'].includes(map.relationship);
548
+ ok = ['null', 'related-to', 'equivalent', 'source-is-narrower-than-target', 'source-is-broader-than-target', 'not-related-to'].includes(map.relationship);
415
549
  }
416
550
  if (ok) {
417
551
  result = true;
@@ -428,7 +562,7 @@ class TranslateWorker extends TerminologyWorker {
428
562
  if (!this.hasMatch(output, outcome)) {
429
563
  const matchParts = [];
430
564
  matchParts.push({
431
- name: 'source',
565
+ name: 'sourceConcept',
432
566
  valueCoding: outcome
433
567
  });
434
568
  matchParts.push({
@@ -446,9 +580,18 @@ class TranslateWorker extends TerminologyWorker {
446
580
  valueCode: map.equivalence
447
581
  });
448
582
  }
583
+ // as above: element.comment describes the source concept, and
584
+ // element.target.comment describes this particular map
585
+ const srcComment = elementComment(em);
586
+ if (srcComment) {
587
+ matchParts.push({
588
+ name: 'sourceComment',
589
+ valueString: srcComment
590
+ });
591
+ }
449
592
  if (map.comment) {
450
593
  matchParts.push({
451
- name: 'message',
594
+ name: 'targetComment',
452
595
  valueString: map.comment
453
596
  });
454
597
  }
@@ -485,6 +628,218 @@ class TranslateWorker extends TerminologyWorker {
485
628
  return result;
486
629
  }
487
630
 
631
+ /**
632
+ * Order the candidate maps for translation. url and version play no part:
633
+ *
634
+ * - dependency first: a map that defers to another (group.unmapped.otherMap) is
635
+ * consulted before the map it defers to
636
+ * - then by date, most recent first, with dateless maps last
637
+ *
638
+ * A dependency edge only counts when the map it names is itself a candidate. Any map
639
+ * caught in a dependency cycle keeps its place at the end, in date order - the cycle
640
+ * itself is reported when the translation actually follows it.
641
+ * @param {ConceptMap[]} conceptMaps
642
+ * @returns {ConceptMap[]} a new, ordered array
643
+ */
644
+ sortConceptMaps(conceptMaps) {
645
+ if (!conceptMaps || conceptMaps.length < 2) {
646
+ return conceptMaps; // nothing to decide
647
+ }
648
+
649
+ const dateOf = (cm) => {
650
+ const d = cm.jsonObj.date;
651
+ if (!d) {
652
+ return null;
653
+ }
654
+ const t = Date.parse(d);
655
+ return isNaN(t) ? null : t; // an unusable date is treated as no date
656
+ };
657
+ const byDate = (a, b) => {
658
+ const da = dateOf(a), db = dateOf(b);
659
+ if (da === null && db === null) return 0; // stable: input order is kept
660
+ if (da === null) return 1;
661
+ if (db === null) return -1;
662
+ return db - da;
663
+ };
664
+
665
+ // a dependency can name its target with or without a version
666
+ const byRef = new Map();
667
+ for (const cm of conceptMaps) {
668
+ if (cm.url && !byRef.has(cm.url)) {
669
+ byRef.set(cm.url, cm);
670
+ }
671
+ if (cm.vurl) {
672
+ byRef.set(cm.vurl, cm);
673
+ }
674
+ }
675
+
676
+ const dependsOn = new Map();
677
+ const waitingOn = new Map();
678
+ for (const cm of conceptMaps) {
679
+ dependsOn.set(cm, new Set());
680
+ waitingOn.set(cm, 0);
681
+ }
682
+ for (const cm of conceptMaps) {
683
+ for (const ref of cm.listDependencies()) {
684
+ const dep = byRef.get(ref) || byRef.get(ref.split('|')[0]);
685
+ if (dep && dep !== cm && !dependsOn.get(cm).has(dep)) {
686
+ dependsOn.get(cm).add(dep);
687
+ waitingOn.set(dep, waitingOn.get(dep) + 1);
688
+ }
689
+ }
690
+ }
691
+
692
+ const ready = conceptMaps.filter(cm => waitingOn.get(cm) === 0).sort(byDate);
693
+ const ordered = [];
694
+ while (ready.length > 0) {
695
+ const cm = ready.shift();
696
+ ordered.push(cm);
697
+ for (const dep of dependsOn.get(cm)) {
698
+ waitingOn.set(dep, waitingOn.get(dep) - 1);
699
+ if (waitingOn.get(dep) === 0) {
700
+ ready.push(dep);
701
+ ready.sort(byDate);
702
+ }
703
+ }
704
+ }
705
+ if (ordered.length < conceptMaps.length) {
706
+ const seen = new Set(ordered);
707
+ ordered.push(...conceptMaps.filter(cm => !seen.has(cm)).sort(byDate));
708
+ }
709
+ return ordered;
710
+ }
711
+
712
+ /**
713
+ * ConceptMap.group.unmapped: what to do when the group is in scope but has no element
714
+ * for this concept. Only consulted once the map itself has produced nothing.
715
+ *
716
+ * use-source-code the source code is also the target code
717
+ * fixed a nominated code (and display) is the target
718
+ * other-map defer to another ConceptMap entirely
719
+ *
720
+ * `visited` guards against a cycle in other-map chains.
721
+ * @returns {Promise<boolean>} whether anything was added
722
+ */
723
+ async translateUsingUnmapped(cm, g, coding, targetScope, targetSystem, params, output, visited, origin) {
724
+ let result = false;
725
+ const originMap = origin || cm.vurl;
726
+ {
727
+ const u = g.unmapped;
728
+ if (!u || !u.mode) {
729
+ return false;
730
+ }
731
+ if (u.valueSet) {
732
+ // "all the codes in the specified value set" - that needs an expansion, which
733
+ // this operation does not do. Better to say so than to silently return nothing.
734
+ throw new Issue('error', 'not-supported', null, null,
735
+ `ConceptMap ${cm.vurl} uses group.unmapped.valueSet, which this server does not support`, null, 422);
736
+ }
737
+ switch (u.mode) {
738
+ case 'use-source-code' :
739
+ result = this._addUnmappedMatch(output, originMap, coding, {system: g.target, code: coding.code}, u.relationship) || result;
740
+ break;
741
+ case 'fixed' :
742
+ if (u.code) {
743
+ const outcome = {system: g.target, code: u.code};
744
+ if (u.display) {
745
+ outcome.display = u.display;
746
+ }
747
+ result = this._addUnmappedMatch(output, originMap, coding, outcome, u.relationship) || result;
748
+ }
749
+ break;
750
+ case 'other-map' :
751
+ if (u.otherMap) {
752
+ const other = await this.findConceptMapByUrl(u.otherMap);
753
+ if (!other) {
754
+ throw new Issue('error', 'not-found', null, null,
755
+ `The ConceptMap ${u.otherMap} named in group.unmapped.otherMap was not found`, null, 422);
756
+ }
757
+ // Key the cycle check on the map that was actually resolved, not on the
758
+ // reference as written: the same map can be named with or without a
759
+ // version, or by a version that resolves to the same instance.
760
+ const key = other.vurl;
761
+ if (visited.has(key)) {
762
+ throw new Issue('error', 'business-rule', null, null,
763
+ `ConceptMap ${cm.vurl} has a circular group.unmapped.otherMap reference to ${other.vurl}`
764
+ + (key === u.otherMap ? '' : ` (referenced as ${u.otherMap})`), null, 422);
765
+ }
766
+ // `visited` is the chain currently being followed, not every map seen: two
767
+ // groups (or two maps) legitimately deferring to the same other map is a
768
+ // diamond, not a cycle, so the entry is removed again on the way back out.
769
+ visited.add(key);
770
+ try {
771
+ // we are about to look in this map, and it is not where the chain started
772
+ this.recordUsedConceptMap(output, other.vurl);
773
+ result = await this.translateUsingGroupsForwards(other, coding, targetScope, targetSystem, params, output, originMap, visited) || result;
774
+ } finally {
775
+ visited.delete(key);
776
+ }
777
+ }
778
+ break;
779
+ default :
780
+ throw new Issue('error', 'not-supported', null, null,
781
+ `ConceptMap ${cm.vurl} uses an unknown group.unmapped.mode '${u.mode}'`, null, 422);
782
+ }
783
+ }
784
+ return result;
785
+ }
786
+
787
+ /**
788
+ * Build the match for an unmapped fallback. The source concept is the one that was
789
+ * asked for - it is, by definition, not an element of the map.
790
+ */
791
+ _addUnmappedMatch(output, originMap, coding, outcome, relationship) {
792
+ if (this.hasMatch(output, outcome)) {
793
+ return false;
794
+ }
795
+ const matchParts = [];
796
+ matchParts.push({
797
+ name: 'concept',
798
+ valueCoding: outcome
799
+ });
800
+ matchParts.push({
801
+ name: 'sourceConcept',
802
+ valueCoding: {
803
+ system: coding.system,
804
+ code: coding.code
805
+ }
806
+ });
807
+ if (relationship) {
808
+ matchParts.push({
809
+ name: 'relationship',
810
+ valueCode: relationship
811
+ });
812
+ }
813
+ matchParts.push({
814
+ name: 'originMap',
815
+ valueCanonical: originMap
816
+ });
817
+ output.push({
818
+ name: 'match',
819
+ part: matchParts
820
+ });
821
+ return true;
822
+ }
823
+
824
+ /**
825
+ * Report a ConceptMap that was consulted but is not where the chain started. Reported
826
+ * once per map, at the top level of the response rather than per match.
827
+ */
828
+ recordUsedConceptMap(output, vurl) {
829
+ if (!vurl) {
830
+ return;
831
+ }
832
+ for (const o of output) {
833
+ if (o.name === 'used-conceptmap' && (o.valueCanonical === vurl || o.valueUri === vurl)) {
834
+ return;
835
+ }
836
+ }
837
+ output.push({
838
+ name: 'used-conceptmap',
839
+ valueUri: vurl
840
+ });
841
+ }
842
+
488
843
  async translateUsingCodeSystem(cm, coding, target, params, output, reverse) {
489
844
  let result = false;
490
845
  const factory = cm.jsonObj.internalSource;
@@ -521,6 +876,13 @@ class TranslateWorker extends TerminologyWorker {
521
876
  name: 'concept',
522
877
  valueCoding: outcome
523
878
  });
879
+ matchParts.push({
880
+ name: 'sourceConcept',
881
+ valueCoding: {
882
+ system: coding.system,
883
+ code: coding.code
884
+ }
885
+ });
524
886
  matchParts.push({
525
887
  name: 'relationship',
526
888
  valueCode: t.relationship
@@ -560,14 +922,20 @@ class TranslateWorker extends TerminologyWorker {
560
922
  const result = [];
561
923
 
562
924
  try {
925
+ // The maps are consulted in order (see sortConceptMaps), and each group within
926
+ // them decides for itself: a group with a mapping for this concept reports it, a
927
+ // group without one falls back to its own group.unmapped. Nothing is remembered
928
+ // across groups - one group having mapped the concept does not stop another group
929
+ // from saying what it does with it.
930
+ const maps = this.sortConceptMaps(conceptMaps);
563
931
  let added = false;
564
- for (const cm of conceptMaps) {
932
+ for (const cm of maps) {
565
933
  if (cm.jsonObj.internalSource) {
566
934
  added = await this.translateUsingCodeSystem(cm, coding, targetSystem, params, result, reverse) || added;
567
935
  } else if (reverse) {
568
936
  added = this.translateUsingGroupsReverse(cm, coding, targetScope, targetSystem, params, result) || added;
569
- } else{
570
- added = this.translateUsingGroupsForwards(cm, coding, targetScope, targetSystem, params, result) || added;
937
+ } else {
938
+ added = await this.translateUsingGroupsForwards(cm, coding, targetScope, targetSystem, params, result) || added;
571
939
  }
572
940
  }
573
941
  result.push({
@@ -581,6 +949,12 @@ class TranslateWorker extends TerminologyWorker {
581
949
  });
582
950
  }
583
951
  } catch (error) {
952
+ if (error instanceof Issue) {
953
+ // A coded problem with the request or with the maps themselves. That is not a
954
+ // translation outcome, so it must not be flattened into result=false plus a
955
+ // message - let it out, to be reported as an OperationOutcome with its status.
956
+ throw error;
957
+ }
584
958
  this.log.error(error);
585
959
  debugLog(error);
586
960
  result.push({
@@ -637,21 +1011,44 @@ class TranslateWorker extends TerminologyWorker {
637
1011
  }
638
1012
  }
639
1013
  /**
640
- * Build an OperationOutcome
1014
+ * Build an OperationOutcome. Delegates to the shared builder so that every outcome this
1015
+ * server emits has details.text and a tx-issue-type coding: diagnostics is stripped by
1016
+ * the test harness, so nothing a caller needs may live there.
641
1017
  * @param {string} severity - error, warning, information
642
- * @param {string} code - Issue code
643
- * @param {string} message - Diagnostic message
1018
+ * @param {string} code - FHIR issue type
1019
+ * @param {string} message - the human readable account of the problem
1020
+ * @param {string} [txIssueType] - tx-issue-type; defaulted from code when not given
644
1021
  * @returns {Object} OperationOutcome resource
645
1022
  */
646
- operationOutcome(severity, code, message) {
647
- return {
648
- resourceType: 'OperationOutcome',
649
- issue: [{
650
- severity,
651
- code,
652
- diagnostics: message
653
- }]
654
- };
1023
+ operationOutcome(severity, code, message, txIssueType = null) {
1024
+ return buildOperationOutcome(severity, code, message, txIssueType);
1025
+ }
1026
+
1027
+ /**
1028
+ * Resolve a ConceptMap by canonical reference. Resources supplied with the request
1029
+ * (tx-resource, or a front-loaded cache) come first: they are part of the caller's
1030
+ * context, and a map named by group.unmapped.otherMap or by the url parameter may
1031
+ * well be one of them rather than something the server already knows.
1032
+ * @param {string} ref - canonical reference, possibly url|version
1033
+ * @param {string} [version] - version, when supplied separately
1034
+ * @returns {Promise<ConceptMap|null>}
1035
+ */
1036
+ async findConceptMapByUrl(ref, version) {
1037
+ if (!ref) {
1038
+ return null;
1039
+ }
1040
+ let url = ref;
1041
+ let ver = version;
1042
+ const bar = ref.indexOf('|');
1043
+ if (!ver && bar !== -1) {
1044
+ url = ref.substring(0, bar);
1045
+ ver = ref.substring(bar + 1);
1046
+ }
1047
+ const supplied = this.findInAdditionalResources(ver ? url : ref, ver || '', 'ConceptMap', false);
1048
+ if (supplied) {
1049
+ return supplied;
1050
+ }
1051
+ return await this.provider.findConceptMap(this.opContext, url, ver);
655
1052
  }
656
1053
 
657
1054
  async findConceptMapsInAdditionalResources(conceptMaps, system, sourceScope, targetScope, targetSystem) {
@@ -664,10 +1061,38 @@ class TranslateWorker extends TerminologyWorker {
664
1061
  }
665
1062
  }
666
1063
 
1064
+ /**
1065
+ * Has some map already stated that this source concept has no mapping? Two maps that
1066
+ * agree there is no map are making the same statement, so it is reported once - the
1067
+ * same way two maps that agree on a target concept report one match. (A noMap match
1068
+ * carries no concept, so hasMatch can never see it.)
1069
+ */
1070
+ hasNoMap(output, system, code) {
1071
+ for (let o of output) {
1072
+ if (o.name !== 'match' || !Array.isArray(o.part)) {
1073
+ continue;
1074
+ }
1075
+ const n = o.part.find(x => x.name === 'noMap');
1076
+ const sc = o.part.find(x => x.name === 'sourceConcept');
1077
+ if (n && n.valueBoolean === true && sc && sc.valueCoding
1078
+ && sc.valueCoding.system === system && sc.valueCoding.code === code) {
1079
+ return true;
1080
+ }
1081
+ }
1082
+ return false;
1083
+ }
1084
+
667
1085
  hasMatch(output, outcome) {
668
1086
  for (let o of output) {
1087
+ // output is not all matches: translateUsingCodeSystem also pushes used-system and
1088
+ // used-conceptmap, which have no parts at all. And a noMap match has parts but no
1089
+ // concept - it is the statement that there is no coded outcome, so it can never
1090
+ // collide with one.
1091
+ if (o.name !== 'match' || !Array.isArray(o.part)) {
1092
+ continue;
1093
+ }
669
1094
  let c = o.part.find(x => x.name === 'concept');
670
- if (c.valueCoding.code === outcome.code && c.valueCoding.system === outcome.system) {
1095
+ if (c && c.valueCoding && c.valueCoding.code === outcome.code && c.valueCoding.system === outcome.system) {
671
1096
  return true;
672
1097
  }
673
1098
  }