fhirsmith 0.11.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +377 -2
  2. package/README.md +1 -1
  3. package/config-template.json +31 -1
  4. package/library/languages.js +191 -34
  5. package/library/path-safety.js +51 -0
  6. package/library/request-token.js +56 -0
  7. package/package.json +2 -2
  8. package/packages/packages.js +17 -118
  9. package/publisher/publisher.js +208 -30
  10. package/publisher/validation.js +210 -0
  11. package/server.js +24 -15
  12. package/stats-db.js +127 -0
  13. package/stats.js +172 -6
  14. package/translations/Messages.properties +5 -1
  15. package/tx/README.md +21 -0
  16. package/tx/cs/cs-api.js +30 -1
  17. package/tx/cs/cs-api.md +1 -1
  18. package/tx/cs/cs-cs.js +1 -1
  19. package/tx/cs/cs-icd11.js +805 -0
  20. package/tx/cs/cs-lang.js +331 -85
  21. package/tx/cs/cs-loinc.js +45 -4
  22. package/tx/cs/cs-mimetypes.js +492 -21
  23. package/tx/cs/cs-snomed.js +141 -58
  24. package/tx/cs/cs-ucum.js +33 -3
  25. package/tx/cs/icd11-expressions.js +243 -0
  26. package/tx/importers/export-icd11-codesystem.js +823 -0
  27. package/tx/importers/icd11-schema.md +285 -0
  28. package/tx/importers/import-icd11.js +861 -0
  29. package/tx/importers/import-loinc.module.js +14 -7
  30. package/tx/importers/import-sct.module.js +10 -1
  31. package/tx/library/codesystem.js +21 -27
  32. package/tx/library/conceptmap.js +39 -0
  33. package/tx/library/nesting-limit.js +79 -0
  34. package/tx/library/operation-outcome.js +97 -3
  35. package/tx/library/parameter-check.js +139 -0
  36. package/tx/library/valueset.js +16 -20
  37. package/tx/library.js +23 -1
  38. package/tx/ocl/cache/cache-utils.cjs +5 -3
  39. package/tx/ocl/cs-ocl.cjs +73 -17
  40. package/tx/ocl/jobs/background-queue.cjs +6 -3
  41. package/tx/ocl/mappers/concept-mapper.cjs +9 -1
  42. package/tx/ocl/shared/patches.cjs +48 -1
  43. package/tx/ocl/vs-ocl.cjs +15 -13
  44. package/tx/operation-context.js +220 -10
  45. package/tx/params.js +226 -175
  46. package/tx/sct/expressions.js +677 -15
  47. package/tx/tx.fhir.org.yml +3 -2
  48. package/tx/tx.js +72 -92
  49. package/tx/workers/batch-validate.js +11 -18
  50. package/tx/workers/batch.js +16 -28
  51. package/tx/workers/cache-control.js +87 -17
  52. package/tx/workers/closure.js +3 -8
  53. package/tx/workers/compare.js +6 -34
  54. package/tx/workers/expand.js +108 -60
  55. package/tx/workers/lookup.js +35 -40
  56. package/tx/workers/read.js +6 -40
  57. package/tx/workers/search.js +2 -8
  58. package/tx/workers/subsumes.js +58 -41
  59. package/tx/workers/translate.js +472 -47
  60. package/tx/workers/validate.js +83 -78
  61. package/tx/workers/worker.js +92 -11
  62. package/tx/xml/xml-base.js +264 -77
  63. package/tx/xversion/xv-conceptmap.js +81 -0
  64. package/tx/xversion/xv-parameters.js +8 -0
  65. package/tx/xversion/xv-valueset.js +39 -1
  66. package/utilities/merge-audit.py +178 -0
  67. package/utilities/why-merge-audit.md +39 -0
  68. package/tx/data/snomed-subset.txt +0 -84
