fhirsmith 0.11.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +377 -2
- package/README.md +1 -1
- package/config-template.json +31 -1
- package/library/languages.js +191 -34
- package/library/path-safety.js +51 -0
- package/library/request-token.js +56 -0
- package/package.json +2 -2
- package/packages/packages.js +17 -118
- package/publisher/publisher.js +208 -30
- package/publisher/validation.js +210 -0
- package/server.js +24 -15
- package/stats-db.js +127 -0
- package/stats.js +172 -6
- package/translations/Messages.properties +5 -1
- package/tx/README.md +21 -0
- package/tx/cs/cs-api.js +30 -1
- package/tx/cs/cs-api.md +1 -1
- package/tx/cs/cs-cs.js +1 -1
- package/tx/cs/cs-icd11.js +805 -0
- package/tx/cs/cs-lang.js +331 -85
- package/tx/cs/cs-loinc.js +45 -4
- package/tx/cs/cs-mimetypes.js +492 -21
- package/tx/cs/cs-snomed.js +141 -58
- package/tx/cs/cs-ucum.js +33 -3
- package/tx/cs/icd11-expressions.js +243 -0
- package/tx/importers/export-icd11-codesystem.js +823 -0
- package/tx/importers/icd11-schema.md +285 -0
- package/tx/importers/import-icd11.js +861 -0
- package/tx/importers/import-loinc.module.js +14 -7
- package/tx/importers/import-sct.module.js +10 -1
- package/tx/library/codesystem.js +21 -27
- package/tx/library/conceptmap.js +39 -0
- package/tx/library/nesting-limit.js +79 -0
- package/tx/library/operation-outcome.js +97 -3
- package/tx/library/parameter-check.js +139 -0
- package/tx/library/valueset.js +16 -20
- package/tx/library.js +23 -1
- package/tx/ocl/cache/cache-utils.cjs +5 -3
- package/tx/ocl/cs-ocl.cjs +73 -17
- package/tx/ocl/jobs/background-queue.cjs +6 -3
- package/tx/ocl/mappers/concept-mapper.cjs +9 -1
- package/tx/ocl/shared/patches.cjs +48 -1
- package/tx/ocl/vs-ocl.cjs +15 -13
- package/tx/operation-context.js +220 -10
- package/tx/params.js +226 -175
- package/tx/sct/expressions.js +677 -15
- package/tx/tx.fhir.org.yml +3 -2
- package/tx/tx.js +72 -92
- package/tx/workers/batch-validate.js +11 -18
- package/tx/workers/batch.js +16 -28
- package/tx/workers/cache-control.js +87 -17
- package/tx/workers/closure.js +3 -8
- package/tx/workers/compare.js +6 -34
- package/tx/workers/expand.js +108 -60
- package/tx/workers/lookup.js +35 -40
- package/tx/workers/read.js +6 -40
- package/tx/workers/search.js +2 -8
- package/tx/workers/subsumes.js +58 -41
- package/tx/workers/translate.js +472 -47
- package/tx/workers/validate.js +83 -78
- package/tx/workers/worker.js +92 -11
- package/tx/xml/xml-base.js +264 -77
- package/tx/xversion/xv-conceptmap.js +81 -0
- package/tx/xversion/xv-parameters.js +8 -0
- package/tx/xversion/xv-valueset.js +39 -1
- package/utilities/merge-audit.py +178 -0
- package/utilities/why-merge-audit.md +39 -0
- package/tx/data/snomed-subset.txt +0 -84
package/tx/xml/xml-base.js
CHANGED
|
@@ -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
|
-
|
|
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, '<')
|
|
497
517
|
.replace(/>/g, '>')
|
|
@@ -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
|
|
643
|
-
this
|
|
644
|
-
return
|
|
699
|
+
this.#parseProlog();
|
|
700
|
+
const root = this.#parseElement();
|
|
701
|
+
return root;
|
|
645
702
|
}
|
|
646
703
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
664
|
-
this.
|
|
739
|
+
#atEnd() {
|
|
740
|
+
return this.pos >= this.xml.length;
|
|
741
|
+
}
|
|
665
742
|
|
|
666
|
-
|
|
667
|
-
|
|
743
|
+
#skipWhitespace() {
|
|
744
|
+
while (this.pos < this.xml.length && /\s/.test(this.xml[this.pos])) {
|
|
745
|
+
this.pos++;
|
|
668
746
|
}
|
|
669
|
-
|
|
747
|
+
}
|
|
670
748
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
this.pos += nameEnd;
|
|
749
|
+
#startsWith(text) {
|
|
750
|
+
return this.xml.startsWith(text, this.pos);
|
|
751
|
+
}
|
|
675
752
|
|
|
676
|
-
|
|
677
|
-
const attributes = {};
|
|
678
|
-
this._skipWhitespace();
|
|
753
|
+
// ========== document structure ==========
|
|
679
754
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
|
777
|
+
this.#mustHaveAdvanced(was);
|
|
686
778
|
}
|
|
779
|
+
}
|
|
687
780
|
|
|
688
|
-
|
|
781
|
+
#skipComment() {
|
|
782
|
+
this.pos = this.#seek('-->', 'comment') + 3;
|
|
783
|
+
}
|
|
689
784
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
801
|
+
#parseElementInner() {
|
|
802
|
+
this.#skipWhitespace();
|
|
803
|
+
if (this.#atEnd() || this.xml[this.pos] !== '<') {
|
|
804
|
+
this.#fail("Expected '<'");
|
|
805
|
+
}
|
|
806
|
+
this.pos++;
|
|
697
807
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
this._skipWhitespace();
|
|
808
|
+
const name = this.#parseName('element name');
|
|
809
|
+
const attributes = this.#parseAttributes(name);
|
|
701
810
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
710
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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
|
-
|
|
729
|
-
|
|
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
|
-
|
|
732
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
this.pos = eqPos + 1;
|
|
866
|
+
this.#mustHaveAdvanced(was);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
739
869
|
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
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 => {
|