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.
- package/CHANGELOG.md +377 -2
- package/README.md +1 -1
- package/config-template.json +31 -1
- package/library/languages.js +191 -34
- package/library/path-safety.js +51 -0
- package/library/request-token.js +56 -0
- package/package.json +2 -2
- package/packages/packages.js +17 -118
- package/publisher/publisher.js +208 -30
- package/publisher/validation.js +210 -0
- package/server.js +24 -15
- package/stats-db.js +127 -0
- package/stats.js +172 -6
- package/translations/Messages.properties +5 -1
- package/tx/README.md +21 -0
- package/tx/cs/cs-api.js +30 -1
- package/tx/cs/cs-api.md +1 -1
- package/tx/cs/cs-cs.js +1 -1
- package/tx/cs/cs-icd11.js +805 -0
- package/tx/cs/cs-lang.js +331 -85
- package/tx/cs/cs-loinc.js +45 -4
- package/tx/cs/cs-mimetypes.js +492 -21
- package/tx/cs/cs-snomed.js +141 -58
- package/tx/cs/cs-ucum.js +33 -3
- package/tx/cs/icd11-expressions.js +243 -0
- package/tx/importers/export-icd11-codesystem.js +823 -0
- package/tx/importers/icd11-schema.md +285 -0
- package/tx/importers/import-icd11.js +861 -0
- package/tx/importers/import-loinc.module.js +14 -7
- package/tx/importers/import-sct.module.js +10 -1
- package/tx/library/codesystem.js +21 -27
- package/tx/library/conceptmap.js +39 -0
- package/tx/library/nesting-limit.js +79 -0
- package/tx/library/operation-outcome.js +97 -3
- package/tx/library/parameter-check.js +139 -0
- package/tx/library/valueset.js +16 -20
- package/tx/library.js +23 -1
- package/tx/ocl/cache/cache-utils.cjs +5 -3
- package/tx/ocl/cs-ocl.cjs +73 -17
- package/tx/ocl/jobs/background-queue.cjs +6 -3
- package/tx/ocl/mappers/concept-mapper.cjs +9 -1
- package/tx/ocl/shared/patches.cjs +48 -1
- package/tx/ocl/vs-ocl.cjs +15 -13
- package/tx/operation-context.js +220 -10
- package/tx/params.js +226 -175
- package/tx/sct/expressions.js +677 -15
- package/tx/tx.fhir.org.yml +3 -2
- package/tx/tx.js +72 -92
- package/tx/workers/batch-validate.js +11 -18
- package/tx/workers/batch.js +16 -28
- package/tx/workers/cache-control.js +87 -17
- package/tx/workers/closure.js +3 -8
- package/tx/workers/compare.js +6 -34
- package/tx/workers/expand.js +108 -60
- package/tx/workers/lookup.js +35 -40
- package/tx/workers/read.js +6 -40
- package/tx/workers/search.js +2 -8
- package/tx/workers/subsumes.js +58 -41
- package/tx/workers/translate.js +472 -47
- package/tx/workers/validate.js +83 -78
- package/tx/workers/worker.js +92 -11
- package/tx/xml/xml-base.js +264 -77
- package/tx/xversion/xv-conceptmap.js +81 -0
- package/tx/xversion/xv-parameters.js +8 -0
- package/tx/xversion/xv-valueset.js +39 -1
- package/utilities/merge-audit.py +178 -0
- package/utilities/why-merge-audit.md +39 -0
- package/tx/data/snomed-subset.txt +0 -84
package/tx/workers/translate.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
165
|
-
const
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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: '
|
|
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:
|
|
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: '
|
|
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: '
|
|
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
|
|
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 -
|
|
643
|
-
* @param {string} 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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
}
|