@@ -486,12 +486,32 @@ class FhirXmlBase {
486
486
 
487
487
  /**
488
488
  * Escape special characters for XML
489
+ *
490
+ * XML 1.0 cannot represent the C0 control characters at all - not literally,
491
+ * and not as numeric character references either - so a value carrying one
492
+ * cannot be serialised as XML by any means. Tab, line feed and carriage
493
+ * return are the three exceptions; DEL (U+007F) is legal in XML 1.0 and is
494
+ * left alone.
495
+ *
496
+ * Rather than emit a document no conformant parser will read, this rejects
497
+ * the value. Anything reaching here with a control character in it came from
498
+ * outside (a remote API, an imported file) and should have been caught at
499
+ * the boundary.
500
+ *
489
501
  * @param {*} value - Value to escape
490
502
  * @returns {string}
503
+ * @throws {Error} if the value contains a character XML cannot carry
491
504
  */
492
505
  static escapeXml(value) {
493
506
  if (value === null || value === undefined) return '';
494
- return String(value)
507
+ const str = String(value);
508
+ // eslint-disable-next-line no-control-regex
509
+ const illegal = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.exec(str);
510
+ if (illegal) {
511
+ const code = illegal[0].charCodeAt(0).toString(16).toUpperCase().padStart(4, '0');
512
+ throw new Error(`Cannot serialise this content as XML: it contains U+${code} at offset ${illegal.index}, a control character XML does not allow`);
513
+ }
514
+ return str
495
515
  .replace(/&/g, '&')
496
516
  .replace(/</g, '&lt;')
497
517
  .replace(/>/g, '&gt;')
@@ -632,122 +652,289 @@ class FhirXmlBase {
632
652
  * Simple XML parser for FHIR resources
633
653
  * Parses XML string into {name, attributes, children} structure
634
654
  */
655
+ /**
656
+ * A deliberately small XML reader for FHIR resources. It is not a general XML processor: FHIR
657
+ * XML has no DTDs, no entities beyond the five predefined ones, and carries its values in
658
+ * attributes, so the grammar it needs is tiny.
659
+ *
660
+ * What it does have to be is total. Every input either produces a tree or throws - it must never
661
+ * loop, and it must never run away with memory or stack. This matters more than it looks: the
662
+ * server accepts application/fhir+xml on every endpoint, parsing is synchronous, and node is
663
+ * single threaded, so a parser that fails to terminate does not hang one request, it stops the
664
+ * process answering anything at all. The cooperative deadCheck cannot help - it needs an await
665
+ * point, and there isn't one inside a parse.
666
+ *
667
+ * Two rules keep that promise, and both are enforced rather than assumed:
668
+ *
669
+ * - no scan may fail silently. Every search for a delimiter goes through #seek(), which throws
670
+ * if the delimiter is absent. The bug this replaces was an indexOf() returning -1 whose result
671
+ * was assigned to the cursor, sending it back to zero and restarting the parse forever.
672
+ * - every loop must consume input. Each iteration asserts the cursor moved forward, so a future
673
+ * edit that forgets to advance it fails loudly on the first malformed document instead of
674
+ * spinning.
675
+ */
676
+
677
+ // FHIR resources nest a couple of dozen deep; narrative markup can go further. This is well
678
+ // clear of anything real and well under the call stack, so deep input is a clean error rather
679
+ // than a RangeError from the recursion in _parseElement.
680
+ const MAX_ELEMENT_DEPTH = 1000;
681
+
635
682
  class FhirXmlParser {
683
+ /**
684
+ * @param {string} xml - the document to read
685
+ */
636
686
  constructor(xml) {
687
+ if (typeof xml !== 'string') {
688
+ throw new Error('XML input must be a string');
689
+ }
637
690
  this.xml = xml;
638
691
  this.pos = 0;
692
+ this.depth = 0;
639
693
  }
640
694
 
695
+ /**
696
+ * @returns {{name: string, attributes: Object, children: Array}} the root element
697
+ */
641
698
  parse() {
642
- this._skipDeclaration();
643
- this._skipWhitespace();
644
- return this._parseElement();
699
+ this.#parseProlog();
700
+ const root = this.#parseElement();
701
+ return root;
645
702
  }
646
703
 
647
- _skipDeclaration() {
648
- this._skipWhitespace();
649
- if (this.xml.substring(this.pos, this.pos + 5) === '<?xml') {
650
- const end = this.xml.indexOf('?>', this.pos);
651
- if (end !== -1) {
652
- this.pos = end + 2;
653
- }
704
+ // ========== scanning primitives ==========
705
+
706
+ /**
707
+ * Throw with the position, which is what makes a malformed document diagnosable
708
+ * @param {string} message
709
+ */
710
+ #fail(message) {
711
+ throw new Error(`${message} at position ${this.pos}`);
712
+ }
713
+
714
+ /**
715
+ * indexOf that cannot return -1. Every delimiter search goes through here
716
+ * @param {string} needle
717
+ * @param {string} what - what we were looking for, for the error message
718
+ * @returns {number}
719
+ */
720
+ #seek(needle, what) {
721
+ const at = this.xml.indexOf(needle, this.pos);
722
+ if (at === -1) {
723
+ this.#fail(`Unterminated ${what}: no '${needle}' found`);
654
724
  }
725
+ return at;
655
726
  }
656
727
 
657
- _skipWhitespace() {
658
- while (this.pos < this.xml.length && /\s/.test(this.xml[this.pos])) {
659
- this.pos++;
728
+ /**
729
+ * Move the cursor, checking it actually moved forward. Called at the end of every loop body
730
+ * @param {number} was - the cursor before the iteration
731
+ */
732
+ #mustHaveAdvanced(was) {
733
+ if (this.pos <= was) {
734
+ // not reachable by any input we know of - it means a code path forgot to consume
735
+ this.#fail('Internal error: the XML parser stopped making progress');
660
736
  }
661
737
  }
662
738
 
663
- _parseElement() {
664
- this._skipWhitespace();
739
+ #atEnd() {
740
+ return this.pos >= this.xml.length;
741
+ }
665
742
 
666
- if (this.xml[this.pos] !== '<') {
667
- throw new Error(`Expected '<' at position ${this.pos}`);
743
+ #skipWhitespace() {
744
+ while (this.pos < this.xml.length && /\s/.test(this.xml[this.pos])) {
745
+ this.pos++;
668
746
  }
669
- this.pos++; // Skip '<'
747
+ }
670
748
 
671
- // Parse element name
672
- const nameEnd = this.xml.substring(this.pos).search(/[\s/>]/);
673
- const name = this.xml.substring(this.pos, this.pos + nameEnd);
674
- this.pos += nameEnd;
749
+ #startsWith(text) {
750
+ return this.xml.startsWith(text, this.pos);
751
+ }
675
752
 
676
- // Parse attributes
677
- const attributes = {};
678
- this._skipWhitespace();
753
+ // ========== document structure ==========
679
754
 
680
- while (this.pos < this.xml.length && this.xml[this.pos] !== '>' && this.xml[this.pos] !== '/') {
681
- const attr = this._parseAttribute();
682
- if (attr) {
683
- attributes[attr.name] = attr.value;
755
+ /**
756
+ * The XML declaration, and any comments or processing instructions before the root element.
757
+ * A DOCTYPE is refused outright: FHIR XML has no DTD, and accepting one is how a parser ends
758
+ * up doing entity expansion on someone else's behalf.
759
+ */
760
+ #parseProlog() {
761
+ this.#skipWhitespace();
762
+ if (this.#startsWith('<?xml')) {
763
+ this.pos = this.#seek('?>', 'XML declaration') + 2;
764
+ }
765
+ for (;;) {
766
+ const was = this.pos;
767
+ this.#skipWhitespace();
768
+ if (this.#startsWith('<!--')) {
769
+ this.#skipComment();
770
+ } else if (this.#startsWith('<!DOCTYPE') || this.#startsWith('<!')) {
771
+ this.#fail('Document type declarations are not allowed in FHIR XML');
772
+ } else if (this.#startsWith('<?')) {
773
+ this.pos = this.#seek('?>', 'processing instruction') + 2;
774
+ } else {
775
+ return;
684
776
  }
685
- this._skipWhitespace();
777
+ this.#mustHaveAdvanced(was);
686
778
  }
779
+ }
687
780
 
688
- const children = [];
781
+ #skipComment() {
782
+ this.pos = this.#seek('-->', 'comment') + 3;
783
+ }
689
784
 
690
- // Self-closing tag
691
- if (this.xml[this.pos] === '/') {
692
- this.pos += 2; // Skip '/>'
693
- return { name, attributes, children };
785
+ /**
786
+ * One element and everything inside it.
787
+ * @returns {{name: string, attributes: Object, children: Array}}
788
+ */
789
+ #parseElement() {
790
+ if (++this.depth > MAX_ELEMENT_DEPTH) {
791
+ this.depth--;
792
+ this.#fail(`XML is nested more than ${MAX_ELEMENT_DEPTH} elements deep`);
793
+ }
794
+ try {
795
+ return this.#parseElementInner();
796
+ } finally {
797
+ this.depth--;
694
798
  }
799
+ }
695
800
 
696
- this.pos++; // Skip '>'
801
+ #parseElementInner() {
802
+ this.#skipWhitespace();
803
+ if (this.#atEnd() || this.xml[this.pos] !== '<') {
804
+ this.#fail("Expected '<'");
805
+ }
806
+ this.pos++;
697
807
 
698
- // Parse children
699
- while (this.pos < this.xml.length) {
700
- this._skipWhitespace();
808
+ const name = this.#parseName('element name');
809
+ const attributes = this.#parseAttributes(name);
701
810
 
702
- if (this.xml.substring(this.pos, this.pos + 2) === '</') {
703
- // Closing tag
704
- const closeEnd = this.xml.indexOf('>', this.pos);
705
- this.pos = closeEnd + 1;
706
- break;
707
- }
811
+ if (this.#startsWith('/>')) {
812
+ this.pos += 2;
813
+ return { name, attributes, children: [] };
814
+ }
815
+ if (this.#atEnd() || this.xml[this.pos] !== '>') {
816
+ this.#fail(`Expected '>' or '/>' to close the start tag of <${name}>`);
817
+ }
818
+ this.pos++;
708
819
 
709
- if (this.xml[this.pos] === '<') {
710
- // Check for comment
711
- if (this.xml.substring(this.pos, this.pos + 4) === '<!--') {
712
- const commentEnd = this.xml.indexOf('-->', this.pos);
713
- this.pos = commentEnd + 3;
714
- continue;
715
- }
820
+ return { name, attributes, children: this.#parseChildren(name) };
821
+ }
716
822
 
717
- children.push(this._parseElement());
718
- } else {
719
- // Text content - skip for FHIR as values are in attributes
720
- const textEnd = this.xml.indexOf('<', this.pos);
721
- this.pos = textEnd;
722
- }
823
+ /**
824
+ * An element or attribute name: everything up to whitespace, '/', '>' or '='.
825
+ * @param {string} what - for the error message
826
+ * @returns {string}
827
+ */
828
+ #parseName(what) {
829
+ const from = this.pos;
830
+ while (this.pos < this.xml.length && !/[\s/>=]/.test(this.xml[this.pos])) {
831
+ this.pos++;
723
832
  }
724
-
725
- return { name, attributes, children };
833
+ if (this.pos === from) {
834
+ this.#fail(`Expected an ${what}`);
835
+ }
836
+ return this.xml.substring(from, this.pos);
726
837
  }
727
838
 
728
- _parseAttribute() {
729
- this._skipWhitespace();
839
+ /**
840
+ * @param {string} elementName - for error messages
841
+ * @returns {Object} name to value
842
+ */
843
+ #parseAttributes(elementName) {
844
+ const attributes = {};
845
+ for (;;) {
846
+ const was = this.pos;
847
+ this.#skipWhitespace();
848
+ if (this.#atEnd()) {
849
+ this.#fail(`Unterminated start tag <${elementName}>`);
850
+ }
851
+ if (this.xml[this.pos] === '>' || this.#startsWith('/>')) {
852
+ return attributes;
853
+ }
854
+ if (this.xml[this.pos] === '/') {
855
+ this.#fail(`Expected '/>' in the start tag of <${elementName}>`);
856
+ }
730
857
 
731
- if (this.xml[this.pos] === '>' || this.xml[this.pos] === '/') {
732
- return null;
733
- }
858
+ const name = this.#parseName('attribute name');
859
+ this.#skipWhitespace();
860
+ if (this.#atEnd() || this.xml[this.pos] !== '=') {
861
+ this.#fail(`Expected '=' after the attribute '${name}' of <${elementName}>`);
862
+ }
863
+ this.pos++;
864
+ attributes[name] = this.#parseAttributeValue(name, elementName);
734
865
 
735
- // Parse attribute name
736
- const eqPos = this.xml.indexOf('=', this.pos);
737
- const name = this.xml.substring(this.pos, eqPos).trim();
738
- this.pos = eqPos + 1;
866
+ this.#mustHaveAdvanced(was);
867
+ }
868
+ }
739
869
 
740
- // Skip whitespace and opening quote
741
- this._skipWhitespace();
870
+ /**
871
+ * A quoted attribute value. XML has no unquoted form, and accepting one is what let a stray
872
+ * character send the cursor somewhere arbitrary
873
+ * @param {string} name
874
+ * @param {string} elementName
875
+ * @returns {string}
876
+ */
877
+ #parseAttributeValue(name, elementName) {
878
+ this.#skipWhitespace();
742
879
  const quote = this.xml[this.pos];
880
+ if (quote !== '"' && quote !== "'") {
881
+ this.#fail(`The value of the attribute '${name}' of <${elementName}> must be quoted`);
882
+ }
743
883
  this.pos++;
884
+ const end = this.#seek(quote, `value of the attribute '${name}' of <${elementName}>`);
885
+ const value = FhirXmlBase.unescapeXml(this.xml.substring(this.pos, end));
886
+ this.pos = end + 1;
887
+ return value;
888
+ }
889
+
890
+ /**
891
+ * Everything between a start tag and its matching end tag. Text is skipped: FHIR carries its
892
+ * values in attributes, and this parser has never returned character data.
893
+ * @param {string} elementName
894
+ * @returns {Array} the child elements
895
+ */
896
+ #parseChildren(elementName) {
897
+ const children = [];
898
+ for (;;) {
899
+ const was = this.pos;
900
+ this.#skipWhitespace();
901
+ if (this.#atEnd()) {
902
+ this.#fail(`Unclosed element <${elementName}>: expected </${elementName}>`);
903
+ }
744
904
 
745
- // Parse attribute value
746
- const valueEnd = this.xml.indexOf(quote, this.pos);
747
- const value = FhirXmlBase.unescapeXml(this.xml.substring(this.pos, valueEnd));
748
- this.pos = valueEnd + 1;
905
+ if (this.#startsWith('</')) {
906
+ this.pos += 2;
907
+ const closing = this.#parseName('element name');
908
+ if (closing !== elementName) {
909
+ this.#fail(`Expected </${elementName}> but found </${closing}>`);
910
+ }
911
+ this.#skipWhitespace();
912
+ if (this.#atEnd() || this.xml[this.pos] !== '>') {
913
+ this.#fail(`Expected '>' to close </${closing}>`);
914
+ }
915
+ this.pos++;
916
+ return children;
917
+ }
749
918
 
750
- return { name, value };
919
+ if (this.xml[this.pos] === '<') {
920
+ if (this.#startsWith('<!--')) {
921
+ this.#skipComment();
922
+ } else if (this.#startsWith('<![CDATA[')) {
923
+ this.pos = this.#seek(']]>', 'CDATA section') + 3;
924
+ } else if (this.#startsWith('<!')) {
925
+ this.#fail('Declarations are not allowed inside a FHIR XML element');
926
+ } else if (this.#startsWith('<?')) {
927
+ this.pos = this.#seek('?>', 'processing instruction') + 2;
928
+ } else {
929
+ children.push(this.#parseElement());
930
+ }
931
+ } else {
932
+ // character data - skipped, but it still has to be consumed
933
+ this.pos = this.#seek('<', `content of <${elementName}>`);
934
+ }
935
+
936
+ this.#mustHaveAdvanced(was);
937
+ }
751
938
  }
752
939
  }
753
940
 
@@ -1,5 +1,63 @@
1
1
  const {VersionUtilities} = require("../../library/version-utilities");
2
2
 
3
+ // R4/R3 have no ConceptMap.group.unmapped.valueSet or .relationship. The Java version
4
+ // convertors carry them across as these cross-version extensions, so read them back.
5
+ const EXT_UNMAPPED_VALUESET = 'http://hl7.org/fhir/5.0/StructureDefinition/extension-ConceptMap.group.unmapped.valueSet';
6
+ const EXT_UNMAPPED_RELATIONSHIP = 'http://hl7.org/fhir/5.0/StructureDefinition/extension-ConceptMap.group.unmapped.relationship';
7
+
8
+ /**
9
+ * R4 spells group.unmapped differently: the target map is 'url' rather than 'otherMap',
10
+ * and the 'provided' mode is called 'use-source-code' in R5.
11
+ */
12
+ function unmappedToR5(unmapped) {
13
+ if (!unmapped) {
14
+ return;
15
+ }
16
+ if (unmapped.mode === 'provided') {
17
+ unmapped.mode = 'use-source-code';
18
+ }
19
+ if (unmapped.url !== undefined && unmapped.otherMap === undefined) {
20
+ unmapped.otherMap = unmapped.url;
21
+ delete unmapped.url;
22
+ }
23
+ for (const ext of unmapped.extension || []) {
24
+ if (ext.url === EXT_UNMAPPED_VALUESET && unmapped.valueSet === undefined) {
25
+ unmapped.valueSet = ext.valueCanonical || ext.valueUri;
26
+ }
27
+ if (ext.url === EXT_UNMAPPED_RELATIONSHIP && unmapped.relationship === undefined) {
28
+ unmapped.relationship = ext.valueCode || ext.valueString;
29
+ }
30
+ }
31
+ if (unmapped.extension) {
32
+ unmapped.extension = unmapped.extension.filter(e => e.url !== EXT_UNMAPPED_VALUESET && e.url !== EXT_UNMAPPED_RELATIONSHIP);
33
+ if (unmapped.extension.length === 0) {
34
+ delete unmapped.extension;
35
+ }
36
+ }
37
+ }
38
+
39
+ /** The inverse of unmappedToR5. */
40
+ function unmappedFromR5(unmapped) {
41
+ if (!unmapped) {
42
+ return;
43
+ }
44
+ if (unmapped.mode === 'use-source-code') {
45
+ unmapped.mode = 'provided';
46
+ }
47
+ if (unmapped.otherMap !== undefined) {
48
+ unmapped.url = unmapped.otherMap;
49
+ delete unmapped.otherMap;
50
+ }
51
+ if (unmapped.valueSet !== undefined) {
52
+ (unmapped.extension = unmapped.extension || []).push({url: EXT_UNMAPPED_VALUESET, valueCanonical: unmapped.valueSet});
53
+ delete unmapped.valueSet;
54
+ }
55
+ if (unmapped.relationship !== undefined) {
56
+ (unmapped.extension = unmapped.extension || []).push({url: EXT_UNMAPPED_RELATIONSHIP, valueCode: unmapped.relationship});
57
+ delete unmapped.relationship;
58
+ }
59
+ }
60
+
3
61
  /**
4
62
  * Converts input ConceptMap to R5 format (modifies input object for performance)
5
63
  * @param {Object} jsonObj - The input ConceptMap object
@@ -45,8 +103,30 @@ function conceptMapToR5(jsonObj, sourceVersion) {
45
103
  // Convert equivalence to relationship in group.element.target
46
104
  if (jsonObj.group && Array.isArray(jsonObj.group)) {
47
105
  jsonObj.group.forEach(group => {
106
+ unmappedToR5(group.unmapped);
48
107
  if (group.element && Array.isArray(group.element)) {
49
108
  group.element.forEach(element => {
109
+ // R4 has no element.noMap. A target with equivalence = 'unmatched' and no
110
+ // code is R4's way of saying the same thing: there is positively no map
111
+ // for this concept. Lift it to noMap and drop the placeholder target, so
112
+ // everything downstream sees one R5 shape. This mirrors what the Java
113
+ // convertors do (ConceptMap40_50.convertSourceElementComponent), including
114
+ // their reservation of 'unmatched' for noMap - a real 'not-related-to'
115
+ // target converts to 'disjoint' and carries a code, so it is untouched here.
116
+ if (element.target && Array.isArray(element.target)) {
117
+ const noMapTargets = element.target.filter(t => t.equivalence === 'unmatched' && !t.code);
118
+ if (noMapTargets.length > 0) {
119
+ element.noMap = true;
120
+ const comment = noMapTargets.find(t => t.comment);
121
+ if (comment && !element.comment) {
122
+ element.comment = comment.comment;
123
+ }
124
+ element.target = element.target.filter(t => !(t.equivalence === 'unmatched' && !t.code));
125
+ if (element.target.length === 0) {
126
+ delete element.target;
127
+ }
128
+ }
129
+ }
50
130
  if (element.target && Array.isArray(element.target)) {
51
131
  element.target.forEach(target => {
52
132
  if (target.equivalence && !target.relationship) {
@@ -142,6 +222,7 @@ function conceptMapR5ToR4(r5Obj) {
142
222
  // Convert relationship back to equivalence in group.element.target
143
223
  if (r5Obj.group && Array.isArray(r5Obj.group)) {
144
224
  r5Obj.group.forEach(group => {
225
+ unmappedFromR5(group.unmapped);
145
226
  if (group.element && Array.isArray(group.element)) {
146
227
  group.element.forEach(element => {
147
228
  if (element.target && Array.isArray(element.target)) {
@@ -88,6 +88,14 @@ function fixMatchParameterfor5(p) {
88
88
 
89
89
  function fixMatchParameterfor4(p) {
90
90
  if (p.part) {
91
+ // R4 calls originMap 'source'. originMap is reported in every version; source is added
92
+ // here for R4 only, and is prohibited everywhere else
93
+ if (!p.part.find(pp => pp.name === 'source')) {
94
+ let om = p.part.find(pp => pp.name === 'originMap');
95
+ if (om && om.valueCanonical) {
96
+ p.part.push({name: "source", valueCanonical: om.valueCanonical});
97
+ }
98
+ }
91
99
  if (!p.part.find(pp => pp.name === 'equivalence')) {
92
100
  let rel = p.part.find(pp => pp.name === 'relationship');
93
101
  if (rel && rel.valueCode) {
@@ -1,7 +1,13 @@
1
1
  const {VersionUtilities} = require("../../library/version-utilities");
2
- const {getValueName} = require("../../library/utilities");
2
+ const {getValueName, getValuePrimitive} = require("../../library/utilities");
3
3
  const {Extensions} = require("../library/extensions");
4
4
 
5
+ // ValueSet.compose.property is R5 and up - it is how a client says which properties it wants
6
+ // back in an expansion. R4 and R3 have nowhere to put it, so it travels as a cross-version
7
+ // extension, the same one the Java convertors use. Dropping it instead means the expansion
8
+ // comes back without the properties that were asked for, which reads as a server fault.
9
+ const COMPOSE_PROPERTY_EXT = "http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.compose.property";
10
+
5
11
  /**
6
12
  * Converts input ValueSet to R5 format (modifies input object for performance)
7
13
  * @param {Object} jsonObj - The input ValueSet object
@@ -20,6 +26,7 @@ function valueSetToR5(jsonObj, sourceVersion) {
20
26
  for (const inc of jsonObj.compose?.exclude || []) {
21
27
  valueSetIncludeToR5(inc);
22
28
  }
29
+ composePropertyToR5(jsonObj.compose);
23
30
  if (VersionUtilities.isR4Ver(sourceVersion)) {
24
31
  return jsonObj; // No conversion needed
25
32
  }
@@ -33,6 +40,28 @@ function valueSetToR5(jsonObj, sourceVersion) {
33
40
  throw new Error(`Unsupported FHIR version: ${sourceVersion}`);
34
41
  }
35
42
 
43
+ /** Read ValueSet.compose.property back out of the cross-version extension. */
44
+ function composePropertyToR5(compose) {
45
+ if (!compose) {
46
+ return;
47
+ }
48
+ const carried = Extensions.list(compose, COMPOSE_PROPERTY_EXT);
49
+ if (carried.length === 0) {
50
+ return;
51
+ }
52
+ compose.property = compose.property || [];
53
+ for (const ext of carried) {
54
+ const value = getValuePrimitive(ext);
55
+ if (value !== undefined && value !== null) {
56
+ compose.property.push(value);
57
+ }
58
+ }
59
+ compose.extension = compose.extension.filter(ext => ext.url !== COMPOSE_PROPERTY_EXT);
60
+ if (compose.extension.length === 0) {
61
+ delete compose.extension;
62
+ }
63
+ }
64
+
36
65
  function valueSetIncludeToR5(inc) {
37
66
  for (const filter of inc.filter || []) {
38
67
  if (filter._op) {
@@ -84,6 +113,15 @@ function valueSetR5ToR4(r5Obj) {
84
113
  delete r5Obj.versionAlgorithmCoding;
85
114
  }
86
115
 
116
+ // ValueSet.compose.property has no home in R4, so it goes into the extension
117
+ if (r5Obj.compose && Array.isArray(r5Obj.compose.property) && r5Obj.compose.property.length > 0) {
118
+ r5Obj.compose.extension = r5Obj.compose.extension || [];
119
+ for (const prop of r5Obj.compose.property) {
120
+ r5Obj.compose.extension.push({ url: COMPOSE_PROPERTY_EXT, valueString: prop });
121
+ }
122
+ delete r5Obj.compose.property;
123
+ }
124
+
87
125
  // Filter out R5-only filter operators in compose
88
126
  if (r5Obj.compose && r5Obj.compose.include) {
89
127
  r5Obj.compose.include = r5Obj.compose.include.map(include => {