fhirsmith 0.12.0 → 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 (56) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/README.md +1 -1
  3. package/config-template.json +13 -0
  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 +60 -30
  10. package/publisher/validation.js +15 -3
  11. package/server.js +12 -3
  12. package/translations/Messages.properties +1 -0
  13. package/tx/README.md +21 -0
  14. package/tx/cs/cs-api.js +30 -1
  15. package/tx/cs/cs-cs.js +1 -1
  16. package/tx/cs/cs-icd11.js +805 -0
  17. package/tx/cs/cs-lang.js +331 -85
  18. package/tx/cs/cs-loinc.js +45 -4
  19. package/tx/cs/cs-mimetypes.js +492 -21
  20. package/tx/cs/cs-snomed.js +38 -4
  21. package/tx/cs/cs-ucum.js +33 -3
  22. package/tx/cs/icd11-expressions.js +243 -0
  23. package/tx/importers/export-icd11-codesystem.js +823 -0
  24. package/tx/importers/icd11-schema.md +285 -0
  25. package/tx/importers/import-icd11.js +861 -0
  26. package/tx/importers/import-loinc.module.js +14 -7
  27. package/tx/importers/import-sct.module.js +10 -1
  28. package/tx/library/codesystem.js +21 -27
  29. package/tx/library/nesting-limit.js +79 -0
  30. package/tx/library/operation-outcome.js +97 -3
  31. package/tx/library/valueset.js +16 -20
  32. package/tx/library.js +23 -1
  33. package/tx/ocl/cache/cache-utils.cjs +5 -3
  34. package/tx/ocl/cs-ocl.cjs +73 -17
  35. package/tx/ocl/jobs/background-queue.cjs +6 -3
  36. package/tx/ocl/mappers/concept-mapper.cjs +9 -1
  37. package/tx/ocl/shared/patches.cjs +48 -1
  38. package/tx/ocl/vs-ocl.cjs +15 -13
  39. package/tx/sct/expressions.js +194 -17
  40. package/tx/tx.fhir.org.yml +2 -1
  41. package/tx/tx.js +15 -42
  42. package/tx/workers/batch-validate.js +6 -16
  43. package/tx/workers/batch.js +16 -28
  44. package/tx/workers/cache-control.js +6 -12
  45. package/tx/workers/closure.js +3 -8
  46. package/tx/workers/compare.js +6 -34
  47. package/tx/workers/expand.js +36 -45
  48. package/tx/workers/lookup.js +21 -39
  49. package/tx/workers/read.js +6 -40
  50. package/tx/workers/search.js +2 -8
  51. package/tx/workers/subsumes.js +58 -41
  52. package/tx/workers/translate.js +11 -17
  53. package/tx/workers/validate.js +76 -77
  54. package/tx/workers/worker.js +70 -1
  55. package/tx/xml/xml-base.js +243 -76
  56. package/tx/xversion/xv-valueset.js +39 -1
@@ -6,7 +6,14 @@
6
6
  * Licensed under BSD-3-Clause
7
7
  */
8
8
 
9
+ const { SnomedConceptList } = require('./structures');
10
+
9
11
  const MAX_TERM_LENGTH = 1024;
12
+ // How deep concept-definition expansion may go while building a long normal form.
13
+ // Attribute values are themselves expanded, so a chain of definitions can descend a
14
+ // long way; in real data it is a handful of levels.
15
+ const MAX_NORMALISATION_DEPTH = 50;
16
+
10
17
  const NO_REFERENCE = 0xFFFFFFFF;
11
18
 
12
19
  // MRCM (machine readable concept model) reference sets and metadata concepts.
