fhirsmith 0.13.3 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +48 -4
  3. package/config-template.json +70 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/nginx.md +391 -0
  7. package/package.json +3 -2
  8. package/publisher/git-remote.js +141 -0
  9. package/publisher/github-release.js +195 -0
  10. package/publisher/publisher.js +146 -14
  11. package/server.js +51 -14
  12. package/stats.js +19 -13
  13. package/testing/readme.md +112 -0
  14. package/testing/render.js +482 -0
  15. package/testing/search.js +378 -0
  16. package/testing/store.js +266 -0
  17. package/testing/testing-template.html +126 -0
  18. package/testing/testing.js +486 -0
  19. package/translations/Messages.properties +32 -0
  20. package/tx/closure/closure-store.js +396 -0
  21. package/tx/cs/cs-api.js +6 -1
  22. package/tx/cs/cs-rxnorm.js +35 -8
  23. package/tx/cs/cs-snomed.js +142 -27
  24. package/tx/html/codesystem-operations.liquid +177 -0
  25. package/tx/html/ecl-panel.liquid +93 -0
  26. package/tx/html/home-metrics.liquid +39 -76
  27. package/tx/html/tx-template.html +1 -0
  28. package/tx/library/conceptmap.js +22 -6
  29. package/tx/library/designations.js +12 -3
  30. package/tx/library/extensions.js +2 -2
  31. package/tx/library/operation-outcome.js +3 -1
  32. package/tx/library/renderer.js +42 -5
  33. package/tx/operation-context.js +111 -15
  34. package/tx/provider.js +85 -0
  35. package/tx/sct/expressions.js +128 -44
  36. package/tx/sct/text-index.js +419 -0
  37. package/tx/tx-html.js +426 -44
  38. package/tx/tx.js +117 -9
  39. package/tx/workers/cache-control.js +2 -2
  40. package/tx/workers/closure.js +483 -13
  41. package/tx/workers/expand.js +20 -0
  42. package/tx/workers/metadata.js +29 -4
  43. package/tx/workers/read.js +18 -3
  44. package/tx/workers/subsumes.js +18 -2
  45. package/tx/workers/translate.js +167 -20
  46. package/tx/workers/validate.js +61 -182
  47. package/tx/workers/worker.js +9 -0
  48. package/tx/xversion/xv-conceptmap.js +17 -8
  49. package/utilities/submit-test-report.js +150 -0
package/tx/tx-html.js CHANGED
@@ -20,6 +20,7 @@ const {ParametersXML} = require("./xml/parameters-xml");
20
20
  const {OperationOutcomeXML} = require("./xml/operationoutcome-xml");
21
21
  const {debugLog} = require("./operation-context");
22
22
  const {InvalidError} = require("./library/errors");
23
+ const {VALID_FILTER_OPS} = require("./library/renderer");
23
24
 
24
25
  const txHtmlLog = Logger.getInstance().child({ module: 'tx-html' });
25
26
 
@@ -50,6 +51,51 @@ const SORT_OPTIONS = ['', 'id', 'url', 'version', 'date', 'name', 'vurl'];
50
51
 
51
52
  const ELEMENT_OPTIONS = ['id', 'url', 'version', 'name', 'title', 'status', 'date', 'publisher', 'description'];
52
53
 
