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
@@ -652,122 +652,289 @@ class FhirXmlBase {
652
652
  * Simple XML parser for FHIR resources
653
653
  * Parses XML string into {name, attributes, children} structure
654
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
+
655
682
  class FhirXmlParser {
683
+ /**
684
+ * @param {string} xml - the document to read
685
+ */
656
686
  constructor(xml) {
687
+ if (typeof xml !== 'string') {
688
+ throw new Error('XML input must be a string');
689
+ }
657
690
  this.xml = xml;
658
691
  this.pos = 0;
692
+ this.depth = 0;
659
693
  }
660
694
 
695
+ /**
696
+ * @returns {{name: string, attributes: Object, children: Array}} the root element
697
+ */
661
698
  parse() {
662
- this._skipDeclaration();
663
- this._skipWhitespace();
664
- return this._parseElement();
699
+ this.#parseProlog();
700
+ const root = this.#parseElement();
701
+ return root;
665
702
  }
666
703
 
667
- _skipDeclaration() {
668
- this._skipWhitespace();
669
- if (this.xml.substring(this.pos, this.pos + 5) === '<?xml') {
670
- const end = this.xml.indexOf('?>', this.pos);
671
- if (end !== -1) {
672
- this.pos = end + 2;
673
- }
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`);
674
724
  }
725
+ return at;
675
726
  }
676
727
 
677
- _skipWhitespace() {
678
- while (this.pos < this.xml.length && /\s/.test(this.xml[this.pos])) {
679
- 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');
680
736
  }
681
737
  }
682
738
 
683
- _parseElement() {
684
- this._skipWhitespace();
739
+ #atEnd() {
740
+ return this.pos >= this.xml.length;
741
+ }
685
742
 
686
- if (this.xml[this.pos] !== '<') {
687
- 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++;
688
746
  }
689
- this.pos++; // Skip '<'
747
+ }
690
748
 
691
- // Parse element name
692
- const nameEnd = this.xml.substring(this.pos).search(/[\s/>]/);
693
- const name = this.xml.substring(this.pos, this.pos + nameEnd);
694
- this.pos += nameEnd;
749
+ #startsWith(text) {
750
+ return this.xml.startsWith(text, this.pos);
751
+ }
695
752
 
696
- // Parse attributes
697
- const attributes = {};
698
- this._skipWhitespace();
753
+ // ========== document structure ==========
699
754
 
700
- while (this.pos < this.xml.length && this.xml[this.pos] !== '>' && this.xml[this.pos] !== '/') {
701
- const attr = this._parseAttribute();
702
- if (attr) {
703
- 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;
704
776
  }
705
- this._skipWhitespace();
777
+ this.#mustHaveAdvanced(was);
706
778
  }
779
+ }
707
780
 
708
- const children = [];
781
+ #skipComment() {
782
+ this.pos = this.#seek('-->', 'comment') + 3;
783
+ }
709
784
 
710
- // Self-closing tag
711
- if (this.xml[this.pos] === '/') {
712
- this.pos += 2; // Skip '/>'
713
- 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`);
714
793
  }
794
+ try {
795
+ return this.#parseElementInner();
796
+ } finally {
797
+ this.depth--;
798
+ }
799
+ }
715
800
 
716
- this.pos++; // Skip '>'
801
+ #parseElementInner() {
802
+ this.#skipWhitespace();
803
+ if (this.#atEnd() || this.xml[this.pos] !== '<') {
804
+ this.#fail("Expected '<'");
805
+ }
806
+ this.pos++;
717
807
 
718
- // Parse children
719
- while (this.pos < this.xml.length) {
720
- this._skipWhitespace();
808
+ const name = this.#parseName('element name');
809
+ const attributes = this.#parseAttributes(name);
721
810
 
722
- if (this.xml.substring(this.pos, this.pos + 2) === '</') {
723
- // Closing tag
724
- const closeEnd = this.xml.indexOf('>', this.pos);
725
- this.pos = closeEnd + 1;
726
- break;
727
- }
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++;
728
819
 
729
- if (this.xml[this.pos] === '<') {
730
- // Check for comment
731
- if (this.xml.substring(this.pos, this.pos + 4) === '<!--') {
732
- const commentEnd = this.xml.indexOf('-->', this.pos);
733
- this.pos = commentEnd + 3;
734
- continue;
735
- }
820
+ return { name, attributes, children: this.#parseChildren(name) };
821
+ }
736
822
 
737
- children.push(this._parseElement());
738
- } else {
739
- // Text content - skip for FHIR as values are in attributes
740
- const textEnd = this.xml.indexOf('<', this.pos);
741
- this.pos = textEnd;
742
- }
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++;
743
832
  }
744
-
745
- return { name, attributes, children };
833
+ if (this.pos === from) {
834
+ this.#fail(`Expected an ${what}`);
835
+ }
836
+ return this.xml.substring(from, this.pos);
746
837
  }
747
838
 
748
- _parseAttribute() {
749
- 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
+ }
750
857
 
751
- if (this.xml[this.pos] === '>' || this.xml[this.pos] === '/') {
752
- return null;
753
- }
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);
754
865
 
755
- // Parse attribute name
756
- const eqPos = this.xml.indexOf('=', this.pos);
757
- const name = this.xml.substring(this.pos, eqPos).trim();
758
- this.pos = eqPos + 1;
866
+ this.#mustHaveAdvanced(was);
867
+ }
868
+ }
759
869
 
760
- // Skip whitespace and opening quote
761
- 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();
762
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
+ }
763
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
+ }
764
889
 
765
- // Parse attribute value
766
- const valueEnd = this.xml.indexOf(quote, this.pos);
767
- const value = FhirXmlBase.unescapeXml(this.xml.substring(this.pos, valueEnd));
768
- this.pos = valueEnd + 1;
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
+ }
769
904
 
770
- return { name, value };
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
+ }
918
+
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
+ }
771
938
  }
772
939
  }
773
940
 
@@ -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 => {