@@ -593,6 +600,15 @@ class SnomedExpressionParser {
593
600
 
594
601
  this.ws();
595
602
  result.code = this.conceptId();
603
+ // Resolve the attribute name the same way concept() resolves a focus concept. Without this
604
+ // the name keeps NO_REFERENCE, and SnomedConcept.matches() then compares by reference on one
605
+ // side and by code on the other, which makes refinement matching (and so subsumption between
606
+ // two refined expressions) asymmetric.
607
+ if (this.conceptList) {
608
+ const found = this.conceptList.findConcept(result.code);
609
+ this.rule(found.found, 'Concept "' + result.code + '" not valid');
610
+ result.reference = found.index;
611
+ }
596
612
  this.ws();
597
613
 
598
614
  if (this.gchar('|')) {
@@ -1035,7 +1051,12 @@ class SnomedExpressionServices {
1035
1051
  }
1036
1052
 
1037
1053
  const result = new SnomedExpressionParser().parse(source);
1038
- this.checkExpression(result);
1054
+ // Resolve concept ids to references, but do NOT run checkExpression: the MRCM rules it
1055
+ // applies are the postcoordination ones, and this is a concept's own stored definition -
1056
+ // precoordinated content, authored under MRCM content types that do not apply here. Running
1057
+ // them rejects the normal form of any concept using an attribute that is precoordinated-only
1058
+ // (e.g. 246075003 |Causative agent| on a disorder), which broke subsumption for expressions.
1059
+ this.resolveExpressionReferences(result);
1039
1060
  return result;
1040
1061
  }
1041
1062
  }