54
+ // Marks a value set that exists only for the request that carried it - the ones the
55
+ // operations tab on a CodeSystem builds and posts to $expand. Nobody else ever sees it,
56
+ // so rendering its uuid url and its status tells the user nothing; what they want is the
57
+ // expansion, and the filters it came from. This is FHIRsmith's own extension, in
58
+ // FHIRsmith's own namespace: it says something about how this server was asked to do
59
+ // something, not about terminology, so it does not belong in the FHIR tools namespace.
60
+ const SNOMED_URI = 'http://snomed.info/sct';
61
+
62
+ const TRANSIENT_VALUESET = 'http://healthintersections.com.au/fhirsmith/StructureDefinition/valueset-transient';
63
+
64
+ const SEVERITY_ALERT_CLASS = {
65
+ fatal: 'alert-danger',
66
+ error: 'alert-danger',
67
+ warning: 'alert-warning',
68
+ information: 'alert-info',
69
+ success: 'alert-info'
70
+ };
71
+
72
+ const SEVERITY_LABEL = {
73
+ fatal: 'Fatal',
74
+ error: 'Error',
75
+ warning: 'Warning',
76
+ information: 'Information',
77
+ success: 'Success'
78
+ };
79
+
80
+ // ValueSet.compose.include.filter.op, in R5 spec order - the operations offered
81
+ // by the $filter form on the CodeSystem operations tab
82
+ const FILTER_OPS = [...VALID_FILTER_OPS];
83
+
84
+ // Filter/property names defined by the base specification, valid for any code system
85
+ const BASE_FILTER_PROPERTIES = ['code', 'designation', 'concept', 'status', 'inactive', 'regex'];
86
+
87
+ // Filter/property names defined by particular code systems. Any property or filter
88
+ // declared in the CodeSystem resource itself is added to these at render time
89
+ const SYSTEM_FILTER_PROPERTIES = {
90
+ 'http://www.ama-assn.org/go/cpt': ['modifier', 'modified', 'kind', 'orthopox', 'telemedicine', 'code'],
91
+ 'http://loinc.org': ['STATUS', 'COMPONENT', 'PROPERTY', 'TIME_ASPCT', 'SYSTEM', 'SCALE_TYP', 'METHOD_TYP',
92
+ 'CLASS', 'CONSUMER_NAME', 'CLASSTYPE', 'ORDER_OBS', 'DOCUMENT_SECTION', 'copyright'],
93
+ 'http://www.nlm.nih.gov/research/umls/rxnorm': ['STY', 'SAB', 'TTY'],
94
+ 'http://snomed.info/sct': ['constraint', 'expressions', 'effectiveTime', 'inactive', 'moduleId',
95
+ 'normalForm', 'normalFormTerse', 'semanticTag', 'sufficientlyDefined'],
96
+ 'http://unitsofmeasure.org': ['property', 'canonical']
97
+ };
98
+
53
99
  /**
54
100
  * Load the TX HTML template
55
101
  */
@@ -86,6 +132,14 @@ function acceptsHtml(req) {
86
132
  return _fmt.includes('text/html');
87
133
  }
88
134
 
135
+ /** Whether this resource is one of the throwaway value sets described at TRANSIENT_VALUESET. */
136
+ function isTransientValueSet(json) {
137
+ if (!json || json.resourceType !== 'ValueSet') {
138
+ return false;
139
+ }
140
+ return (json.extension || []).some((e) => e.url === TRANSIENT_VALUESET && e.valueBoolean !== false);
141
+ }
142
+
89
143
  // Whether this server publishes its library source YAML. Off unless the operator turns
90
144
  // modules.tx.publishLibrarySource on, and it gates both the route and the nav item, so a
91
145
  // server that does not publish it has no link to a 404.
@@ -253,6 +307,10 @@ class TxHtmlRenderer {
253
307
  return `${severity.charAt(0).toUpperCase() + severity.slice(1)}`;
254
308
  }
255
309
 
310
+ if (isTransientValueSet(json)) {
311
+ return 'Expansion';
312
+ }
313
+
256
314
  if (json.id) {
257
315
  return `${pfx} ${json.id}`;
258
316
  }