@@ -1202,13 +1223,46 @@ class SnomedExpressionServices {
1202
1223
  /**
1203
1224
  * Normalize expression to normal form
1204
1225
  */
1205
- normaliseExpression(exp) {
1226
+ /**
1227
+ * Expand an expression to its long normal form, ready for structural comparison.
1228
+ *
1229
+ * A fully defined concept is replaced by its definition, because that definition is
1230
+ * both necessary and sufficient. A primitive concept is NOT replaced - its definition
1231
+ * is only necessary, and substituting it would make the expression look equivalent to
1232
+ * things it merely subsumes. But the primitive's defining attributes are still
1233
+ * entailed by it, so they are added alongside the concept rather than discarded:
1234
+ * `4846001 |Anicteric viral hepatitis|` is primitive and carries an inferred
1235
+ * `363698007 |Finding site| = 10200004 |Liver structure|`, so every instance of it
1236
+ * really is a disease with that finding site, and `64572001:{363698007=10200004}`
1237
+ * really does subsume it. Dropping those attributes - which is what this used to do -
1238
+ * lost that answer and reported not-subsumed, which is simply wrong.
1239
+ *
1240
+ * Keeping the concept itself in the focus is what stops the addition being unsound in
1241
+ * the other direction: the primitive stays as an atom that nothing else can match, so
1242
+ * an expression carrying the same attributes is never reported as equivalent to it.
1243
+ *
1244
+ * @param {SnomedExpression} exp
1245
+ * @param {number} [depth] - recursion guard, see MAX_NORMALISATION_DEPTH
1246
+ * @returns {SnomedExpression}
1247
+ */
1248
+ normaliseExpression(exp, depth = 0) {
1249
+ if (depth > MAX_NORMALISATION_DEPTH) {
1250
+ // Expanding attribute values can descend a long way, and truncating silently is
1251
+ // not safe in both directions: dropping conditions from the subsuming side makes
1252
+ // it demand less and so subsume more, which would be a false positive. Fail
1253
+ // instead, and let the caller report that it could not answer.
1254
+ throw new Error(`Could not normalise the expression: it nests more than ${MAX_NORMALISATION_DEPTH}`
1255
+ + ' levels deep once concept definitions are expanded');
1256
+ }
1206
1257
  const work = new SnomedExpression();
1207
1258
 
1208
1259
  // Process concepts
1209
1260
  for (const concept of exp.concepts) {
1210
- if (concept.reference === NO_REFERENCE || this.isPrimitive(concept.reference)) {
1261
+ if (concept.reference === NO_REFERENCE) {
1262
+ work.concepts.push(concept);
1263
+ } else if (this.isPrimitive(concept.reference)) {
1211
1264
  work.concepts.push(concept);
1265
+ this.addDefiningAttributes(concept.reference, work, depth);
1212
1266
  } else {
1213
1267
  const ex = this.createNormalForm(concept.reference);
1214
1268
  work.merge(ex);
@@ -1220,7 +1274,7 @@ class SnomedExpressionServices {
1220
1274
  const refDst = new SnomedRefinement();
1221
1275
  work.refinements.push(refDst);
1222
1276
  refDst.name = refSrc.name;
1223
- refDst.value = this.normaliseExpression(refSrc.value);
1277
+ refDst.value = this.normaliseExpression(refSrc.value, depth + 1);
1224
1278
  }
1225
1279
 
1226
1280
  // Process refinement groups
@@ -1232,7 +1286,7 @@ class SnomedExpressionServices {
1232
1286
  const refDst = new SnomedRefinement();
1233
1287
  grpDst.refinements.push(refDst);
1234
1288
  refDst.name = refSrc.name;
1235
- refDst.value = this.normaliseExpression(refSrc.value);
1289
+ refDst.value = this.normaliseExpression(refSrc.value, depth + 1);
1236
1290
  }
1237
1291
  }
1238
1292
 
@@ -1241,6 +1295,56 @@ class SnomedExpressionServices {
1241
1295
  return work2.canonical();
1242
1296
  }
1243
1297
 
1298
+ /**
1299
+ * Add a concept's own defining (non is-a) relationships to an expression, as grouped
1300
+ * and ungrouped refinements with their values normalised.
1301
+ *
1302
+ * Used for primitives, whose attributes are entailed but whose concept must stay in
1303
+ * the focus - see normaliseExpression. The stored normal form cannot be used for this:
1304
+ * it is built by createDefinedExpression, which stops at a primitive too, so the
1305
+ * cached normal form of a primitive is just the concept itself. Reading the
1306
+ * relationships here means existing caches keep working rather than needing a rebuild.
1307
+ *
1308
+ * @param {number} reference - the concept whose attributes to add
1309
+ * @param {SnomedExpression} work - expression to add them to
1310
+ * @param {number} depth - current normalisation depth
1311
+ */
1312
+ addDefiningAttributes(reference, work, depth) {
1313
+ const groups = new Map();
1314
+
1315
+ for (const relIndex of this.getDefiningRelationships(reference)) {
1316
+ const rel = this.relationships.getRelationship(relIndex);
1317
+
1318
+ const ref = new SnomedRefinement();
1319
+ ref.name = new SnomedConcept(rel.relType);
1320
+ ref.name.code = this.getConceptId(rel.relType);
1321
+
1322
+ const value = new SnomedExpression();
1323
+ const target = new SnomedConcept(rel.target);
1324
+ target.code = this.getConceptId(rel.target);
1325
+ value.concepts.push(target);
1326
+ ref.value = this.normaliseExpression(value, depth + 1);
1327
+
1328
+ if (rel.group === 0) {
1329
+ if (!work.hasRefinement(ref)) {
1330
+ work.refinements.push(ref);
1331
+ }
1332
+ } else {
1333
+ const key = rel.group.toString();
1334
+ if (!groups.has(key)) {
1335
+ groups.set(key, new SnomedRefinementGroup());
1336
+ }
1337
+ groups.get(key).refinements.push(ref);
1338
+ }
1339
+ }
1340
+
1341
+ for (const grp of groups.values()) {
1342
+ if (!work.hasRefinementGroup(grp)) {
1343
+ work.refinementGroups.push(grp);
1344
+ }
1345
+ }
1346
+ }
1347
+
1244
1348
  /**
1245
1349
  * Check if expression a subsumes expression b
1246
1350
  */
@@ -1266,10 +1370,20 @@ class SnomedExpressionServices {
1266
1370
  }
1267
1371
  }
1268
1372
 
1269
- // Check refinement groups
1373
+ // Check refinement groups. Every group in e1 has to be satisfied by SOME group in e2 - try
1374
+ // them all rather than only the first one whose attribute names line up. Two normalisations
1375
+ // of the same expression can put equivalent groups in a different order, and when several
1376
+ // groups share the same attribute names (e.g. two morphology/site groups on one disorder)
1377
+ // picking the first match and giving up made subsumption depend on that order.
1270
1378
  for (const r of e1.refinementGroups) {
1271
- const rt = this.findMatchingGroup(r, e2);
1272
- if (!rt || !this.subsumesGroup(r, rt)) {
1379
+ let ok = false;
1380
+ for (const rt of e2.refinementGroups) {
1381
+ if (this.subsumesGroup(r, rt)) {
1382
+ ok = true;
1383
+ break;
1384
+ }
1385
+ }
1386
+ if (!ok) {
1273
1387
  return false;
1274
1388
  }
1275
1389
  }
@@ -1454,6 +1568,28 @@ class SnomedExpressionServices {
1454
1568
  return null;
1455
1569
  }
1456
1570
 
1571
+ /**
1572
+ * Resolve every concept id in an expression to its internal reference, without applying any
1573
+ * of the concept model rules. Used for expressions the server generated itself (normal forms),
1574
+ * where the ids are known good and the MRCM postcoordination rules do not apply
1575
+ * @param {SnomedExpression} expression
1576
+ */
1577
+ resolveExpressionReferences(expression) {
1578
+ for (const concept of expression.concepts) {
1579
+ this.checkConcept(concept);
1580
+ }
1581
+ for (const refinement of expression.refinements) {
1582
+ this.checkConcept(refinement.name);
1583
+ this.resolveExpressionReferences(refinement.value);
1584
+ }
1585
+ for (const group of expression.refinementGroups) {
1586
+ for (const refinement of group.refinements) {
1587
+ this.checkConcept(refinement.name);
1588
+ this.resolveExpressionReferences(refinement.value);
1589
+ }
1590
+ }
1591
+ }
1592
+
1457
1593
  /**
1458
1594
  * Validate expression structure and concept references
1459
1595
  */
@@ -1467,6 +1603,7 @@ class SnomedExpressionServices {
1467
1603
  this.checkRefinement(refinement);
1468
1604
  this.checkRefinementDomain(expression, refinement);
1469
1605
  this.checkRefinementRange(refinement);
1606
+ this.checkRefinementGrouped(refinement);
1470
1607
  }
1471
1608
  }
1472
1609
 
@@ -1493,7 +1630,8 @@ class SnomedExpressionServices {
1493
1630
  * applied to a structure that cannot be lateralized, so it is enforced.
1494
1631
  *
1495
1632
  * Attributes with no rule in this edition's MRCM are not checked, and neither
1496
- * are cardinality, grouping, or the range constraint (which needs ECL).
1633
+ * is cardinality (checkExpressionCardinality), grouping (checkRefinementGrouped)
1634
+ * or the range constraint (checkRefinementRange, which needs ECL).
1497
1635
  *
1498
1636
  * @param {SnomedExpression} expression - the expression the refinement is on
1499
1637
  * @param {SnomedRefinement} refinement
@@ -1543,12 +1681,51 @@ class SnomedExpressionServices {
1543
1681
  }
1544
1682
  }
1545
1683
 
1684
+ /**
1685
+ * Check that an attribute the MRCM marks as grouped is actually in a relationship
1686
+ * group. This is only ever called for refinements that are NOT in a group.
1687
+ *
1688
+ * The MRCM attribute domain reference set carries a `grouped` flag per rule, and it
1689
+ * is a real constraint rather than a stylistic preference: `363698007 |Finding site|`
1690
+ * is grouped, so `40468003:363698007=10200004` is not a valid expression even though
1691
+ * every part of it is - it has to be written `40468003:{363698007=10200004}`. The
1692
+ * distinction matters because a group is what ties an attribute to the others that
1693
+ * apply with it, and an expression that omits the braces has not said which.
1694
+ *
1695
+ * SNOMED's postcoordination guide calls the ungrouped form Close To User Form and
1696
+ * defines a transformation ("Adding a Self-grouped Attribute") that converts it to
1697
+ * the Classifiable Form. That transformation is not implemented here, so the
1698
+ * ungrouped form is rejected rather than silently repaired: telling the caller their
1699
+ * expression is not classifiable is honest, quietly guessing what they meant is not.
1700
+ *
1701
+ * As elsewhere in this check, an attribute with no rule in this edition's MRCM is
1702
+ * left alone, and where several rules apply the strictest wins.
1703
+ *
1704
+ * @param {SnomedRefinement} refinement - a refinement outside any group
1705
+ */
1706
+ checkRefinementGrouped(refinement) {
1707
+ const attribute = refinement.name ? refinement.name.reference : NO_REFERENCE;
1708
+ if (attribute === undefined || attribute === null || attribute === NO_REFERENCE) {
1709
+ return;
1710
+ }
1711
+ const rules = this.mrcmAttributeDomains().get(attribute);
1712
+ if (!rules || rules.length === 0) {
1713
+ return;
1714
+ }
1715
+ if (!rules.some(rule => rule.grouped)) {
1716
+ return;
1717
+ }
1718
+ const attrDesc = this.describeConceptForMessage(attribute);
1719
+ throw new Error(`The SNOMED CT concept model requires the attribute ${attrDesc} to be in a`
1720
+ + ` relationship group, so it cannot be used ungrouped (write it as {${attrDesc} = ...})`);
1721
+ }
1722
+
1546
1723
  /**
1547
1724
  * The MRCM attribute domain reference set, indexed by attribute concept.
1548
1725
  * Built once per edition and cached; an edition with no MRCM yields an empty
1549
1726
  * map, which turns the domain check off rather than failing.
1550
1727
  *
1551
- * @returns {Map<number, Array<{domain: number, isRefset: boolean}>>}
1728
+ * @returns {Map<number, Array<{domain: number, isRefset: boolean, grouped: boolean, max: number, maxInGroup: number}>>}
1552
1729
  */
1553
1730
  mrcmAttributeDomains() {
1554
1731
  if (this._mrcmAttributeDomains) {
@@ -1602,6 +1779,9 @@ class SnomedExpressionServices {
1602
1779
  list.push({
1603
1780
  domain: domain,
1604
1781
  isRefset: refSetConcepts.has(domain),
1782
+ // The grouped column is a plain 0/1 (type 4), not a reference into the string
1783
+ // table like the cardinalities beside it - reading it as one yields garbage.
1784
+ grouped: values[2] === 1,
1605
1785
  // Only the maximums are enforced. A minimum ("1..1") says a concept in
1606
1786
  // this domain must have the attribute in its own definition; it does not
1607
1787
  // say a refinement of that concept has to restate it.
@@ -2160,9 +2340,7 @@ class SnomedExpressionServices {
2160
2340
  // In SNOMED CT, primitive concepts are not fully defined by their relationships
2161
2341
  try {
2162
2342
  const concept = this.concepts.getConcept(reference);
2163
- // Bit 0 typically indicates if concept is primitive (1) or defined (0)
2164
- // This may vary based on the specific implementation
2165
- return (concept.flags & 1) !== 0;
2343
+ return (concept.flags & SnomedConceptList.MASK_CONCEPT_PRIMITIVE) !== 0;
2166
2344
  } catch (error) {
2167
2345
  // If we can't read the concept, assume it's primitive for safety
2168
2346
  this.log.warn(`Warning: Could not check primitive status for concept ${reference}: ${error.message}`);
@@ -2487,10 +2665,9 @@ class SnomedExpressionServicesExtended extends SnomedExpressionServices {
2487
2665
  isPrimitive(reference) {
2488
2666
  try {
2489
2667
  const concept = this.concepts.getConcept(reference);
2490
- // In SNOMED CT, primitive concepts have the primitive flag set
2491
- // The definitionStatusId in RF2 determines this:
2492
- // 900000000000074008 = primitive, 900000000000073002 = fully defined
2493
- return (concept.flags & 1) !== 0;
2668
+ // definitionStatusId 900000000000074008 (primitive) is recorded at import time as
2669
+ // MASK_CONCEPT_PRIMITIVE in the concept flags byte
2670
+ return (concept.flags & SnomedConceptList.MASK_CONCEPT_PRIMITIVE) !== 0;
2494
2671
  } catch (error) {
2495
2672
  // If we can't read the concept, assume it's primitive for safety
2496
2673
  if (this.building) {
@@ -26,7 +26,7 @@ sources:
26
26
  - snomed:sct_us_20230301.cache
27
27
  - snomed:sct_us_20240301.cache
28
28
  - snomed:sct_us_20250901.cache
29
- - snomed:sct_test_20250909.cache
29
+ - snomed:sct_test_20250909b.cache
30
30
  - cpt:CodeSystem-cpt.db|cpt-2023-fragment-0.1.db
31
31
  - omop:omop_v20250227.db
32
32
  - npm:hl7.terminology
@@ -37,6 +37,7 @@ sources:
37
37
  - npm:us.cdc.phinvads
38
38
  - npm:hl7.fhir.uv.sdc
39
39
  - internal:vsac
40
+ - icd11:icd11-2026-01.db
40
41
 
41
42
  ignored:
42
43
  - urn:iso:std:iso:3166#20210120
package/tx/tx.js CHANGED
@@ -51,6 +51,7 @@ const ClosureWorker = require("./workers/closure");
51
51
  const {BundleXML} = require("./xml/bundle-xml");
52
52
  const ConceptUsageTracker = require("./usage-tracker");
53
53
  const ProblemFinder = require("./problems");
54
+ const { buildOperationOutcome } = require('./library/operation-outcome');
54
55
  // const {writeFileSync} = require("fs");
55
56
 
56
57
  class TXModule {
@@ -437,14 +438,7 @@ class TXModule {
437
438
  }
438
439
  } catch (e) {
439
440
  this.log.error(`JSON parse error: ${e.message}`);
440
- return res.status(400).json({
441
- resourceType: 'OperationOutcome',
442
- issue: [{
443
- severity: 'error',
444
- code: 'invalid',
445
- diagnostics: `Invalid JSON: ${e.message}`
446
- }]
447
- });
441
+ return res.status(400).json(buildOperationOutcome('error', 'invalid', `Invalid JSON: ${e.message}`));
448
442
  }
449
443
  }
450
444
 
@@ -465,25 +459,11 @@ class TXModule {
465
459
  req.body = this.convertXmlToResource(xmlStr);
466
460
  } catch (e) {
467
461
  this.log.error(`XML parse error: ${e.message}`);
468
- return res.status(400).json({
469
- resourceType: 'OperationOutcome',
470
- issue: [{
471
- severity: 'error',
472
- code: 'invalid',
473
- diagnostics: `Invalid XML: ${e.message}`
474
- }]
475
- });
462
+ return res.status(400).json(buildOperationOutcome('error', 'invalid', `Invalid XML: ${e.message}`));
476
463
  }
477
464
  }
478
465
  } else if (contentType != 'application/x-www-form-urlencoded') {
479
- return res.status(415).json({
480
- resourceType: 'OperationOutcome',
481
- issue: [{
482
- severity: 'error',
483
- code: 'invalid',
484
- diagnostics: `Unsupported Media Type: ${contentType}`
485
- }]
486
- });
466
+ return res.status(415).json(buildOperationOutcome('error', 'invalid', `Unsupported Media Type: ${contentType}`));
487
467
  }
488
468
 
489
469
  if (req.body) {
@@ -1010,14 +990,7 @@ class TXModule {
1010
990
  router.get('/', async (req, res) => {
1011
991
  const start = Date.now();
1012
992
  try {
1013
- await res.json({
1014
- resourceType: 'OperationOutcome',
1015
- issue: [{
1016
- severity: 'information',
1017
- code: 'informational',
1018
- diagnostics: `FHIR Terminology Server - FHIR v${req.txEndpoint.fhirVersion}`
1019
- }]
1020
- });
993
+ await res.json(buildOperationOutcome('information', 'informational', `FHIR Terminology Server - FHIR v${req.txEndpoint.fhirVersion}`));
1021
994
  } finally {
1022
995
  this.countRequest(endpointPath, 'home', Date.now() - start);
1023
996
  }
@@ -1125,17 +1098,17 @@ class TXModule {
1125
1098
  }
1126
1099
 
1127
1100
  /**
1128
- * Build an OperationOutcome for errors
1101
+ * Build an OperationOutcome. Delegates to the shared builder so that every outcome this
1102
+ * server emits has details.text and a tx-issue-type coding: diagnostics is stripped by
1103
+ * the test harness, so nothing a caller needs may live there.
1104
+ * @param {string} severity - error, warning, information
1105
+ * @param {string} code - FHIR issue type
1106
+ * @param {string} message - the human readable account of the problem
1107
+ * @param {string} [txIssueType] - tx-issue-type; defaulted from code when not given
1108
+ * @returns {Object} OperationOutcome resource
1129
1109
  */
1130
- operationOutcome(severity, code, message) {
1131
- return {
1132
- resourceType: 'OperationOutcome',
1133
- issue: [{
1134
- severity,
1135
- code,
1136
- diagnostics: message
1137
- }]
1138
- };
1110
+ operationOutcome(severity, code, message, txIssueType = null) {
1111
+ return buildOperationOutcome(severity, code, message, txIssueType);
1139
1112
  }
1140
1113
 
1141
1114
  /**
@@ -13,7 +13,7 @@
13
13
  //
14
14
 
15
15
  const { TerminologyWorker } = require('./worker');
16
- const {OperationOutcome, Issue} = require("../library/operation-outcome");
16
+ const { OperationOutcome, Issue, buildOperationOutcome, outcomeFromError } = require('../library/operation-outcome');
17
17
  const {Parameters} = require("../library/parameters");
18
18
  const {ValidateWorker} = require("./validate");
19
19
  const {debugLog} = require("../operation-context");
@@ -137,7 +137,7 @@ class BatchValidateWorker extends TerminologyWorker {
137
137
  op.addIssue(error);
138
138
  output.push({name: "validation", resource : op.jsonObj});
139
139
  } else {
140
- output.push({name: "validation", resource : this.operationOutcome('error', error.issueCode || 'exception', error.message) } );
140
+ output.push({name: "validation", resource : outcomeFromError(error) } );
141
141
  }
142
142
  }
143
143
  }
@@ -156,8 +156,7 @@ class BatchValidateWorker extends TerminologyWorker {
156
156
  oo.addIssue(error);
157
157
  return res.status(error.statusCode || 500).json(oo.jsonObj);
158
158
  }
159
- return res.status(error.statusCode || 500).json(this.operationOutcome(
160
- 'error', error.issueCode || 'exception', error.message));
159
+ return res.status(error.statusCode || 500).json(outcomeFromError(error));
161
160
  }
162
161
  }
163
162
 
@@ -218,18 +217,9 @@ class BatchValidateWorker extends TerminologyWorker {
218
217
  /**
219
218
  * Build an OperationOutcome
220
219
  */
221
- operationOutcome(severity, code, message) {
222
- return {
223
- resourceType: 'OperationOutcome',
224
- issue: [{
225
- severity,
226
- code,
227
- details: {
228
- text: message
229
- },
230
- diagnostics: message
231
- }]
232
- };
220
+ operationOutcome(severity, code, message, txIssueType = null) {
221
+ // the shared builder, so that every outcome has details.text and a tx-issue-type coding
222
+ return buildOperationOutcome(severity, code, message, txIssueType);
233
223
  }
234
224
 
235
225
  hasValueSet(parameter) {
@@ -5,7 +5,7 @@
5
5
  //
6
6
 
7
7
  const { TerminologyWorker } = require('./worker');
8
- const { Issue, OperationOutcome } = require('../library/operation-outcome');
8
+ const { Issue, OperationOutcome, buildOperationOutcome, outcomeFromError } = require('../library/operation-outcome');
9
9
  const {debugLog} = require("../operation-context");
10
10
 
11
11
  class BatchWorker extends TerminologyWorker {
@@ -47,8 +47,7 @@ class BatchWorker extends TerminologyWorker {
47
47
  oo.addIssue(error);
48
48
  return res.status(error.statusCode || 500).json(oo.jsonObj);
49
49
  } else {
50
- return res.status(error.statusCode || 500).json(this.operationOutcome(
51
- 'error', error.issueCode || 'exception', error.message));
50
+ return res.status(error.statusCode || 500).json(outcomeFromError(error));
52
51
  }
53
52
  }
54
53
  }
@@ -163,9 +162,8 @@ class BatchWorker extends TerminologyWorker {
163
162
  this.log.error(error);
164
163
  debugLog(error);
165
164
  const statusCode = error.statusCode || 500;
166
- const issueCode = error.issueCode || 'exception';
167
-
168
- return this.errorEntry(statusCode, issueCode, error.message);
165
+ // on a plain Error, issueCode is the FHIR issue type and txIssueType the tx-issue-type
166
+ return this.errorEntry(statusCode, error.issueCode || 'exception', error.message, error.txIssueType);
169
167
  }
170
168
  }
171
169
 
@@ -319,19 +317,13 @@ class BatchWorker extends TerminologyWorker {
319
317
  /**
320
318
  * Build an error response entry
321
319
  * @param {number} statusCode - HTTP status code
322
- * @param {string} issueCode - FHIR issue code
320
+ * @param {string} issueCode - FHIR issue type
323
321
  * @param {string} message - Error message
322
+ * @param {string} [txIssueType] - tx-issue-type; defaulted from issueCode when not given
324
323
  * @returns {Object} Error entry
325
324
  */
326
- errorEntry(statusCode, issueCode, message) {
327
- const outcome = {
328
- resourceType: 'OperationOutcome',
329
- issue: [{
330
- severity: 'error',
331
- code: issueCode,
332
- diagnostics: message
333
- }]
334
- };
325
+ errorEntry(statusCode, issueCode, message, txIssueType = null) {
326
+ const outcome = buildOperationOutcome('error', issueCode, message, txIssueType);
335
327
 
336
328
  return {
337
329
  resource: outcome,
@@ -343,21 +335,17 @@ class BatchWorker extends TerminologyWorker {
343
335
  }
344
336
 
345
337
  /**
346
- * Build an OperationOutcome
338
+ * Build an OperationOutcome. Delegates to the shared builder so that every outcome this
339
+ * server emits has details.text and a tx-issue-type coding: diagnostics is stripped by
340
+ * the test harness, so nothing a caller needs may live there.
347
341
  * @param {string} severity - error, warning, information
348
- * @param {string} code - Issue code
349
- * @param {string} message - Diagnostic message
342
+ * @param {string} code - FHIR issue type
343
+ * @param {string} message - the human readable account of the problem
344
+ * @param {string} [txIssueType] - tx-issue-type; defaulted from code when not given
350
345
  * @returns {Object} OperationOutcome resource
351
346
  */
352
- operationOutcome(severity, code, message) {
353
- return {
354
- resourceType: 'OperationOutcome',
355
- issue: [{
356
- severity,
357
- code,
358
- diagnostics: message
359
- }]
360
- };
347
+ operationOutcome(severity, code, message, txIssueType = null) {
348
+ return buildOperationOutcome(severity, code, message, txIssueType);
361
349
  }
362
350
  }
363
351
 
@@ -16,7 +16,7 @@
16
16
  const crypto = require('crypto');
17
17
  const { TerminologyWorker, CACHE_ID_HEADER } = require('./worker');
18
18
  const { Parameters } = require('../library/parameters');
19
- const { Issue } = require('../library/operation-outcome');
19
+ const { Issue, buildOperationOutcome, outcomeFromError } = require('../library/operation-outcome');
20
20
  const { debugLog } = require('../operation-context');
21
21
 
22
22
  class CacheControlWorker extends TerminologyWorker {
@@ -71,8 +71,8 @@ class CacheControlWorker extends TerminologyWorker {
71
71
  debugLog(error);
72
72
  req.logInfo = this.usedSources.join('|') + ' - error' + (error.msgId ? ' ' + error.msgId : '');
73
73
  const statusCode = error.statusCode || 500;
74
- const issueCode = error.issueCode || 'exception';
75
- return res.status(statusCode).json(this.operationOutcome('error', issueCode, error.message));
74
+ return res.status(statusCode).json(
75
+ outcomeFromError(error));
76
76
  }
77
77
  }
78
78
 
@@ -276,15 +276,9 @@ class CacheControlWorker extends TerminologyWorker {
276
276
  * @param {string} message - Diagnostic message
277
277
  * @returns {Object} OperationOutcome resource
278
278
  */
279
- operationOutcome(severity, code, message) {
280
- return {
281
- resourceType: 'OperationOutcome',
282
- issue: [{
283
- severity,
284
- code,
285
- details: { text: message }
286
- }]
287
- };
279
+ operationOutcome(severity, code, message, txIssueType = null) {
280
+ // the shared builder, so that every outcome has details.text and a tx-issue-type coding
281
+ return buildOperationOutcome(severity, code, message, txIssueType);
288
282
  }
289
283
  }
290
284
 
@@ -5,6 +5,8 @@
5
5
  // POST /ConceptMap/$closure
6
6
  //
7
7
 
8
+ const { buildOperationOutcome } = require('../library/operation-outcome');
9
+
8
10
  class ClosureWorker {
9
11
  /**
10
12
  * Handle a $closure request
@@ -18,14 +20,7 @@ class ClosureWorker {
18
20
  this.log.debug('ConceptMap $closure with params:', params);
19
21
 
20
22
  // TODO: Implement closure logic using provider
21
- res.status(501).json({
22
- resourceType: 'OperationOutcome',
23
- issue: [{
24
- severity: 'error',
25
- code: 'not-supported',
26
- diagnostics: 'ConceptMap $closure operation not yet implemented'
27
- }]
28
- });
23
+ res.status(501).json(buildOperationOutcome('error', 'not-supported', 'ConceptMap $closure operation not yet implemented'));
29
24
  }
30
25
  }
31
26