@@ -403,7 +461,7 @@ class TxHtmlRenderer {
403
461
 
404
462
  for (const factory of uniqueFactories) {
405
463
  html += '<tr>';
406
- html += `<td>${escape(factory.name())}</td>`;
464
+ html += `<td>${this.factoryLink(factory)}</td>`;
407
465
  html += `<td>${escape(factory.system())}</td>`;
408
466
  html += `<td>${escape(factory.version() || '-')}</td>`;
409
467
  html += `<td>${factory.useCount ? factory.useCount() : '-'}</td>`;
@@ -416,6 +474,80 @@ class TxHtmlRenderer {
416
474
  return html;
417
475
  }
418
476
 
477
+ /**
478
+ * The name of a special code system, linked to its page where there is one.
479
+ *
480
+ * These are the code systems the server implements natively rather than loading as a
481
+ * resource, so they have no id of their own in the resource space. They are still
482
+ * readable: read.js serves them under the factory's id with an "x-" prefix, which is
483
+ * also the id search.js puts on the placeholder it synthesises. A factory whose id() is
484
+ * null - SNOMED, when its version is not one of the recognised edition URIs - has no
485
+ * page to link to, so the name is left as text rather than pointing at a 404.
486
+ *
487
+ * @param {Object} factory a CodeSystemFactoryProvider
488
+ * @returns {string} the cell content
489
+ */
490
+ factoryLink(factory) {
491
+ const name = escape(factory.name());
492
+ let id;
493
+ try {
494
+ id = factory.id();
495
+ } catch {
496
+ id = null;
497
+ }
498
+ if (!id) {
499
+ return name;
500
+ }
501
+ return `<a href="${escape(this.path)}/CodeSystem/x-${encodeURIComponent(id)}">${name}</a>`;
502
+ }
503
+
504
+ /**
505
+ * The SNOMED CT editions this endpoint has loaded: the default one first, because it is
506
+ * what the rest of the server uses when no version is given, then the others by name.
507
+ * The factory map is keyed several ways for the same factory - by system, by
508
+ * system|version and by system|major.minor - so it has to be deduplicated.
509
+ *
510
+ * @param {Object} provider the endpoint's provider
511
+ * @returns {{value: string, label: string, isDefault: boolean}[]}
512
+ */
513
+ snomedVersions(provider) {
514
+ const byVersion = new Map();
515
+ const dflt = provider.codeSystemFactories.get(SNOMED_URI);
516
+ for (const factory of provider.codeSystemFactories.values()) {
517
+ if (factory.system() !== SNOMED_URI) {
518
+ continue;
519
+ }
520
+ const version = factory.version();
521
+ if (!version || byVersion.has(version)) {
522
+ continue;
523
+ }
524
+ byVersion.set(version, {
525
+ value: version,
526
+ label: factory.describeVersion(version),
527
+ isDefault: factory === dflt
528
+ });
529
+ }
530
+ const versions = [...byVersion.values()];
531
+ versions.sort((a, b) => (a.isDefault === b.isDefault ? a.label.localeCompare(b.label) : (a.isDefault ? -1 : 1)));
532
+ return versions;
533
+ }
534
+
535
+ /**
536
+ * The ECL panel at <endpoint>/ecl.
537
+ */
538
+ async buildEclPage(req) {
539
+ const versions = this.snomedVersions(req.txProvider);
540
+ return await this.liquid.renderFile('ecl-panel', {
541
+ eclId: this.generateResourceId(),
542
+ system: escape(SNOMED_URI),
543
+ transientExtension: escape(TRANSIENT_VALUESET),
544
+ expandUrl: escape(this.path + '/ValueSet/$expand?_format=html/fragment&includeDefinition=true'),
545
+ versionOptions: versions.map((v) =>
546
+ `<option value="${escape(v.value)}">${escape(v.label)}${v.isDefault ? ' (default)' : ''}</option>`).join(''),
547
+ hasVersions: versions.length > 0
548
+ });
549
+ }
550
+
419
551
  /**
420
552
  * Main render - determines what to render based on resource type
421
553
  */
@@ -478,55 +610,127 @@ class TxHtmlRenderer {
478
610
  * Render Parameters resource
479
611
  */
480
612
  async renderParameters(json) {
613
+ const codes = await this.parametersCodeContext(json);
481
614
  let html = '<table class="table grid">';
482
615
  html += '<thead><tr><th>Name</th><th>Value</th></tr></thead>';
483
616
  html += '<tbody>';
484
617
 
485
618
  if (json.parameter && Array.isArray(json.parameter)) {
486
619
  for (const param of json.parameter) {
487
- html += await this.renderParameter(param);
620
+ html += await this.renderParameter(param, codes);
488
621
  }
489
622
  }
490
623
 
491
624
  html += '</tbody></table>';
625
+ html += this.renderJsonSource(json);
492
626
 
493
- // Collapsible JSON source
627
+ return html;
628
+ }
629
+
630
+ /**
631
+ * The "Show JSON Source" disclosure: the resource as it went on the wire, for
632
+ * anything the HTML rendering summarises or leaves out.
633
+ */
634
+ renderJsonSource(json) {
494
635
  const resourceId = this.generateResourceId();
495
- html += '<div class="json-source">';
636
+ let html = '<div class="json-source">';
496
637
  html += `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="toggleJsonSource('${resourceId}')">`;
497
638
  html += 'Show JSON Source</button>';
498
639
  html += `<div id="${resourceId}" class="json-content" style="display: none; margin-top: 10px;">`;
499
640
  html += `<pre>${escape(JSON.stringify(json, null, 2))}</pre>`;
500
641
  html += '</div>';
501
642
  html += '</div>';
502
-
503
643
  return html;
504
644
  }
505
645
 
506
646
  /**
507
647
  * Render a single parameter row
508
648
  */
509
- async renderParameter(param) {
649
+ async renderParameter(param, codes) {
510
650
  let html = '<tr>';
511
651
  html += `<td>${escape(param.name || '')}</td>`;
512
652
  html += '<td>';
513
- html += await this.renderParameterValue(param);
653
+ html += await this.renderParameterValue(param, codes, null);
514
654
  html += '</td>';
515
655
  html += '</tr>';
516
656
  return html;
517
657
  }
518
658
 
659
+ /**
660
+ * What a Parameters resource says about the code system its codes belong to.
661
+ *
662
+ * $lookup answers about one concept, and names the code system it is in - the system
663
+ * and version parameters - so a property that came back as a code can be linked back
664
+ * into it. Building a checker here means the code system is built once for the page
665
+ * rather than once per property.
666
+ *
667
+ * @param {Object} json a Parameters resource
668
+ * @returns {Promise<{system: string, version: string|null, exists: function}|null>}
669
+ */
670
+ async parametersCodeContext(json) {
671
+ const value = (name) => (json.parameter || []).find((p) => p.name === name);
672
+ const system = value('system')?.valueUri || value('system')?.valueString;
673
+ if (!system || !this.renderer?.linkResolver?.codeChecker || !this.renderer.opContext) {
674
+ return null;
675
+ }
676
+ const version = value('version')?.valueString || null;
677
+ try {
678
+ const exists = await this.renderer.linkResolver.codeChecker(this.renderer.opContext, system, version);
679
+ return exists ? { system, version, exists } : null;
680
+ } catch (e) {
681
+ // A link is a courtesy; failing to work out whether to offer one is not a reason
682
+ // to fail the page.
683
+ debugLog(e);
684
+ return null;
685
+ }
686
+ }
687
+
688
+ /**
689
+ * A code in a $lookup response, linked to $lookup on it where that will work.
690
+ *
691
+ * Which values are links follows from the output alone. A Coding says which code system
692
+ * it is in. A code does not - but CodeSystem.property.type of 'code' is defined as a
693
+ * concept in the same code system, so a property's value part that came back as a code
694
+ * belongs to the system this response is about. Nothing else does: the code part of a
695
+ * property is the property's name, and a designation's language and status are codes
696
+ * from elsewhere entirely.
697
+ *
698
+ * Even then the code is checked before it is linked, because a provider can declare a
699
+ * property as a code and put something else in it.
700
+ */
701
+ async renderPropertyCode(code, codes) {
702
+ const plain = `<code>${escape(code)}</code>`;
703
+ if (!codes) {
704
+ return plain;
705
+ }
706
+ let display;
707
+ try {
708
+ display = await codes.exists(code);
709
+ } catch (e) {
710
+ debugLog(e);
711
+ return plain;
712
+ }
713
+ if (display === null || display === undefined) {
714
+ return plain;
715
+ }
716
+ const link = this.renderer.linkResolver.lookupLink(codes.system, codes.version, code);
717
+ if (!link) {
718
+ return plain;
719
+ }
720
+ return `<a href="${escape(link)}"${display ? ` title="${escape(display)}"` : ''}>${plain}</a>`;
721
+ }
722
+
519
723
  /**
520
724
  * Render the value portion of a parameter
521
725
  */
522
- async renderParameterValue(param) {
726
+ async renderParameterValue(param, codes, parentName) {
523
727
  // Check for parts (nested parameters)
524
728
  if (param.part && Array.isArray(param.part)) {
525
729
  let html = '<ul>';
526
730
  for (const part of param.part) {
527
731
  html += '<li>';
528
732
  html += `<strong>${escape(part.name || '')}:</strong> `;
529
- html += await this.renderParameterValue(part);
733
+ html += await this.renderParameterValue(part, codes, param.name);
530
734
  html += '</li>';
531
735
  }
532
736
  html += '</ul>';
@@ -540,10 +744,10 @@ class TxHtmlRenderer {
540
744
 
541
745
  // Check for complex datatypes
542
746
  if (param.valueCoding) {
543
- return this.renderCoding(param.valueCoding);
747
+ return await this.renderCoding(param.valueCoding, codes);
544
748
  }
545
749
  if (param.valueCodeableConcept) {
546
- return this.renderCodeableConcept(param.valueCodeableConcept);
750
+ return await this.renderCodeableConcept(param.valueCodeableConcept, codes);
547
751
  }
548
752
  if (param.valueQuantity) {
549
753
  return this.renderQuantity(param.valueQuantity);
@@ -581,6 +785,9 @@ class TxHtmlRenderer {
581
785
  return escape(param.valueCanonical);
582
786
  }
583
787
  if (param.valueCode !== undefined) {
788
+ if (param.name === 'value' && ['property', 'subproperty'].includes(parentName)) {
789
+ return await this.renderPropertyCode(param.valueCode, codes);
790
+ }
584
791
  return `<code>${escape(param.valueCode)}</code>`;
585
792
  }
586
793
  if (param.valueId !== undefined) {
@@ -629,7 +836,7 @@ class TxHtmlRenderer {
629
836
  /**
630
837
  * Render Coding datatype
631
838
  */
632
- async renderCoding(coding) {
839
+ async renderCoding(coding, codes) {
633
840
  if (!coding) return '';
634
841
 
635
842
  let parts = [];
@@ -637,7 +844,14 @@ class TxHtmlRenderer {
637
844
  parts.push(escape(coding.system));
638
845
  }
639
846
  if (coding.code) {
640
- parts.push(`<code>${escape(coding.code)}</code>`);
847
+ // A Coding says which code system it is in, so it can be linked - but only when
848
+ // that is the code system this page already built a checker for. Building one per
849
+ // coding would mean building a code system per row.
850
+ const sameSystem = codes && coding.system === codes.system
851
+ && (!coding.version || !codes.version || coding.version === codes.version);
852
+ parts.push(sameSystem
853
+ ? await this.renderPropertyCode(coding.code, codes)
854
+ : `<code>${escape(coding.code)}</code>`);
641
855
  }
642
856
  if (coding.display) {
643
857
  parts.push(`"${escape(coding.display)}"`);
@@ -652,7 +866,7 @@ class TxHtmlRenderer {
652
866
  /**
653
867
  * Render CodeableConcept datatype
654
868
  */
655
- async renderCodeableConcept(cc) {
869
+ async renderCodeableConcept(cc, codes) {
656
870
  if (!cc) return '';
657
871
 
658
872
  let html = '';
@@ -665,7 +879,7 @@ class TxHtmlRenderer {
665
879
  if (cc.text) html += '<br/>';
666
880
  html += '<ul style="margin: 0; padding-left: 20px;">';
667
881
  for (const coding of cc.coding) {
668
- html += `<li>${this.renderCoding(coding)}</li>`;
882
+ html += `<li>${await this.renderCoding(coding, codes)}</li>`;
669
883
  }
670
884
  html += '</ul>';
671
885
  }
@@ -787,7 +1001,7 @@ class TxHtmlRenderer {
787
1001
  html += this.tab(_fmt && _fmt == 'html/json', 'JSON', json.resourceType, 'html/json', json.id);
788
1002
  html += this.tab(_fmt && _fmt == 'html/xml', 'XML', json.resourceType, 'html/xml', json.id);
789
1003
  html += this.tab(_fmt && _fmt == 'html/narrative', 'Original Narrative', json.resourceType, 'html/narrative', json.id);
790
- html += this.tab(_fmt && _fmt == 'html/ops', 'LookUp / Subsumes', json.resourceType, 'html/ops', json.id);
1004
+ html += this.tab(_fmt && _fmt == 'html/ops', 'Operations', json.resourceType, 'html/ops', json.id);
791
1005
  html += `</ul>`;
792
1006
 
793
1007
  if (!_fmt || _fmt == 'html') {
@@ -803,7 +1017,13 @@ class TxHtmlRenderer {
803
1017
  opsId: this.generateResourceId(),
804
1018
  vcSystemId: this.generateResourceId(),
805
1019
  inferSystemId: this.generateResourceId(),
806
- url: escape(json.url || '')
1020
+ searchId: this.generateResourceId(),
1021
+ filterId: this.generateResourceId(),
1022
+ url: escape(json.url || ''),
1023
+ systemVersion: escape(json.version || ''),
1024
+ expandUrl: escape(this.path + '/ValueSet/$expand?_format=html'),
1025
+ filterOpOptions: FILTER_OPS.map((op) => `<option value="${escape(op)}">${escape(op)}</option>`).join(''),
1026
+ filterPropertyOptions: this.buildFilterPropertyNames(json).map((n) => `<option value="${escape(n)}"></option>`).join('')
807
1027
  });
808
1028
  }
809
1029
 
@@ -823,6 +1043,9 @@ class TxHtmlRenderer {
823
1043
  * Render ValueSet resource
824
1044
  */
825
1045
  async renderValueSet(json, inBundle, _fmt, op, exp) {
1046
+ if (isTransientValueSet(json) && json.expansion) {
1047
+ return await this.renderTransientExpansion(json, _fmt === 'html/fragment');
1048
+ }
826
1049
  if (inBundle || op) {
827
1050
  return await this.renderResourceWithNarrative(json, await this.renderer.renderValueSet(json));
828
1051
  } else {
@@ -862,6 +1085,113 @@ class TxHtmlRenderer {
862
1085
  }
863
1086
  }
864
1087
 
1088
+ /**
1089
+ * Render the expansion of a value set that only ever existed for this request.
1090
+ *
1091
+ * The usual value set summary is all metadata - defining url, status, expansion
1092
+ * identifier - and every one of those is a uuid this server minted a moment ago and
1093
+ * will never use again, so it says nothing to anyone. What is worth saying is what was
1094
+ * expanded and how, so that is what leads: the code system, the text searched for if
1095
+ * there was one, and, when the expansion came from a filter, the compose that produced
1096
+ * it - as JSON, because a filter is read by people who work in JSON.
1097
+ *
1098
+ * None of that when the rendering is a fragment, though. A fragment goes inside a page
1099
+ * that has just been used to ask the question - the ECL panel still has the expression
1100
+ * and the edition on screen - so restating them, and warning that a SNOMED expansion is
1101
+ * not closed, is telling the user what they are looking at. There the expansion alone
1102
+ * is the answer.
1103
+ *
1104
+ * @param {Object} json the expanded value set
1105
+ * @param {boolean} embedded whether this is going inside a page that already has the context
1106
+ */
1107
+ async renderTransientExpansion(json, embedded) {
1108
+ const include = json.compose?.include?.[0] || {};
1109
+ const filter = (json.expansion.parameter || []).find((p) => p.name === 'filter')?.valueString;
1110
+
1111
+ let html = '<div class="narrative">';
1112
+
1113
+ if (!embedded) {
1114
+ html += '<p>An expansion of a value set built for this request alone - this server does '
1115
+ + 'not hold it, and it has no URL of its own.</p>';
1116
+
1117
+ html += '<table class="grid">';
1118
+ if (include.system) {
1119
+ html += `<tr><td><b>Code System</b></td><td>${escape(include.system)}</td></tr>`;
1120
+ }
1121
+ if (include.version) {
1122
+ html += `<tr><td><b>Version</b></td><td>${escape(include.version)}</td></tr>`;
1123
+ }
1124
+ if (filter) {
1125
+ html += `<tr><td><b>Text Search</b></td><td>${escape(filter)}</td></tr>`;
1126
+ }
1127
+ html += '</table>';
1128
+
1129
+ if ((include.filter || []).length > 0) {
1130
+ html += '<h3>Filters</h3>';
1131
+ html += `<pre>${escape(JSON.stringify(json.compose, null, 2))}</pre>`;
1132
+ }
1133
+ }
1134
+
1135
+ // The expansion, without the two identifiers that are noise here: the value set's own
1136
+ // url and the expansion identifier. Everything else - timestamp, total, the
1137
+ // parameters the server echoed back - is real information about this request.
1138
+ // compose stays: renderVSExpansion never renders it, but it is where the version each
1139
+ // code system was expanded at can be read from, for the $lookup links on the codes.
1140
+ const shown = structuredClone(json);
1141
+ delete shown.url;
1142
+ delete shown.expansion.identifier;
1143
+
1144
+ if (embedded) {
1145
+ // The unclosed warning and the list of code systems the expansion drew on say the
1146
+ // same thing the page already said. A too-costly warning is not in that class - it
1147
+ // means the answer on screen is not the whole answer - so it stays.
1148
+ shown.expansion.extension = (shown.expansion.extension || [])
1149
+ .filter((e) => !e.url.startsWith('http://hl7.org/fhir/StructureDefinition/valueset-unclosed'));
1150
+ shown.expansion.parameter = (shown.expansion.parameter || [])
1151
+ .filter((p) => !['used-codesystem', 'used-valueset', 'used-supplement'].includes(p.name));
1152
+ }
1153
+
1154
+ if (embedded) {
1155
+ html += this.expansionCount(json.expansion);
1156
+ }
1157
+
1158
+ html += await this.renderer.renderVSExpansion(shown, !embedded);
1159
+ html += '</div>';
1160
+ html += this.renderJsonSource(json);
1161
+ return html;
1162
+ }
1163
+
1164
+ /**
1165
+ * How many concepts an expansion found, as one line.
1166
+ *
1167
+ * On a page of its own this is in the expansion properties table; in a fragment that
1168
+ * table is gone, and the count is the one thing there that the reader cannot work out by
1169
+ * looking - it is usually the answer they were after. total is the size of the whole
1170
+ * expansion, which is not what is on screen when a page was asked for, and some paths
1171
+ * report no total at all, so both are said when they differ.
1172
+ *
1173
+ * @param {Object} expansion the ValueSet.expansion
1174
+ * @returns {string} a paragraph, or nothing when the expansion is empty (the renderer
1175
+ * says so itself in that case)
1176
+ */
1177
+ expansionCount(expansion) {
1178
+ const concepts = (list) => (list || []).reduce((n, c) => n + 1 + concepts(c.contains), 0);
1179
+ const plural = (n) => `${n} concept${n === 1 ? '' : 's'}`;
1180
+
1181
+ const shown = concepts(expansion.contains);
1182
+ const total = typeof expansion.total === 'number' ? expansion.total : null;
1183
+ if (total === null && shown === 0) {
1184
+ return '';
1185
+ }
1186
+ if (total === null) {
1187
+ return `<p><b>${plural(shown)}</b></p>`;
1188
+ }
1189
+ if (total === 0) {
1190
+ return '';
1191
+ }
1192
+ return `<p><b>${plural(total)}</b>${total === shown ? '' : ` (${shown} shown)`}</p>`;
1193
+ }
1194
+
865
1195
  /**
866
1196
  * Render ConceptMap resource
867
1197
  */
@@ -927,41 +1257,68 @@ class TxHtmlRenderer {
927
1257
  }
928
1258
 
929
1259
  /**
930
- * Render OperationOutcome resource
1260
+ * Render OperationOutcome resource.
1261
+ *
1262
+ * details.text is the human account of the problem; diagnostics is server-specific
1263
+ * detail, most often the operation's timing trace. So the text leads, and the
1264
+ * diagnostics are kept but folded away - rendering diagnostics first (as this did)
1265
+ * turns a perfectly good error message into a row of milliseconds.
931
1266
  */
932
1267
  async renderOperationOutcome(json) {
933
1268
  let html = '<div class="operation-outcome">';
934
- html += `<h4>OperationOutcome</h4>`;
935
-
936
- if (json.issue && Array.isArray(json.issue)) {
937
- for (const issue of json.issue) {
938
- html += '<div class="alert ';
939
-
940
- // Determine alert style based on this issue's severity
941
- const severity = issue.severity || 'information';
942
- switch (severity) {
943
- case 'error':
944
- case 'fatal':
945
- html += 'alert-danger';
946
- break;
947
- case 'warning':
948
- html += 'alert-warning';
949
- break;
950
- case 'information':
951
- html += 'alert-info';
952
- break;
953
- default:
954
- html += 'alert-secondary';
955
- }
956
1269
 
957
- html += '">';
958
- html += `<strong>${escape(issue.severity || 'unknown')}:</strong> `;
959
- html += `[${escape(issue.code || 'unknown')}] `;
960
- html += escape(issue.diagnostics || issue.details?.text || 'No details');
1270
+ for (const issue of json.issue || []) {
1271
+ const severity = issue.severity || 'information';
1272
+ html += `<div class="alert ${SEVERITY_ALERT_CLASS[severity] || 'alert-secondary'}">`;
1273
+ html += `<strong>${escape(SEVERITY_LABEL[severity] || severity)}</strong>: `;
1274
+
1275
+ const text = issue.details?.text || issue.diagnostics;
1276
+ html += text ? escape(text) : '<em>(this issue carries no message)</em>';
1277
+
1278
+ // The issue-type and the tx-issue-type, which are usually - but not always - the
1279
+ // same word, plus the message id if the issue names one. Shown because they are
1280
+ // what a client actually branches on.
1281
+ const codes = [];
1282
+ if (issue.code) {
1283
+ codes.push(issue.code);
1284
+ }
1285
+ for (const coding of issue.details?.coding || []) {
1286
+ if (coding.code && !codes.includes(coding.code)) {
1287
+ codes.push(coding.code);
1288
+ }
1289
+ }
1290
+ const msgId = (issue.extension || []).find((e) =>
1291
+ e.url === 'http://hl7.org/fhir/StructureDefinition/operationoutcome-message-id')?.valueString;
1292
+ if (msgId) {
1293
+ codes.push(msgId);
1294
+ }
1295
+ const where = [...(issue.expression || []), ...(issue.location || [])];
1296
+ if (codes.length > 0 || where.length > 0) {
1297
+ html += '<div style="margin-top: 6px; font-size: 90%;">';
1298
+ html += codes.map((c) => `<code>${escape(c)}</code>`).join(' ');
1299
+ if (where.length > 0) {
1300
+ html += (codes.length > 0 ? ' at ' : 'at ') + where.map((w) => `<code>${escape(w)}</code>`).join(', ');
1301
+ }
961
1302
  html += '</div>';
962
1303
  }
1304
+
1305
+ // Not when it is already the message - an issue with nothing but diagnostics
1306
+ // should not say the same sentence twice.
1307
+ if (issue.diagnostics && issue.diagnostics !== text) {
1308
+ html += '<details style="margin-top: 6px; font-size: 90%;">';
1309
+ html += '<summary>Server diagnostics</summary>';
1310
+ html += `<pre style="margin-top: 6px;">${escape(issue.diagnostics)}</pre>`;
1311
+ html += '</details>';
1312
+ }
1313
+
1314
+ html += '</div>';
963
1315
  }
964
1316
 
1317
+ if (!json.issue || json.issue.length === 0) {
1318
+ html += '<div class="alert alert-secondary"><em>(no issues)</em></div>';
1319
+ }
1320
+
1321
+ html += this.renderJsonSource(json);
965
1322
  html += '</div>';
966
1323
  return html;
967
1324
  }
@@ -1391,6 +1748,31 @@ class TxHtmlRenderer {
1391
1748
  let
1392
1749
  resourceIdCounter = 0;
1393
1750
 
1751
+ /**
1752
+ * The property/filter names offered by the $filter form for a code system: the names
1753
+ * the base specification defines for any code system, then any names known for this
1754
+ * particular code system, then anything the CodeSystem resource declares itself.
1755
+ * @param {Object} json the CodeSystem resource
1756
+ * @returns {string[]} names, in that order, without duplicates
1757
+ */
1758
+ buildFilterPropertyNames(json) {
1759
+ const names = new Set(BASE_FILTER_PROPERTIES);
1760
+ for (const name of SYSTEM_FILTER_PROPERTIES[json.url] || []) {
1761
+ names.add(name);
1762
+ }
1763
+ for (const prop of json.property || []) {
1764
+ if (prop.code) {
1765
+ names.add(prop.code);
1766
+ }
1767
+ }
1768
+ for (const filter of json.filter || []) {
1769
+ if (filter.code) {
1770
+ names.add(filter.code);
1771
+ }
1772
+ }
1773
+ return [...names];
1774
+ }
1775
+
1394
1776
  generateResourceId() {
1395
1777
  return 'resource_' + (++this.resourceIdCounter);
1396
1778
  }