From b1bae0aa01e51df1005bd7d7890e7ba21c98fc14 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Mon, 12 Jan 2026 07:49:31 +1100 Subject: [PATCH 1/3] implement tx api xml adaptor --- tests/library/codesystem.test.js | 217 +------- tests/tx/xml.test.js | 754 ++++++++++++++++++++++++++ tx/README.md | 1 - tx/cm/cm-api.js | 4 + tx/cm/cm-database.js | 3 + tx/cm/cm-package.js | 3 + tx/library.js | 16 + tx/library/codesystem-xml.js | 573 ------------------- tx/library/conceptmap-xml.js | 333 ------------ tx/library/namingsystem-xml.js | 316 ----------- tx/library/valueset-xml.js | 347 ------------ tx/library/valueset.js | 56 +- tx/provider.js | 13 +- tx/tx-html.js | 281 ++++++++-- tx/tx.js | 127 ++++- tx/vs/vs-api.js | 8 + tx/vs/vs-database.js | 4 + tx/vs/vs-package.js | 5 + tx/vs/vs-vsac.js | 6 + tx/workers/batch.js | 2 +- tx/workers/expand.js | 15 +- tx/workers/metadata.js | 55 +- tx/workers/translate.js | 38 +- tx/workers/worker.js | 132 +---- tx/xml/capabilitystatement-xml.js | 62 +++ tx/xml/codesystem-xml.js | 62 +++ tx/xml/conceptmap-xml.js | 62 +++ tx/xml/namingsystem-xml.js | 62 +++ tx/xml/operationoutcome-xml.js | 124 +++++ tx/xml/parameters-xml.js | 312 +++++++++++ tx/xml/terminologycapabilities-xml.js | 61 +++ tx/xml/valueset-xml.js | 61 +++ tx/xml/xml-base.js | 602 ++++++++++++++++++++ 33 files changed, 2684 insertions(+), 2033 deletions(-) create mode 100644 tests/tx/xml.test.js delete mode 100644 tx/library/codesystem-xml.js delete mode 100644 tx/library/conceptmap-xml.js delete mode 100644 tx/library/namingsystem-xml.js delete mode 100644 tx/library/valueset-xml.js create mode 100644 tx/xml/capabilitystatement-xml.js create mode 100644 tx/xml/codesystem-xml.js create mode 100644 tx/xml/conceptmap-xml.js create mode 100644 tx/xml/namingsystem-xml.js create mode 100644 tx/xml/operationoutcome-xml.js create mode 100644 tx/xml/parameters-xml.js create mode 100644 tx/xml/terminologycapabilities-xml.js create mode 100644 tx/xml/valueset-xml.js create mode 100644 tx/xml/xml-base.js diff --git a/tests/library/codesystem.test.js b/tests/library/codesystem.test.js index a01e9cc..7f28c2e 100644 --- a/tests/library/codesystem.test.js +++ b/tests/library/codesystem.test.js @@ -3,7 +3,7 @@ * These tests can be run with Jest, Mocha, or any similar testing framework */ const { CodeSystem } = require('../../tx/library/codesystem'); -const CodeSystemXML = require('../../tx/library/codesystem-xml'); +const CodeSystemXML = require('../../tx/xml/codesystem-xml'); describe('CodeSystem', () => { // Test data @@ -1021,221 +1021,6 @@ describe('CodeSystem', () => { }); }); -describe('CodeSystem XML Support', () => { - // Sample FHIR XML data for testing - const r5CodeSystemXML = ` - - - - - - - - - - - - - - - <status value="active"/> - <filter> - <code value="concept"/> - <operator value="="/> - <operator value="is-a"/> - <operator value="generalizes"/> - <operator value="regex"/> - <value value="A string value"/> - </filter> - <concept> - <code value="parent"/> - <display value="Parent Concept"/> - <concept> - <code value="child1"/> - <display value="Child 1"/> - </concept> - <concept> - <code value="child2"/> - <display value="Child 2"/> - </concept> - </concept> - <concept> - <code value="standalone"/> - <display value="Standalone Concept"/> - </concept> -</CodeSystem>`; - - const r3CodeSystemXML = `<?xml version="1.0" encoding="UTF-8"?> -<CodeSystem xmlns="http://hl7.org/fhir"> - <url value="http://example.org/fhir/CodeSystem/r3-xml-test"/> - <identifier> - <system value="http://example.org/identifiers"/> - <value value="r3-identifier"/> - </identifier> - <name value="R3XMLTestCodeSystem"/> - <status value="active"/> - <concept> - <code value="test-concept"/> - <display value="Test Concept"/> - </concept> -</CodeSystem>`; - - test('should validate FHIR CodeSystem XML', () => { - expect(CodeSystemXML.isValidCodeSystemXML(r5CodeSystemXML)).toBe(true); - expect(CodeSystemXML.isValidCodeSystemXML(r3CodeSystemXML)).toBe(true); - - const invalidXML = '<Patient><name>Not a CodeSystem</name></Patient>'; - expect(CodeSystemXML.isValidCodeSystemXML(invalidXML)).toBe(false); - }); - - test('should load CodeSystem from R5 XML', () => { - const cs = CodeSystemXML.fromXML(r5CodeSystemXML, 'R5'); - - expect(cs.getFHIRVersion()).toBe('R5'); - expect(cs.jsonObj.url).toBe('http://example.org/fhir/CodeSystem/xml-test'); - expect(cs.jsonObj.name).toBe('XMLTestCodeSystem'); - expect(cs.jsonObj.versionAlgorithmString).toBe('semver'); - - // Check identifier array - expect(Array.isArray(cs.jsonObj.identifier)).toBe(true); - expect(cs.jsonObj.identifier.length).toBe(2); - expect(cs.jsonObj.identifier[0].value).toBe('xml-test-1'); - - // Check filter operator array - expect(cs.jsonObj.filter[0].operator).toContain('generalizes'); - expect(cs.jsonObj.filter[0].operator).toContain('='); - - // Check concept hierarchy - expect(cs.hasCode('parent')).toBe(true); - expect(cs.hasCode('child1')).toBe(true); - expect(cs.getChildren('parent')).toContain('child1'); - expect(cs.getChildren('parent')).toContain('child2'); - }); - - test('should load CodeSystem from R3 XML with identifier conversion', () => { - const cs = CodeSystemXML.fromXML(r3CodeSystemXML, 'R3'); - - expect(cs.getFHIRVersion()).toBe('R3'); - expect(cs.hasCode('test-concept')).toBe(true); - - // Should have converted single identifier to array internally - expect(Array.isArray(cs.jsonObj.identifier)).toBe(true); - expect(cs.jsonObj.identifier.length).toBe(1); - expect(cs.jsonObj.identifier[0].value).toBe('r3-identifier'); - }); - - test('should convert CodeSystem to R5 XML', () => { - const cs = CodeSystemXML.fromXML(r5CodeSystemXML, 'R5'); - const xmlOutput = CodeSystemXML.toXMLString(cs, 'R5'); - - expect(xmlOutput).toContain('<CodeSystem xmlns="http://hl7.org/fhir">'); - expect(xmlOutput).toContain('<url value="http://example.org/fhir/CodeSystem/xml-test"/>'); - expect(xmlOutput).toContain('<versionAlgorithmString value="semver"/>'); - expect(xmlOutput).toContain('<operator value="generalizes"/>'); - - // Should have multiple identifier elements - const identifierMatches = xmlOutput.match(/<identifier>/g); - expect(identifierMatches).toHaveLength(2); - }); - - test('should convert CodeSystem to R4 XML without R5 elements', () => { - const cs = CodeSystemXML.fromXML(r5CodeSystemXML, 'R5'); - const xmlOutput = CodeSystemXML.toXMLString(cs, 'R4'); - - expect(xmlOutput).toContain('<CodeSystem xmlns="http://hl7.org/fhir">'); - expect(xmlOutput).not.toContain('versionAlgorithmString'); - expect(xmlOutput).not.toContain('<operator value="generalizes"/>'); - expect(xmlOutput).toContain('<operator value="="/>'); - expect(xmlOutput).toContain('<operator value="is-a"/>'); - - // Should still have multiple identifier elements in R4 - const identifierMatches = xmlOutput.match(/<identifier>/g); - expect(identifierMatches).toHaveLength(2); - }); - - test('should convert CodeSystem to R3 XML with single identifier', () => { - const cs = CodeSystemXML.fromXML(r5CodeSystemXML, 'R5'); - const xmlOutput = CodeSystemXML.toXMLString(cs, 'R3'); - - expect(xmlOutput).toContain('<CodeSystem xmlns="http://hl7.org/fhir">'); - expect(xmlOutput).not.toContain('versionAlgorithmString'); - expect(xmlOutput).not.toContain('<operator value="generalizes"/>'); - - // Should have only one identifier element in R3 - const identifierMatches = xmlOutput.match(/<identifier>/g); - expect(identifierMatches).toHaveLength(1); - expect(xmlOutput).toContain('<value value="xml-test-1"/>'); // First identifier - }); - - test('should handle nested concepts in XML', () => { - const cs = CodeSystemXML.fromXML(r5CodeSystemXML, 'R5'); - - // Check that nested concepts are properly parsed - expect(cs.hasCode('parent')).toBe(true); - expect(cs.hasCode('child1')).toBe(true); - expect(cs.hasCode('child2')).toBe(true); - expect(cs.hasCode('standalone')).toBe(true); - - // Check hierarchy relationships - expect(cs.getChildren('parent')).toContain('child1'); - expect(cs.getChildren('parent')).toContain('child2'); - expect(cs.getParents('child1')).toContain('parent'); - expect(cs.getRootConcepts()).toContain('parent'); - expect(cs.getRootConcepts()).toContain('standalone'); - }); - - test('should handle filter operators array correctly', () => { - const cs = CodeSystemXML.fromXML(r5CodeSystemXML, 'R5'); - - // Check that operators are properly parsed as array - expect(cs.jsonObj.filter).toHaveLength(1); - expect(Array.isArray(cs.jsonObj.filter[0].operator)).toBe(true); - expect(cs.jsonObj.filter[0].operator).toEqual(['=', 'is-a', 'generalizes', 'regex']); - }); - - test('should round-trip conversion preserve data', () => { - // R5: XML -> CodeSystem -> XML - const cs = CodeSystemXML.fromXML(r5CodeSystemXML, 'R5'); - const xmlOutput = CodeSystemXML.toXMLString(cs, 'R5'); - const cs2 = CodeSystemXML.fromXML(xmlOutput, 'R5'); - - expect(cs2.jsonObj.url).toBe(cs.jsonObj.url); - expect(cs2.jsonObj.name).toBe(cs.jsonObj.name); - expect(cs2.getAllCodes()).toEqual(cs.getAllCodes()); - expect(cs2.jsonObj.versionAlgorithmString).toBe(cs.jsonObj.versionAlgorithmString); - }); - - test('should handle cross-version XML conversion', () => { - // Load R3 XML, output as R4 XML - const cs = CodeSystemXML.fromXML(r3CodeSystemXML, 'R3'); - const r4XML = CodeSystemXML.toXMLString(cs, 'R4'); - - expect(r4XML).toContain('<CodeSystem xmlns="http://hl7.org/fhir">'); - expect(r4XML).toContain('<identifier>'); // Should still have identifier - - // Load the R4 XML back - const cs2 = CodeSystemXML.fromXML(r4XML, 'R4'); - expect(cs2.hasCode('test-concept')).toBe(true); - expect(cs2.getFHIRVersion()).toBe('R4'); - }); - - test('should handle empty or minimal CodeSystems', () => { - const minimalXML = `<?xml version="1.0" encoding="UTF-8"?> -<CodeSystem xmlns="http://hl7.org/fhir"> - <url value="http://example.org/minimal"/> - <name value="Minimal"/> - <status value="draft"/> -</CodeSystem>`; - - const cs = CodeSystemXML.fromXML(minimalXML, 'R5'); - expect(cs.jsonObj.url).toBe('http://example.org/minimal'); - expect(cs.getAllCodes()).toEqual([]); - - const xmlOutput = CodeSystemXML.toXMLString(cs, 'R4'); - expect(xmlOutput).toContain('<name value="Minimal"/>'); - }); -}); - /** * Additional tests for enhanced CodeSystem validation * Add these to your existing CodeSystem test suite diff --git a/tests/tx/xml.test.js b/tests/tx/xml.test.js new file mode 100644 index 0000000..14bb15f --- /dev/null +++ b/tests/tx/xml.test.js @@ -0,0 +1,754 @@ +/** + * Test cases for FHIR XML serialization/deserialization + * Tests the FhirXmlBase class and resource-specific XML classes + */ +const { FhirXmlBase, FhirXmlParser } = require('../../tx/xml/xml-base'); +const { CodeSystemXML } = require('../../tx/xml/codesystem-xml'); +const { ValueSetXML } = require('../../tx/xml/valueset-xml'); +const { ParametersXML } = require('../../tx/xml/parameters-xml'); +const { OperationOutcomeXML } = require('../../tx/xml/operationoutcome-xml'); +const { ConceptMapXML } = require('../../tx/xml/conceptmap-xml'); + +describe('FhirXmlBase', () => { + + describe('XML Parsing Utilities', () => { + + test('should escape XML special characters', () => { + expect(FhirXmlBase.escapeXml('a < b')).toBe('a < b'); + expect(FhirXmlBase.escapeXml('a > b')).toBe('a > b'); + expect(FhirXmlBase.escapeXml('a & b')).toBe('a & b'); + expect(FhirXmlBase.escapeXml('a "b" c')).toBe('a "b" c'); + expect(FhirXmlBase.escapeXml("a 'b' c")).toBe("a 'b' c"); + expect(FhirXmlBase.escapeXml(null)).toBe(''); + expect(FhirXmlBase.escapeXml(undefined)).toBe(''); + }); + + test('should unescape XML entities', () => { + expect(FhirXmlBase.unescapeXml('a < b')).toBe('a < b'); + expect(FhirXmlBase.unescapeXml('a > b')).toBe('a > b'); + expect(FhirXmlBase.unescapeXml('a & b')).toBe('a & b'); + expect(FhirXmlBase.unescapeXml('a "b" c')).toBe('a "b" c'); + expect(FhirXmlBase.unescapeXml("a 'b' c")).toBe("a 'b' c"); + }); + + test('should generate correct indentation', () => { + expect(FhirXmlBase.indent(0)).toBe(''); + expect(FhirXmlBase.indent(1)).toBe(' '); + expect(FhirXmlBase.indent(2)).toBe(' '); + expect(FhirXmlBase.indent(3)).toBe(' '); + }); + + test('should return FHIR namespace', () => { + expect(FhirXmlBase.getNamespace()).toBe('http://hl7.org/fhir'); + }); + }); + + describe('Primitive Value Conversion', () => { + + test('should convert boolean element values', () => { + expect(FhirXmlBase._convertPrimitiveValue('valueBoolean', 'true')).toBe(true); + expect(FhirXmlBase._convertPrimitiveValue('valueBoolean', 'false')).toBe(false); + expect(FhirXmlBase._convertPrimitiveValue('experimental', 'true')).toBe(true); + expect(FhirXmlBase._convertPrimitiveValue('caseSensitive', 'false')).toBe(false); + expect(FhirXmlBase._convertPrimitiveValue('inactive', 'true')).toBe(true); + }); + + test('should convert integer element values', () => { + expect(FhirXmlBase._convertPrimitiveValue('valueInteger', '42')).toBe(42); + expect(FhirXmlBase._convertPrimitiveValue('valueUnsignedInt', '100')).toBe(100); + expect(FhirXmlBase._convertPrimitiveValue('count', '5')).toBe(5); + expect(FhirXmlBase._convertPrimitiveValue('offset', '10')).toBe(10); + expect(FhirXmlBase._convertPrimitiveValue('total', '1000')).toBe(1000); + }); + + test('should convert decimal element values', () => { + expect(FhirXmlBase._convertPrimitiveValue('valueDecimal', '3.14')).toBeCloseTo(3.14); + expect(FhirXmlBase._convertPrimitiveValue('valueDecimal', '0.001')).toBeCloseTo(0.001); + }); + + test('should keep string values as strings', () => { + // Filter values should NOT be converted to boolean even if they look like booleans + expect(FhirXmlBase._convertPrimitiveValue('value', 'true')).toBe('true'); + expect(FhirXmlBase._convertPrimitiveValue('value', 'false')).toBe('false'); + expect(FhirXmlBase._convertPrimitiveValue('code', 'active')).toBe('active'); + expect(FhirXmlBase._convertPrimitiveValue('display', 'Test Display')).toBe('Test Display'); + }); + }); + + describe('Array Element Detection', () => { + + test('should identify known array elements', () => { + expect(FhirXmlBase._isArrayElement('coding', 'any')).toBe(true); + expect(FhirXmlBase._isArrayElement('extension', 'any')).toBe(true); + expect(FhirXmlBase._isArrayElement('identifier', 'any')).toBe(true); + expect(FhirXmlBase._isArrayElement('concept', 'any')).toBe(true); + expect(FhirXmlBase._isArrayElement('include', 'any')).toBe(true); + expect(FhirXmlBase._isArrayElement('filter', 'any')).toBe(true); + }); + + test('should handle context-dependent property element', () => { + // property is an array inside concept but NOT inside filter + expect(FhirXmlBase._isArrayElement('property', 'concept')).toBe(true); + expect(FhirXmlBase._isArrayElement('property', 'CodeSystem')).toBe(true); + expect(FhirXmlBase._isArrayElement('property', 'filter')).toBe(false); + }); + + test('should identify non-array elements', () => { + expect(FhirXmlBase._isArrayElement('url', 'any')).toBe(false); + expect(FhirXmlBase._isArrayElement('version', 'any')).toBe(false); + expect(FhirXmlBase._isArrayElement('status', 'any')).toBe(false); + expect(FhirXmlBase._isArrayElement('name', 'any')).toBe(false); + }); + }); +}); + +describe('FhirXmlParser', () => { + + test('should parse simple self-closing element', () => { + const xml = '<status value="active"/>'; + const parser = new FhirXmlParser(xml); + const result = parser.parse(); + + expect(result.name).toBe('status'); + expect(result.attributes.value).toBe('active'); + expect(result.children).toEqual([]); + }); + + test('should parse element with children', () => { + const xml = `<identifier> + <system value="http://example.org"/> + <value value="test-123"/> + </identifier>`; + const parser = new FhirXmlParser(xml); + const result = parser.parse(); + + expect(result.name).toBe('identifier'); + expect(result.children.length).toBe(2); + expect(result.children[0].name).toBe('system'); + expect(result.children[0].attributes.value).toBe('http://example.org'); + expect(result.children[1].name).toBe('value'); + expect(result.children[1].attributes.value).toBe('test-123'); + }); + + test('should parse XML declaration', () => { + const xml = `<?xml version="1.0" encoding="UTF-8"?> + <status value="active"/>`; + const parser = new FhirXmlParser(xml); + const result = parser.parse(); + + expect(result.name).toBe('status'); + expect(result.attributes.value).toBe('active'); + }); + + test('should unescape XML entities in attribute values', () => { + const xml = '<display value="Test & Display <1>"/>'; + const parser = new FhirXmlParser(xml); + const result = parser.parse(); + + expect(result.attributes.value).toBe('Test & Display <1>'); + }); + + test('should handle extension url attribute', () => { + const xml = `<extension url="http://example.org/ext"> + <valueString value="test"/> + </extension>`; + const parser = new FhirXmlParser(xml); + const result = parser.parse(); + + expect(result.name).toBe('extension'); + expect(result.attributes.url).toBe('http://example.org/ext'); + expect(result.children[0].name).toBe('valueString'); + }); +}); + +describe('CodeSystemXML', () => { + + const simpleCodeSystemXml = `<?xml version="1.0" encoding="UTF-8"?> +<CodeSystem xmlns="http://hl7.org/fhir"> + <url value="http://example.org/fhir/CodeSystem/test"/> + <version value="1.0.0"/> + <name value="TestCodeSystem"/> + <status value="active"/> + <caseSensitive value="true"/> + <concept> + <code value="A"/> + <display value="Concept A"/> + </concept> + <concept> + <code value="B"/> + <display value="Concept B"/> + </concept> +</CodeSystem>`; + + const nestedConceptXml = `<?xml version="1.0" encoding="UTF-8"?> +<CodeSystem xmlns="http://hl7.org/fhir"> + <url value="http://example.org/fhir/CodeSystem/nested"/> + <name value="NestedCodeSystem"/> + <status value="active"/> + <concept> + <code value="parent"/> + <display value="Parent"/> + <concept> + <code value="child1"/> + <display value="Child 1"/> + </concept> + <concept> + <code value="child2"/> + <display value="Child 2"/> + </concept> + </concept> +</CodeSystem>`; + + const codeSystemWithDesignationsXml = `<?xml version="1.0" encoding="UTF-8"?> +<CodeSystem xmlns="http://hl7.org/fhir"> + <url value="http://example.org/fhir/CodeSystem/designations"/> + <name value="DesignationCodeSystem"/> + <status value="active"/> + <concept> + <code value="A"/> + <display value="Concept A"/> + <designation> + <language value="de"/> + <value value="Konzept A"/> + </designation> + <designation> + <language value="fr"/> + <value value="Concept A (fr)"/> + </designation> + </concept> +</CodeSystem>`; + + const codeSystemWithPropertiesXml = `<?xml version="1.0" encoding="UTF-8"?> +<CodeSystem xmlns="http://hl7.org/fhir"> + <url value="http://example.org/fhir/CodeSystem/properties"/> + <name value="PropertyCodeSystem"/> + <status value="active"/> + <property> + <code value="status"/> + <type value="code"/> + </property> + <concept> + <code value="A"/> + <display value="Concept A"/> + <property> + <code value="status"/> + <valueCode value="active"/> + </property> + <property> + <code value="notSelectable"/> + <valueBoolean value="true"/> + </property> + </concept> +</CodeSystem>`; + + describe('fromXml', () => { + + test('should parse simple CodeSystem', () => { + const result = CodeSystemXML.fromXml(simpleCodeSystemXml, 5); + + expect(result.resourceType).toBe('CodeSystem'); + expect(result.url).toBe('http://example.org/fhir/CodeSystem/test'); + expect(result.version).toBe('1.0.0'); + expect(result.name).toBe('TestCodeSystem'); + expect(result.status).toBe('active'); + expect(result.caseSensitive).toBe(true); + expect(result.concept).toHaveLength(2); + expect(result.concept[0].code).toBe('A'); + expect(result.concept[1].code).toBe('B'); + }); + + test('should parse nested concepts', () => { + const result = CodeSystemXML.fromXml(nestedConceptXml, 5); + + expect(result.concept).toHaveLength(1); + expect(result.concept[0].code).toBe('parent'); + expect(result.concept[0].concept).toHaveLength(2); + expect(result.concept[0].concept[0].code).toBe('child1'); + expect(result.concept[0].concept[1].code).toBe('child2'); + }); + + test('should parse designations as arrays', () => { + const result = CodeSystemXML.fromXml(codeSystemWithDesignationsXml, 5); + + expect(result.concept[0].designation).toHaveLength(2); + expect(result.concept[0].designation[0].language).toBe('de'); + expect(result.concept[0].designation[0].value).toBe('Konzept A'); + expect(result.concept[0].designation[1].language).toBe('fr'); + }); + + test('should parse concept properties as arrays with correct types', () => { + const result = CodeSystemXML.fromXml(codeSystemWithPropertiesXml, 5); + + // Resource-level property + expect(result.property).toHaveLength(1); + expect(result.property[0].code).toBe('status'); + + // Concept-level properties + expect(result.concept[0].property).toHaveLength(2); + expect(result.concept[0].property[0].code).toBe('status'); + expect(result.concept[0].property[0].valueCode).toBe('active'); + expect(result.concept[0].property[1].valueBoolean).toBe(true); + }); + }); + + describe('toXml', () => { + + test('should generate valid XML', () => { + const json = { + resourceType: 'CodeSystem', + url: 'http://example.org/test', + name: 'Test', + status: 'active', + concept: [ + { code: 'A', display: 'Concept A' } + ] + }; + + const xml = CodeSystemXML.toXml(json, 5); + + expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>'); + expect(xml).toContain('<CodeSystem xmlns="http://hl7.org/fhir">'); + expect(xml).toContain('<url value="http://example.org/test"/>'); + expect(xml).toContain('<name value="Test"/>'); + expect(xml).toContain('<status value="active"/>'); + expect(xml).toContain('<code value="A"/>'); + expect(xml).toContain('</CodeSystem>'); + }); + + test('should escape special characters', () => { + const json = { + resourceType: 'CodeSystem', + url: 'http://example.org/test', + name: 'Test & Demo', + status: 'active' + }; + + const xml = CodeSystemXML.toXml(json, 5); + expect(xml).toContain('<name value="Test & Demo"/>'); + }); + }); + + describe('round-trip', () => { + + test('should preserve data through XML round-trip', () => { + const original = CodeSystemXML.fromXml(simpleCodeSystemXml, 5); + const xml = CodeSystemXML.toXml(original, 5); + const restored = CodeSystemXML.fromXml(xml, 5); + + expect(restored.url).toBe(original.url); + expect(restored.name).toBe(original.name); + expect(restored.status).toBe(original.status); + expect(restored.concept.length).toBe(original.concept.length); + }); + }); +}); + +describe('ValueSetXML', () => { + + const simpleValueSetXml = `<?xml version="1.0" encoding="UTF-8"?> +<ValueSet xmlns="http://hl7.org/fhir"> + <url value="http://example.org/fhir/ValueSet/test"/> + <name value="TestValueSet"/> + <status value="active"/> + <compose> + <include> + <system value="http://example.org/CodeSystem/test"/> + </include> + </compose> +</ValueSet>`; + + const valueSetWithFilterXml = `<?xml version="1.0" encoding="UTF-8"?> +<ValueSet xmlns="http://hl7.org/fhir"> + <url value="http://example.org/fhir/ValueSet/filtered"/> + <name value="FilteredValueSet"/> + <status value="active"/> + <compose> + <include> + <system value="http://example.org/CodeSystem/test"/> + <filter> + <property value="concept"/> + <op value="is-a"/> + <value value="parent"/> + </filter> + <filter> + <property value="status"/> + <op value="="/> + <value value="active"/> + </filter> + </include> + </compose> +</ValueSet>`; + + const valueSetWithExtensionXml = `<?xml version="1.0" encoding="UTF-8"?> +<ValueSet xmlns="http://hl7.org/fhir"> + <url value="http://example.org/fhir/ValueSet/extension"/> + <name value="ExtensionValueSet"/> + <status value="active"/> + <compose> + <extension url="http://example.org/ext/compose-param"> + <extension url="name"> + <valueCode value="displayLanguage"/> + </extension> + <extension url="value"> + <valueCode value="de"/> + </extension> + </extension> + <include> + <system value="http://example.org/CodeSystem/test"/> + </include> + </compose> +</ValueSet>`; + + describe('fromXml', () => { + + test('should parse simple ValueSet', () => { + const result = ValueSetXML.fromXml(simpleValueSetXml, 5); + + expect(result.resourceType).toBe('ValueSet'); + expect(result.url).toBe('http://example.org/fhir/ValueSet/test'); + expect(result.name).toBe('TestValueSet'); + expect(result.compose.include).toHaveLength(1); + expect(result.compose.include[0].system).toBe('http://example.org/CodeSystem/test'); + }); + + test('should parse filters with property as single value (not array)', () => { + const result = ValueSetXML.fromXml(valueSetWithFilterXml, 5); + + expect(result.compose.include[0].filter).toHaveLength(2); + + // property should be a string, NOT an array + expect(result.compose.include[0].filter[0].property).toBe('concept'); + expect(typeof result.compose.include[0].filter[0].property).toBe('string'); + + expect(result.compose.include[0].filter[0].op).toBe('is-a'); + expect(result.compose.include[0].filter[0].value).toBe('parent'); + + expect(result.compose.include[0].filter[1].property).toBe('status'); + expect(result.compose.include[0].filter[1].value).toBe('active'); + }); + + test('should parse filter value as string even when it looks like boolean', () => { + const xml = `<?xml version="1.0" encoding="UTF-8"?> +<ValueSet xmlns="http://hl7.org/fhir"> + <url value="http://example.org/fhir/ValueSet/bool-filter"/> + <name value="BoolFilterValueSet"/> + <status value="active"/> + <compose> + <include> + <system value="http://example.org/CodeSystem/test"/> + <filter> + <property value="notSelectable"/> + <op value="="/> + <value value="false"/> + </filter> + </include> + </compose> +</ValueSet>`; + + const result = ValueSetXML.fromXml(xml, 5); + + // filter.value should be string "false", not boolean false + expect(result.compose.include[0].filter[0].value).toBe('false'); + expect(typeof result.compose.include[0].filter[0].value).toBe('string'); + }); + + test('should parse nested extensions correctly', () => { + const result = ValueSetXML.fromXml(valueSetWithExtensionXml, 5); + + // compose should have extension array + expect(result.compose.extension).toHaveLength(1); + expect(result.compose.extension[0].url).toBe('http://example.org/ext/compose-param'); + + // nested extensions + expect(result.compose.extension[0].extension).toHaveLength(2); + expect(result.compose.extension[0].extension[0].url).toBe('name'); + expect(result.compose.extension[0].extension[0].valueCode).toBe('displayLanguage'); + expect(result.compose.extension[0].extension[1].url).toBe('value'); + expect(result.compose.extension[0].extension[1].valueCode).toBe('de'); + + // include should still be there + expect(result.compose.include).toHaveLength(1); + }); + }); + + describe('Primitive Extensions', () => { + + test('should parse primitive extension with no value', () => { + const xml = `<?xml version="1.0" encoding="UTF-8"?> +<ValueSet xmlns="http://hl7.org/fhir"> + <url value="http://example.org/fhir/ValueSet/prim-ext"/> + <name value="PrimitiveExtValueSet"/> + <status value="active"/> + <compose> + <include> + <system value="http://example.org/CodeSystem/test"/> + <filter> + <property value="concept"/> + <op value="is-a"/> + <value> + <extension url="http://hl7.org/fhir/StructureDefinition/data-absent-reason"> + <valueCode value="not-applicable"/> + </extension> + </value> + </filter> + </include> + </compose> +</ValueSet>`; + + const result = ValueSetXML.fromXml(xml, 5); + + // filter.value should be null/undefined and _value should have extension + expect(result.compose.include[0].filter[0].value).toBeUndefined(); + expect(result.compose.include[0].filter[0]._value).toBeDefined(); + expect(result.compose.include[0].filter[0]._value.extension).toHaveLength(1); + expect(result.compose.include[0].filter[0]._value.extension[0].url).toBe('http://hl7.org/fhir/StructureDefinition/data-absent-reason'); + expect(result.compose.include[0].filter[0]._value.extension[0].valueCode).toBe('not-applicable'); + }); + }); +}); + +describe('ParametersXML', () => { + + const simpleParametersXml = `<?xml version="1.0" encoding="UTF-8"?> +<Parameters xmlns="http://hl7.org/fhir"> + <parameter> + <name value="url"/> + <valueUri value="http://example.org/ValueSet/test"/> + </parameter> + <parameter> + <name value="count"/> + <valueInteger value="100"/> + </parameter> + <parameter> + <name value="active"/> + <valueBoolean value="true"/> + </parameter> +</Parameters>`; + + const parametersWithResourceXml = `<?xml version="1.0" encoding="UTF-8"?> +<Parameters xmlns="http://hl7.org/fhir"> + <parameter> + <name value="tx-resource"/> + <resource> + <CodeSystem xmlns="http://hl7.org/fhir"> + <url value="http://example.org/CodeSystem/embedded"/> + <name value="EmbeddedCodeSystem"/> + <status value="active"/> + <concept> + <code value="A"/> + <display value="Concept A"/> + </concept> + </CodeSystem> + </resource> + </parameter> +</Parameters>`; + + const parametersWithPartsXml = `<?xml version="1.0" encoding="UTF-8"?> +<Parameters xmlns="http://hl7.org/fhir"> + <parameter> + <name value="result"/> + <part> + <name value="code"/> + <valueCode value="ABC"/> + </part> + <part> + <name value="display"/> + <valueString value="ABC Display"/> + </part> + </parameter> +</Parameters>`; + + describe('fromXml', () => { + + test('should parse simple parameters', () => { + const result = ParametersXML.fromXml(simpleParametersXml, 5); + + expect(result.resourceType).toBe('Parameters'); + expect(result.parameter).toHaveLength(3); + + expect(result.parameter[0].name).toBe('url'); + expect(result.parameter[0].valueUri).toBe('http://example.org/ValueSet/test'); + + expect(result.parameter[1].name).toBe('count'); + expect(result.parameter[1].valueInteger).toBe(100); + + expect(result.parameter[2].name).toBe('active'); + expect(result.parameter[2].valueBoolean).toBe(true); + }); + + test('should parse embedded resource', () => { + const result = ParametersXML.fromXml(parametersWithResourceXml, 5); + + expect(result.parameter[0].name).toBe('tx-resource'); + expect(result.parameter[0].resource).toBeDefined(); + expect(result.parameter[0].resource.resourceType).toBe('CodeSystem'); + expect(result.parameter[0].resource.url).toBe('http://example.org/CodeSystem/embedded'); + expect(result.parameter[0].resource.concept).toHaveLength(1); + expect(result.parameter[0].resource.concept[0].code).toBe('A'); + }); + + test('should parse nested parts', () => { + const result = ParametersXML.fromXml(parametersWithPartsXml, 5); + + expect(result.parameter[0].name).toBe('result'); + expect(result.parameter[0].part).toHaveLength(2); + expect(result.parameter[0].part[0].name).toBe('code'); + expect(result.parameter[0].part[0].valueCode).toBe('ABC'); + expect(result.parameter[0].part[1].name).toBe('display'); + expect(result.parameter[0].part[1].valueString).toBe('ABC Display'); + }); + + test('should parse complex valueCodeableConcept with coding array', () => { + const xml = `<?xml version="1.0" encoding="UTF-8"?> +<Parameters xmlns="http://hl7.org/fhir"> + <parameter> + <name value="result"/> + <valueCodeableConcept> + <coding> + <system value="http://example.org"/> + <code value="ABC"/> + </coding> + </valueCodeableConcept> + </parameter> +</Parameters>`; + + const result = ParametersXML.fromXml(xml, 5); + + expect(result.parameter[0].valueCodeableConcept).toBeDefined(); + expect(result.parameter[0].valueCodeableConcept.coding).toHaveLength(1); + expect(result.parameter[0].valueCodeableConcept.coding[0].system).toBe('http://example.org'); + expect(result.parameter[0].valueCodeableConcept.coding[0].code).toBe('ABC'); + }); + }); + + describe('toXml', () => { + + test('should generate valid XML', () => { + const json = { + resourceType: 'Parameters', + parameter: [ + { name: 'url', valueUri: 'http://example.org/test' }, + { name: 'active', valueBoolean: true } + ] + }; + + const xml = ParametersXML.toXml(json, 5); + + expect(xml).toContain('<Parameters xmlns="http://hl7.org/fhir">'); + expect(xml).toContain('<name value="url"/>'); + expect(xml).toContain('<valueUri value="http://example.org/test"/>'); + expect(xml).toContain('<valueBoolean value="true"/>'); + }); + }); +}); + +describe('OperationOutcomeXML', () => { + + const simpleOutcomeXml = `<?xml version="1.0" encoding="UTF-8"?> +<OperationOutcome xmlns="http://hl7.org/fhir"> + <issue> + <severity value="error"/> + <code value="invalid"/> + <diagnostics value="Invalid code"/> + </issue> +</OperationOutcome>`; + + const multiIssueOutcomeXml = `<?xml version="1.0" encoding="UTF-8"?> +<OperationOutcome xmlns="http://hl7.org/fhir"> + <issue> + <severity value="error"/> + <code value="invalid"/> + <diagnostics value="First error"/> + </issue> + <issue> + <severity value="warning"/> + <code value="informational"/> + <diagnostics value="A warning"/> + </issue> +</OperationOutcome>`; + + describe('fromXml', () => { + + test('should parse simple OperationOutcome', () => { + const result = OperationOutcomeXML.fromXml(simpleOutcomeXml, 5); + + expect(result.resourceType).toBe('OperationOutcome'); + expect(result.issue).toHaveLength(1); + expect(result.issue[0].severity).toBe('error'); + expect(result.issue[0].code).toBe('invalid'); + expect(result.issue[0].diagnostics).toBe('Invalid code'); + }); + + test('should parse multiple issues', () => { + const result = OperationOutcomeXML.fromXml(multiIssueOutcomeXml, 5); + + expect(result.issue).toHaveLength(2); + expect(result.issue[0].severity).toBe('error'); + expect(result.issue[1].severity).toBe('warning'); + }); + }); + + describe('toXml', () => { + + test('should generate valid XML with correct element order', () => { + const json = { + resourceType: 'OperationOutcome', + issue: [ + { + severity: 'error', + code: 'invalid', + diagnostics: 'Test error' + } + ] + }; + + const xml = OperationOutcomeXML.toXml(json, 5); + + expect(xml).toContain('<OperationOutcome xmlns="http://hl7.org/fhir">'); + expect(xml).toContain('<severity value="error"/>'); + expect(xml).toContain('<code value="invalid"/>'); + + // Check element order: severity should come before code + const severityIndex = xml.indexOf('<severity'); + const codeIndex = xml.indexOf('<code'); + expect(severityIndex).toBeLessThan(codeIndex); + }); + }); +}); + +describe('ConceptMapXML', () => { + + const simpleConceptMapXml = `<?xml version="1.0" encoding="UTF-8"?> +<ConceptMap xmlns="http://hl7.org/fhir"> + <url value="http://example.org/fhir/ConceptMap/test"/> + <name value="TestConceptMap"/> + <status value="active"/> + <group> + <source value="http://example.org/source"/> + <target value="http://example.org/target"/> + <element> + <code value="A"/> + <target> + <code value="X"/> + <relationship value="equivalent"/> + </target> + </element> + </group> +</ConceptMap>`; + + describe('fromXml', () => { + + test('should parse simple ConceptMap', () => { + const result = ConceptMapXML.fromXml(simpleConceptMapXml, 5); + + expect(result.resourceType).toBe('ConceptMap'); + expect(result.url).toBe('http://example.org/fhir/ConceptMap/test'); + expect(result.group).toHaveLength(1); + expect(result.group[0].source).toBe('http://example.org/source'); + expect(result.group[0].element).toHaveLength(1); + expect(result.group[0].element[0].code).toBe('A'); + expect(result.group[0].element[0].target).toHaveLength(1); + expect(result.group[0].element[0].target[0].code).toBe('X'); + }); + }); +}); \ No newline at end of file diff --git a/tx/README.md b/tx/README.md index 8df3133..2b89952 100644 --- a/tx/README.md +++ b/tx/README.md @@ -8,7 +8,6 @@ The TX module provides FHIR terminology services for CodeSystem, ValueSet, and C * add more tests for the code system providers - filters, extended lookup, designations and languages * more refactoring in validate.js and expand.js * full batch support -* check R4 and R3 support * check vsac support * get tx tests running in pipelines diff --git a/tx/cm/cm-api.js b/tx/cm/cm-api.js index 44989bb..a44db2d 100644 --- a/tx/cm/cm-api.js +++ b/tx/cm/cm-api.js @@ -94,6 +94,10 @@ class AbstractConceptMapProvider { async findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem) { // nothing } + + cmCount() { + return 0; + } } module.exports = { diff --git a/tx/cm/cm-database.js b/tx/cm/cm-database.js index 737f673..d825625 100644 --- a/tx/cm/cm-database.js +++ b/tx/cm/cm-database.js @@ -10,6 +10,8 @@ const INDEXED_COLUMNS = ['id', 'url', 'version', 'date', 'description', 'name', * Handles SQLite operations for indexing and searching ConceptMaps */ class ConceptMapDatabase { + cmCount; + /** * @param {string} dbPath - Path to the SQLite database file */ @@ -344,6 +346,7 @@ class ConceptMapDatabase { try { const conceptMapMap = new Map(); + this.cmCount = rows.length; for (const row of rows) { const conceptMap = new ConceptMap(JSON.parse(row.content)); diff --git a/tx/cm/cm-package.js b/tx/cm/cm-package.js index 653afcd..7a8a3c1 100644 --- a/tx/cm/cm-package.js +++ b/tx/cm/cm-package.js @@ -314,6 +314,9 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider { } } + cmCount() { + return this.database.cmCount; +} } diff --git a/tx/library.js b/tx/library.js index 008ad48..17d9dda 100644 --- a/tx/library.js +++ b/tx/library.js @@ -59,6 +59,8 @@ class Library { */ conceptMapProviders; + contentSources = []; + baseUrl = null; cacheFolder = null; startTime = Date.now(); @@ -394,6 +396,8 @@ class Library { const contentLoader = new PackageContentLoader(fullPackagePath); await contentLoader.initialize(); + this.contentSources.push(contentLoader.id()+"#"+contentLoader.version()); + let cp = new ListCodeSystemProvider(); const resources = await contentLoader.getResourcesByType("CodeSystem"); let csc = 0; @@ -542,6 +546,17 @@ class Library { provider.codeSystems = new Map(); provider.valueSetProviders = []; provider.conceptMapProviders = []; + if (VersionUtilities.isR5Ver(fhirVersion)) { + provider.fhirVersion = 5; + } else if (VersionUtilities.isR4Ver(fhirVersion)) { + provider.fhirVersion = 4; + } else if (VersionUtilities.isR3Ver(fhirVersion)) { + provider.fhirVersion = 3; + } else { + provider.fhirVersion = 6; + } + + // Load FHIR core packages first const fhirPackages = this.#getFhirPackagesForVersion(fhirVersion); @@ -571,6 +586,7 @@ class Library { provider.lastTime = this.lastTime; provider.lastMemory = this.lastMemory; provider.totalDownloaded = this.totalDownloaded; + provider.contentSources = this.contentSources; // Now add the existing value set providers after the FHIR core packages diff --git a/tx/library/codesystem-xml.js b/tx/library/codesystem-xml.js deleted file mode 100644 index 1c013e2..0000000 --- a/tx/library/codesystem-xml.js +++ /dev/null @@ -1,573 +0,0 @@ -/** - * XML support for FHIR CodeSystem resources - * Handles conversion between FHIR XML format and CodeSystem objects - */ - -const { XMLParser } = require('fast-xml-parser'); -const { CodeSystem } = require('./codesystem'); - -/** - * XML support for FHIR CodeSystem resources - * @class - */ -class CodeSystemXML { - - /** - * FHIR CodeSystem elements that should be arrays in JSON - * @type {Set<string>} - * @private - */ - static _arrayElements = new Set([ - 'identifier', // R4+: array of identifiers - 'contact', // Array of contact details - 'useContext', // Array of usage contexts - 'jurisdiction', // Array of jurisdictions - 'concept', // Array of concept definitions - 'filter', // Array of filters - 'operator', // Array of filter operators (within filter) - 'property', // Array of property definitions - 'designation', // Array of designations (within concepts) - 'extension', // Array of extensions - 'modifierExtension' // Array of modifier extensions - ]); - - /** - * Creates CodeSystem from FHIR XML string - * @param {string} xmlString - FHIR XML representation of CodeSystem - * @param {string} [version='R5'] - FHIR version ('R3', 'R4', or 'R5') - * @returns {CodeSystem} New CodeSystem instance - */ - static fromXML(xmlString, version = 'R5') { - // Parse XML to JSON using FHIR-aware configuration - const jsonObj = this._xmlToJson(xmlString); - - // Use existing CodeSystem constructor with version conversion - return new CodeSystem(jsonObj, version); - } - - /** - * Converts CodeSystem to FHIR XML string - * @param {CodeSystem} codeSystem - CodeSystem instance - * @param {string} [version='R5'] - Target FHIR version ('R3', 'R4', or 'R5') - * @returns {string} FHIR XML string - */ - static toXMLString(codeSystem, version = 'R5') { - // Get JSON in target version using existing version conversion - const jsonString = codeSystem.toJSONString(version); - let jsonObj = JSON.parse(jsonString); - - // Special handling for R3 format (identifier needs to be a single object) - if (version === 'R3' && jsonObj.identifier && !Array.isArray(jsonObj.identifier)) { - // Already converted to R3 format with single identifier - } else if (version === 'R3' && jsonObj.identifier && Array.isArray(jsonObj.identifier) && jsonObj.identifier.length > 0) { - // Need to ensure identifier is a single object for R3 XML - jsonObj.identifier = jsonObj.identifier[0]; - } - - // Convert JSON to FHIR XML format - return this._jsonToXml(jsonObj, version); - } - - /** - * Converts FHIR XML to JSON object - * @param {string} xmlString - FHIR XML string - * @returns {Object} JSON representation - * @private - */ - static _xmlToJson(xmlString) { - // Fast-xml-parser configuration for FHIR - const parserOptions = { - ignoreAttributes: false, - attributeNamePrefix: '@_', - attributesGroupName: false, - textNodeName: '#text', - // eslint-disable-next-line no-unused-vars - isArray: (name, path, isLeaf, isAttribute) => { - // These elements should always be arrays even if there's only one - return this._arrayElements.has(name); - }, - parseAttributeValue: true, - parseTagValue: false, - trimValues: true, - cdataPropName: '__cdata', - numberParseOptions: { - leadingZeros: false, - hex: true, - skipLike: /^[-+]?0\d+/ - } - }; - - const parser = new XMLParser(parserOptions); - const parsed = parser.parse(xmlString); - - // Get the CodeSystem element - const codeSystem = parsed.CodeSystem; - if (!codeSystem) { - throw new Error('Invalid XML: Missing CodeSystem element'); - } - - // Convert to FHIR JSON format - return this._convertXmlToFhirJson(codeSystem); - } - - /** - * Converts XML parser output to FHIR JSON format - * @param {Object} xmlObj - XML parser output - * @returns {Object} FHIR JSON object - * @private - */ - static _convertXmlToFhirJson(xmlObj) { - const result = { resourceType: 'CodeSystem' }; - - // Process each property - for (const [key, value] of Object.entries(xmlObj)) { - // Skip attributes and namespace - if (key === '@_xmlns' || key.startsWith('@_')) continue; - - if (key === 'identifier') { - // Handle identifier array - result.identifier = this._processIdentifiers(value); - } else if (key === 'filter') { - // Handle filter array - result.filter = this._processFilters(value); - } else if (key === 'concept') { - // Handle concept array - result.concept = this._processConcepts(value); - } else if (typeof value === 'object' && value !== null && value['@_value'] !== undefined) { - // Handle primitive with value attribute - result[key] = value['@_value']; - } else if (typeof value === 'object' && !Array.isArray(value)) { - // Handle complex objects - result[key] = this._processComplexObject(value); - } else { - // Default handling - result[key] = value; - } - } - - return result; - } - - /** - * Process identifier elements from XML - * @param {Object|Array} identifiers - XML parser output for identifiers - * @returns {Array} Array of identifier objects - * @private - */ - static _processIdentifiers(identifiers) { - // Ensure we have an array - const idArray = Array.isArray(identifiers) ? identifiers : [identifiers]; - - return idArray.map(id => { - const result = {}; - - // Process system - if (id.system && id.system['@_value']) { - result.system = id.system['@_value']; - } - - // Process value - if (id.value && id.value['@_value']) { - result.value = id.value['@_value']; - } - - return result; - }); - } - - /** - * Process filter elements from XML - * @param {Object|Array} filters - XML parser output for filters - * @returns {Array} Array of filter objects - * @private - */ - static _processFilters(filters) { - // Ensure we have an array - const filterArray = Array.isArray(filters) ? filters : [filters]; - - return filterArray.map(filter => { - const result = {}; - - // Process code - if (filter.code && filter.code['@_value']) { - result.code = filter.code['@_value']; - } - - // Process operators - if (filter.operator) { - const operators = Array.isArray(filter.operator) ? filter.operator : [filter.operator]; - result.operator = operators.map(op => op['@_value']); - } - - // Process value - if (filter.value && filter.value['@_value']) { - result.value = filter.value['@_value']; - } - - return result; - }); - } - - /** - * Process concept elements from XML - * @param {Object|Array} concepts - XML parser output for concepts - * @returns {Array} Array of concept objects - * @private - */ - static _processConcepts(concepts) { - // Ensure we have an array - const conceptArray = Array.isArray(concepts) ? concepts : [concepts]; - - return conceptArray.map(concept => { - const result = {}; - - // Process code - if (concept.code && concept.code['@_value']) { - result.code = concept.code['@_value']; - } - - // Process display - if (concept.display && concept.display['@_value']) { - result.display = concept.display['@_value']; - } - - // Process nested concepts - if (concept.concept) { - result.concept = this._processConcepts(concept.concept); - } - - // Process properties - if (concept.property) { - result.property = this._processProperties(concept.property); - } - - return result; - }); - } - - /** - * Process property elements from XML - * @param {Object|Array} properties - XML parser output for properties - * @returns {Array} Array of property objects - * @private - */ - static _processProperties(properties) { - // Ensure we have an array - const propArray = Array.isArray(properties) ? properties : [properties]; - - return propArray.map(prop => { - const result = {}; - - // Process code - if (prop.code && prop.code['@_value']) { - result.code = prop.code['@_value']; - } - - // Process valueCode - if (prop.valueCode && prop.valueCode['@_value']) { - result.valueCode = prop.valueCode['@_value']; - } - - return result; - }); - } - - /** - * Process complex object from XML - * @param {Object} obj - XML parser output for complex object - * @returns {Object} Processed object - * @private - */ - static _processComplexObject(obj) { - const result = {}; - - for (const [key, value] of Object.entries(obj)) { - if (key.startsWith('@_')) continue; - - if (typeof value === 'object' && value !== null && value['@_value'] !== undefined) { - result[key] = value['@_value']; - } else if (typeof value === 'object' && !Array.isArray(value)) { - result[key] = this._processComplexObject(value); - } else { - result[key] = value; - } - } - - return result; - } - - /** - * Converts JSON object to FHIR XML - * @param {Object} jsonObj - JSON representation - * @param {string} version - FHIR version for XML namespace - * @returns {string} FHIR XML string - * @private - */ - static _jsonToXml(jsonObj, version = 'R5') { - // Generate XML manually to have full control over the format - let xml = '<?xml version="1.0" encoding="UTF-8"?>\n'; - xml += `<CodeSystem xmlns="${this._getFhirNamespace(version)}">\n`; - - // Add each property as XML elements - xml += this._generateXmlElements(jsonObj, 2); // 2 spaces indentation - - xml += '</CodeSystem>'; - return xml; - } - - /** - * Recursively generates XML elements from JSON - * @param {Object} obj - JSON object to convert - * @param {number} indent - Number of spaces for indentation - * @returns {string} XML elements - * @private - */ - static _generateXmlElements(obj, indent) { - if (!obj || typeof obj !== 'object') return ''; - - const spaces = ' '.repeat(indent); - let xml = ''; - - // Iterate through all properties - for (const [key, value] of Object.entries(obj)) { - // Skip resourceType as it's represented by the root element - if (key === 'resourceType') continue; - - if (Array.isArray(value)) { - // Handle arrays by creating multiple elements with the same name - for (const item of value) { - if (typeof item === 'object') { - // Complex object array element - xml += `${spaces}<${key}>\n`; - xml += this._generateXmlElements(item, indent + 2); - xml += `${spaces}</${key}>\n`; - } else { - // Simple value array element - xml += `${spaces}<${key} value="${this._escapeXml(item)}"/>\n`; - } - } - } else if (typeof value === 'object' && value !== null) { - // Handle complex object - xml += `${spaces}<${key}>\n`; - xml += this._generateXmlElements(value, indent + 2); - xml += `${spaces}</${key}>\n`; - } else if (value !== undefined && value !== null) { - // Handle primitive values - xml += `${spaces}<${key} value="${this._escapeXml(value)}"/>\n`; - } - } - - return xml; - } - - /** - * Escapes special characters in XML - * @param {string|number|boolean} value - Value to escape - * @returns {string} Escaped XML string - * @private - */ - static _escapeXml(value) { - if (value === undefined || value === null) return ''; - - const str = String(value); - return str - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - /** - * Determines if an XML element should be converted to an array in JSON - * @param {string} tagName - XML element name - * @param {string} jPath - JSON path context - * @returns {boolean} True if should be array - * @private - */ - static _shouldBeArray(tagName, jPath) { - // Check if this is a known FHIR array element - if (this._arrayElements.has(tagName)) { - return true; - } - - // Special cases based on context - if (tagName === 'concept' && jPath.includes('concept')) { - // Nested concepts are arrays - return true; - } - - if (tagName === 'property' && jPath.includes('concept')) { - // Properties within concepts are arrays - return true; - } - - if (tagName === 'operator' && jPath.includes('filter')) { - // Operators within filters are arrays - return true; - } - - return false; - } - - /** - * Processes FHIR-specific JSON structure after XML parsing - * @param {Object} obj - Parsed object from XML - * @returns {Object} FHIR-compliant JSON object - * @private - */ - static _processFhirStructure(obj) { - if (!obj || typeof obj !== 'object') { - return obj; - } - - const processed = {}; - - for (const [key, value] of Object.entries(obj)) { - if (key.startsWith('@')) { - // Skip XML attributes that aren't FHIR data - continue; - } - - if (Array.isArray(value)) { - // Process array elements - processed[key] = value.map(item => this._processFhirStructure(item)); - } else if (typeof value === 'object' && value !== null) { - // Handle FHIR primitive types with 'value' attribute - if (value.value !== undefined && Object.keys(value).length === 1) { - // This is a FHIR primitive - extract the value - processed[key] = value.value; - } else { - // Complex object - recurse - processed[key] = this._processFhirStructure(value); - } - } else { - // Simple value - processed[key] = value; - } - } - - // Add resourceType if missing - if (!processed.resourceType) { - processed.resourceType = 'CodeSystem'; - } - - return processed; - } - - /** - * Prepares JSON object for XML conversion (handles FHIR primitives) - * @param {Object} obj - JSON object - * @returns {Object} XML-ready object - * @private - */ - static _prepareForXml(obj) { - if (!obj || typeof obj !== 'object') { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map(item => this._prepareForXml(item)); - } - - const prepared = {}; - - for (const [key, value] of Object.entries(obj)) { - // Skip resourceType - if (key === 'resourceType') continue; - - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - // FHIR primitive - wrap in object with 'value' attribute for XML - prepared[key] = { '@value': value.toString() }; - } else if (Array.isArray(value)) { - // Handle arrays - create multiple elements with the same name - if (value.length > 0) { - prepared[key] = value.map(item => this._prepareForXml(item)); - } - } else if (typeof value === 'object' && value !== null) { - // Complex object - recurse - prepared[key] = this._prepareForXml(value); - } else if (value !== undefined && value !== null) { - prepared[key] = value; - } - } - - return prepared; - } - - /** - * Gets FHIR namespace for XML based on version - * @param {string} version - FHIR version - * @returns {string} FHIR namespace URL - * @private - */ - static _getFhirNamespace(version) { - const namespaces = { - 'R3': 'http://hl7.org/fhir', - 'R4': 'http://hl7.org/fhir', - 'R5': 'http://hl7.org/fhir' - }; - return namespaces[version] || namespaces['R5']; - } - - /** - * Gets the element name for array items in XML - * @param {string} arrayName - Array property name - * @returns {string} Element name for array items - * @private - */ - static _getArrayElementName(arrayName) { - // Most FHIR arrays use singular element names - const singularMap = { - 'identifiers': 'identifier', - 'contacts': 'contact', - 'useContexts': 'useContext', - 'jurisdictions': 'jurisdiction', - 'concepts': 'concept', - 'filters': 'filter', - 'operators': 'operator', - 'properties': 'property', - 'designations': 'designation', - 'extensions': 'extension', - 'modifierExtensions': 'modifierExtension' - }; - - return singularMap[arrayName] || arrayName.replace(/s$/, ''); - } - - /** - * Validates that XML string is a FHIR CodeSystem - * @param {string} xmlString - XML string to validate - * @returns {boolean} True if valid FHIR CodeSystem XML - */ - static isValidCodeSystemXML(xmlString) { - try { - // More precise check using regular expressions - const rootElementRegex = /<CodeSystem\s+[^>]*xmlns\s*=\s*["']http:\/\/hl7\.org\/fhir["'][^>]*>/; - const closingTagRegex = /<\/CodeSystem>/; - - return rootElementRegex.test(xmlString) && closingTagRegex.test(xmlString); - } catch (error) { - return false; - } - } - - /** - * Gets XML parser/builder library info for debugging - * @returns {Object} Library information - */ - static getLibraryInfo() { - return { - library: 'fast-xml-parser', - features: [ - 'High performance XML parsing', - 'Configurable array detection', - 'FHIR attribute handling', - 'Namespace support', - 'Bidirectional conversion' - ] - }; - } -} - -module.exports = CodeSystemXML; \ No newline at end of file diff --git a/tx/library/conceptmap-xml.js b/tx/library/conceptmap-xml.js deleted file mode 100644 index 7ca07a7..0000000 --- a/tx/library/conceptmap-xml.js +++ /dev/null @@ -1,333 +0,0 @@ -/** - * XML support for FHIR ConceptMap resources - * Handles conversion between FHIR XML format and ConceptMap objects - */ - -import { ConceptMap } from './ConceptMap.js'; -import {XMLBuilder, XMLParser} from "fast-xml-parser"; - -/** - * XML support for FHIR ConceptMap resources - * @class - */ -export class ConceptMapXML { - - /** - * FHIR ConceptMap elements that should be arrays in JSON - * @type {Set<string>} - * @private - */ - static _arrayElements = new Set([ - 'identifier', // Array in R5, single in R3/R4 - 'contact', // Array of contact details - 'useContext', // Array of usage contexts - 'jurisdiction', // Array of jurisdictions - 'group', // Array of mapping groups - 'element', // Array of elements (within group) - 'target', // Array of targets (within element) - 'property', // Array of properties (R5 only) - 'additionalAttribute', // Array of additional attributes (R5 only) - 'extension', // Array of extensions - 'modifierExtension' // Array of modifier extensions - ]); - - /** - * Creates ConceptMap from FHIR XML string - * @param {string} xmlString - FHIR XML representation of ConceptMap - * @param {string} [version='R5'] - FHIR version ('R3', 'R4', or 'R5') - * @returns {ConceptMap} New ConceptMap instance - */ - static fromXML(xmlString, version = 'R5') { - // Parse XML to JSON using FHIR-aware configuration - const jsonObj = this._xmlToJson(xmlString); - - // Use existing ConceptMap constructor with version conversion - return new ConceptMap(jsonObj, version); - } - - /** - * Converts ConceptMap to FHIR XML string - * @param {ConceptMap} conceptMap - ConceptMap instance - * @param {string} [version='R5'] - Target FHIR version ('R3', 'R4', or 'R5') - * @returns {string} FHIR XML string - */ - static toXMLString(conceptMap, version = 'R5') { - // Get JSON in target version using existing version conversion - const jsonString = conceptMap.toJSONString(version); - const jsonObj = JSON.parse(jsonString); - - // Convert JSON to FHIR XML format - return this._jsonToXml(jsonObj, version); - } - - /** - * Converts FHIR XML to JSON object - * @param {string} xmlString - FHIR XML string - * @returns {Object} JSON representation - * @private - */ - static _xmlToJson(xmlString) { - // Fast-xml-parser configuration for FHIR - const parserOptions = { - ignoreAttributes: false, - attributeNamePrefix: '', - textNodeName: 'value', // FHIR puts primitive values in 'value' attribute - parseAttributeValue: true, - removeNSPrefix: true, // Remove namespace prefixes - isArray: (tagName, jPath) => { - // Check if this element should be an array based on FHIR rules - return this._shouldBeArray(tagName, jPath); - }, - transformAttributeName: (attrName) => { - // Handle FHIR attribute naming - if (attrName === 'value') return 'value'; - return attrName; - } - }; - - const parser = new XMLParser(parserOptions); - const result = parser.parse(xmlString); - - // Extract the ConceptMap root element and process FHIR-specific structures - const conceptMap = result.ConceptMap || result; - return this._processFhirStructure(conceptMap); - } - - /** - * Converts JSON object to FHIR XML - * @param {Object} jsonObj - JSON representation - * @param {string} version - FHIR version for XML namespace - * @returns {string} FHIR XML string - * @private - */ - static _jsonToXml(jsonObj, version = 'R5') { - // Prepare object for XML conversion (handle FHIR-specific structures) - const xmlReadyObj = this._prepareForXml(jsonObj); - - // Fast-xml-parser builder configuration for FHIR - const builderOptions = { - ignoreAttributes: false, - attributeNamePrefix: '', - textNodeName: 'value', - format: true, - indentBy: ' ', - rootNodeName: 'ConceptMap', - xmlns: this._getFhirNamespace(version), - arrayNodeName: (tagName) => { - // For arrays, use the singular form as the repeated element name - return this._getArrayElementName(tagName); - } - }; - - const builder = new XMLBuilder(builderOptions); - - // Wrap in ConceptMap root with proper namespace - const rootObj = { - ConceptMap: { - '@xmlns': this._getFhirNamespace(version), - ...xmlReadyObj - } - }; - - return builder.build(rootObj); - } - - /** - * Determines if an XML element should be converted to an array in JSON - * @param {string} tagName - XML element name - * @param {string} jPath - JSON path context - * @returns {boolean} True if should be array - * @private - */ - static _shouldBeArray(tagName, jPath) { - // Check if this is a known FHIR array element - if (this._arrayElements.has(tagName)) { - return true; - } - - // Special cases based on context - if (tagName === 'group') { - // Groups are always arrays in ConceptMap - return true; - } - - if (tagName === 'element' && jPath.includes('group')) { - // Elements within groups are arrays - return true; - } - - if (tagName === 'target' && jPath.includes('element')) { - // Targets within elements are arrays - return true; - } - - return false; - } - - /** - * Processes FHIR-specific JSON structure after XML parsing - * @param {Object} obj - Parsed object from XML - * @returns {Object} FHIR-compliant JSON object - * @private - */ - static _processFhirStructure(obj) { - if (!obj || typeof obj !== 'object') { - return obj; - } - - const processed = {}; - - for (const [key, value] of Object.entries(obj)) { - if (key.startsWith('@')) { - // Skip XML attributes that aren't FHIR data - continue; - } - - if (Array.isArray(value)) { - // Process array elements - processed[key] = value.map(item => this._processFhirStructure(item)); - } else if (typeof value === 'object' && value !== null) { - // Handle FHIR primitive types with 'value' attribute - if (value.value !== undefined && Object.keys(value).length === 1) { - // This is a FHIR primitive - extract the value - processed[key] = value.value; - } else { - // Complex object - recurse - processed[key] = this._processFhirStructure(value); - } - } else { - // Simple value - processed[key] = value; - } - } - - return processed; - } - - /** - * Prepares JSON object for XML conversion (handles FHIR primitives) - * @param {Object} obj - JSON object - * @returns {Object} XML-ready object - * @private - */ - static _prepareForXml(obj) { - if (!obj || typeof obj !== 'object') { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map(item => this._prepareForXml(item)); - } - - const prepared = {}; - - for (const [key, value] of Object.entries(obj)) { - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - // FHIR primitive - wrap in object with 'value' attribute for XML - prepared[key] = { '@value': value }; - } else if (Array.isArray(value)) { - // Array - process elements - prepared[key] = value.map(item => this._prepareForXml(item)); - } else if (typeof value === 'object' && value !== null) { - // Complex object - recurse - prepared[key] = this._prepareForXml(value); - } else { - prepared[key] = value; - } - } - - return prepared; - } - - /** - * Gets FHIR namespace for XML based on version - * @param {string} version - FHIR version - * @returns {string} FHIR namespace URL - * @private - */ - static _getFhirNamespace(version) { - const namespaces = { - 'R3': 'http://hl7.org/fhir', - 'R4': 'http://hl7.org/fhir', - 'R5': 'http://hl7.org/fhir' - }; - return namespaces[version] || namespaces['R5']; - } - - /** - * Gets the element name for array items in XML - * @param {string} arrayName - Array property name - * @returns {string} Element name for array items - * @private - */ - static _getArrayElementName(arrayName) { - // Most FHIR arrays use singular element names - const singularMap = { - 'identifiers': 'identifier', - 'contacts': 'contact', - 'useContexts': 'useContext', - 'jurisdictions': 'jurisdiction', - 'groups': 'group', - 'elements': 'element', - 'targets': 'target', - 'properties': 'property', - 'additionalAttributes': 'additionalAttribute', - 'extensions': 'extension', - 'modifierExtensions': 'modifierExtension' - }; - - return singularMap[arrayName] || arrayName.replace(/s$/, ''); - } - - /** - * Validates that XML string is a FHIR ConceptMap - * @param {string} xmlString - XML string to validate - * @returns {boolean} True if valid FHIR ConceptMap XML - */ - static isValidConceptMapXML(xmlString) { - try { - // Basic check for ConceptMap root element and namespace - return xmlString.includes('<ConceptMap') && - xmlString.includes('http://hl7.org/fhir') && - xmlString.includes('</ConceptMap>'); - } catch (error) { - return false; - } - } - - /** - * Gets XML parser/builder library info for debugging - * @returns {Object} Library information - */ - static getLibraryInfo() { - return { - library: 'fast-xml-parser', - version: '4.x', // Would be dynamic in real implementation - features: [ - 'High performance XML parsing', - 'Configurable array detection', - 'FHIR attribute handling', - 'Namespace support', - 'Bidirectional conversion' - ] - }; - } -} - -// Usage examples: -/* -// Load from XML -const conceptMap = ConceptMapXML.fromXML(xmlString, 'R4'); - -// Convert to different version XML -const r3Xml = ConceptMapXML.toXMLString(conceptMap, 'R3'); - -// Validate XML -if (ConceptMapXML.isValidConceptMapXML(xmlString)) { - const cm = ConceptMapXML.fromXML(xmlString); -} - -// Find mappings -const mappings = conceptMap.findMappings('http://loinc.org', 'LA6113-0'); -const reverseMappings = conceptMap.findReverseMappings('http://snomed.info/sct', '260385009'); -*/ \ No newline at end of file diff --git a/tx/library/namingsystem-xml.js b/tx/library/namingsystem-xml.js deleted file mode 100644 index f4231c8..0000000 --- a/tx/library/namingsystem-xml.js +++ /dev/null @@ -1,316 +0,0 @@ -/** - * XML support for FHIR NamingSystem resources - * Handles conversion between FHIR XML format and NamingSystem objects - */ - -import { NamingSystem } from './NamingSystem.js'; -import {XMLBuilder, XMLParser} from "fast-xml-parser"; - -/** - * XML support for FHIR NamingSystem resources - * @class - */ -export class NamingSystemXML { - - /** - * FHIR NamingSystem elements that should be arrays in JSON - * @type {Set<string>} - * @private - */ - static _arrayElements = new Set([ - 'contact', // Array of contact details - 'useContext', // Array of usage contexts - 'jurisdiction', // Array of jurisdictions - 'uniqueId', // Array of unique identifiers (core element) - 'extension', // Array of extensions - 'modifierExtension' // Array of modifier extensions - ]); - - /** - * Creates NamingSystem from FHIR XML string - * @param {string} xmlString - FHIR XML representation of NamingSystem - * @param {string} [version='R5'] - FHIR version ('R3', 'R4', or 'R5') - * @returns {NamingSystem} New NamingSystem instance - */ - static fromXML(xmlString, version = 'R5') { - // Parse XML to JSON using FHIR-aware configuration - const jsonObj = this._xmlToJson(xmlString); - - // Use existing NamingSystem constructor with version conversion - return new NamingSystem(jsonObj, version); - } - - /** - * Converts NamingSystem to FHIR XML string - * @param {NamingSystem} namingSystem - NamingSystem instance - * @param {string} [version='R5'] - Target FHIR version ('R3', 'R4', or 'R5') - * @returns {string} FHIR XML string - */ - static toXMLString(namingSystem, version = 'R5') { - // Get JSON in target version using existing version conversion - const jsonString = namingSystem.toJSONString(version); - const jsonObj = JSON.parse(jsonString); - - // Convert JSON to FHIR XML format - return this._jsonToXml(jsonObj, version); - } - - /** - * Converts FHIR XML to JSON object - * @param {string} xmlString - FHIR XML string - * @returns {Object} JSON representation - * @private - */ - static _xmlToJson(xmlString) { - // Fast-xml-parser configuration for FHIR - const parserOptions = { - ignoreAttributes: false, - attributeNamePrefix: '', - textNodeName: 'value', // FHIR puts primitive values in 'value' attribute - parseAttributeValue: true, - removeNSPrefix: true, // Remove namespace prefixes - isArray: (tagName, jPath) => { - // Check if this element should be an array based on FHIR rules - return this._shouldBeArray(tagName, jPath); - }, - transformAttributeName: (attrName) => { - // Handle FHIR attribute naming - if (attrName === 'value') return 'value'; - return attrName; - } - }; - - const parser = new XMLParser(parserOptions); - const result = parser.parse(xmlString); - - // Extract the NamingSystem root element and process FHIR-specific structures - const namingSystem = result.NamingSystem || result; - return this._processFhirStructure(namingSystem); - } - - /** - * Converts JSON object to FHIR XML - * @param {Object} jsonObj - JSON representation - * @param {string} version - FHIR version for XML namespace - * @returns {string} FHIR XML string - * @private - */ - static _jsonToXml(jsonObj, version = 'R5') { - // Prepare object for XML conversion (handle FHIR-specific structures) - const xmlReadyObj = this._prepareForXml(jsonObj); - - // Fast-xml-parser builder configuration for FHIR - const builderOptions = { - ignoreAttributes: false, - attributeNamePrefix: '', - textNodeName: 'value', - format: true, - indentBy: ' ', - rootNodeName: 'NamingSystem', - xmlns: this._getFhirNamespace(version), - arrayNodeName: (tagName) => { - // For arrays, use the singular form as the repeated element name - return this._getArrayElementName(tagName); - } - }; - - const builder = new XMLBuilder(builderOptions); - - // Wrap in NamingSystem root with proper namespace - const rootObj = { - NamingSystem: { - '@xmlns': this._getFhirNamespace(version), - ...xmlReadyObj - } - }; - - return builder.build(rootObj); - } - - /** - * Determines if an XML element should be converted to an array in JSON - * @param {string} tagName - XML element name - * @param {string} jPath - JSON path context - * @returns {boolean} True if should be array - * @private - */ - static _shouldBeArray(tagName) { - // Check if this is a known FHIR array element - if (this._arrayElements.has(tagName)) { - return true; - } - - // uniqueId is always an array in NamingSystem - if (tagName === 'uniqueId') { - return true; - } - - return false; - } - - /** - * Processes FHIR-specific JSON structure after XML parsing - * @param {Object} obj - Parsed object from XML - * @returns {Object} FHIR-compliant JSON object - * @private - */ - static _processFhirStructure(obj) { - if (!obj || typeof obj !== 'object') { - return obj; - } - - const processed = {}; - - for (const [key, value] of Object.entries(obj)) { - if (key.startsWith('@')) { - // Skip XML attributes that aren't FHIR data - continue; - } - - if (Array.isArray(value)) { - // Process array elements - processed[key] = value.map(item => this._processFhirStructure(item)); - } else if (typeof value === 'object' && value !== null) { - // Handle FHIR primitive types with 'value' attribute - if (value.value !== undefined && Object.keys(value).length === 1) { - // This is a FHIR primitive - extract the value - processed[key] = value.value; - } else { - // Complex object - recurse - processed[key] = this._processFhirStructure(value); - } - } else { - // Simple value - processed[key] = value; - } - } - - return processed; - } - - /** - * Prepares JSON object for XML conversion (handles FHIR primitives) - * @param {Object} obj - JSON object - * @returns {Object} XML-ready object - * @private - */ - static _prepareForXml(obj) { - if (!obj || typeof obj !== 'object') { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map(item => this._prepareForXml(item)); - } - - const prepared = {}; - - for (const [key, value] of Object.entries(obj)) { - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - // FHIR primitive - wrap in object with 'value' attribute for XML - prepared[key] = { '@value': value }; - } else if (Array.isArray(value)) { - // Array - process elements - prepared[key] = value.map(item => this._prepareForXml(item)); - } else if (typeof value === 'object' && value !== null) { - // Complex object - recurse - prepared[key] = this._prepareForXml(value); - } else { - prepared[key] = value; - } - } - - return prepared; - } - - /** - * Gets FHIR namespace for XML based on version - * @param {string} version - FHIR version - * @returns {string} FHIR namespace URL - * @private - */ - static _getFhirNamespace(version) { - const namespaces = { - 'R3': 'http://hl7.org/fhir', - 'R4': 'http://hl7.org/fhir', - 'R5': 'http://hl7.org/fhir' - }; - return namespaces[version] || namespaces['R5']; - } - - /** - * Gets the element name for array items in XML - * @param {string} arrayName - Array property name - * @returns {string} Element name for array items - * @private - */ - static _getArrayElementName(arrayName) { - // Most FHIR arrays use singular element names - const singularMap = { - 'contacts': 'contact', - 'useContexts': 'useContext', - 'jurisdictions': 'jurisdiction', - 'uniqueIds': 'uniqueId', - 'extensions': 'extension', - 'modifierExtensions': 'modifierExtension' - }; - - return singularMap[arrayName] || arrayName.replace(/s$/, ''); - } - - /** - * Validates that XML string is a FHIR NamingSystem - * @param {string} xmlString - XML string to validate - * @returns {boolean} True if valid FHIR NamingSystem XML - */ - static isValidNamingSystemXML(xmlString) { - try { - // Basic check for NamingSystem root element and namespace - return xmlString.includes('<NamingSystem') && - xmlString.includes('http://hl7.org/fhir') && - xmlString.includes('</NamingSystem>'); - } catch (error) { - console.error('Error message:', error.message); - console.error('Stack trace:', error.stack); - return false; - } - } - - /** - * Gets XML parser/builder library info for debugging - * @returns {Object} Library information - */ - static getLibraryInfo() { - return { - library: 'fast-xml-parser', - version: '4.x', // Would be dynamic in real implementation - features: [ - 'High performance XML parsing', - 'Configurable array detection', - 'FHIR attribute handling', - 'Namespace support', - 'Bidirectional conversion' - ] - }; - } -} - -// Usage examples: -/* -// Load from XML -const namingSystem = NamingSystemXML.fromXML(xmlString, 'R4'); - -// Convert to different version XML -const r3Xml = NamingSystemXML.toXMLString(namingSystem, 'R3'); - -// Validate XML -if (NamingSystemXML.isValidNamingSystemXML(xmlString)) { - const ns = NamingSystemXML.fromXML(xmlString); -} - -// Check unique identifiers -if (namingSystem.hasUniqueId('uri', 'http://example.org/my-system')) { - const preferredId = namingSystem.getPreferredUniqueId(); - console.log(preferredId?.value); -} -*/ \ No newline at end of file diff --git a/tx/library/valueset-xml.js b/tx/library/valueset-xml.js deleted file mode 100644 index b4cea70..0000000 --- a/tx/library/valueset-xml.js +++ /dev/null @@ -1,347 +0,0 @@ -/** - * XML support for FHIR ValueSet resources - * Handles conversion between FHIR XML format and ValueSet objects - */ -import {XMLBuilder} from "fast-xml-parser"; - -const { XMLParser } = require('fast-xml-parser'); -import { ValueSet } from './ValueSet.js'; - -/** - * XML support for FHIR ValueSet resources - * @class - */ -export class ValueSetXML { - - /** - * FHIR ValueSet elements that should be arrays in JSON - * @type {Set<string>} - * @private - */ - static _arrayElements = new Set([ - 'identifier', // Array of identifiers (always array for ValueSet) - 'contact', // Array of contact details - 'useContext', // Array of usage contexts - 'jurisdiction', // Array of jurisdictions - 'include', // Array of compose includes - 'exclude', // Array of compose excludes - 'concept', // Array of concepts (within include/exclude) - 'filter', // Array of filters (within include/exclude) - 'valueSet', // Array of value sets (within include) - 'parameter', // Array of expansion parameters - 'contains', // Array of expansion contains items - 'designation', // Array of designations (within expansion contains) - 'extension', // Array of extensions - 'modifierExtension' // Array of modifier extensions - ]); - - /** - * Creates ValueSet from FHIR XML string - * @param {string} xmlString - FHIR XML representation of ValueSet - * @param {string} [version='R5'] - FHIR version ('R3', 'R4', or 'R5') - * @returns {ValueSet} New ValueSet instance - */ - static fromXML(xmlString, version = 'R5') { - // Parse XML to JSON using FHIR-aware configuration - const jsonObj = this._xmlToJson(xmlString); - - // Use existing ValueSet constructor with version conversion - return new ValueSet(jsonObj, version); - } - - /** - * Converts ValueSet to FHIR XML string - * @param {ValueSet} valueSet - ValueSet instance - * @param {string} [version='R5'] - Target FHIR version ('R3', 'R4', or 'R5') - * @returns {string} FHIR XML string - */ - static toXMLString(valueSet, version = 'R5') { - // Get JSON in target version using existing version conversion - const jsonString = valueSet.toJSONString(version); - const jsonObj = JSON.parse(jsonString); - - // Convert JSON to FHIR XML format - return this._jsonToXml(jsonObj, version); - } - - /** - * Converts FHIR XML to JSON object - * @param {string} xmlString - FHIR XML string - * @returns {Object} JSON representation - * @private - */ - static _xmlToJson(xmlString) { - // Fast-xml-parser configuration for FHIR - const parserOptions = { - ignoreAttributes: false, - attributeNamePrefix: '', - textNodeName: 'value', // FHIR puts primitive values in 'value' attribute - parseAttributeValue: true, - removeNSPrefix: true, // Remove namespace prefixes - isArray: (tagName, jPath) => { - // Check if this element should be an array based on FHIR rules - return this._shouldBeArray(tagName, jPath); - }, - transformAttributeName: (attrName) => { - // Handle FHIR attribute naming - if (attrName === 'value') return 'value'; - return attrName; - } - }; - - const parser = new XMLParser(parserOptions); - const result = parser.parse(xmlString); - - // Extract the ValueSet root element and process FHIR-specific structures - const valueSet = result.ValueSet || result; - return this._processFhirStructure(valueSet); - } - - /** - * Converts JSON object to FHIR XML - * @param {Object} jsonObj - JSON representation - * @param {string} version - FHIR version for XML namespace - * @returns {string} FHIR XML string - * @private - */ - static _jsonToXml(jsonObj, version = 'R5') { - // Prepare object for XML conversion (handle FHIR-specific structures) - const xmlReadyObj = this._prepareForXml(jsonObj); - - // Fast-xml-parser builder configuration for FHIR - const builderOptions = { - ignoreAttributes: false, - attributeNamePrefix: '', - textNodeName: 'value', - format: true, - indentBy: ' ', - rootNodeName: 'ValueSet', - xmlns: this._getFhirNamespace(version), - arrayNodeName: (tagName) => { - // For arrays, use the singular form as the repeated element name - return this._getArrayElementName(tagName); - } - }; - - const builder = new XMLBuilder(builderOptions); - - // Wrap in ValueSet root with proper namespace - const rootObj = { - ValueSet: { - '@xmlns': this._getFhirNamespace(version), - ...xmlReadyObj - } - }; - - return builder.build(rootObj); - } - - /** - * Determines if an XML element should be converted to an array in JSON - * @param {string} tagName - XML element name - * @param {string} jPath - JSON path context - * @returns {boolean} True if should be array - * @private - */ - static _shouldBeArray(tagName, jPath) { - // Check if this is a known FHIR array element - if (this._arrayElements.has(tagName)) { - return true; - } - - // Special cases based on context - if (tagName === 'concept' && (jPath.includes('include') || jPath.includes('exclude'))) { - // Concepts within include/exclude are arrays - return true; - } - - if (tagName === 'filter' && (jPath.includes('include') || jPath.includes('exclude'))) { - // Filters within include/exclude are arrays - return true; - } - - if (tagName === 'contains' && jPath.includes('expansion')) { - // Contains within expansion are arrays - return true; - } - - if (tagName === 'parameter' && jPath.includes('expansion')) { - // Parameters within expansion are arrays - return true; - } - - return false; - } - - /** - * Processes FHIR-specific JSON structure after XML parsing - * @param {Object} obj - Parsed object from XML - * @returns {Object} FHIR-compliant JSON object - * @private - */ - static _processFhirStructure(obj) { - if (!obj || typeof obj !== 'object') { - return obj; - } - - const processed = {}; - - for (const [key, value] of Object.entries(obj)) { - if (key.startsWith('@')) { - // Skip XML attributes that aren't FHIR data - continue; - } - - if (Array.isArray(value)) { - // Process array elements - processed[key] = value.map(item => this._processFhirStructure(item)); - } else if (typeof value === 'object' && value !== null) { - // Handle FHIR primitive types with 'value' attribute - if (value.value !== undefined && Object.keys(value).length === 1) { - // This is a FHIR primitive - extract the value - processed[key] = value.value; - } else { - // Complex object - recurse - processed[key] = this._processFhirStructure(value); - } - } else { - // Simple value - processed[key] = value; - } - } - - return processed; - } - - /** - * Prepares JSON object for XML conversion (handles FHIR primitives) - * @param {Object} obj - JSON object - * @returns {Object} XML-ready object - * @private - */ - static _prepareForXml(obj) { - if (!obj || typeof obj !== 'object') { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map(item => this._prepareForXml(item)); - } - - const prepared = {}; - - for (const [key, value] of Object.entries(obj)) { - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - // FHIR primitive - wrap in object with 'value' attribute for XML - prepared[key] = { '@value': value }; - } else if (Array.isArray(value)) { - // Array - process elements - prepared[key] = value.map(item => this._prepareForXml(item)); - } else if (typeof value === 'object' && value !== null) { - // Complex object - recurse - prepared[key] = this._prepareForXml(value); - } else { - prepared[key] = value; - } - } - - return prepared; - } - - /** - * Gets FHIR namespace for XML based on version - * @param {string} version - FHIR version - * @returns {string} FHIR namespace URL - * @private - */ - static _getFhirNamespace(version) { - const namespaces = { - 'R3': 'http://hl7.org/fhir', - 'R4': 'http://hl7.org/fhir', - 'R5': 'http://hl7.org/fhir' - }; - return namespaces[version] || namespaces['R5']; - } - - /** - * Gets the element name for array items in XML - * @param {string} arrayName - Array property name - * @returns {string} Element name for array items - * @private - */ - static _getArrayElementName(arrayName) { - // Most FHIR arrays use singular element names - const singularMap = { - 'identifiers': 'identifier', - 'contacts': 'contact', - 'useContexts': 'useContext', - 'jurisdictions': 'jurisdiction', - 'includes': 'include', - 'excludes': 'exclude', - 'concepts': 'concept', - 'filters': 'filter', - 'valueSets': 'valueSet', - 'parameters': 'parameter', - 'contains': 'contains', // Same singular/plural - 'designations': 'designation', - 'extensions': 'extension', - 'modifierExtensions': 'modifierExtension' - }; - - return singularMap[arrayName] || arrayName.replace(/s$/, ''); - } - - /** - * Validates that XML string is a FHIR ValueSet - * @param {string} xmlString - XML string to validate - * @returns {boolean} True if valid FHIR ValueSet XML - */ - static isValidValueSetXML(xmlString) { - try { - // Basic check for ValueSet root element and namespace - return xmlString.includes('<ValueSet') && - xmlString.includes('http://hl7.org/fhir') && - xmlString.includes('</ValueSet>'); - } catch (error) { - return false; - } - } - - /** - * Gets XML parser/builder library info for debugging - * @returns {Object} Library information - */ - static getLibraryInfo() { - return { - library: 'fast-xml-parser', - version: '4.x', // Would be dynamic in real implementation - features: [ - 'High performance XML parsing', - 'Configurable array detection', - 'FHIR attribute handling', - 'Namespace support', - 'Bidirectional conversion' - ] - }; - } -} - -// Usage examples: -/* -// Load from XML -const valueSet = ValueSetXML.fromXML(xmlString, 'R4'); - -// Convert to different version XML -const r3Xml = ValueSetXML.toXMLString(valueSet, 'R3'); - -// Validate XML -if (ValueSetXML.isValidValueSetXML(xmlString)) { - const vs = ValueSetXML.fromXML(xmlString); -} - -// Check expansion codes -if (valueSet.hasCode('http://loinc.org', 'LA6113-0')) { - const codeItem = valueSet.getCode('http://loinc.org', 'LA6113-0'); - console.log(codeItem.display); -} -*/ \ No newline at end of file diff --git a/tx/library/valueset.js b/tx/library/valueset.js index 32689bc..0066bc3 100644 --- a/tx/library/valueset.js +++ b/tx/library/valueset.js @@ -1,4 +1,5 @@ const {CanonicalResource} = require("./canonical-resource"); +const {getValueName} = require("../../library/utilities"); /** * Represents a FHIR ValueSet resource with version conversion support @@ -49,7 +50,7 @@ class ValueSet extends CanonicalResource { * @returns {string} JSON string */ toJSONString(version = 'R5') { - const outputObj = this._convertFromR5(this.jsonObj, version); + const outputObj = this.convertFromR5(this.jsonObj, version); return JSON.stringify(outputObj); } @@ -89,7 +90,7 @@ class ValueSet extends CanonicalResource { * @returns {Object} New object in target version format * @private */ - _convertFromR5(r5Obj, targetVersion) { + convertFromR5(r5Obj, targetVersion) { if (targetVersion === 'R5') { return r5Obj; // No conversion needed } @@ -152,9 +153,60 @@ class ValueSet extends CanonicalResource { }); } + if (r5Obj.expansion) { + let exp = r5Obj.expansion; + + // Convert ValueSet.expansion.property to extensions + if (exp.property && exp.property.length > 0) { + exp.extension = exp.extension || []; + for (let prop of exp.property) { + exp.extension.push({ + url: "http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.expansion.property", + extension: [ + { url: "code", valueCode: prop.code }, + { url: "uri", valueUri: prop.uri } + ] + }); + } + delete exp.property; + this.convertContainsPropertyR5ToR4(exp.contains); + + } + } + return r5Obj; } + // Recursive function to convert contains.property + convertContainsPropertyR5ToR4(containsList) { + if (!containsList) return; + + for (let item of containsList) { + if (item.property && item.property.length > 0) { + item.extension = item.extension || []; + for (let prop of item.property) { + let ext = { + url: "http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.expansion.contains.property", + extension: [ + { url: "code", valueCode: prop.code } + ] + }; + let pn = getValueName(prop); + let subExt = { url: "value" }; + subExt[pn] = prop[pn]; + ext.extension.push(subExt); + item.extension.push(ext); + } + delete item.property; + } + + // Recurse into nested contains + if (item.contains) { + this.convertContainsPropertyR5ToR4(item.contains); + } + } + } + /** * Converts R5 ValueSet to R3 format * @param {Object} r5Obj - Cloned R5 ValueSet object diff --git a/tx/provider.js b/tx/provider.js index 2bdb89b..3a7d454 100644 --- a/tx/provider.js +++ b/tx/provider.js @@ -8,7 +8,6 @@ const {PackageContentLoader} = require("../library/package-manager"); const {PackageValueSetProvider} = require("./vs/vs-package"); const ValueSet = require("./library/valueset"); const {PackageConceptMapProvider} = require("./cm/cm-package"); -const {ConceptMap} = require("./library/conceptmap"); /** * This class holds what information is in context @@ -23,6 +22,7 @@ const {ConceptMap} = require("./library/conceptmap"); */ class Provider { i18n; + fhirVersion; /** * {Map<String, CodeSystemFactoryProvider>} A list of code system factories that contains all the preloaded native code systems @@ -43,11 +43,14 @@ class Provider { */ conceptMapProviders; + contentSources; + baseUrl = null; cacheFolder = null; startTime = Date.now(); startMemory = process.memoryUsage(); lastTime = null; + requestCount = 0; totalDownloaded = 0; /** @@ -241,6 +244,14 @@ class Provider { } } + getFhirVersion() { + switch (this.fhirVersion) { + case 5: return "R5"; + case 4: return "R4"; + case 3: return "R3"; + default: return "R5"; + } + } } module.exports = { Provider }; \ No newline at end of file diff --git a/tx/tx-html.js b/tx/tx-html.js index a8f237a..e4d567a 100644 --- a/tx/tx-html.js +++ b/tx/tx-html.js @@ -128,29 +128,217 @@ function buildTitle(json) { return resourceType; } + +function buildSearchForm(req, mode, params) { + let html = ''; + + // Search form + html += '<h3>Search</h3>'; + + html += `<form method="get" action="${escapeHtml(req.baseUrl)}/CodeSystem">`; + html += '<table class="grid" cellpadding="0" cellspacing="0">'; + html += '<tr>'; + html += '<td colspan="2">URL: <input type="text" name="url" size="40"/></td>'; + html += '<td>Version: <input type="text" name="version"/></td>'; + html += '</tr>'; + html += '<tr>'; + html += '<td title="Searches in name, title, description, publisher">Text: <input type="text" name="text"/></td>'; + html += '<td>Status: <select name="status" class="form-select"><option value="">(any status)</option>'; + html += '<option value="draft">draft</option><option value="active">active</option>'; + html += '<option value="retired">retired</option><option value="unknown">unknown</option></select></td>'; + html += '<td>Language: <input type="text" name="lang" size="10"/> (ietf code)</td>'; + html += '</tr>'; + html += '<tr>'; + html += '<td colspan="2" title="CodeSystem - for supplements, value sets, and concept maps">System: <input type="text" name="system" size="40"/></td>'; + html += '<td>CS Content: <select name="content-mode" class="form-select"><option value="">(any content)</option>'; + html += '<option value="not-present">not-present</option><option value="example">example</option>'; + html += '<option value="fragment">fragment</option><option value="complete">complete</option>'; + html += '<option value="supplement">supplement</option></select></td>'; + html += '</tr>'; + html += '</table>'; + html += '<button type="submit" class="btn btn-primary">Search:</button>'; + html += ' <input type="radio" name="mode" value="cs" selected/> CodeSystems'; + html += ' <input type="radio" name="mode" value="vs"/> ValueSets'; + html += ' <input type="radio" name="mode" value="""cm"/> ConceptMaps'; + html += '</form>'; + + return html; +} + +function buildHomePage(req) { + const provider = req.txProvider; + + let html = ''; + + // ===== Summary Section ===== + html += '<h3>Server Summary</h3>'; + + // Calculate uptime + const uptimeMs = Date.now() - provider.startTime; + const uptimeSeconds = Math.floor(uptimeMs / 1000); + const uptimeDays = Math.floor(uptimeSeconds / 86400); + const uptimeHours = Math.floor((uptimeSeconds % 86400) / 3600); + const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60); + const uptimeSecs = uptimeSeconds % 60; + let uptimeStr = ''; + if (uptimeDays > 0) uptimeStr += `${uptimeDays}d `; + if (uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeHours}h `; + if (uptimeMinutes > 0 || uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeMinutes}m `; + uptimeStr += `${uptimeSecs}s`; + + // Memory usage + const memUsage = process.memoryUsage(); + const heapUsedMB = (memUsage.heapUsed / 1024 / 1024).toFixed(2); + const heapTotalMB = (memUsage.heapTotal / 1024 / 1024).toFixed(2); + const rssMB = (memUsage.rss / 1024 / 1024).toFixed(2); + + html += '<table class="grid">'; + html += '<tr>'; + html += `<td><strong>FHIR Version:</strong> ${escapeHtml(provider.getFhirVersion())}</td>`; + html += `<td><strong>Uptime:</strong> ${escapeHtml(uptimeStr)}</td>`; + html += `<td><strong>Request Count:</strong> ${provider.requestCount}</td>`; + html += '</tr>'; + html += '<tr>'; + html += `<td><strong>Heap Used:</strong> ${heapUsedMB} MB</td>`; + html += `<td><strong>Heap Total:</strong> ${heapTotalMB} MB</td>`; + html += `<td><strong>Process Memory:</strong> ${rssMB} MB</td>`; + html += '</tr>'; + + // Count unique code systems + const uniqueFactorySystems = new Set(); + for (const factory of provider.codeSystemFactories.values()) { + uniqueFactorySystems.add(factory.system()); + } + const uniqueCodeSystems = new Set(); + for (const cs of provider.codeSystems.values()) { + uniqueCodeSystems.add(cs.url); + } + html += '<tr>'; + html += `<td><strong>CodeSystem #:</strong> ${new Set([...uniqueFactorySystems, ...uniqueCodeSystems]).size}</td>`; + + // Count value sets + let totalValueSets = 0; + for (const vsp of provider.valueSetProviders) { + totalValueSets += vsp.vsCount(); + } + html += `<td><strong>ValueSet #:</strong> ${totalValueSets || 'Unknown'}</td>`; + + let totalConceptMaps = 0; + for (const cmp of provider.conceptMapProviders) { + totalConceptMaps += cmp.cmCount(); + } + html += `<td><strong>ConceptMap #:</strong> ${totalConceptMaps || 'Unknown'}</td>`; + html += '</tr>'; + html += '</table>'; + + html += '<hr/>'; + html += buildSearchForm(req); + // + // + // // Translation form + // html += '<h6 class="mt-4">Translate</h6>'; + // html += `<form method="get" action="${escapeHtml(req.baseUrl)}/ConceptMap/$translate">`; + // html += '<div class="row">'; + // html += '<div><input type="text" name="system" placeholder="Source System" required/></div>'; + // html += '<div><input type="text" name="code" placeholder="Code" required/></div>'; + // html += '<div><input type="text" name="targetSystem" placeholder="Target System"/></div>'; + // html += '</div>'; + // html += '<div class="row">'; + // html += '<div class="col-md-6 mb-2">'; + // html += '</div>'; + // html += '</div>'; + // html += '<button type="submit" class="btn btn-primary">Translate</button>'; + // html += '</form>'; + // + // Search form for concept maps + // html += '<h6 class="mt-4">Search Concept Maps</h6>'; + // html += `<form method="get" action="${escapeHtml(req.baseUrl)}/ConceptMap">`; + // html += '<div class="row">'; + // html += '<div><input type="text" name="_id" placeholder="ID"/></div>'; + // html += '<div><input type="text" name="url" placeholder="URL"/></div>'; + // html += '<div><input type="text" name="name" placeholder="Name"/></div>'; + // html += '<div><input type="text" name="title" placeholder="Title"/></div>'; + // html += '</div>'; + // html += '<button type="submit" class="btn btn-primary">Search</button>'; + // html += '</form>'; + // html += '</div></div>'; + + // ===== Packages and Factories Section ===== + html += '<hr/><h3>Content Sources & Code System Factories</h3>'; + + // List content sources + html += '<h6>Content Sources</h6>'; + if (provider.contentSources && provider.contentSources.length > 0) { + const sorted = [...provider.contentSources].sort(); + html += '<ul>'; + for (const source of sorted) { + html += `<li>${escapeHtml(source)}</li>`; + } + html += '</ul>'; + } else { + html += '<p><em>No content sources available</em></p>'; + } + + // Code System Factories table +// Code System Factories table + html += '<h6 class="mt-4">External CodeSystems</h6>'; + html += '<table class="grid">'; + html += '<thead><tr><th>System</th><th>Version</th><th>Use Count</th></tr></thead>'; + html += '<tbody>'; + +// Deduplicate factories and sort by system URL + const seenFactories = new Set(); + const uniqueFactories = []; + for (const factory of provider.codeSystemFactories.values()) { + const key = factory.system() + '|' + (factory.version() || ''); + if (!seenFactories.has(key)) { + seenFactories.add(key); + uniqueFactories.push(factory); + } + } + uniqueFactories.sort((a, b) => a.system().localeCompare(b.system())); + + for (const factory of uniqueFactories) { + html += '<tr>'; + html += `<td>${escapeHtml(factory.system())}</td>`; + html += `<td>${escapeHtml(factory.version() || '-')}</td>`; + html += `<td>${factory.useCount ? factory.useCount() : '-'}</td>`; + html += '</tr>'; + } + + html += '</tbody></table>'; + html += '</div></div>'; + + return html; +} + /** * Main render function - determines what to render based on resource type */ function render(json, req) { - const resourceType = json.resourceType; - - switch (resourceType) { - case 'Parameters': - return renderParameters(json); - case 'CodeSystem': - return renderCodeSystem(json); - case 'ValueSet': - return renderValueSet(json); - case 'ConceptMap': - return renderConceptMap(json); - case 'CapabilityStatement': - return renderCapabilityStatement(json); - case 'Bundle': - return renderBundle(json, req); - case 'OperationOutcome': - return renderOperationOutcome(json); - default: - return renderGeneric(json); + if (req.path == "/") { + return buildHomePage(req); + } else { + const resourceType = json.resourceType; + + switch (resourceType) { + case 'Parameters': + return renderParameters(json); + case 'CodeSystem': + return renderCodeSystem(json); + case 'ValueSet': + return renderValueSet(json); + case 'ConceptMap': + return renderConceptMap(json); + case 'CapabilityStatement': + return renderCapabilityStatement(json); + case 'Bundle': + return renderBundle(json, req); + case 'OperationOutcome': + return renderOperationOutcome(json, req); + default: + return renderGeneric(json); + } } } @@ -197,40 +385,39 @@ function renderCapabilityStatement(json) { /** * Render OperationOutcome resource */ -function renderOperationOutcome(json) { - let html = '<div class="alert '; - - // Determine alert style based on severity - const severity = json.issue?.[0]?.severity || 'information'; - switch (severity) { - case 'error': - case 'fatal': - html += 'alert-danger'; - break; - case 'warning': - html += 'alert-warning'; - break; - case 'information': - html += 'alert-info'; - break; - default: - html += 'alert-secondary'; - } - - html += '">'; +function renderOperationOutcome(json, req) { + let html = '<div class="operation-outcome">'; html += `<h4>OperationOutcome</h4>`; - + if (json.issue && Array.isArray(json.issue)) { - html += '<ul>'; for (const issue of json.issue) { - html += `<li><strong>${escapeHtml(issue.severity || 'unknown')}:</strong> `; + html += '<div class="alert '; + + // Determine alert style based on this issue's severity + const severity = issue.severity || 'information'; + switch (severity) { + case 'error': + case 'fatal': + html += 'alert-danger'; + break; + case 'warning': + html += 'alert-warning'; + break; + case 'information': + html += 'alert-info'; + break; + default: + html += 'alert-secondary'; + } + + html += '">'; + html += `<strong>${escapeHtml(issue.severity || 'unknown')}:</strong> `; html += `[${escapeHtml(issue.code || 'unknown')}] `; html += escapeHtml(issue.diagnostics || issue.details?.text || 'No details'); - html += '</li>'; + html += '</div>'; } - html += '</ul>'; } - + html += '</div>'; return html; } @@ -315,7 +502,7 @@ function renderSearchForm(json, req) { } html += '</select>'; } else { - html += `<input type="text" name="${param.name}" id="${param.name}" class="form-control"/>`; + html += `<input type="text" name="${param.name}" id="${param.name}"/>`; } html += '</div>'; diff --git a/tx/tx.js b/tx/tx.js index 195e3b6..da9a5ab 100644 --- a/tx/tx.js +++ b/tx/tx.js @@ -5,6 +5,7 @@ // with support for multiple endpoints at different FHIR versions. // +const fs = require('fs'); const express = require('express'); const path = require('path'); const Logger = require('../common/logger'); @@ -12,6 +13,7 @@ const { Library } = require('./library'); const { OperationContext, ResourceCache, ExpansionCache } = require('./operation-context'); const { LanguageDefinitions } = require('../library/languages'); const { I18nSupport } = require('../library/i18nsupport'); +const { CodeSystemXML } = require('./xml/codesystem-xml'); const txHtml = require('./tx-html'); // Import workers @@ -22,9 +24,14 @@ const { ValidateWorker } = require('./workers/validate'); const TranslateWorker = require('./workers/translate'); const LookupWorker = require('./workers/lookup'); const SubsumesWorker = require('./workers/subsumes'); -const ClosureWorker = require('./workers/closure'); const { MetadataHandler } = require('./workers/metadata'); const { BatchValidateWorker } = require('./workers/batch-validate'); +const {CapabilityStatementXML} = require("./xml/capabilitystatement-xml"); +const {TerminologyCapabilitiesXML} = require("./xml/terminologycapabilities-xml"); +const {ParametersXML} = require("./xml/parameters-xml"); +const {OperationOutcomeXML} = require("./xml/operationoutcome-xml"); +const {ValueSetXML} = require("./xml/valueset-xml"); +const {ConceptMapXML} = require("./xml/conceptmap-xml"); class TXModule { constructor() { @@ -48,6 +55,12 @@ class TXModule { return `tx-${this.requestIdCounter}`; } + acceptsXml(req) { + const accept = req.headers.accept || ''; + return accept.includes('application/fhir+xml') || accept.includes('application/xml+fhir'); + } + + /** * Initialize the TX module * @param {Object} config - Module configuration @@ -149,6 +162,9 @@ class TXModule { // Middleware to attach provider, context, and timing to request, and wrap res.json for HTML router.use((req, res, next) => { + // Increment request count + provider.requestCount++; + // Generate unique request ID const requestId = this.generateRequestId(); @@ -182,6 +198,7 @@ class TXModule { const operation = `${req.method} ${req.baseUrl}${req.path}`; const params = req.method === 'POST' ? req.body : req.query; const isHtml = txHtml.acceptsHtml(req); + const isXml = this.acceptsXml(req); let responseSize; let result; @@ -193,6 +210,21 @@ class TXModule { responseSize = Buffer.byteLength(html, 'utf8'); res.setHeader('Content-Type', 'text/html'); result = res.send(html); + } else if (isXml) { + try { + const xml = this.convertResourceToXml(data); + this.logToFile('/Users/grahamegrieve/temp/res-out.xml', xml); + this.logToFile('/Users/grahamegrieve/temp/res-out.json', JSON.stringify(data)); + responseSize = Buffer.byteLength(xml, 'utf8'); + res.setHeader('Content-Type', 'application/fhir+xml'); + result = res.send(xml); + } catch (err) { + // Fall back to JSON if XML conversion not supported + log.warn(`XML conversion failed for ${data.resourceType}: ${err.message}, falling back to JSON`); + const jsonStr = JSON.stringify(data); + responseSize = Buffer.byteLength(jsonStr, 'utf8'); + result = originalJson(data); + } } else { const jsonStr = JSON.stringify(data); responseSize = Buffer.byteLength(jsonStr, 'utf8'); @@ -201,7 +233,8 @@ class TXModule { // Log the request with request ID const paramStr = Object.keys(params).length > 0 ? ` params=${JSON.stringify(this.trimParameters(params))}` : ''; - log.info(`[${requestId}] ${operation}${paramStr} - ${res.statusCode} - ${isHtml ? 'html' : 'json'} - ${responseSize} bytes - ${duration}ms`); + const format = isHtml ? 'html' : (isXml ? 'xml' : 'json'); + log.info(`[${requestId}] ${operation}${paramStr} - ${res.statusCode} - ${format} - ${responseSize} bytes - ${duration}ms`); return result; }; @@ -225,11 +258,14 @@ class TXModule { router.use((req, res, next) => { const contentType = req.get('Content-Type') || ''; - // Only process POST/PUT with JSON-like content types - if ((req.method === 'POST' || req.method === 'PUT') && - (contentType.includes('application/json') || + // Only process POST/PUT + if (req.method !== 'POST' && req.method !== 'PUT') { + return next(); + } + + if (contentType.includes('application/json') || contentType.includes('application/fhir+json') || - contentType.includes('application/json+fhir'))) { + contentType.includes('application/json+fhir')) { // If body is a Buffer, parse it if (Buffer.isBuffer(req.body)) { @@ -250,7 +286,36 @@ class TXModule { }); } } + + } else if (contentType.includes('application/xml') || + // Handle XML + contentType.includes('application/fhir+xml') || + contentType.includes('application/xml+fhir')) { + + let xmlStr; + if (Buffer.isBuffer(req.body)) { + xmlStr = req.body.toString('utf8'); + } else if (typeof req.body === 'string') { + xmlStr = req.body; + } + + if (xmlStr) { + try { + req.body = this.convertXmlToResource(xmlStr); + } catch (e) { + this.log.error(`XML parse error: ${e.message}`); + return res.status(400).json({ + resourceType: 'OperationOutcome', + issue: [{ + severity: 'error', + code: 'invalid', + diagnostics: `Invalid XML: ${e.message}` + }] + }); + } + } } + next(); }); @@ -549,6 +614,56 @@ class TXModule { return params; } + + convertResourceToXml(res) { + switch (res.resourceType) { + case "CodeSystem" : return CodeSystemXML._jsonToXml(res); + case "CapabilityStatement" : return new CapabilityStatementXML(res, "R5").toXml(); + case "TerminologyCapabilities" : return new TerminologyCapabilitiesXML(res, "R5").toXml(); + case "Parameters": return ParametersXML.toXml(res, this.fhirVersion); + case "OperationOutcome": return OperationOutcomeXML.toXml(res, this.fhirVersion); + } + throw new Error(`Resource type ${res.resourceType} not supported in XML`); + } + + convertXmlToResource(xml) { + // Detect resource type from root element + const rootMatch = xml.match(/<([A-Za-z]+)\s/); + if (!rootMatch) { + throw new Error('Could not detect resource type from XML'); + } + + this.logToFile('/Users/grahamegrieve/temp/res-in.xml', xml); + + const resourceType = rootMatch[1]; + + let data; + switch (resourceType) { + case "Parameters": + data = ParametersXML.fromXml(xml); + break; + case "CodeSystem": + data = CodeSystemXML.fromXml(xml); + break; + case "ValueSet": + data = ValueSetXML.fromXml(xml); + break; + case "ConceptMap": + data = ConceptMapXML.fromXml(xml); + break; + default: + throw new Error(`Resource type ${resourceType} not supported for XML input`); + } + + this.logToFile('/Users/grahamegrieve/temp/res-in.json', JSON.stringify(data)); + return data; + } + + logToFile(fn, cnt) { + fs.writeFile(fn, cnt, (err) => { + if (err) console.error('Error writing log file:', err); + }); + } } module.exports = TXModule; \ No newline at end of file diff --git a/tx/vs/vs-api.js b/tx/vs/vs-api.js index 60c1451..a104bbd 100644 --- a/tx/vs/vs-api.js +++ b/tx/vs/vs-api.js @@ -55,6 +55,14 @@ class AbstractValueSetProvider { throw new Error('searchValueSets must be implemented by subclass'); } + /** + * + * @returns {number} total number of value sets + */ + vsCount() { + return 0; + } + /** * Validates search parameters * @param {Array<{name: string, value: string}>} searchParams - Search parameters to validate diff --git a/tx/vs/vs-database.js b/tx/vs/vs-database.js index 596839d..7fd92e1 100644 --- a/tx/vs/vs-database.js +++ b/tx/vs/vs-database.js @@ -11,6 +11,8 @@ const INDEXED_COLUMNS = ['id', 'url', 'version', 'date', 'description', 'name', * Handles SQLite operations for indexing and searching ValueSets */ class ValueSetDatabase { + vsCount; + /** * @param {string} dbPath - Path to the SQLite database file */ @@ -344,6 +346,7 @@ class ValueSetDatabase { } try { + this.vsCount = rows.length; const valueSetMap = new Map(); for (const row of rows) { @@ -726,6 +729,7 @@ class ValueSetDatabase { assignIds(ids) { // nothing - we don't do any assigning. } + } module.exports = { diff --git a/tx/vs/vs-package.js b/tx/vs/vs-package.js index 9ab9879..3fc7514 100644 --- a/tx/vs/vs-package.js +++ b/tx/vs/vs-package.js @@ -304,6 +304,11 @@ class PackageValueSetProvider extends AbstractValueSetProvider { assignIds(ids) { // nothing - we don't do any assigning. } + + vsCount() { + return this.database.vsCount; + } + } module.exports = { diff --git a/tx/vs/vs-vsac.js b/tx/vs/vs-vsac.js index acc2fb5..609c030 100644 --- a/tx/vs/vs-vsac.js +++ b/tx/vs/vs-vsac.js @@ -345,6 +345,12 @@ class VSACValueSetProvider extends AbstractValueSetProvider { getLastRefreshTime() { return this.lastRefresh; } + + count() { + return this.database.vsCount; + } + + } // Usage examples: diff --git a/tx/workers/batch.js b/tx/workers/batch.js index 98e2672..797bf85 100644 --- a/tx/workers/batch.js +++ b/tx/workers/batch.js @@ -284,7 +284,7 @@ class BatchWorker extends TerminologyWorker { * @param {Object} request - Original request element * @returns {Object} Mock request object */ - buildMockRequest(method, parsedOp, resource, request) { + buildMockRequest(method, parsedOp, resource) { return { method, params: { diff --git a/tx/workers/expand.js b/tx/workers/expand.js index f9396a4..6ed2984 100644 --- a/tx/workers/expand.js +++ b/tx/workers/expand.js @@ -1555,10 +1555,10 @@ class ExpandWorker extends TerminologyWorker { if (error instanceof Issue) { let oo = new OperationOutcome(); oo.addIssue(error); - return res.status(error.statusCode || 500).json(oo.jsonObj); + return res.status(error.statusCode || 500).json(this.fixForVersion(oo.jsonObj)); } else { const issueCode = error.issueCode || 'exception'; - return res.status(statusCode).json({ + return res.status(statusCode).json(this.fixForVersion({ resourceType: 'OperationOutcome', issue: [{ severity: 'error', @@ -1568,7 +1568,7 @@ class ExpandWorker extends TerminologyWorker { }, diagnostics: error.message }] - }); + })); } } } @@ -1587,7 +1587,7 @@ class ExpandWorker extends TerminologyWorker { console.error('$expand error:', error); // Full stack trace to console for debugging const statusCode = error.statusCode || 500; const issueCode = error.issueCode || 'exception'; - return res.status(statusCode).json({ + return res.status(statusCode).json(this.fixForVersion({ resourceType: 'OperationOutcome', issue: [{ severity: 'error', @@ -1597,7 +1597,7 @@ class ExpandWorker extends TerminologyWorker { }, diagnostics: error.message }] - }); + })); } } @@ -1674,7 +1674,7 @@ class ExpandWorker extends TerminologyWorker { // Perform the expansion const result = await this.doExpand(valueSet, txp); - return res.json(result); + return res.json(this.fixForVersion(result)); } /** @@ -1722,7 +1722,7 @@ class ExpandWorker extends TerminologyWorker { // Perform the expansion const result = await this.doExpand(valueSet, txp); - return res.json(result); + return res.json(this.fixForVersion(result)); } // Note: setupAdditionalResources, queryToParameters, formToParameters, @@ -1825,6 +1825,7 @@ class ExpandWorker extends TerminologyWorker { }] }; } + } module.exports = { diff --git a/tx/workers/metadata.js b/tx/workers/metadata.js index b38d381..aa23fe4 100644 --- a/tx/workers/metadata.js +++ b/tx/workers/metadata.js @@ -271,7 +271,6 @@ class MetadataHandler { */ async buildTerminologyCapabilities(endpoint, provider) { const now = new Date().toISOString(); - const fhirVersion = this.mapFhirVersion(endpoint.fhirVersion); const baseUrl = this.config.baseUrl || `http://localhost${endpoint.path}`; const serverVersion = this.config.serverVersion || '1.0.0'; @@ -280,7 +279,6 @@ class MetadataHandler { id: this.config.id || 'FhirServer', url: `${baseUrl}/TerminologyCapabilities/tx`, version: serverVersion, - fhirVersion: fhirVersion, name: this.config.name || 'FHIRTerminologyServerCapabilities', title: this.config.title || 'FHIR Terminology Server Capability Statement', status: 'active', @@ -303,11 +301,6 @@ class MetadataHandler { translation: this.buildTranslationCapabilities() }; - // Only add closure if supported - if (this.config.supportsClosure !== false) { - tc.closure = this.buildClosureCapabilities(); - } - return tc; } @@ -432,26 +425,7 @@ class MetadataHandler { */ buildValidateCodeCapabilities() { return { - parameter: [ - { name: 'cache-id' }, - { name: 'tx-resource' }, - { name: 'code' }, - { name: 'system' }, - { name: 'systemVersion' }, - { name: 'display' }, - { name: 'coding' }, - { name: 'codeableConcept' }, - { name: 'url' }, - { name: 'valueSetVersion' }, - { name: 'valueSet' }, - { name: 'displayLanguage' }, - { name: 'abstract' }, - { name: 'inferSystem' }, - { name: 'activeOnly' }, - { name: 'membershipOnly' }, - { name: 'displayWarning' }, - { name: 'diagnostics' } - ] + "translations" : true }; } @@ -461,32 +435,7 @@ class MetadataHandler { */ buildTranslationCapabilities() { return { - parameter: [ - { name: 'cache-id' }, - { name: 'tx-resource' }, - { name: 'code' }, - { name: 'system' }, - { name: 'version' }, - { name: 'coding' }, - { name: 'codeableConcept' }, - { name: 'source' }, - { name: 'target' }, - { name: 'targetSystem' }, - { name: 'url' }, - { name: 'conceptMap' }, - { name: 'conceptMapVersion' }, - { name: 'reverse' } - ] - }; - } - - /** - * Build closure capabilities - * @returns {Object} Closure capabilities object - */ - buildClosureCapabilities() { - return { - translation: true + needsMap : false }; } diff --git a/tx/workers/translate.js b/tx/workers/translate.js index 374d0a4..a64a289 100644 --- a/tx/workers/translate.js +++ b/tx/workers/translate.js @@ -12,7 +12,6 @@ const { TxParameters } = require('../params'); const { Parameters } = require('../library/parameters'); const { Issue, OperationOutcome } = require('../library/operation-outcome'); const {ConceptMap} = require("../library/conceptmap"); -const {CodeSystemProvider} = require("../cs/cs-api"); const DEBUG_LOGGING = true; @@ -432,6 +431,7 @@ class TranslateWorker extends TerminologyWorker { }; } + // eslint-disable-next-line no-unused-vars isOkTarget(cm, vs) { // if cm.target != null then // result := cm.target.url = vs.url @@ -440,24 +440,24 @@ class TranslateWorker extends TerminologyWorker { // todo: or it might be ok to use this value set if it's a subset of the specified one? } - isOkSourceWithValueSet(cm, vs, coding) { - let result = { found: false, group: null, match: null }; - - if (true /* (vs == null) || ((cm.source != null) && (cm.source.url === vs.url)) */) { - for (const g of cm.groups || []) { - for (const em of g.elements || []) { - if ((g.source === coding.system) && (em.code === coding.code)) { - result = { - found: true, - group: g, - match: em - }; - } - } - } - } - return result; - } + // isOkSourceWithValueSet(cm, vs, coding) { + // let result = { found: false, group: null, match: null }; + // + // if (true /* (vs == null) || ((cm.source != null) && (cm.source.url === vs.url)) */) { + // for (const g of cm.groups || []) { + // for (const em of g.elements || []) { + // if ((g.source === coding.system) && (em.code === coding.code)) { + // result = { + // found: true, + // group: g, + // match: em + // }; + // } + // } + // } + // } + // return result; + // } findConceptMap(cm) { diff --git a/tx/workers/worker.js b/tx/workers/worker.js index 98688f1..f504f4f 100644 --- a/tx/workers/worker.js +++ b/tx/workers/worker.js @@ -555,13 +555,13 @@ class TerminologyWorker { */ wrapRawResource(resource) { if (resource.resourceType === 'CodeSystem') { - return new CodeSystem(resource); + return new CodeSystem(resource, this.provider.getFhirVersion()); } if (resource.resourceType === 'ValueSet') { - return new ValueSet(resource); + return new ValueSet(resource, this.provider.getFhirVersion()); } if (resource.resourceType === 'ConceptMap') { - return new ConceptMap(resource); + return new ConceptMap(resource, this.provider.getFhirVersion()); } return null; } @@ -832,123 +832,33 @@ class TerminologyWorker { } return result; } -} - -/** - * Code system information provider for lookup operations - */ -class CodeSystemInformationProvider extends TerminologyWorker { - constructor(opContext, provider, additionalResources, languages, i18n) { - super(opContext, provider, additionalResources, languages, i18n); - } - - /** - * Lookup a code in a code system - * @param {Object} coding - Coding to lookup - * @param {OperationParameters} profile - Operation parameters - * @param {Array<string>} props - Requested properties - * @param {Object} resp - Response object to populate - */ - async lookupCode(coding, profile, props = [], resp) { - const params = profile || this.createDefaultParams(); - params.defaultToLatestVersion = true; - - const provider = await this.findCodeSystem( - coding.systemUri || coding.system, - coding.version, - profile, - ['complete', 'fragment'], - null, - false - ); - - try { - resp.name = provider.name(); - resp.systemUri = provider.systemUri; - - const version = provider.version; - if (version) { - resp.version = version; - } - - const ctxt = await provider.locate(this.opContext, coding.code); - if (!ctxt) { - throw new TerminologyError( - `Unable to find code ${coding.code} in ${coding.systemUri || coding.system} version ${version}` - ); - } - - try { - // Helper function to check if property should be included - const hasProp = (name, def = true) => { - if (!props || props.length === 0) { - return def; - } - return props.includes(name) || props.includes('*'); - }; - - // Add abstract property - if (hasProp('abstract', true) && await provider.isAbstract(this.opContext, ctxt)) { - const p = resp.addProperty('abstract'); - p.value = { valueBoolean: true }; - } - - // Add inactive property - if (hasProp('inactive', true)) { - const p = resp.addProperty('inactive'); - p.value = { valueBoolean: await provider.isInactive(this.opContext, ctxt) }; - } - - // Add definition property - if (hasProp('definition', true)) { - const definition = await provider.definition(this.opContext, ctxt); - if (definition) { - const p = resp.addProperty('definition'); - p.value = { valueString: definition }; - } - } - - resp.code = coding.code; - resp.display = await provider.display(this.opContext, ctxt, this.opContext.langs); - - // Allow provider to extend lookup with additional properties - if (provider.extendLookup) { - await provider.extendLookup(this.opContext, ctxt, this.opContext.langs, props, resp); - } + // Note: findParameter, getStringParam, getResourceParam, getCodingParam, + // and getCodeableConceptParam are inherited from TerminologyWorker base class - } finally { - // Clean up context - if (ctxt && ctxt.cleanup) { - ctxt.cleanup(); + fixForVersion(resource) { + if (this.provider.fhirVersion >= 5) { + return resource; + } + let rt = resource.resourceType; + switch (rt) { + case "ValueSet": { + let vs = new ValueSet(resource); + if (this.provider.fhirVersion == 4) { + return vs.convertFromR5(resource, "R4"); + } else if (this.provider.fhirVersion == 3) { + return vs.convertFromR5(resource, "R3"); + } else { + return resource; } } - } finally { - // Clean up provider - if (provider && provider.cleanup) { - provider.cleanup(); - } + default: + return resource; } } - - /** - * Create default operation parameters - * @returns {OperationParameters} Default parameters - */ - createDefaultParams() { - // This would create default parameters - implementation depends on your parameter structure - return { - defaultToLatestVersion: true - }; - } - - // Note: findParameter, getStringParam, getResourceParam, getCodingParam, - // and getCodeableConceptParam are inherited from TerminologyWorker base class - } module.exports = { TerminologyWorker, - CodeSystemInformationProvider, TerminologySetupError }; \ No newline at end of file diff --git a/tx/xml/capabilitystatement-xml.js b/tx/xml/capabilitystatement-xml.js new file mode 100644 index 0000000..c1e7ec5 --- /dev/null +++ b/tx/xml/capabilitystatement-xml.js @@ -0,0 +1,62 @@ +// +// CapabilityStatement XML Serialization +// + +const { FhirXmlBase } = require('./xml-base'); + +/** + * XML support for FHIR CapabilityStatement resources + */ +class CapabilityStatementXML extends FhirXmlBase { + + /** + * Element order for CapabilityStatement (FHIR requires specific order) + */ + static _elementOrder = [ + 'id', 'meta', 'implicitRules', 'language', 'text', 'contained', + 'extension', 'modifierExtension', + 'url', 'identifier', 'version', 'versionAlgorithmString', 'versionAlgorithmCoding', + 'name', 'title', 'status', 'experimental', 'date', 'publisher', + 'contact', 'description', 'useContext', 'jurisdiction', 'purpose', 'copyright', + 'copyrightLabel', 'kind', 'instantiates', 'imports', + 'software', 'implementation', 'fhirVersion', 'format', 'patchFormat', + 'acceptLanguage', 'implementationGuide', 'rest', 'messaging', 'document' + ]; + + /** + * Convert CapabilityStatement JSON to XML string + * @param {Object} json - CapabilityStatement as JSON + * @param {number} fhirVersion - FHIR version (3, 4, or 5) + * @returns {string} XML string + */ + static toXml(json, fhirVersion) { + const content = this.renderElementsInOrder(json, 1, this._elementOrder); + return this.wrapInRootElement('CapabilityStatement', content); + } + + /** + * Convert XML string to CapabilityStatement JSON + * @param {string} xml - XML string + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXml(xml, fhirVersion) { + const element = this.parseXmlString(xml); + if (element.name !== 'CapabilityStatement') { + throw new Error(`Expected CapabilityStatement root element, got ${element.name}`); + } + return this.convertElementToFhirJson(element, 'CapabilityStatement'); + } + + /** + * Parse from a pre-parsed XML element + * @param {Object} element - Parsed element with {name, attributes, children} + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXmlElement(element, fhirVersion) { + return this.convertElementToFhirJson(element, 'CapabilityStatement'); + } +} + +module.exports = { CapabilityStatementXML }; diff --git a/tx/xml/codesystem-xml.js b/tx/xml/codesystem-xml.js new file mode 100644 index 0000000..4261b27 --- /dev/null +++ b/tx/xml/codesystem-xml.js @@ -0,0 +1,62 @@ +// +// CodeSystem XML Serialization +// + +const { FhirXmlBase, FhirXmlParser } = require('./xml-base'); + +/** + * XML support for FHIR CodeSystem resources + */ +class CodeSystemXML extends FhirXmlBase { + + /** + * Element order for CodeSystem (FHIR requires specific order) + */ + static _elementOrder = [ + 'id', 'meta', 'implicitRules', 'language', 'text', 'contained', + 'extension', 'modifierExtension', + 'url', 'identifier', 'version', 'versionAlgorithmString', 'versionAlgorithmCoding', + 'name', 'title', 'status', 'experimental', 'date', 'publisher', + 'contact', 'description', 'useContext', 'jurisdiction', 'purpose', 'copyright', + 'copyrightLabel', 'approvalDate', 'lastReviewDate', 'effectivePeriod', + 'caseSensitive', 'valueSet', 'hierarchyMeaning', 'compositional', 'versionNeeded', + 'content', 'supplements', 'count', 'filter', 'property', 'concept' + ]; + + /** + * Convert CodeSystem JSON to XML string + * @param {Object} json - CodeSystem as JSON + * @param {number} fhirVersion - FHIR version (3, 4, or 5) + * @returns {string} XML string + */ + static toXml(json, fhirVersion) { + const content = this.renderElementsInOrder(json, 1, this._elementOrder); + return this.wrapInRootElement('CodeSystem', content); + } + + /** + * Convert XML string to CodeSystem JSON + * @param {string} xml - XML string + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXml(xml, fhirVersion) { + const element = this.parseXmlString(xml); + if (element.name !== 'CodeSystem') { + throw new Error(`Expected CodeSystem root element, got ${element.name}`); + } + return this.convertElementToFhirJson(element, 'CodeSystem'); + } + + /** + * Parse from a pre-parsed XML element + * @param {Object} element - Parsed element with {name, attributes, children} + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXmlElement(element, fhirVersion) { + return this.convertElementToFhirJson(element, 'CodeSystem'); + } +} + +module.exports = { CodeSystemXML }; diff --git a/tx/xml/conceptmap-xml.js b/tx/xml/conceptmap-xml.js new file mode 100644 index 0000000..8a207bd --- /dev/null +++ b/tx/xml/conceptmap-xml.js @@ -0,0 +1,62 @@ +// +// ConceptMap XML Serialization +// + +const { FhirXmlBase } = require('./xml-base'); + +/** + * XML support for FHIR ConceptMap resources + */ +class ConceptMapXML extends FhirXmlBase { + + /** + * Element order for ConceptMap (FHIR requires specific order) + */ + static _elementOrder = [ + 'id', 'meta', 'implicitRules', 'language', 'text', 'contained', + 'extension', 'modifierExtension', + 'url', 'identifier', 'version', 'versionAlgorithmString', 'versionAlgorithmCoding', + 'name', 'title', 'status', 'experimental', 'date', 'publisher', + 'contact', 'description', 'useContext', 'jurisdiction', 'purpose', 'copyright', + 'copyrightLabel', 'approvalDate', 'lastReviewDate', 'effectivePeriod', + 'sourceScopeUri', 'sourceScopeCanonical', 'targetScopeUri', 'targetScopeCanonical', + 'group' + ]; + + /** + * Convert ConceptMap JSON to XML string + * @param {Object} json - ConceptMap as JSON + * @param {number} fhirVersion - FHIR version (3, 4, or 5) + * @returns {string} XML string + */ + static toXml(json, fhirVersion) { + const content = this.renderElementsInOrder(json, 1, this._elementOrder); + return this.wrapInRootElement('ConceptMap', content); + } + + /** + * Convert XML string to ConceptMap JSON + * @param {string} xml - XML string + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXml(xml, fhirVersion) { + const element = this.parseXmlString(xml); + if (element.name !== 'ConceptMap') { + throw new Error(`Expected ConceptMap root element, got ${element.name}`); + } + return this.convertElementToFhirJson(element, 'ConceptMap'); + } + + /** + * Parse from a pre-parsed XML element + * @param {Object} element - Parsed element with {name, attributes, children} + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXmlElement(element, fhirVersion) { + return this.convertElementToFhirJson(element, 'ConceptMap'); + } +} + +module.exports = { ConceptMapXML }; diff --git a/tx/xml/namingsystem-xml.js b/tx/xml/namingsystem-xml.js new file mode 100644 index 0000000..25c465f --- /dev/null +++ b/tx/xml/namingsystem-xml.js @@ -0,0 +1,62 @@ +// +// NamingSystem XML Serialization +// + +const { FhirXmlBase } = require('./xml-base'); + +/** + * XML support for FHIR NamingSystem resources + */ +class NamingSystemXML extends FhirXmlBase { + + /** + * Element order for NamingSystem (FHIR requires specific order) + */ + static _elementOrder = [ + 'id', 'meta', 'implicitRules', 'language', 'text', 'contained', + 'extension', 'modifierExtension', + 'url', 'identifier', 'version', 'versionAlgorithmString', 'versionAlgorithmCoding', + 'name', 'title', 'status', 'kind', 'experimental', 'date', 'publisher', + 'contact', 'responsible', 'type', 'description', 'useContext', 'jurisdiction', + 'purpose', 'copyright', 'copyrightLabel', 'approvalDate', 'lastReviewDate', + 'effectivePeriod', 'topic', 'author', 'editor', 'reviewer', 'endorser', + 'relatedArtifact', 'usage', 'uniqueId' + ]; + + /** + * Convert NamingSystem JSON to XML string + * @param {Object} json - NamingSystem as JSON + * @param {number} fhirVersion - FHIR version (3, 4, or 5) + * @returns {string} XML string + */ + static toXml(json, fhirVersion) { + const content = this.renderElementsInOrder(json, 1, this._elementOrder); + return this.wrapInRootElement('NamingSystem', content); + } + + /** + * Convert XML string to NamingSystem JSON + * @param {string} xml - XML string + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXml(xml, fhirVersion) { + const element = this.parseXmlString(xml); + if (element.name !== 'NamingSystem') { + throw new Error(`Expected NamingSystem root element, got ${element.name}`); + } + return this.convertElementToFhirJson(element, 'NamingSystem'); + } + + /** + * Parse from a pre-parsed XML element + * @param {Object} element - Parsed element with {name, attributes, children} + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXmlElement(element, fhirVersion) { + return this.convertElementToFhirJson(element, 'NamingSystem'); + } +} + +module.exports = { NamingSystemXML }; diff --git a/tx/xml/operationoutcome-xml.js b/tx/xml/operationoutcome-xml.js new file mode 100644 index 0000000..2e76fc3 --- /dev/null +++ b/tx/xml/operationoutcome-xml.js @@ -0,0 +1,124 @@ +// +// OperationOutcome XML Serialization +// + +const { FhirXmlBase } = require('./xml-base'); + +/** + * XML support for FHIR OperationOutcome resources + */ +class OperationOutcomeXML extends FhirXmlBase { + + /** + * Element order for OperationOutcome (FHIR requires specific order) + */ + static _elementOrder = [ + 'id', 'meta', 'implicitRules', 'language', 'text', 'contained', + 'extension', 'modifierExtension', 'issue' + ]; + + /** + * Element order for issue elements + */ + static _issueElementOrder = [ + 'severity', 'code', 'details', 'diagnostics', 'location', 'expression' + ]; + + /** + * Convert OperationOutcome JSON to XML string + * @param {Object} json - OperationOutcome as JSON + * @param {number} fhirVersion - FHIR version (3, 4, or 5) + * @returns {string} XML string + */ + static toXml(json, fhirVersion) { + const content = this._renderOperationOutcome(json, 1); + return this.wrapInRootElement('OperationOutcome', content); + } + + /** + * Render OperationOutcome with special handling for issues + * @private + */ + static _renderOperationOutcome(obj, level) { + let xml = ''; + + // Process elements in order + for (const key of this._elementOrder) { + if (obj.hasOwnProperty(key) && key !== 'resourceType') { + if (key === 'issue') { + xml += this._renderIssues(obj.issue, level); + } else { + xml += this.renderElement(key, obj[key], level); + } + } + } + + // Process any remaining elements + for (const key of Object.keys(obj)) { + if (!this._elementOrder.includes(key) && key !== 'resourceType' && !key.startsWith('_')) { + xml += this.renderElement(key, obj[key], level); + } + } + + return xml; + } + + /** + * Render issue array with proper element ordering + * @private + */ + static _renderIssues(issues, level) { + let xml = ''; + if (!Array.isArray(issues)) { + issues = [issues]; + } + + for (const issue of issues) { + xml += `${this.indent(level)}<issue>\n`; + + // Render issue elements in correct order + for (const key of this._issueElementOrder) { + if (issue.hasOwnProperty(key)) { + xml += this.renderElement(key, issue[key], level + 1); + } + } + + // Render any remaining elements + for (const key of Object.keys(issue)) { + if (!this._issueElementOrder.includes(key) && !key.startsWith('_')) { + xml += this.renderElement(key, issue[key], level + 1); + } + } + + xml += `${this.indent(level)}</issue>\n`; + } + + return xml; + } + + /** + * Convert XML string to OperationOutcome JSON + * @param {string} xml - XML string + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXml(xml, fhirVersion) { + const element = this.parseXmlString(xml); + if (element.name !== 'OperationOutcome') { + throw new Error(`Expected OperationOutcome root element, got ${element.name}`); + } + return this.convertElementToFhirJson(element, 'OperationOutcome'); + } + + /** + * Parse from a pre-parsed XML element + * @param {Object} element - Parsed element with {name, attributes, children} + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXmlElement(element, fhirVersion) { + return this.convertElementToFhirJson(element, 'OperationOutcome'); + } +} + +module.exports = { OperationOutcomeXML }; diff --git a/tx/xml/parameters-xml.js b/tx/xml/parameters-xml.js new file mode 100644 index 0000000..ca5abde --- /dev/null +++ b/tx/xml/parameters-xml.js @@ -0,0 +1,312 @@ +// +// Parameters XML Serialization +// + +const { FhirXmlBase } = require('./xml-base'); + +// Forward declarations - will be loaded lazily to avoid circular dependencies +let CodeSystemXML, ValueSetXML, ConceptMapXML, OperationOutcomeXML; + +/** + * XML support for FHIR Parameters resources + */ +class ParametersXML extends FhirXmlBase { + + /** + * Element order for Parameters (FHIR requires specific order) + */ + static _elementOrder = [ + 'id', 'meta', 'implicitRules', 'language', 'parameter' + ]; + + /** + * Lazily load resource XML classes to avoid circular dependencies + */ + static _loadResourceClasses() { + if (!CodeSystemXML) { + CodeSystemXML = require('./codesystem-xml').CodeSystemXML; + ValueSetXML = require('./valueset-xml').ValueSetXML; + ConceptMapXML = require('./conceptmap-xml').ConceptMapXML; + OperationOutcomeXML = require('./operationoutcome-xml').OperationOutcomeXML; + } + } + + /** + * Convert Parameters JSON to XML string + * @param {Object} json - Parameters as JSON + * @param {number} fhirVersion - FHIR version (3, 4, or 5) + * @returns {string} XML string + */ + static toXml(json, fhirVersion) { + this._loadResourceClasses(); + const content = this._renderParameters(json, 1, fhirVersion); + return this.wrapInRootElement('Parameters', content); + } + + /** + * Render Parameters with special handling for parameter elements + * @private + */ + static _renderParameters(obj, level, fhirVersion) { + let xml = ''; + + // Process elements in order + for (const key of this._elementOrder) { + if (obj.hasOwnProperty(key) && key !== 'resourceType') { + if (key === 'parameter') { + xml += this._renderParameterArray(obj.parameter, level, fhirVersion); + } else { + xml += this.renderElement(key, obj[key], level); + } + } + } + + // Process any remaining elements + for (const key of Object.keys(obj)) { + if (!this._elementOrder.includes(key) && key !== 'resourceType' && !key.startsWith('_')) { + xml += this.renderElement(key, obj[key], level); + } + } + + return xml; + } + + /** + * Render parameter array + * @private + */ + static _renderParameterArray(parameters, level, fhirVersion) { + let xml = ''; + if (!Array.isArray(parameters)) { + parameters = [parameters]; + } + + for (const param of parameters) { + xml += `${this.indent(level)}<parameter>\n`; + xml += this._renderParameter(param, level + 1, fhirVersion); + xml += `${this.indent(level)}</parameter>\n`; + } + + return xml; + } + + /** + * Render a single parameter + * @private + */ + static _renderParameter(param, level, fhirVersion) { + let xml = ''; + + // Parameter element order: name, value[x], resource, part + if (param.name !== undefined) { + xml += `${this.indent(level)}<name value="${this.escapeXml(param.name)}"/>\n`; + } + + // Handle value[x] - find the value property + for (const [key, value] of Object.entries(param)) { + if (key.startsWith('value')) { + xml += this.renderElement(key, value, level); + break; + } + } + + // Handle resource + if (param.resource) { + xml += `${this.indent(level)}<resource>\n`; + xml += this._renderResource(param.resource, level + 1, fhirVersion); + xml += `${this.indent(level)}</resource>\n`; + } + + // Handle nested parts + if (param.part) { + for (const part of param.part) { + xml += `${this.indent(level)}<part>\n`; + xml += this._renderParameter(part, level + 1, fhirVersion); + xml += `${this.indent(level)}</part>\n`; + } + } + + return xml; + } + + /** + * Render an embedded resource + * @private + */ + static _renderResource(resource, level, fhirVersion) { + const resourceType = resource.resourceType; + if (!resourceType) { + return ''; + } + + // Try to use dedicated XML converter + let fullXml; + try { + switch (resourceType) { + case 'CodeSystem': + fullXml = CodeSystemXML.toXml(resource, fhirVersion); + break; + case 'ValueSet': + fullXml = ValueSetXML.toXml(resource, fhirVersion); + break; + case 'ConceptMap': + fullXml = ConceptMapXML.toXml(resource, fhirVersion); + break; + case 'OperationOutcome': + fullXml = OperationOutcomeXML.toXml(resource, fhirVersion); + break; + case 'Parameters': + fullXml = ParametersXML.toXml(resource, fhirVersion); + break; + default: + // Fall back to generic rendering + return this._renderGenericResource(resource, level); + } + + // Remove XML declaration and re-indent + let fragment = fullXml.replace(/^<\?xml[^?]*\?>\s*\n?/, ''); + + // Add indentation to each line + const indent = this.indent(level); + fragment = fragment.split('\n').map(line => line ? indent + line : line).join('\n'); + + return fragment; + } catch (e) { + return this._renderGenericResource(resource, level); + } + } + + /** + * Generic resource rendering fallback + * @private + */ + static _renderGenericResource(resource, level) { + let xml = ''; + const resourceType = resource.resourceType; + if (resourceType) { + xml += `${this.indent(level)}<${resourceType} xmlns="${this.getNamespace()}">\n`; + for (const [key, value] of Object.entries(resource)) { + if (key !== 'resourceType') { + xml += this.renderElement(key, value, level + 1); + } + } + xml += `${this.indent(level)}</${resourceType}>\n`; + } + return xml; + } + + /** + * Convert XML string to Parameters JSON + * @param {string} xml - XML string + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXml(xml, fhirVersion) { + this._loadResourceClasses(); + const element = this.parseXmlString(xml); + if (element.name !== 'Parameters') { + throw new Error(`Expected Parameters root element, got ${element.name}`); + } + return this._parseParametersElement(element, fhirVersion); + } + + /** + * Parse from a pre-parsed XML element + * @param {Object} element - Parsed element with {name, attributes, children} + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXmlElement(element, fhirVersion) { + this._loadResourceClasses(); + return this._parseParametersElement(element, fhirVersion); + } + + /** + * Parse Parameters element + * @private + */ + static _parseParametersElement(element, fhirVersion) { + const json = { resourceType: 'Parameters' }; + + for (const child of element.children) { + if (child.name === 'id') { + json.id = child.attributes.value; + } else if (child.name === 'meta') { + json.meta = this.convertChildElement(child); + } else if (child.name === 'implicitRules') { + json.implicitRules = child.attributes.value; + } else if (child.name === 'language') { + json.language = child.attributes.value; + } else if (child.name === 'parameter') { + if (!json.parameter) { + json.parameter = []; + } + json.parameter.push(this._parseParameter(child, fhirVersion)); + } + } + + return json; + } + + /** + * Parse a parameter element + * @private + */ + static _parseParameter(element, fhirVersion) { + const param = {}; + + for (const child of element.children) { + if (child.name === 'name') { + param.name = child.attributes.value; + } else if (child.name.startsWith('value')) { + const { value, primitiveExt } = this._convertChildElementWithExt(child); + if (value !== null) { + param[child.name] = value; + } + if (primitiveExt !== null) { + param['_' + child.name] = primitiveExt; + } + } else if (child.name === 'resource') { + param.resource = this._parseResourceElement(child, fhirVersion); + } else if (child.name === 'part') { + if (!param.part) { + param.part = []; + } + param.part.push(this._parseParameter(child, fhirVersion)); + } + } + + return param; + } + + /** + * Parse a resource element + * @private + */ + static _parseResourceElement(element, fhirVersion) { + if (element.children.length > 0) { + const resourceElement = element.children[0]; + const resourceType = resourceElement.name; + + // Try to use dedicated parser + switch (resourceType) { + case 'CodeSystem': + return CodeSystemXML.fromXmlElement(resourceElement, fhirVersion); + case 'ValueSet': + return ValueSetXML.fromXmlElement(resourceElement, fhirVersion); + case 'ConceptMap': + return ConceptMapXML.fromXmlElement(resourceElement, fhirVersion); + case 'OperationOutcome': + return OperationOutcomeXML.fromXmlElement(resourceElement, fhirVersion); + case 'Parameters': + return ParametersXML.fromXmlElement(resourceElement, fhirVersion); + default: + // Generic parsing + return this.convertElementToFhirJson(resourceElement, resourceType); + } + } + return null; + } +} + +module.exports = { ParametersXML }; diff --git a/tx/xml/terminologycapabilities-xml.js b/tx/xml/terminologycapabilities-xml.js new file mode 100644 index 0000000..e61d5c1 --- /dev/null +++ b/tx/xml/terminologycapabilities-xml.js @@ -0,0 +1,61 @@ +// +// TerminologyCapabilities XML Serialization +// + +const { FhirXmlBase } = require('./xml-base'); + +/** + * XML support for FHIR TerminologyCapabilities resources + */ +class TerminologyCapabilitiesXML extends FhirXmlBase { + + /** + * Element order for TerminologyCapabilities (FHIR requires specific order) + */ + static _elementOrder = [ + 'id', 'meta', 'implicitRules', 'language', 'text', 'contained', + 'extension', 'modifierExtension', + 'url', 'identifier', 'version', 'versionAlgorithmString', 'versionAlgorithmCoding', + 'name', 'title', 'status', 'experimental', 'date', 'publisher', + 'contact', 'description', 'useContext', 'jurisdiction', 'purpose', 'copyright', + 'copyrightLabel', 'kind', 'software', 'implementation', 'lockedDate', + 'codeSystem', 'expansion', 'codeSearch', 'validateCode', 'translation', 'closure' + ]; + + /** + * Convert TerminologyCapabilities JSON to XML string + * @param {Object} json - TerminologyCapabilities as JSON + * @param {number} fhirVersion - FHIR version (3, 4, or 5) + * @returns {string} XML string + */ + static toXml(json, fhirVersion) { + const content = this.renderElementsInOrder(json, 1, this._elementOrder); + return this.wrapInRootElement('TerminologyCapabilities', content); + } + + /** + * Convert XML string to TerminologyCapabilities JSON + * @param {string} xml - XML string + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXml(xml, fhirVersion) { + const element = this.parseXmlString(xml); + if (element.name !== 'TerminologyCapabilities') { + throw new Error(`Expected TerminologyCapabilities root element, got ${element.name}`); + } + return this.convertElementToFhirJson(element, 'TerminologyCapabilities'); + } + + /** + * Parse from a pre-parsed XML element + * @param {Object} element - Parsed element with {name, attributes, children} + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXmlElement(element, fhirVersion) { + return this.convertElementToFhirJson(element, 'TerminologyCapabilities'); + } +} + +module.exports = { TerminologyCapabilitiesXML }; diff --git a/tx/xml/valueset-xml.js b/tx/xml/valueset-xml.js new file mode 100644 index 0000000..2aabe2e --- /dev/null +++ b/tx/xml/valueset-xml.js @@ -0,0 +1,61 @@ +// +// ValueSet XML Serialization +// + +const { FhirXmlBase } = require('./xml-base'); + +/** + * XML support for FHIR ValueSet resources + */ +class ValueSetXML extends FhirXmlBase { + + /** + * Element order for ValueSet (FHIR requires specific order) + */ + static _elementOrder = [ + 'id', 'meta', 'implicitRules', 'language', 'text', 'contained', + 'extension', 'modifierExtension', + 'url', 'identifier', 'version', 'versionAlgorithmString', 'versionAlgorithmCoding', + 'name', 'title', 'status', 'experimental', 'date', 'publisher', + 'contact', 'description', 'useContext', 'jurisdiction', 'purpose', 'copyright', + 'copyrightLabel', 'approvalDate', 'lastReviewDate', 'effectivePeriod', + 'immutable', 'compose', 'expansion' + ]; + + /** + * Convert ValueSet JSON to XML string + * @param {Object} json - ValueSet as JSON + * @param {number} fhirVersion - FHIR version (3, 4, or 5) + * @returns {string} XML string + */ + static toXml(json, fhirVersion) { + const content = this.renderElementsInOrder(json, 1, this._elementOrder); + return this.wrapInRootElement('ValueSet', content); + } + + /** + * Convert XML string to ValueSet JSON + * @param {string} xml - XML string + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXml(xml, fhirVersion) { + const element = this.parseXmlString(xml); + if (element.name !== 'ValueSet') { + throw new Error(`Expected ValueSet root element, got ${element.name}`); + } + return this.convertElementToFhirJson(element, 'ValueSet'); + } + + /** + * Parse from a pre-parsed XML element + * @param {Object} element - Parsed element with {name, attributes, children} + * @param {number} fhirVersion - FHIR version + * @returns {Object} JSON object + */ + static fromXmlElement(element, fhirVersion) { + return this.convertElementToFhirJson(element, 'ValueSet'); + } +} + +module.exports = { ValueSetXML }; diff --git a/tx/xml/xml-base.js b/tx/xml/xml-base.js new file mode 100644 index 0000000..5a1e3dd --- /dev/null +++ b/tx/xml/xml-base.js @@ -0,0 +1,602 @@ +// +// FHIR XML Base Class +// Common functionality for all FHIR resource XML serialization/deserialization +// + +/** + * Base class for FHIR XML serialization/deserialization + * Contains shared methods for converting between FHIR JSON and XML formats + */ +class FhirXmlBase { + + /** + * FHIR elements that should always be arrays in JSON + * These are elements that are arrays at the TOP LEVEL of resources or in unambiguous contexts + * @type {Set<string>} + */ + static _arrayElements = new Set([ + // Common resource elements + 'identifier', + 'contact', + 'useContext', + 'jurisdiction', + 'extension', + 'modifierExtension', + + // CodeSystem elements (at resource level) + 'concept', // CodeSystem.concept is array + 'filter', // CodeSystem.filter is array (but ValueSet.compose.include.filter is also array) + 'operator', // CodeSystem.filter.operator is array + 'designation', // concept.designation is array + + // ValueSet elements + 'include', + 'exclude', + 'contains', + 'parameter', + 'valueSet', + + // ConceptMap elements + 'group', + 'element', + 'target', + 'dependsOn', + 'product', + 'additionalAttribute', + + // OperationOutcome elements + 'issue', + 'location', + 'expression', + + // Parameters elements + 'part', + + // Common data type elements + 'coding', + 'telecom', + 'address', + 'given', + 'prefix', + 'suffix', + 'line', + 'link', + 'entry', + + // NamingSystem elements + 'uniqueId', + ]); + + /** + * Elements that are arrays ONLY at resource/backbone level, not inside filters/other contexts + * 'property' is an array in CodeSystem.property and CodeSystem.concept.property + * but NOT in ValueSet.compose.include.filter.property (which is a single code) + * @type {Set<string>} + */ + static _resourceLevelArrayElements = new Set([ + 'property', // Array in CodeSystem.property and concept.property, but single in filter + ]); + + /** + * Element names that represent boolean types in FHIR + * @type {Set<string>} + */ + static _booleanElements = new Set([ + 'valueBoolean', + 'experimental', + 'caseSensitive', + 'compositional', + 'versionNeeded', + 'inactive', + 'notSelectable', + 'abstract', + 'immutable', + 'lockedDate', + 'preferred', + ]); + + /** + * Element names that represent integer types in FHIR + * @type {Set<string>} + */ + static _integerElements = new Set([ + 'valueInteger', + 'valueUnsignedInt', + 'valuePositiveInt', + 'count', + 'offset', + 'total', + ]); + + /** + * Element names that represent decimal types in FHIR + * @type {Set<string>} + */ + static _decimalElements = new Set([ + 'valueDecimal', + ]); + + // ==================== XML PARSING (XML -> JSON) ==================== + + /** + * Convert a manually parsed XML element to FHIR JSON format + * @param {Object} element - Element with {name, attributes, children} + * @param {string} resourceType - The FHIR resource type + * @returns {Object} FHIR JSON object + */ + static convertElementToFhirJson(element, resourceType) { + const result = { resourceType }; + + for (const child of element.children) { + const key = child.name; + const { value, primitiveExt } = this._convertChildElementWithExt(child, resourceType); + + // Handle the value + if (value !== null) { + if (this._isArrayElement(key, resourceType)) { + if (!result[key]) { + result[key] = []; + } + result[key].push(value); + } else { + result[key] = value; + } + } + + // Handle primitive extension (e.g., _value with extension) + if (primitiveExt !== null) { + const extKey = '_' + key; + result[extKey] = primitiveExt; + } + } + + return result; + } + + /** + * Check if an element should be an array based on its name and parent context + * @param {string} elementName - The element name + * @param {string} parentContext - The parent element name or context + * @returns {boolean} + */ + static _isArrayElement(elementName, parentContext) { + // These are always arrays regardless of context + if (this._arrayElements.has(elementName)) { + return true; + } + + // 'property' is an array in CodeSystem.property, CodeSystem.concept.property + // but NOT in filter.property (which is a single code) + if (elementName === 'property') { + // property is array at resource level or inside concept, but not inside filter + return parentContext !== 'filter'; + } + + return false; + } + + /** + * Converts a child element to appropriate JSON value, also handling primitive extensions + * @param {Object} child - Child element with {name, attributes, children} + * @param {string} parentContext - Parent element name for context-dependent array handling + * @returns {{value: *, primitiveExt: Object|null}} Converted value and primitive extension if any + * @private + */ + static _convertChildElementWithExt(child, parentContext = '') { + const hasValue = child.attributes.value !== undefined; + const hasChildren = child.children.length > 0; + const isExtensionElement = child.name === 'extension' || child.name === 'modifierExtension'; + + // Extension elements are NEVER primitive extensions - they are always complex elements + // Only primitive FHIR elements (like string, code, uri, etc.) can have primitive extensions + if (!isExtensionElement) { + // Check if children are only extensions (for primitive extension detection) + const extensionChildren = child.children.filter( + c => c.name === 'extension' || c.name === 'modifierExtension' + ); + const nonExtensionChildren = child.children.filter( + c => c.name !== 'extension' && c.name !== 'modifierExtension' + ); + const hasOnlyExtensions = hasChildren && nonExtensionChildren.length === 0; + + // Case 1: Simple primitive with value, no children + if (hasValue && !hasChildren) { + return { value: this._convertPrimitiveValue(child.name, child.attributes.value), primitiveExt: null }; + } + + // Case 2: Primitive with extension but no value - this is a primitive extension only + // This ONLY applies when there are NO non-extension children + if (!hasValue && hasOnlyExtensions) { + const ext = this._buildExtensionObject(extensionChildren); + return { value: null, primitiveExt: ext }; + } + + // Case 3: Primitive with both value and extensions (no other children) + // This ONLY applies when there are NO non-extension children + if (hasValue && hasOnlyExtensions) { + const ext = this._buildExtensionObject(extensionChildren); + return { value: this._convertPrimitiveValue(child.name, child.attributes.value), primitiveExt: ext }; + } + } + + // Case 4: Complex element - process normally (includes extensions as regular children) + const obj = {}; + const currentContext = child.name; // Use current element name as context for children + + // Copy non-value attributes (like url for extensions) + for (const [attrName, attrValue] of Object.entries(child.attributes)) { + if (attrName !== 'value' && attrName !== 'xmlns') { + obj[attrName] = attrValue; + } + } + + // Process ALL children (including extensions as normal array elements) + for (const grandchild of child.children) { + const key = grandchild.name; + const { value, primitiveExt } = this._convertChildElementWithExt(grandchild, currentContext); + + // Handle the value + if (value !== null) { + if (this._isArrayElement(key, currentContext)) { + if (!obj[key]) { + obj[key] = []; + } + obj[key].push(value); + } else if (obj[key] !== undefined) { + // Convert to array if we see the same key twice + if (!Array.isArray(obj[key])) { + obj[key] = [obj[key]]; + } + obj[key].push(value); + } else { + obj[key] = value; + } + } + + // Handle primitive extension + if (primitiveExt !== null) { + const extKey = '_' + key; + obj[extKey] = primitiveExt; + } + } + + return { value: Object.keys(obj).length > 0 ? obj : null, primitiveExt: null }; + } + + /** + * Build an extension object from extension children + * @param {Array} extensionChildren - Array of extension child elements + * @returns {Object} Extension object with extension/modifierExtension arrays + * @private + */ + static _buildExtensionObject(extensionChildren) { + const ext = {}; + for (const extChild of extensionChildren) { + const key = extChild.name; + const { value } = this._convertChildElementWithExt(extChild); + if (!ext[key]) { + ext[key] = []; + } + ext[key].push(value); + } + return ext; + } + + /** + * Convert a primitive value to the appropriate JavaScript type + * @param {string} elementName - The element name + * @param {string} value - The string value from XML + * @returns {*} Converted value + * @private + */ + static _convertPrimitiveValue(elementName, value) { + if (this._booleanElements.has(elementName)) { + return value === 'true'; + } + if (this._integerElements.has(elementName)) { + return parseInt(value, 10); + } + if (this._decimalElements.has(elementName)) { + return parseFloat(value); + } + // Everything else stays as string + return value; + } + + /** + * Simple child element conversion (without tracking primitive extensions) + * @param {Object} child - Child element with {name, attributes, children} + * @param {string} parentContext - Parent context for array handling + * @returns {*} Converted value + */ + static convertChildElement(child, parentContext = '') { + return this._convertChildElementWithExt(child, parentContext).value; + } + + // ==================== XML GENERATION (JSON -> XML) ==================== + + /** + * Get the FHIR namespace + * @returns {string} + */ + static getNamespace() { + return 'http://hl7.org/fhir'; + } + + /** + * Get indentation string + * @param {number} level - Indentation level + * @returns {string} + */ + static indent(level) { + return ' '.repeat(level); + } + + /** + * Escape special characters for XML + * @param {*} value - Value to escape + * @returns {string} + */ + static escapeXml(value) { + if (value === null || value === undefined) return ''; + return String(value) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** + * Unescape XML entities + * @param {string} str - String to unescape + * @returns {string} + */ + static unescapeXml(str) { + return str + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); + } + + /** + * Render an element to XML + * @param {string} name - Element name + * @param {*} value - Element value + * @param {number} level - Indentation level + * @returns {string} XML string + */ + static renderElement(name, value, level) { + if (value === null || value === undefined) { + return ''; + } + + let xml = ''; + + if (Array.isArray(value)) { + for (const item of value) { + xml += this.renderElement(name, item, level); + } + } else if (typeof value === 'object') { + // Special handling for extension - url is an attribute + if (name === 'extension' || name === 'modifierExtension') { + const url = value.url ? ` url="${this.escapeXml(value.url)}"` : ''; + xml += `${this.indent(level)}<${name}${url}>\n`; + xml += this.renderObject(value, level + 1, ['url']); + xml += `${this.indent(level)}</${name}>\n`; + } else { + xml += `${this.indent(level)}<${name}>\n`; + xml += this.renderObject(value, level + 1); + xml += `${this.indent(level)}</${name}>\n`; + } + } else if (typeof value === 'boolean' || typeof value === 'number') { + xml += `${this.indent(level)}<${name} value="${value}"/>\n`; + } else { + xml += `${this.indent(level)}<${name} value="${this.escapeXml(value)}"/>\n`; + } + + return xml; + } + + /** + * Render an object's properties to XML + * @param {Object} obj - Object to render + * @param {number} level - Indentation level + * @param {Array<string>} skipKeys - Keys to skip + * @returns {string} XML string + */ + static renderObject(obj, level, skipKeys = []) { + let xml = ''; + + for (const [key, value] of Object.entries(obj)) { + // Skip primitive extension keys (handled separately) + if (key.startsWith('_')) { + continue; + } + if (skipKeys.includes(key)) { + continue; + } + xml += this.renderElement(key, value, level); + } + + return xml; + } + + /** + * Render elements in a specific order + * @param {Object} obj - Object to render + * @param {number} level - Indentation level + * @param {Array<string>} elementOrder - Ordered list of element names + * @returns {string} XML string + */ + static renderElementsInOrder(obj, level, elementOrder) { + let xml = ''; + + // Process elements in order + for (const key of elementOrder) { + if (obj.hasOwnProperty(key) && key !== 'resourceType') { + xml += this.renderElement(key, obj[key], level); + } + } + + // Process any remaining elements not in the order list + for (const key of Object.keys(obj)) { + if (!elementOrder.includes(key) && key !== 'resourceType' && !key.startsWith('_')) { + xml += this.renderElement(key, obj[key], level); + } + } + + return xml; + } + + /** + * Generate XML declaration and root element wrapper + * @param {string} resourceType - FHIR resource type + * @param {string} content - Inner XML content + * @returns {string} Complete XML document + */ + static wrapInRootElement(resourceType, content) { + let xml = '<?xml version="1.0" encoding="UTF-8"?>\n'; + xml += `<${resourceType} xmlns="${this.getNamespace()}">\n`; + xml += content; + xml += `</${resourceType}>`; + return xml; + } + + // ==================== XML STRING PARSING ==================== + + /** + * Manual XML parser - parses XML string to element tree + */ + static parseXmlString(xml) { + const parser = new FhirXmlParser(xml); + return parser.parse(); + } +} + +/** + * Simple XML parser for FHIR resources + * Parses XML string into {name, attributes, children} structure + */ +class FhirXmlParser { + constructor(xml) { + this.xml = xml; + this.pos = 0; + } + + parse() { + this._skipDeclaration(); + this._skipWhitespace(); + return this._parseElement(); + } + + _skipDeclaration() { + this._skipWhitespace(); + if (this.xml.substring(this.pos, this.pos + 5) === '<?xml') { + const end = this.xml.indexOf('?>', this.pos); + if (end !== -1) { + this.pos = end + 2; + } + } + } + + _skipWhitespace() { + while (this.pos < this.xml.length && /\s/.test(this.xml[this.pos])) { + this.pos++; + } + } + + _parseElement() { + this._skipWhitespace(); + + if (this.xml[this.pos] !== '<') { + throw new Error(`Expected '<' at position ${this.pos}`); + } + this.pos++; // Skip '<' + + // Parse element name + const nameEnd = this.xml.substring(this.pos).search(/[\s\/>]/); + const name = this.xml.substring(this.pos, this.pos + nameEnd); + this.pos += nameEnd; + + // Parse attributes + const attributes = {}; + this._skipWhitespace(); + + while (this.pos < this.xml.length && this.xml[this.pos] !== '>' && this.xml[this.pos] !== '/') { + const attr = this._parseAttribute(); + if (attr) { + attributes[attr.name] = attr.value; + } + this._skipWhitespace(); + } + + const children = []; + + // Self-closing tag + if (this.xml[this.pos] === '/') { + this.pos += 2; // Skip '/>' + return { name, attributes, children }; + } + + this.pos++; // Skip '>' + + // Parse children + while (this.pos < this.xml.length) { + this._skipWhitespace(); + + if (this.xml.substring(this.pos, this.pos + 2) === '</') { + // Closing tag + const closeEnd = this.xml.indexOf('>', this.pos); + this.pos = closeEnd + 1; + break; + } + + if (this.xml[this.pos] === '<') { + // Check for comment + if (this.xml.substring(this.pos, this.pos + 4) === '<!--') { + const commentEnd = this.xml.indexOf('-->', this.pos); + this.pos = commentEnd + 3; + continue; + } + + children.push(this._parseElement()); + } else { + // Text content - skip for FHIR as values are in attributes + const textEnd = this.xml.indexOf('<', this.pos); + this.pos = textEnd; + } + } + + return { name, attributes, children }; + } + + _parseAttribute() { + this._skipWhitespace(); + + if (this.xml[this.pos] === '>' || this.xml[this.pos] === '/') { + return null; + } + + // Parse attribute name + const eqPos = this.xml.indexOf('=', this.pos); + const name = this.xml.substring(this.pos, eqPos).trim(); + this.pos = eqPos + 1; + + // Skip whitespace and opening quote + this._skipWhitespace(); + const quote = this.xml[this.pos]; + this.pos++; + + // Parse attribute value + const valueEnd = this.xml.indexOf(quote, this.pos); + const value = FhirXmlBase.unescapeXml(this.xml.substring(this.pos, valueEnd)); + this.pos = valueEnd + 1; + + return { name, value }; + } +} + +module.exports = { FhirXmlBase, FhirXmlParser }; \ No newline at end of file From f912dd15b631e6086c83683a82d6a03faea68058 Mon Sep 17 00:00:00 2001 From: Grahame Grieve <grahameg@gmail.ccom> Date: Mon, 12 Jan 2026 07:58:30 +1100 Subject: [PATCH 2/3] fix eslint issues --- tests/library/codesystem.test.js | 1 - tx/tx-html.js | 3 ++- tx/xml/capabilitystatement-xml.js | 1 + tx/xml/conceptmap-xml.js | 3 +++ tx/xml/namingsystem-xml.js | 3 +++ tx/xml/operationoutcome-xml.js | 7 +++++-- tx/xml/parameters-xml.js | 2 +- tx/xml/terminologycapabilities-xml.js | 3 +++ tx/xml/valueset-xml.js | 3 +++ tx/xml/xml-base.js | 5 +++-- 10 files changed, 24 insertions(+), 7 deletions(-) diff --git a/tests/library/codesystem.test.js b/tests/library/codesystem.test.js index 7f28c2e..3d45b20 100644 --- a/tests/library/codesystem.test.js +++ b/tests/library/codesystem.test.js @@ -3,7 +3,6 @@ * These tests can be run with Jest, Mocha, or any similar testing framework */ const { CodeSystem } = require('../../tx/library/codesystem'); -const CodeSystemXML = require('../../tx/xml/codesystem-xml'); describe('CodeSystem', () => { // Test data diff --git a/tx/tx-html.js b/tx/tx-html.js index e4d567a..ee7ba2c 100644 --- a/tx/tx-html.js +++ b/tx/tx-html.js @@ -129,6 +129,7 @@ function buildTitle(json) { } +// eslint-disable-next-line no-unused-vars function buildSearchForm(req, mode, params) { let html = ''; @@ -385,7 +386,7 @@ function renderCapabilityStatement(json) { /** * Render OperationOutcome resource */ -function renderOperationOutcome(json, req) { +function renderOperationOutcome(json) { let html = '<div class="operation-outcome">'; html += `<h4>OperationOutcome</h4>`; diff --git a/tx/xml/capabilitystatement-xml.js b/tx/xml/capabilitystatement-xml.js index c1e7ec5..7908e0a 100644 --- a/tx/xml/capabilitystatement-xml.js +++ b/tx/xml/capabilitystatement-xml.js @@ -29,6 +29,7 @@ class CapabilityStatementXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version (3, 4, or 5) * @returns {string} XML string */ + // eslint-disable-next-line no-unused-vars static toXml(json, fhirVersion) { const content = this.renderElementsInOrder(json, 1, this._elementOrder); return this.wrapInRootElement('CapabilityStatement', content); diff --git a/tx/xml/conceptmap-xml.js b/tx/xml/conceptmap-xml.js index 8a207bd..22980c4 100644 --- a/tx/xml/conceptmap-xml.js +++ b/tx/xml/conceptmap-xml.js @@ -29,6 +29,7 @@ class ConceptMapXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version (3, 4, or 5) * @returns {string} XML string */ + // eslint-disable-next-line no-unused-vars static toXml(json, fhirVersion) { const content = this.renderElementsInOrder(json, 1, this._elementOrder); return this.wrapInRootElement('ConceptMap', content); @@ -40,6 +41,7 @@ class ConceptMapXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version * @returns {Object} JSON object */ + // eslint-disable-next-line no-unused-vars static fromXml(xml, fhirVersion) { const element = this.parseXmlString(xml); if (element.name !== 'ConceptMap') { @@ -54,6 +56,7 @@ class ConceptMapXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version * @returns {Object} JSON object */ + // eslint-disable-next-line no-unused-vars static fromXmlElement(element, fhirVersion) { return this.convertElementToFhirJson(element, 'ConceptMap'); } diff --git a/tx/xml/namingsystem-xml.js b/tx/xml/namingsystem-xml.js index 25c465f..a96d741 100644 --- a/tx/xml/namingsystem-xml.js +++ b/tx/xml/namingsystem-xml.js @@ -29,6 +29,7 @@ class NamingSystemXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version (3, 4, or 5) * @returns {string} XML string */ + // eslint-disable-next-line no-unused-vars static toXml(json, fhirVersion) { const content = this.renderElementsInOrder(json, 1, this._elementOrder); return this.wrapInRootElement('NamingSystem', content); @@ -40,6 +41,7 @@ class NamingSystemXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version * @returns {Object} JSON object */ + // eslint-disable-next-line no-unused-vars static fromXml(xml, fhirVersion) { const element = this.parseXmlString(xml); if (element.name !== 'NamingSystem') { @@ -54,6 +56,7 @@ class NamingSystemXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version * @returns {Object} JSON object */ + // eslint-disable-next-line no-unused-vars static fromXmlElement(element, fhirVersion) { return this.convertElementToFhirJson(element, 'NamingSystem'); } diff --git a/tx/xml/operationoutcome-xml.js b/tx/xml/operationoutcome-xml.js index 2e76fc3..1d59afe 100644 --- a/tx/xml/operationoutcome-xml.js +++ b/tx/xml/operationoutcome-xml.js @@ -30,6 +30,7 @@ class OperationOutcomeXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version (3, 4, or 5) * @returns {string} XML string */ + // eslint-disable-next-line no-unused-vars static toXml(json, fhirVersion) { const content = this._renderOperationOutcome(json, 1); return this.wrapInRootElement('OperationOutcome', content); @@ -44,7 +45,7 @@ class OperationOutcomeXML extends FhirXmlBase { // Process elements in order for (const key of this._elementOrder) { - if (obj.hasOwnProperty(key) && key !== 'resourceType') { + if (Object.hasOwn(obj, key) && key !== 'resourceType') { if (key === 'issue') { xml += this._renderIssues(obj.issue, level); } else { @@ -78,7 +79,7 @@ class OperationOutcomeXML extends FhirXmlBase { // Render issue elements in correct order for (const key of this._issueElementOrder) { - if (issue.hasOwnProperty(key)) { + if (Object.hasOwn(issue, key)) { xml += this.renderElement(key, issue[key], level + 1); } } @@ -102,6 +103,7 @@ class OperationOutcomeXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version * @returns {Object} JSON object */ + // eslint-disable-next-line no-unused-vars static fromXml(xml, fhirVersion) { const element = this.parseXmlString(xml); if (element.name !== 'OperationOutcome') { @@ -116,6 +118,7 @@ class OperationOutcomeXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version * @returns {Object} JSON object */ + // eslint-disable-next-line no-unused-vars static fromXmlElement(element, fhirVersion) { return this.convertElementToFhirJson(element, 'OperationOutcome'); } diff --git a/tx/xml/parameters-xml.js b/tx/xml/parameters-xml.js index ca5abde..9d4530a 100644 --- a/tx/xml/parameters-xml.js +++ b/tx/xml/parameters-xml.js @@ -52,7 +52,7 @@ class ParametersXML extends FhirXmlBase { // Process elements in order for (const key of this._elementOrder) { - if (obj.hasOwnProperty(key) && key !== 'resourceType') { + if (Object.hasOwn(obj, key) && key !== 'resourceType') { if (key === 'parameter') { xml += this._renderParameterArray(obj.parameter, level, fhirVersion); } else { diff --git a/tx/xml/terminologycapabilities-xml.js b/tx/xml/terminologycapabilities-xml.js index e61d5c1..491c478 100644 --- a/tx/xml/terminologycapabilities-xml.js +++ b/tx/xml/terminologycapabilities-xml.js @@ -28,6 +28,7 @@ class TerminologyCapabilitiesXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version (3, 4, or 5) * @returns {string} XML string */ + // eslint-disable-next-line no-unused-vars static toXml(json, fhirVersion) { const content = this.renderElementsInOrder(json, 1, this._elementOrder); return this.wrapInRootElement('TerminologyCapabilities', content); @@ -39,6 +40,7 @@ class TerminologyCapabilitiesXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version * @returns {Object} JSON object */ + // eslint-disable-next-line no-unused-vars static fromXml(xml, fhirVersion) { const element = this.parseXmlString(xml); if (element.name !== 'TerminologyCapabilities') { @@ -53,6 +55,7 @@ class TerminologyCapabilitiesXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version * @returns {Object} JSON object */ + // eslint-disable-next-line no-unused-vars static fromXmlElement(element, fhirVersion) { return this.convertElementToFhirJson(element, 'TerminologyCapabilities'); } diff --git a/tx/xml/valueset-xml.js b/tx/xml/valueset-xml.js index 2aabe2e..add2f3d 100644 --- a/tx/xml/valueset-xml.js +++ b/tx/xml/valueset-xml.js @@ -28,6 +28,7 @@ class ValueSetXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version (3, 4, or 5) * @returns {string} XML string */ + // eslint-disable-next-line no-unused-vars static toXml(json, fhirVersion) { const content = this.renderElementsInOrder(json, 1, this._elementOrder); return this.wrapInRootElement('ValueSet', content); @@ -39,6 +40,7 @@ class ValueSetXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version * @returns {Object} JSON object */ + // eslint-disable-next-line no-unused-vars static fromXml(xml, fhirVersion) { const element = this.parseXmlString(xml); if (element.name !== 'ValueSet') { @@ -53,6 +55,7 @@ class ValueSetXML extends FhirXmlBase { * @param {number} fhirVersion - FHIR version * @returns {Object} JSON object */ + // eslint-disable-next-line no-unused-vars static fromXmlElement(element, fhirVersion) { return this.convertElementToFhirJson(element, 'ValueSet'); } diff --git a/tx/xml/xml-base.js b/tx/xml/xml-base.js index 5a1e3dd..782bdf2 100644 --- a/tx/xml/xml-base.js +++ b/tx/xml/xml-base.js @@ -182,6 +182,7 @@ class FhirXmlBase { * @returns {{value: *, primitiveExt: Object|null}} Converted value and primitive extension if any * @private */ + // eslint-disable-next-line no-unused-vars static _convertChildElementWithExt(child, parentContext = '') { const hasValue = child.attributes.value !== undefined; const hasChildren = child.children.length > 0; @@ -436,7 +437,7 @@ class FhirXmlBase { // Process elements in order for (const key of elementOrder) { - if (obj.hasOwnProperty(key) && key !== 'resourceType') { + if (Object.hasOwn(obj, key) && key !== 'resourceType') { xml += this.renderElement(key, obj[key], level); } } @@ -517,7 +518,7 @@ class FhirXmlParser { this.pos++; // Skip '<' // Parse element name - const nameEnd = this.xml.substring(this.pos).search(/[\s\/>]/); + const nameEnd = this.xml.substring(this.pos).search(/[\s/>]/); const name = this.xml.substring(this.pos, this.pos + nameEnd); this.pos += nameEnd; From 71aa0de4c240d88f76c22007a389ef9f084d5859 Mon Sep 17 00:00:00 2001 From: Grahame Grieve <grahameg@gmail.ccom> Date: Tue, 13 Jan 2026 21:07:11 +1100 Subject: [PATCH 3/3] pass validator tests --- common/logger.js | 184 +++++++++--- library/package-manager.js | 4 + library/version-utilities.js | 5 +- tests/cs/cs-areacode.test.js | 16 +- tests/cs/cs-country.test.js | 2 +- tests/cs/cs-cpt.test.js | 2 +- tests/cs/cs-cs.test.js | 4 +- tests/cs/cs-currency.test.js | 4 +- tests/cs/cs-lang.test.js | 2 +- tests/cs/cs-loinc.test.js | 2 +- tests/cs/cs-mimetypes.test.js | 2 +- tests/cs/cs-ndc.test.js | 2 +- tests/cs/cs-omop.test.js | 2 +- tests/cs/cs-unii.test.js | 4 +- tests/cs/cs-usstates.test.js | 4 +- tests/tx/designations.test.js | 17 +- tests/tx/expand.test.js | 2 +- tests/tx/validate.test.js | 14 +- translations/Messages.properties | 4 +- tx/cs/cs-api.js | 33 +- tx/cs/cs-areacode.js | 8 +- tx/cs/cs-country.js | 4 +- tx/cs/cs-cpt.js | 5 +- tx/cs/cs-cs.js | 25 +- tx/cs/cs-currency.js | 4 +- tx/cs/cs-hgvs.js | 2 +- tx/cs/cs-lang.js | 6 +- tx/cs/cs-loinc.js | 13 +- tx/cs/cs-mimetypes.js | 4 +- tx/cs/cs-ndc.js | 2 +- tx/cs/cs-omop.js | 4 +- tx/cs/cs-rxnorm.js | 2 +- tx/cs/cs-snomed.js | 38 ++- tx/cs/cs-unii.js | 2 +- tx/cs/cs-usstates.js | 4 +- tx/library.js | 5 + tx/library/capabilitystatement.js | 292 ++++++++++++++++++ tx/library/codesystem.js | 7 +- tx/library/conceptmap.js | 2 +- tx/library/designations.js | 71 ++--- tx/library/extensions.js | 11 +- tx/library/namingsystem.js | 6 +- tx/library/operation-outcome.js | 20 +- tx/library/terminologycapabilities.js | 418 ++++++++++++++++++++++++++ tx/library/ucum-parsers.js | 12 +- tx/library/valueset.js | 11 +- tx/params.js | 10 +- tx/provider.js | 2 +- tx/sct/expressions.js | 20 +- tx/tx.fhir.org.yml | 3 +- tx/tx.js | 32 +- tx/vs/vs-database.js | 11 +- tx/vs/vs-package.js | 20 +- tx/vs/vs-vsac.js | 6 +- tx/workers/batch-validate.js | 15 +- tx/workers/batch.js | 13 +- tx/workers/closure.js | 2 +- tx/workers/expand.js | 56 ++-- tx/workers/lookup.js | 31 +- tx/workers/metadata.js | 13 +- tx/workers/read.js | 5 +- tx/workers/search.js | 6 +- tx/workers/subsumes.js | 20 +- tx/workers/translate.js | 12 +- tx/workers/validate.js | 232 ++++++++------ tx/workers/worker.js | 38 ++- 66 files changed, 1381 insertions(+), 453 deletions(-) create mode 100644 tx/library/capabilitystatement.js create mode 100644 tx/library/terminologycapabilities.js diff --git a/common/logger.js b/common/logger.js index ac732a1..b759fb5 100644 --- a/common/logger.js +++ b/common/logger.js @@ -17,6 +17,8 @@ class Logger { level: options.level || 'info', logDir: options.logDir || './logs', console: options.console !== undefined ? options.console : true, + consoleErrors: options.consoleErrors !== undefined ? options.consoleErrors : false, + telnetErrors: options.telnetErrors !== undefined ? options.telnetErrors : false, file: { filename: options.file?.filename || 'server-%DATE%.log', datePattern: options.file?.datePattern || 'YYYY-MM-DD', @@ -30,7 +32,20 @@ class Logger { fs.mkdirSync(this.options.logDir, { recursive: true }); } - // Define formats for file output (with full metadata) + // Telnet clients storage + this.telnetClients = new Set(); + + this._createLogger(); + + // Log logger initialization + this.info('Logger initialized @ ' + this.options.logDir, { + level: this.options.level, + logDir: this.options.logDir + }); + } + + _createLogger() { + // Define formats for file output (with full metadata including stack traces) const fileFormats = [ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), winston.format.errors({ stack: true }), @@ -41,7 +56,7 @@ class Logger { // Create transports const transports = []; - // Add file transport with rotation (includes all metadata) + // Add file transport with rotation (includes ALL levels with full metadata) const fileTransport = new winston.transports.DailyRotateFile({ dirname: this.options.logDir, filename: this.options.file.filename, @@ -53,15 +68,15 @@ class Logger { }); transports.push(fileTransport); - // Add console transport if enabled (without metadata) + // Add console transport if enabled if (this.options.console) { - // Console format with timestamps and colors, but NO metadata const consoleFormat = winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), + winston.format.errors({ stack: true }), winston.format.colorize({ all: true }), winston.format.printf(info => { - // Only display timestamp, level and message (no metadata) - return `${info.timestamp} ${info.level.padEnd(7)} ${info.message}`; + const stack = info.stack ? `\n${info.stack}` : ''; + return `${info.timestamp} ${info.level.padEnd(7)} ${info.message}${stack}`; }) ); @@ -79,70 +94,141 @@ class Logger { transports, exitOnError: false }); + } - // Log logger initialization - this.info('Logger initialized @ '+this.options.logDir, { - level: this.options.level, - logDir: this.options.logDir - }); + // Telnet client management + addTelnetClient(socket) { + this.telnetClients.add(socket); + } + + removeTelnetClient(socket) { + this.telnetClients.delete(socket); + } + + _sendToTelnet(level, message, stack, options) { + // Check if we should send errors/warnings to telnet + if (!options.telnetErrors && (level === 'error' || level === 'warn')) { + return; + } + + const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 23); + let line = `${timestamp} ${level.padEnd(7)} ${message}\n`; + if (stack) { + line += stack + '\n'; + } + + for (const client of this.telnetClients) { + try { + client.write(line); + } catch (e) { + // Client disconnected, remove it + this.telnetClients.delete(client); + } + } + } + + _shouldLogToConsole(level, options) { + if (level === 'error' || level === 'warn') { + return options.consoleErrors; + } + return true; + } + + _log(level, messageOrError, meta, options) { + let message; + let stack; + + // Handle Error objects + if (messageOrError instanceof Error) { + message = messageOrError.message; + stack = messageOrError.stack; + // Pass message and stack separately to winston, not the Error object + this.logger[level](message, { stack, ...meta }); + } else { + message = String(messageOrError); + stack = meta?.stack; + this.logger[level](message, meta); + } + + this._sendToTelnet(level, message, stack, options); } error(message, meta = {}) { - this.logger.error(message, meta); + this._log('error', message, meta, this.options); } warn(message, meta = {}) { - this.logger.warn(message, meta); + this._log('warn', message, meta, this.options); } info(message, meta = {}) { - this.logger.info(message, meta); + this._log('info', message, meta, this.options); } debug(message, meta = {}) { - this.logger.debug(message, meta); + this._log('debug', message, meta, this.options); } verbose(message, meta = {}) { - this.logger.verbose(message, meta); + this._log('verbose', message, meta, this.options); } log(level, message, meta = {}) { - this.logger.log(level, message, meta); + this._log(level, message, meta, this.options); } child(defaultMeta = {}) { - // For module-specific loggers, create a better formatted prefix - if (defaultMeta.module) { - const modulePrefix = `{${defaultMeta.module}}`; - const childLogger = { - error: (message, meta = {}) => this.error(`${modulePrefix}: ${message}`, meta), - warn: (message, meta = {}) => this.warn(`${modulePrefix}: ${message}`, meta), - info: (message, meta = {}) => this.info(`${modulePrefix}: ${message}`, meta), - debug: (message, meta = {}) => this.debug(`${modulePrefix}: ${message}`, meta), - verbose: (message, meta = {}) => this.verbose(`${modulePrefix}: ${message}`, meta), - log: (level, message, meta = {}) => this.log(level, `${modulePrefix}: ${message}`, meta) - }; - return childLogger; - } + const self = this; - // For other metadata, use winston's child functionality - const childLogger = Object.create(this); - const originalMethods = { - error: this.error, - warn: this.warn, - info: this.info, - debug: this.debug, - verbose: this.verbose, - log: this.log + // Build module-specific options + const childOptions = { + consoleErrors: defaultMeta.consoleErrors ?? self.options.consoleErrors, + telnetErrors: defaultMeta.telnetErrors ?? self.options.telnetErrors }; - // Override each method to include the default metadata - Object.keys(originalMethods).forEach(method => { - childLogger[method] = function(message, meta = {}) { - originalMethods[method].call(this, message, { ...defaultMeta, ...meta }); + // Remove our custom options from defaultMeta so they don't pollute log output + const cleanMeta = { ...defaultMeta }; + delete cleanMeta.consoleErrors; + delete cleanMeta.telnetErrors; + + if (cleanMeta.module) { + const modulePrefix = `{${cleanMeta.module}}`; + + return { + error: (messageOrError, meta = {}) => { + if (messageOrError instanceof Error) { + const prefixedError = new Error(`${modulePrefix}: ${messageOrError.message}`); + prefixedError.stack = messageOrError.stack; + self._log('error', prefixedError, meta, childOptions); + } else { + self._log('error', `${modulePrefix}: ${messageOrError}`, meta, childOptions); + } + }, + warn: (messageOrError, meta = {}) => { + if (messageOrError instanceof Error) { + const prefixedError = new Error(`${modulePrefix}: ${messageOrError.message}`); + prefixedError.stack = messageOrError.stack; + self._log('warn', prefixedError, meta, childOptions); + } else { + self._log('warn', `${modulePrefix}: ${messageOrError}`, meta, childOptions); + } + }, + info: (message, meta = {}) => self._log('info', `${modulePrefix}: ${message}`, meta, childOptions), + debug: (message, meta = {}) => self._log('debug', `${modulePrefix}: ${message}`, meta, childOptions), + verbose: (message, meta = {}) => self._log('verbose', `${modulePrefix}: ${message}`, meta, childOptions), + log: (level, message, meta = {}) => self._log(level, `${modulePrefix}: ${message}`, meta, childOptions) }; - }); + } + + // For other metadata without module prefix + const childLogger = { + error: (messageOrError, meta = {}) => self._log('error', messageOrError, { ...cleanMeta, ...meta }, childOptions), + warn: (messageOrError, meta = {}) => self._log('warn', messageOrError, { ...cleanMeta, ...meta }, childOptions), + info: (message, meta = {}) => self._log('info', message, { ...cleanMeta, ...meta }, childOptions), + debug: (message, meta = {}) => self._log('debug', message, { ...cleanMeta, ...meta }, childOptions), + verbose: (message, meta = {}) => self._log('verbose', message, { ...cleanMeta, ...meta }, childOptions), + log: (level, message, meta = {}) => self._log(level, message, { ...cleanMeta, ...meta }, childOptions) + }; return childLogger; } @@ -155,6 +241,16 @@ class Logger { this.info(`Log level changed to ${level}`); } + setConsoleErrors(enabled) { + this.options.consoleErrors = enabled; + this.info(`Console errors ${enabled ? 'enabled' : 'disabled'}`); + } + + setTelnetErrors(enabled) { + this.options.telnetErrors = enabled; + this.info(`Telnet errors ${enabled ? 'enabled' : 'disabled'}`); + } + stream() { return { write: (message) => { diff --git a/library/package-manager.js b/library/package-manager.js index 483b13a..bd14cae 100644 --- a/library/package-manager.js +++ b/library/package-manager.js @@ -526,6 +526,10 @@ class PackageContentLoader { version() { return this.package.version; } + + pid() { + return this.id()+"#"+this.version(); + } } diff --git a/library/version-utilities.js b/library/version-utilities.js index 641d667..f51c509 100644 --- a/library/version-utilities.js +++ b/library/version-utilities.js @@ -862,6 +862,9 @@ class VersionUtilities { validateOptionalParameter(candidate, "candidate", String); validateOptionalParameter(versionAlgorithm, "versionAlgorithm", String); + if (criteria == candidate) { + return true; + } if (!versionAlgorithm) { versionAlgorithm = this.guessVersionFormat(candidate); } @@ -869,7 +872,7 @@ class VersionUtilities { return false; } switch (versionAlgorithm) { - case 'semver' : return VersionUtilities.versionMatches(criteria, candidate); + case 'semver' : return VersionUtilities.isSemVerWithWildcards(criteria) && VersionUtilities.isSemVer(candidate) && VersionUtilities.versionMatches(criteria, candidate); case 'integer' : return false; default: return candidate.startsWith(criteria); } diff --git a/tests/cs/cs-areacode.test.js b/tests/cs/cs-areacode.test.js index 8fc29a4..71bde13 100644 --- a/tests/cs/cs-areacode.test.js +++ b/tests/cs/cs-areacode.test.js @@ -52,7 +52,7 @@ describe('AreaCodeServices', () => { test('should return error for invalid codes', async () => { const result = await provider.locate('999'); expect(result.context).toBeNull(); - expect(result.message).toContain('not found'); + expect(result.message).toBeUndefined(); }); test('should return correct displays', async () => { @@ -112,12 +112,12 @@ describe('AreaCodeServices', () => { describe('Filter Support', () => { test('should support class/type equals filters', async () => { - expect(await provider.doesFilter('class', 'equals', 'country')).toBe(true); - expect(await provider.doesFilter('type', 'equals', 'region')).toBe(true); + expect(await provider.doesFilter('class', '=', 'country')).toBe(true); + expect(await provider.doesFilter('type', '=', 'region')).toBe(true); }); test('should not support other filters', async () => { - expect(await provider.doesFilter('display', 'equals', 'test')).toBe(false); + expect(await provider.doesFilter('display', '=', 'test')).toBe(false); expect(await provider.doesFilter('class', 'contains', 'country')).toBe(false); expect(await provider.doesFilter('class', 'in', 'country,region')).toBe(false); }); @@ -130,7 +130,7 @@ describe('AreaCodeServices', () => { beforeEach(async () => { ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'class', 'equals', 'country'); + await provider.filter(ctxt, 'class', '=', 'country'); const filters = await provider.executeFilters(ctxt); countryFilter = filters[0]; }); @@ -195,7 +195,7 @@ describe('AreaCodeServices', () => { beforeEach(async () => { ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'type', 'equals', 'region'); + await provider.filter(ctxt, 'type', '=', 'region'); const filters = await provider.executeFilters(ctxt); regionFilter = filters[0]; }); @@ -253,7 +253,7 @@ describe('AreaCodeServices', () => { describe('Filter Error Cases', () => { test('should throw error for unsupported property', async () => { await expect( - provider.filter(await provider.getPrepContext(false), 'display', 'equals', 'test') + provider.filter(await provider.getPrepContext(false), 'display', '=', 'test') ).rejects.toThrow('not supported'); }); @@ -274,7 +274,7 @@ describe('AreaCodeServices', () => { describe('Execute Filters', () => { test('should execute single filter', async () => { const ctxt = await provider.getPrepContext(false); - await provider.filter(ctxt, 'class', 'equals', 'country'); + await provider.filter(ctxt, 'class', '=', 'country'); const results = await provider.executeFilters(ctxt); const countryFilter = results[0]; diff --git a/tests/cs/cs-country.test.js b/tests/cs/cs-country.test.js index 0e28de4..eb83812 100644 --- a/tests/cs/cs-country.test.js +++ b/tests/cs/cs-country.test.js @@ -108,7 +108,7 @@ describe('CountryCodeServices', () => { for (const code of invalidCodes) { const result = await provider.locate(code); expect(result.context).toBeNull(); - expect(result.message).toContain('not found'); + expect(result.message).toBeUndefined(); } }); diff --git a/tests/cs/cs-cpt.test.js b/tests/cs/cs-cpt.test.js index 89881b2..ad18101 100644 --- a/tests/cs/cs-cpt.test.js +++ b/tests/cs/cs-cpt.test.js @@ -131,7 +131,7 @@ describe('CPT Provider', () => { test('should return null for non-existent code', async () => { const result = await provider.locate('99999'); expect(result.context).toBeNull(); - expect(result.message).toContain('not found'); + expect(result.message).toBeUndefined(); }); test('should return correct code for context', async () => { diff --git a/tests/cs/cs-cs.test.js b/tests/cs/cs-cs.test.js index b5f9534..39029c1 100644 --- a/tests/cs/cs-cs.test.js +++ b/tests/cs/cs-cs.test.js @@ -286,7 +286,7 @@ describe('FHIR CodeSystem Provider', () => { test('should return null for non-existent code', async () => { const result = await simpleProvider.locate('nonexistent'); expect(result.context).toBeNull(); - expect(result.message).toContain('not found'); + expect(result.message).toBeUndefined(); }); test('should handle empty code', async () => { @@ -385,7 +385,7 @@ describe('FHIR CodeSystem Provider', () => { test('should return null for non-existent code', async () => { const result = await simpleProvider.locate('nonexistent'); expect(result.context).toBeNull(); - expect(result.message).toContain('not found'); + expect(result.message).toBeUndefined(); }); test('should handle empty code', async () => { diff --git a/tests/cs/cs-currency.test.js b/tests/cs/cs-currency.test.js index 1a19f24..8f7edad 100644 --- a/tests/cs/cs-currency.test.js +++ b/tests/cs/cs-currency.test.js @@ -54,7 +54,7 @@ describe('Iso4217Services', () => { test('should return error for invalid codes', async () => { const result = await provider.locate('ZZZ'); expect(result.context).toBeNull(); - expect(result.message).toContain('not found'); + expect(result.message).toBeUndefined(); }); test('should return error for empty codes', async () => { @@ -617,7 +617,7 @@ describe('Iso4217Services', () => { // Should not find lowercase codes const result = await provider.locate('usd'); expect(result.context).toBeNull(); - expect(result.message).toContain('not found'); + expect(result.message).toBeUndefined(); }); }); diff --git a/tests/cs/cs-lang.test.js b/tests/cs/cs-lang.test.js index b92f1fe..ddf927a 100644 --- a/tests/cs/cs-lang.test.js +++ b/tests/cs/cs-lang.test.js @@ -90,7 +90,7 @@ describe('IETF Language CodeSystem Provider', () => { test('should reject invalid language codes', async () => { const result = await provider.locate('invalid-code'); expect(result.context).toBe(null); - expect(result.message).toContain('Invalid language code'); + expect(result.message).toBeUndefined(); }); test('should handle empty codes', async () => { diff --git a/tests/cs/cs-loinc.test.js b/tests/cs/cs-loinc.test.js index ffd01be..a4de203 100644 --- a/tests/cs/cs-loinc.test.js +++ b/tests/cs/cs-loinc.test.js @@ -509,7 +509,7 @@ describe('LOINC Provider', () => { test('should return null for non-existent code', async () => { const result = await provider.locate('99999-999'); expect(result.context).toBeNull(); - expect(result.message).toContain('not found'); + expect(result.message).toBeUndefined(); }); test('should get display for codes', async () => { diff --git a/tests/cs/cs-mimetypes.test.js b/tests/cs/cs-mimetypes.test.js index 7a660fe..ded28cb 100644 --- a/tests/cs/cs-mimetypes.test.js +++ b/tests/cs/cs-mimetypes.test.js @@ -99,7 +99,7 @@ describe('MimeTypeServices', () => { console.log('Mimetype: '+invalidType); const result = await provider.locate(invalidType); expect(result.context).toBeNull(); - expect(result.message).toContain('Invalid MIME type'); + expect(result.message).toBeUndefined(); } const result = await provider.locate(''); diff --git a/tests/cs/cs-ndc.test.js b/tests/cs/cs-ndc.test.js index c2d9e8d..f76b43b 100644 --- a/tests/cs/cs-ndc.test.js +++ b/tests/cs/cs-ndc.test.js @@ -376,7 +376,7 @@ describe('NDC Provider', () => { const result = await provider.locate('9999-9999'); expect(result.context).toBeNull(); - expect(result.message).toContain('not found'); + expect(result.message).toBeUndefined(); }); }); diff --git a/tests/cs/cs-omop.test.js b/tests/cs/cs-omop.test.js index 846d857..ec9240a 100644 --- a/tests/cs/cs-omop.test.js +++ b/tests/cs/cs-omop.test.js @@ -144,7 +144,7 @@ describe('OMOP Provider', () => { test('should return null for non-existent concept', async () => { const result = await provider.locate('999999999'); expect(result.context).toBeNull(); - expect(result.message).toContain('not found'); + expect(result.message).toBeUndefined(); }); test('should return correct code for context', async () => { diff --git a/tests/cs/cs-unii.test.js b/tests/cs/cs-unii.test.js index 7bf1a27..74571b5 100644 --- a/tests/cs/cs-unii.test.js +++ b/tests/cs/cs-unii.test.js @@ -102,7 +102,7 @@ describe('UniiServices', () => { for (const code of testCodes) { const result = await provider.locate(code); expect(result.context).toBeTruthy(); - expect(result.message).toBeNull(); + expect(result.message).toBeUndefined(); expect(result.context).toBeInstanceOf(UniiConcept); expect(result.context.code).toBe(code); } @@ -412,7 +412,7 @@ describe('UniiServices', () => { for (let i = 0; i < 3; i++) { const result = await provider.locate('2T8Q726O95'); expect(result.context).toBeTruthy(); - expect(result.message).toBeNull(); + expect(result.message).toBeUndefined(); const display = await provider.display(result.context); expect(display).toBe('LAMIVUDINE'); diff --git a/tests/cs/cs-usstates.test.js b/tests/cs/cs-usstates.test.js index 5591331..fd8cf06 100644 --- a/tests/cs/cs-usstates.test.js +++ b/tests/cs/cs-usstates.test.js @@ -63,7 +63,7 @@ describe('USStateServices', () => { test('should return error for invalid codes', async () => { const result = await provider.locate('ZZ'); expect(result.context).toBeNull(); - expect(result.message).toContain('not found'); + expect(result.message).toBeUndefined(); }); test('should return error for empty codes', async () => { @@ -343,7 +343,7 @@ describe('USStateServices', () => { // Should not find lowercase codes const result = await provider.locate('ca'); expect(result.context).toBeNull(); - expect(result.message).toContain('not found'); + expect(result.message).toBeUndefined(); }); }); diff --git a/tests/tx/designations.test.js b/tests/tx/designations.test.js index bc59b75..d5e6a06 100644 --- a/tests/tx/designations.test.js +++ b/tests/tx/designations.test.js @@ -124,7 +124,7 @@ describe('Designations', () => { test('should find preferred designation without language list', () => { const preferred = designations.preferredDesignation(); expect(preferred.display).toBe('Base English'); - expect(preferred.isUseADisplay()).toBe(true); + expect(designations.isDisplay(preferred)).toBe(true); }); test('should find preferred designation with language preference', () => { @@ -293,20 +293,7 @@ describe('Designations', () => { const preferred = designations.preferredDesignation(langList); expect(preferred.display).toBe('Base US English'); - expect(preferred.isUseADisplay()).toBe(true); - }); - - test('should prefer display when no base available for exact match', () => { - // Remove US base designation - designations.designations = designations.designations.filter(d => - !(d.isDisplay() && d.language.code === 'en-US')); - - const langList = Languages.fromAcceptLanguage('en-US'); - const preferred = designations.preferredDesignation(langList); - - expect(preferred.display).toBe('US English Display'); - expect(preferred.isDisplay()).toBe(false); - expect(preferred.isUseADisplay()).toBe(true); + expect(designations.isDisplay(preferred)).toBe(true); }); test('should fall back to language-region match', () => { diff --git a/tests/tx/expand.test.js b/tests/tx/expand.test.js index dc765da..b956a07 100644 --- a/tests/tx/expand.test.js +++ b/tests/tx/expand.test.js @@ -373,7 +373,7 @@ describe('Expand Worker', () => { // Currently returns 200 with empty expansion (stub behavior) // When doExpand is implemented, this should return an error // because the CodeSystem can't be found - expect(response.status).toBe(500); + expect(response.status).toBe(404); // expect(response.status).toBe(404); // or 400 when fully implemented }); }); diff --git a/tests/tx/validate.test.js b/tests/tx/validate.test.js index bf6140f..3cf3549 100644 --- a/tests/tx/validate.test.js +++ b/tests/tx/validate.test.js @@ -415,22 +415,12 @@ describe('ValidateWorker', () => { await worker.handleCodeSystem(req, res); - expect(res.status).toHaveBeenCalledWith(400); + expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - resourceType: 'OperationOutcome' + resourceType: 'Parameters' })); }); - test('handleCodeSystem returns error when no code specified', async () => { - const { req, res } = createMockReqRes('GET', { - url: 'http://hl7.org/fhir/administrative-gender' - }); - - await worker.handleCodeSystem(req, res); - - expect(res.status).toHaveBeenCalledWith(400); - }); - test('handleCodeSystem validates successfully with url and code', async () => { const { req, res } = createMockReqRes('GET', { url: 'http://hl7.org/fhir/administrative-gender', diff --git a/translations/Messages.properties b/translations/Messages.properties index 6e9b55e..afaeaa2 100644 --- a/translations/Messages.properties +++ b/translations/Messages.properties @@ -536,13 +536,13 @@ No_reference_resolving_discriminator__from_ = No reference resolving discriminat No_type_found_on_ = No type found on ''{0}'' no_url_in_expand_value_set = No url in expand value set no_url_in_expand_value_set_2 = No url in expand value set 2 -NO_VALID_DISPLAY_AT_ALL = Cannot validate display Name ''{0}'' for {1}#{2}: No displays are known +NO_VALID_DISPLAY_AT_ALL = Cannot validate display ''{0}'' for {1}#{2} in language {3}: No displays are known NO_VALID_DISPLAY_FOUND_LANG_NONE_one = ''{5}'' is the default display; the code system {1} has no Display Names for the language {4} NO_VALID_DISPLAY_FOUND_LANG_NONE_other = ''{5}'' is the default display; the code system {1} has no Display Names found for the language {4} NO_VALID_DISPLAY_FOUND_LANG_SOME_one = ''{5}'' is the default display; no valid Display Names found for {1}#{2} in the language {4} NO_VALID_DISPLAY_FOUND_LANG_SOME_other = ''{5}'' is the default display; no valid Display Names found for {1}#{2} in the language {4} NO_VALID_DISPLAY_FOUND_NONE_FOR_LANG_ERR = Wrong Display Name ''{0}'' for {1}#{2}. There are no valid display names found for language(s) ''{3}''. Default display is ''{4}'' -NO_VALID_DISPLAY_FOUND_NONE_FOR_LANG_OK = There are no valid display names found for the code {1}#{2} for language(s) ''{3}''. The display is ''{4}'' the default language display +NO_VALID_DISPLAY_FOUND_NONE_FOR_LANG_OK = There are no valid display names found for the code {1}#{2} for language(s) ''{3}''. The display is ''{4}'' which is the default language display NO_VALID_DISPLAY_FOUND_one = No valid Display Names found for {1}#{2} in the language {4} NO_VALID_DISPLAY_FOUND_other = No valid Display Names found for {1}#{2} in the languages {4} No_validator_configured = No validator configured diff --git a/tx/cs/cs-api.js b/tx/cs/cs-api.js index 666dafd..282a79a 100644 --- a/tx/cs/cs-api.js +++ b/tx/cs/cs-api.js @@ -7,6 +7,7 @@ const { OperationContext } = require("../operation-context"); const {Extensions} = require("../library/extensions"); const {validateParameter, validateArrayParameter} = require("../../library/utilities"); const {I18nSupport} = require("../../library/i18nsupport"); +const {VersionUtilities} = require("../../library/version-utilities"); class FilterExecutionContext { filters = []; @@ -213,11 +214,13 @@ class CodeSystemProvider { listFeatures() { return null; } /** - * @param {string} v1 - first version - * @param {string} v2 - second version - * @returns {boolean} True if something.... + * @param {string} checkVersion - first version + * @param {string} actualVersion - second version + * @returns {boolean} True if actualVersion is more detailed than checkVersion (for SCT) */ - versionIsMoreDetailed(v1, v2) { return false; } + versionIsMoreDetailed(checkVersion, actualVersion) { + return false; + } /** * @returns { {status, standardsStatus : String, experimental : boolean} } applicable Features @@ -340,7 +343,15 @@ class CodeSystemProvider { async parent(code) { return null; } /** - + * This is calleed if the designation is not marked with a usual use code indicating that it is considered as a display + * @param designation + * @returns {boolean} + */ + isDisplay(designation) { + return false; + } + + /** * @param {string | CodeSystemProviderContext} code * @param {ConceptDesignations} designation list * @returns {Designation[]} whatever designations exist (in all languages) @@ -641,6 +652,10 @@ class CodeSystemProvider { return null; } + versionNeeded() { + return false; + } + /** * @returns {string} valueset for the code system */ @@ -692,7 +707,13 @@ class CodeSystemFactoryProvider { */ version() { throw new Error("Must override"); } - + getPartialVersion() { + let ver = this.version(); + if (ver && VersionUtilities.isSemVer(ver)) { + return VersionUtilities.getMajMin(ver); + } + return ver; + } /** * @returns {number} how many times the factory has been asked to construct a provider */ diff --git a/tx/cs/cs-areacode.js b/tx/cs/cs-areacode.js index 017789c..99c0664 100644 --- a/tx/cs/cs-areacode.js +++ b/tx/cs/cs-areacode.js @@ -99,7 +99,7 @@ class AreaCodeServices extends CodeSystemProvider { const ctxt = await this.#ensureContext(code); if (ctxt != null) { displays.addDesignation(true, 'active', 'en', CodeSystem.makeUseForDisplay(), ctxt.display); - this._listSupplementDesignations(ctxt, displays); + this._listSupplementDesignations(ctxt.code, displays); } } @@ -131,7 +131,7 @@ class AreaCodeServices extends CodeSystemProvider { if (concept) { return { context: concept, message: null }; } - return { context: null, message: `Area Code '${code}' not found` }; + return { context: null, message: undefined}; } // Iterator methods @@ -162,7 +162,7 @@ class AreaCodeServices extends CodeSystemProvider { assert(op != null && typeof op === 'string', 'op must be a non-null string'); assert(value != null && typeof value === 'string', 'value must be a non-null string'); - return (prop === 'type' || prop === 'class') && op === 'equals'; + return (prop === 'type' || prop === 'class') && op === '='; } async searchFilter(filterContext, filter, sort) { @@ -181,7 +181,7 @@ class AreaCodeServices extends CodeSystemProvider { assert(op != null && typeof op === 'string', 'op must be a non-null string'); assert(value != null && typeof value === 'string', 'value must be a non-null string'); - if ((prop === 'type' || prop === 'class') && op === 'equals') { + if ((prop === 'type' || prop === 'class') && op === '=') { const result = new AreaCodeConceptFilter(); for (const concept of this.codes) { diff --git a/tx/cs/cs-country.js b/tx/cs/cs-country.js index 8e4fca8..0628091 100644 --- a/tx/cs/cs-country.js +++ b/tx/cs/cs-country.js @@ -107,7 +107,7 @@ class CountryCodeServices extends CodeSystemProvider { const ctxt = await this.#ensureContext(code); if (ctxt != null) { displays.addDesignation( true, 'active', 'en', CodeSystem.makeUseForDisplay(), ctxt.display); - this._listSupplementDesignations(ctxt, displays); + this._listSupplementDesignations(ctxt.code, displays); } } @@ -139,7 +139,7 @@ class CountryCodeServices extends CodeSystemProvider { if (concept) { return { context: concept, message: null }; } - return { context: null, message: `Country Code '${code}' not found` }; + return { context: null, message: undefined}; } // Iterator methods diff --git a/tx/cs/cs-cpt.js b/tx/cs/cs-cpt.js index 227b95b..25b336e 100644 --- a/tx/cs/cs-cpt.js +++ b/tx/cs/cs-cpt.js @@ -78,7 +78,6 @@ class CPTFilterContext { for (let i = Math.max(0, list.length - 10); i < list.length; i++) { logCodes += ',' + list[i].code; } - console.log(`CPT filter ${name}: ${list.length} concepts in filter (closed = ${closed}): ${logCodes}`); } next() { @@ -296,7 +295,7 @@ class CPTServices extends CodeSystemProvider { if (typeof context === 'string') { const ctxt = await this.locate(context); if (ctxt.context == null) { - throw new Error(ctxt.message); + throw new Error(ctxt.message ? ctxt.message : `Code '${context}' not found in CPT`); } else { return ctxt.context; } @@ -429,7 +428,7 @@ class CPTServices extends CodeSystemProvider { if (context) { return { context: context, message: null }; } - return { context: null, message: `Code '${code}' not found in CPT` }; + return { context: null, message: undefined }; } } diff --git a/tx/cs/cs-cs.js b/tx/cs/cs-cs.js index 5da376b..1876d96 100644 --- a/tx/cs/cs-cs.js +++ b/tx/cs/cs-cs.js @@ -245,12 +245,12 @@ class FhirCodeSystemProvider extends CodeSystemProvider { } /** - * @param {string} v1 - First version - * @param {string} v2 - Second version + * @param {string} checkVersion - First version + * @param {string} actualVersion - Second version * @returns {boolean} True if v1 is more detailed than v2 */ - versionIsMoreDetailed(v1, v2) { - return VersionUtilities.versionMatchesByAlgorithm(v1, v2, this.versionAlgorithm()); + versionIsMoreDetailed(checkVersion, actualVersion) { + return VersionUtilities.versionMatchesByAlgorithm(checkVersion, actualVersion, this.versionAlgorithm()); } /** @@ -300,7 +300,7 @@ class FhirCodeSystemProvider extends CodeSystemProvider { return { context: null, - message: `Code '${code}' not found in CodeSystem '${this.system()}'` + message: undefined }; } @@ -318,7 +318,7 @@ class FhirCodeSystemProvider extends CodeSystemProvider { if (typeof context === 'string') { const result = await this.locate(context); if (result.context == null) { - throw new Error(result.message); + throw new Error(result.message ? result.message : `Code '${context}' not found in CodeSystem '${this.system()}'`); } return result.context; } @@ -1507,6 +1507,19 @@ class FhirCodeSystemProvider extends CodeSystemProvider { versionAlgorithm() { return this.codeSystem.versionAlgorithm(); } + + versionNeeded() { + return this.codeSystem.jsonObj.versionNeeded; + } + + + /** + * @returns {string} source package for the code system, if known + */ + sourcePackage() { + return this.codeSystem.sourcePackage; + } + } class FhirCodeSystemFactory extends CodeSystemFactoryProvider { diff --git a/tx/cs/cs-currency.js b/tx/cs/cs-currency.js index ca6e71f..c9e7135 100644 --- a/tx/cs/cs-currency.js +++ b/tx/cs/cs-currency.js @@ -117,7 +117,7 @@ class Iso4217Services extends CodeSystemProvider { if (typeof code === 'string') { const ctxt = await this.locate(code); if (ctxt.context == null) { - throw new Error(ctxt.message); + throw new Error(ctxt.message ? ctxt.message : `Currency Code '${code}' not found`); } else { return ctxt.context; } @@ -138,7 +138,7 @@ class Iso4217Services extends CodeSystemProvider { if (concept) { return { context: concept, message: null }; } - return { context: null, message: `Currency Code '${code}' not found` }; + return { context: null, message: undefined }; } // Iterator methods diff --git a/tx/cs/cs-hgvs.js b/tx/cs/cs-hgvs.js index ac2df1d..e77599e 100644 --- a/tx/cs/cs-hgvs.js +++ b/tx/cs/cs-hgvs.js @@ -107,7 +107,7 @@ class HGVSServices extends CodeSystemProvider { } else { return { context: null, - message: result.message || `HGVS code '${code}' is not valid` + message: result.message || undefined }; } } catch (error) { diff --git a/tx/cs/cs-lang.js b/tx/cs/cs-lang.js index 70baa6e..5f0904b 100644 --- a/tx/cs/cs-lang.js +++ b/tx/cs/cs-lang.js @@ -155,7 +155,7 @@ class IETFLanguageCodeProvider extends CodeSystemProvider { if (altDisplay && altDisplay !== primaryDisplay) { displays.addDesignation(false, 'active', 'en', CodeSystem.makeUseForDisplay(), altDisplay); // Add region variants for alternatives too - if (ctxt.language.isLangRegion()) { + if (ctxt.isLangRegion()) { const langDisplay = this.languageDefinitions.getDisplayForLang(ctxt.language, i); const regionDisplay = this.languageDefinitions.getDisplayForRegion(ctxt.region); const altRegionVariant = `${langDisplay} (${regionDisplay})`; @@ -176,7 +176,7 @@ class IETFLanguageCodeProvider extends CodeSystemProvider { if (typeof code === 'string') { const ctxt = await this.locate(code); if (ctxt.context == null) { - throw new Error(ctxt.message); + throw new Error(ctxt.message ? ctxt.message : `Invalid language code: ${code}`); } else { return ctxt.context; } @@ -196,7 +196,7 @@ class IETFLanguageCodeProvider extends CodeSystemProvider { const language = this.languageDefinitions.parse(code); if (!language) { - return { context: null, message: `Invalid language code: ${code}` }; + return { context: null, message: undefined }; } return { context: language, message: null }; diff --git a/tx/cs/cs-loinc.js b/tx/cs/cs-loinc.js index cf74dad..9be87da 100644 --- a/tx/cs/cs-loinc.js +++ b/tx/cs/cs-loinc.js @@ -243,7 +243,7 @@ class LoincServices extends CodeSystemProvider { const ctxt = await this.#ensureContext(context); if (ctxt) { // Add main display - displays.addDesignation(true, 'active', 'en-US', CodeSystem.makeUseForDisplay(), ctxt.desc); + displays.addDesignation(true, 'active', 'en-US', CodeSystem.makeUseForDisplay(), ctxt.desc.trim()); // Add cached designations if (ctxt.displays.length === 0) { @@ -262,7 +262,7 @@ class LoincServices extends CodeSystemProvider { if (!use) { use = entry.display ? CodeSystem.makeUseForDisplay() : null; } - displays.addDesignation(false, 'active', entry.lang, use, entry.value); + displays.addDesignation(false, 'active', entry.lang, use, entry.value.trim()); } // Add supplement designations @@ -560,7 +560,7 @@ class LoincServices extends CodeSystemProvider { return { context: context, message: null }; } - return { context: null, message: `LOINC Code '${code}' not found` }; + return { context: null, message: undefined }; } // Iterator methods @@ -1040,6 +1040,10 @@ class LoincServices extends CodeSystemProvider { versionAlgorithm() { return 'natural'; } + + isDisplay(designation) { + return designation.use.code == "SHORTNAME" || designation.use.code == "LONG_COMMON_NAME" || designation.use.code == "LinguisticVariantDisplayName"; + } } class LoincServicesFactory extends CodeSystemFactoryProvider { @@ -1415,6 +1419,9 @@ class LoincServicesFactory extends CodeSystemFactoryProvider { }); }); } + + + } module.exports = { diff --git a/tx/cs/cs-mimetypes.js b/tx/cs/cs-mimetypes.js index 28cc78b..be81f0b 100644 --- a/tx/cs/cs-mimetypes.js +++ b/tx/cs/cs-mimetypes.js @@ -137,7 +137,7 @@ class MimeTypeServices extends CodeSystemProvider { if (typeof code === 'string') { const ctxt = await this.locate(code); if (ctxt.context == null) { - throw new Error(ctxt.message); + throw new Error(ctxt.message ? ctxt.message : `Invalid MIME type '${code}'`); } else { return ctxt.context; } @@ -159,7 +159,7 @@ class MimeTypeServices extends CodeSystemProvider { return { context: concept, message: null }; } - return { context: null, message: `Invalid MIME type '${code}'` }; + return { context: null, message: undefined}; } // Subsumption - not supported diff --git a/tx/cs/cs-ndc.js b/tx/cs/cs-ndc.js index 2067748..c582533 100644 --- a/tx/cs/cs-ndc.js +++ b/tx/cs/cs-ndc.js @@ -333,7 +333,7 @@ class NdcServices extends CodeSystemProvider { return { context: productResult, message: null }; } - return { context: null, message: `NDC Code '${code}' not found` }; + return { context: null, message: undefined }; } async #locateInPackages(code) { diff --git a/tx/cs/cs-omop.js b/tx/cs/cs-omop.js index 0029450..682df48 100644 --- a/tx/cs/cs-omop.js +++ b/tx/cs/cs-omop.js @@ -476,7 +476,7 @@ class OMOPServices extends CodeSystemProvider { if (typeof context === 'string') { const ctxt = await this.locate(context); if (ctxt.context == null) { - throw new Error(ctxt.message); + throw new Error(ctxt.message ? ctxt.message : `OMOP Concept '${context}' not found`); } else { return ctxt.context; } @@ -519,7 +519,7 @@ class OMOPServices extends CodeSystemProvider { ); resolve({ context: concept, message: null }); } else { - resolve({ context: null, message: `OMOP Concept '${code}' not found` }); + resolve({ context: null, message: undefined }); } }); }); diff --git a/tx/cs/cs-rxnorm.js b/tx/cs/cs-rxnorm.js index 7c233af..5e5b8a8 100644 --- a/tx/cs/cs-rxnorm.js +++ b/tx/cs/cs-rxnorm.js @@ -225,7 +225,7 @@ class RxNormServices extends CodeSystemProvider { } if (archiveRows.length === 0) { - resolve({ context: null, message: `${this.isNCI ? 'NCI' : 'RxNorm'} Code '${code}' not found` }); + resolve({ context: null, message: undefined}); return; } diff --git a/tx/cs/cs-snomed.js b/tx/cs/cs-snomed.js index bbb7102..a61953a 100644 --- a/tx/cs/cs-snomed.js +++ b/tx/cs/cs-snomed.js @@ -470,6 +470,20 @@ class SnomedProvider extends CodeSystemProvider { return this.sct.getVersion(); } + + /** + * @param {string} checkVersion - first version + * @param {string} actualVersion - second version + * @returns {boolean} True if actualVersion is more detailed than checkVersion (for SCT) + */ + versionIsMoreDetailed(checkVersion, actualVersion) { + console.log('checkVersion:', checkVersion); + console.log('actualVersion:', actualVersion); + console.log('lengths:', checkVersion.length, actualVersion ? actualVersion.length : "??"); + console.log('outcome:', actualVersion && actualVersion.startsWith(checkVersion)); + return actualVersion && actualVersion.startsWith(checkVersion); + } + description() { return this.sct.getDescription(); } @@ -655,7 +669,7 @@ class SnomedProvider extends CodeSystemProvider { } else { return { context: null, - message: `SNOMED CT Code '${code}' not found` + message: undefined }; } } @@ -834,12 +848,12 @@ class SnomedProvider extends CodeSystemProvider { const conceptResult = await this.locate(code); if (!conceptResult.context) { - return { context: null, message: conceptResult.message }; + return conceptResult.message; } const ctxt = conceptResult.context; if (ctxt.isComplex()) { - return { context: null, message: 'Complex expressions not supported in filters' }; + return 'Complex expressions not supported in filters'; } const reference = ctxt.getReference(); @@ -854,9 +868,9 @@ class SnomedProvider extends CodeSystemProvider { } if (found) { - return { context: ctxt, message: null }; + return ctxt; } else { - return { context: null, message: `Code ${code} is not in the specified filter` }; + return `Code ${code} is not in the specified filter`; } } @@ -958,6 +972,12 @@ class SnomedProvider extends CodeSystemProvider { isNotClosed() { return true; } + + isDisplay(cd) { + return cd.use.system === this.system() && + (cd.use.code === '900000000000013009' || cd.use.code === '900000000000003001'); + } + } /** @@ -980,6 +1000,14 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider { return this._sharedData.versionUri; } + getPartialVersion() { + let ver = this.version(); + if (ver.includes("/version")) { + return ver.substring(0, ver.indexOf("/version")); + } else { + return null; + } + } // eslint-disable-next-line no-unused-vars async buildKnownValueSet(url, version) { return null; diff --git a/tx/cs/cs-unii.js b/tx/cs/cs-unii.js index 1751d84..b64ecec 100644 --- a/tx/cs/cs-unii.js +++ b/tx/cs/cs-unii.js @@ -186,7 +186,7 @@ class UniiServices extends CodeSystemProvider { } }); - resolve({ context: concept, message: null }); + resolve({ context: concept, message: undefined }); }); }); }); diff --git a/tx/cs/cs-usstates.js b/tx/cs/cs-usstates.js index b80be21..5902ef9 100644 --- a/tx/cs/cs-usstates.js +++ b/tx/cs/cs-usstates.js @@ -108,7 +108,7 @@ class USStateServices extends CodeSystemProvider { if (typeof code === 'string') { const ctxt = await this.locate(code); if (ctxt.context == null) { - throw new Error(ctxt.message); + throw new Error(ctxt.message ? ctxt.message : `US State Code '${code}' not found`); } else { return ctxt.context; } @@ -129,7 +129,7 @@ class USStateServices extends CodeSystemProvider { if (concept) { return { context: concept, message: null }; } - return { context: null, message: `US State Code '${code}' not found` }; + return { context: null, message: undefined }; } // Iterator methods diff --git a/tx/library.js b/tx/library.js index 17d9dda..5934557 100644 --- a/tx/library.js +++ b/tx/library.js @@ -75,6 +75,10 @@ class Library { } const ver = factory.version() ?? ""; this.codeSystemFactories.set(factory.system()+"|"+ver, factory); + const verMin = factory.getPartialVersion(); + if (verMin) { + this.codeSystemFactories.set(factory.system()+"|"+verMin, factory); + } } @@ -403,6 +407,7 @@ class Library { let csc = 0; for (const resource of resources) { const cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion())); + cs.sourcePackage = contentLoader.pid(); cp.codeSystems.set(cs.url, cs); cp.codeSystems.set(cs.vurl, cs); csc++; diff --git a/tx/library/capabilitystatement.js b/tx/library/capabilitystatement.js new file mode 100644 index 0000000..b98938a --- /dev/null +++ b/tx/library/capabilitystatement.js @@ -0,0 +1,292 @@ +const {CanonicalResource} = require("./canonical-resource"); +const {VersionUtilities} = require("../../library/version-utilities"); + +/** + * Represents a FHIR CapabilityStatement resource with version conversion support + * @class + */ +class CapabilityStatement extends CanonicalResource { + + /** + * Creates a new CapabilityStatement instance + * @param {Object} jsonObj - The JSON object containing CapabilityStatement data + * @param {string} [fhirVersion='R5'] - FHIR version ('R3', 'R4', or 'R5') + */ + constructor(jsonObj, fhirVersion = 'R5') { + super(jsonObj, fhirVersion); + // Convert to R5 format internally (modifies input for performance) + this.jsonObj = this._convertToR5(jsonObj, fhirVersion); + this.validate(); + this.id = this.jsonObj.id; + } + + /** + * Static factory method for convenience + * @param {string} jsonString - JSON string representation of CapabilityStatement + * @param {string} [version='R5'] - FHIR version ('R3', 'R4', or 'R5') + * @returns {CapabilityStatement} New CapabilityStatement instance + */ + static fromJSON(jsonString, version = 'R5') { + return new CapabilityStatement(JSON.parse(jsonString), version); + } + + /** + * Returns JSON string representation + * @param {string} [version='R5'] - Target FHIR version ('R3', 'R4', or 'R5') + * @returns {string} JSON string + */ + toJSONString(version = 'R5') { + const outputObj = this._convertFromR5(this.jsonObj, version); + return JSON.stringify(outputObj); + } + + /** + * Returns JSON object in target version format + * @param {string} [version='R5'] - Target FHIR version ('R3', 'R4', or 'R5') + * @returns {Object} JSON object + */ + toJSON(version = 'R5') { + return this._convertFromR5(this.jsonObj, version); + } + + /** + * Converts input CapabilityStatement to R5 format (modifies input object for performance) + * @param {Object} jsonObj - The input CapabilityStatement object + * @param {string} version - Source FHIR version + * @returns {Object} The same object, potentially modified to R5 format + * @private + */ + _convertToR5(jsonObj, version) { + if (version === 'R5') { + return jsonObj; // Already R5, no conversion needed + } + + if (version === 'R3') { + // R3: resourceType was "CapabilityStatement" (same as R4/R5) + // Convert identifier from single object to array if present + if (jsonObj.identifier && !Array.isArray(jsonObj.identifier)) { + jsonObj.identifier = [jsonObj.identifier]; + } + return jsonObj; + } + + if (version === 'R4') { + // R4 to R5: No major structural changes needed + return jsonObj; + } + + throw new Error(`Unsupported FHIR version: ${version}`); + } + + /** + * Converts R5 CapabilityStatement to target version format (clones object first) + * @param {Object} r5Obj - The R5 format CapabilityStatement object + * @param {string} targetVersion - Target FHIR version + * @returns {Object} New object in target version format + * @private + */ + _convertFromR5(r5Obj, targetVersion) { + if (VersionUtilities.isR5Ver(targetVersion)) { + return r5Obj; // No conversion needed + } + + // Clone the object to avoid modifying the original + const cloned = JSON.parse(JSON.stringify(r5Obj)); + + if (VersionUtilities.isR4Ver(targetVersion)) { + return this._convertR5ToR4(cloned); + } else if (VersionUtilities.isR3Ver(targetVersion)) { + return this._convertR5ToR3(cloned); + } + + throw new Error(`Unsupported target FHIR version: ${targetVersion}`); + } + + /** + * Converts R5 CapabilityStatement to R4 format + * @param {Object} r5Obj - Cloned R5 CapabilityStatement object + * @returns {Object} R4 format CapabilityStatement + * @private + */ + _convertR5ToR4(r5Obj) { + // Remove R5-specific elements + if (r5Obj.versionAlgorithmString) { + delete r5Obj.versionAlgorithmString; + } + if (r5Obj.versionAlgorithmCoding) { + delete r5Obj.versionAlgorithmCoding; + } + + return r5Obj; + } + + /** + * Converts R5 CapabilityStatement to R3 format + * @param {Object} r5Obj - Cloned R5 CapabilityStatement object + * @returns {Object} R3 format CapabilityStatement + * @private + */ + _convertR5ToR3(r5Obj) { + // First apply R4 conversions + const r4Obj = this._convertR5ToR4(r5Obj); + + // Convert identifier array back to single object + if (r4Obj.identifier && Array.isArray(r4Obj.identifier)) { + if (r4Obj.identifier.length > 0) { + r4Obj.identifier = r4Obj.identifier[0]; + } else { + delete r4Obj.identifier; + } + } + + // Convert valueCanonical to valueUri throughout the object + this._convertCanonicalToUri(r5Obj); + + + // Convert rest.operation.definition from canonical string to Reference object + for (const rest of r4Obj.rest || []) { + for (const operation of rest.operation || []) { + if (typeof operation.definition === 'string') { + operation.definition = {reference: operation.definition}; + } + for (const resource of rest.resource || []) { + delete resource.operation; + } + } + } + + return r4Obj; + } + + /** + * Recursively converts valueCanonical to valueUri in an object + * R3 doesn't have canonical type, so valueCanonical must become valueUri + * @param {Object} obj - Object to convert + * @private + */ + _convertCanonicalToUri(obj) { + if (!obj || typeof obj !== 'object') { + return; + } + + if (Array.isArray(obj)) { + obj.forEach(item => this._convertCanonicalToUri(item)); + return; + } + + // Convert valueCanonical to valueUri + if (obj.valueCanonical !== undefined) { + obj.valueUri = obj.valueCanonical; + delete obj.valueCanonical; + } + + // Recurse into all properties + for (const key of Object.keys(obj)) { + if (typeof obj[key] === 'object') { + this._convertCanonicalToUri(obj[key]); + } + } + } + + /** + * Validates that this is a proper CapabilityStatement resource + * @throws {Error} If validation fails + */ + validate() { + if (!this.jsonObj || typeof this.jsonObj !== 'object') { + throw new Error('Invalid CapabilityStatement: expected object'); + } + + if (this.jsonObj.resourceType !== 'CapabilityStatement') { + throw new Error(`Invalid CapabilityStatement: resourceType must be "CapabilityStatement", got "${this.jsonObj.resourceType}"`); + } + + if (!this.jsonObj.status || typeof this.jsonObj.status !== 'string') { + throw new Error('Invalid CapabilityStatement: status is required and must be a string'); + } + + const validStatuses = ['draft', 'active', 'retired', 'unknown']; + if (!validStatuses.includes(this.jsonObj.status)) { + throw new Error(`Invalid CapabilityStatement: status must be one of ${validStatuses.join(', ')}, got "${this.jsonObj.status}"`); + } + + if (!this.jsonObj.kind || typeof this.jsonObj.kind !== 'string') { + throw new Error('Invalid CapabilityStatement: kind is required and must be a string'); + } + + const validKinds = ['instance', 'capability', 'requirements']; + if (!validKinds.includes(this.jsonObj.kind)) { + throw new Error(`Invalid CapabilityStatement: kind must be one of ${validKinds.join(', ')}, got "${this.jsonObj.kind}"`); + } + + if (!this.jsonObj.fhirVersion || typeof this.jsonObj.fhirVersion !== 'string') { + throw new Error('Invalid CapabilityStatement: fhirVersion is required and must be a string'); + } + + if (!this.jsonObj.format || !Array.isArray(this.jsonObj.format)) { + throw new Error('Invalid CapabilityStatement: format is required and must be an array'); + } + } + + /** + * Gets the software information + * @returns {Object|undefined} Software information object + */ + getSoftware() { + return this.jsonObj.software; + } + + /** + * Gets the implementation information + * @returns {Object|undefined} Implementation information object + */ + getImplementation() { + return this.jsonObj.implementation; + } + + /** + * Gets the rest capabilities + * @returns {Object[]} Array of rest capability objects + */ + getRest() { + return this.jsonObj.rest || []; + } + + /** + * Gets supported formats + * @returns {string[]} Array of supported mime types + */ + getFormats() { + return this.jsonObj.format || []; + } + + /** + * Gets the FHIR version this capability statement describes + * @returns {string} FHIR version string + */ + getDescribedFhirVersion() { + return this.jsonObj.fhirVersion; + } + + /** + * Gets basic info about this capability statement + * @returns {Object} Basic information object + */ + getInfo() { + return { + resourceType: this.jsonObj.resourceType, + url: this.jsonObj.url, + version: this.jsonObj.version, + name: this.jsonObj.name, + title: this.jsonObj.title, + status: this.jsonObj.status, + kind: this.jsonObj.kind, + fhirVersion: this.jsonObj.fhirVersion, + formats: this.getFormats(), + software: this.getSoftware()?.name, + restModes: this.getRest().map(r => r.mode) + }; + } +} + +module.exports = { CapabilityStatement }; \ No newline at end of file diff --git a/tx/library/codesystem.js b/tx/library/codesystem.js index 535d915..da4695f 100644 --- a/tx/library/codesystem.js +++ b/tx/library/codesystem.js @@ -1,5 +1,6 @@ const { Language } = require("../../library/languages"); const {CanonicalResource} = require("./canonical-resource"); +const {VersionUtilities} = require("../../library/version-utilities"); const CodeSystemContentMode = Object.freeze({ Complete: 'complete', @@ -126,16 +127,16 @@ class CodeSystem extends CanonicalResource { * @private */ _convertFromR5(r5Obj, targetVersion) { - if (targetVersion === 'R5') { + if (VersionUtilities.isR5Ver(targetVersion)) { return r5Obj; // No conversion needed } // Clone the object to avoid modifying the original const cloned = JSON.parse(JSON.stringify(r5Obj)); - if (targetVersion === 'R4') { + if (VersionUtilities.isR4Ver(targetVersion)) { return this._convertR5ToR4(cloned); - } else if (targetVersion === 'R3') { + } else if (VersionUtilities.isR3Ver(targetVersion)) { return this._convertR5ToR3(cloned); } diff --git a/tx/library/conceptmap.js b/tx/library/conceptmap.js index 551819d..587ede5 100644 --- a/tx/library/conceptmap.js +++ b/tx/library/conceptmap.js @@ -115,7 +115,7 @@ class ConceptMap extends CanonicalResource { * @private */ _convertFromR5(r5Obj, targetVersion) { - if (targetVersion === 'R5') { + if (VersionUtilities.isR5Ver(targetVersion)) { return r5Obj; // No conversion needed } diff --git a/tx/library/designations.js b/tx/library/designations.js index 52725fd..5ad779c 100644 --- a/tx/library/designations.js +++ b/tx/library/designations.js @@ -289,36 +289,6 @@ class Designation { return coding.system || '--'; } - /** - * Check if this designation's use indicates it's a display - * @returns {boolean} - */ - isUseADisplay() { - if (!this.use) { - return true; // No use specified, assume display - } - - // Check for standard display use codes - if (this.use.system === DesignationUse.DISPLAY.system && - this.use.code === DesignationUse.DISPLAY.code) { - return true; - } - - // SNOMED preferred term is a display - if (this.use.system === DesignationUse.PREFERRED.system && - this.use.code === DesignationUse.PREFERRED.code) { - return true; - } - - // No use or unknown use - treat as display - return !this.use.code; - } - - isDisplay() { - return this.use && this.use.system == DesignationUse.DISPLAY.system && - this.use.code == DesignationUse.DISPLAY.code; - } - isPreferred() { return this.use && this.use.system == DesignationUse.PREFERRED.system && this.use.code == DesignationUse.PREFERRED.code; @@ -426,7 +396,7 @@ class Designations { addDesignationFromConcept(concept, baseLanguage = null) { validateOptionalParameter(concept, 'concept', Object); if (!concept) return; - this.addDesignation(false, "unknown", concept.language, concept.use, concept.value, concept.extension); + this.addDesignation(false, "unknown", concept.language, concept.use, concept.value.trim(), concept.extension); // // if (context.display) { // this.addDesignation(true, true, baseLanguage, null, context.display) @@ -452,9 +422,7 @@ class Designations { for (const cd of this.designations) { if (this._langsMatch(langList, cd.language, LangMatchType.LANG, defLang) && - (!active || cd.isActive()) && - cd.value && - this._stringMatches(value, cd.value, mode, cd.language)) { + (!active || cd.isActive()) && cd.value && this._stringMatches(value, cd.value, mode, cd.language)) { result.found = true; return result; } @@ -499,7 +467,7 @@ class Designations { // Try full match first for (const cd of this.designations) { - if ((!displayOnly || cd.base || this._isDisplay(cd)) && + if ((!displayOnly || this.isDisplay(cd)) && this._langsMatch(langList, cd.language, LangMatchType.FULL, defLang) && cd.value) { result++; @@ -509,7 +477,7 @@ class Designations { if (result === 0) { // Try language-region match for (const cd of this.designations) { - if ((!displayOnly || cd.base || this._isDisplay(cd)) && + if ((!displayOnly || this.isDisplay(cd)) && this._langsMatch(langList, cd.language, LangMatchType.LANG_REGION, defLang) && cd.value) { result++; @@ -520,7 +488,7 @@ class Designations { if (result === 0) { // Try language-only match for (const cd of this.designations) { - if ((!displayOnly || cd.base || this._isDisplay(cd)) && + if ((!displayOnly || this.isDisplay(cd)) && this._langsMatch(langList, cd.language, LangMatchType.LANG, defLang) && cd.value) { result++; @@ -544,7 +512,7 @@ class Designations { // Collect matching designations for (const cd of this.designations) { - if ((!displayOnly || cd.base || this._isDisplay(cd)) && + if ((!displayOnly || this.isDisplay(cd)) && this._langsMatch(langList, cd.language, LangMatchType.LANG, null) && cd.value) { count++; @@ -559,7 +527,7 @@ class Designations { // If no language-specific matches, get all if (count === 0) { for (const cd of this.designations) { - if ((!displayOnly || cd.base || this._isDisplay(cd)) && cd.value) { + if ((!displayOnly || this.isDisplay(cd)) && cd.value) { count++; if (cd.language) { results.push(`'${cd.display}' (${cd.language.code})`); @@ -595,7 +563,7 @@ class Designations { if (!langList || langList.length == 0) { // No language list, prefer base designations for (const cd of this.designations) { - if (this._isDisplay(cd)) { + if (this.isDisplay(cd)) { return cd; } } @@ -614,7 +582,7 @@ class Designations { for (const matchType of matchTypes) { for (const cd of this.designations) { - if (this._langMatches(lang, cd.language, matchType) && this._isDisplay(cd)) { + if (this._langMatches(lang, cd.language, matchType) && this.isDisplay(cd)) { return cd; } } @@ -652,7 +620,7 @@ class Designations { const getDesignationTypePriority = (cd) => { if (cd.base) return 3; - if (this._isDisplay(cd)) return 2; + if (this.isDisplay(cd)) return 2; return 1; }; @@ -728,12 +696,17 @@ class Designations { /** * Check if designation is a display designation */ - _isDisplay(cd) { - return !cd.use || - (cd.use.system === DesignationUse.DISPLAY.system && + isDisplay(cd) { + if (!cd.use) { + return true; + } + if ((cd.use.system === DesignationUse.DISPLAY.system && cd.use.code === DesignationUse.DISPLAY.code) || (cd.use.system === DesignationUse.PREFERRED.system && - cd.use.code === DesignationUse.PREFERRED.code); + cd.use.code === DesignationUse.PREFERRED.code)) { + return true; + } + return this.source && this.source.isDisplay(cd); } /** @@ -854,7 +827,7 @@ class Designations { if (items.length === 2) return `${items[0]} or ${items[1]}`; const lastItem = items.pop(); - return `${items.join(', ')}, or ${lastItem}`; + return `${items.join(', ')} or ${lastItem}`; } /** @@ -867,7 +840,7 @@ class Designations { const seen = new Set(); for (const d of this.designations) { - if (!d.isActive() || !d.isUseADisplay() || !d.value) continue; + if (!d.isActive() || !(this.isDisplay(d)) || !d.value) continue; if (seen.has(d.value)) continue; // Check language match @@ -895,7 +868,7 @@ class Designations { status(display) { for (const d of this.designations) { if (d.value === display) { - return this.status; + return d.status; } } return ''; diff --git a/tx/library/extensions.js b/tx/library/extensions.js index eb700b4..ca5ddb5 100644 --- a/tx/library/extensions.js +++ b/tx/library/extensions.js @@ -37,7 +37,16 @@ const Extensions = { element = element.jsonObj } if (element.modifierExtension) { - throw new Issue("error", "business-rule", null, null, 'Cannot process resource "'+name+'" due to the presence of modifier extensions @'+place); + let urls = new Set(); + for (const extension of element.modifierExtension) { + urls.add(extension.url); + } + const urlList = [...urls].join('\', \''); + if (urls.size > 1) { + throw new Issue("error", "business-rule", null, null, 'Cannot process resource at "' + name + '" due to the presence of modifier extensions '+urlList); + } else { + throw new Issue("error", "business-rule", null, null, 'Cannot process resource at "' + name + '" due to the presence of the modifier extension '+urlList); + } } return true; }, diff --git a/tx/library/namingsystem.js b/tx/library/namingsystem.js index 9fc1fc7..bf6e7c6 100644 --- a/tx/library/namingsystem.js +++ b/tx/library/namingsystem.js @@ -89,16 +89,16 @@ class NamingSystem { * @private */ _convertFromR5(r5Obj, targetVersion) { - if (targetVersion === 'R5') { + if (VersionUtilities.isR5Ver(targetVersion)) { return r5Obj; // No conversion needed } // Clone the object to avoid modifying the original const cloned = JSON.parse(JSON.stringify(r5Obj)); - if (targetVersion === 'R4') { + if (VersionUtilities.isR4Ver(targetVersion)) { return this._convertR5ToR4(cloned); - } else if (targetVersion === 'R3') { + } else if (VersionUtilities.isR3Ver(targetVersion)) { return this._convertR5ToR3(cloned); } diff --git a/tx/library/operation-outcome.js b/tx/library/operation-outcome.js index 0b6a626..120a0dc 100644 --- a/tx/library/operation-outcome.js +++ b/tx/library/operation-outcome.js @@ -5,19 +5,20 @@ class Issue extends Error { cause; path; msgId; - issue; + issueCode; statusCode; isSetForhandleAsOO; diagnostics; + issues = []; - constructor (level, cause, path, msgId, message, issue = null, statusCode = 500) { + constructor (level, cause, path, msgId, message, issueCode = null, statusCode = 500) { super(message); this.level = level; this.cause = cause; this.path = path; this.message = message; this.msgId = msgId; - this.issue = issue; + this.issueCode = issueCode; this.statusCode = statusCode; } @@ -31,8 +32,8 @@ class Issue extends Error { location: [ this.path ], expression: [ this.path ] } - if (this.issue) { - res.details.coding = [{ system: "http://hl7.org/fhir/tools/CodeSystem/tx-issue-type", code : this.issue }]; + if (this.issueCode) { + res.details.coding = [{ system: "http://hl7.org/fhir/tools/CodeSystem/tx-issue-type", code : this.issueCode }]; } if (this.msgId) { res.extension = [{ url: "http://hl7.org/fhir/StructureDefinition/operationoutcome-message-id", valueString: this.msgId }]; @@ -61,6 +62,12 @@ class Issue extends Error { this.unknownSystem = s; return this; } + addIssue(issue) { + if (issue) { + this.issues.push(issue); + } + return this; + } withDiagnostics(diagnostics) { this.diagnostics = diagnostics; @@ -88,6 +95,9 @@ class OperationOutcome { this.jsonObj.issue = []; } this.jsonObj.issue.push(newIssue.asIssue()); + for (let extra of newIssue.issues) { + this.addIssue(extra, false); + } return true; } diff --git a/tx/library/terminologycapabilities.js b/tx/library/terminologycapabilities.js new file mode 100644 index 0000000..0abe8b2 --- /dev/null +++ b/tx/library/terminologycapabilities.js @@ -0,0 +1,418 @@ +const {CanonicalResource} = require("./canonical-resource"); +const {VersionUtilities} = require("../../library/version-utilities"); + +/** + * Represents a FHIR TerminologyCapabilities resource with version conversion support. + * Note: TerminologyCapabilities was introduced in R4. For R3, it is represented as a + * Parameters resource with a specific structure. + * @class + */ +class TerminologyCapabilities extends CanonicalResource { + + /** + * Creates a new TerminologyCapabilities instance + * @param {Object} jsonObj - The JSON object containing TerminologyCapabilities data + * @param {string} [fhirVersion='R5'] - FHIR version ('R3', 'R4', or 'R5') + */ + constructor(jsonObj, fhirVersion = 'R5') { + super(jsonObj, fhirVersion); + // Convert to R5 format internally (modifies input for performance) + this.jsonObj = this._convertToR5(jsonObj, fhirVersion); + this.validate(); + this.id = this.jsonObj.id; + } + + /** + * Static factory method for convenience + * @param {string} jsonString - JSON string representation of TerminologyCapabilities + * @param {string} [version='R5'] - FHIR version ('R3', 'R4', or 'R5') + * @returns {TerminologyCapabilities} New TerminologyCapabilities instance + */ + static fromJSON(jsonString, version = 'R5') { + return new TerminologyCapabilities(JSON.parse(jsonString), version); + } + + /** + * Returns JSON string representation + * @param {string} [version='R5'] - Target FHIR version ('R3', 'R4', or 'R5') + * @returns {string} JSON string + */ + toJSONString(version = 'R5') { + const outputObj = this._convertFromR5(this.jsonObj, version); + return JSON.stringify(outputObj); + } + + /** + * Returns JSON object in target version format + * @param {string} [version='R5'] - Target FHIR version ('R3', 'R4', or 'R5') + * @returns {Object} JSON object + */ + toJSON(version = 'R5') { + return this._convertFromR5(this.jsonObj, version); + } + + /** + * Converts input TerminologyCapabilities to R5 format (modifies input object for performance) + * @param {Object} jsonObj - The input TerminologyCapabilities object + * @param {string} version - Source FHIR version + * @returns {Object} The same object, potentially modified to R5 format + * @private + */ + _convertToR5(jsonObj, version) { + if (version === 'R5') { + return jsonObj; // Already R5, no conversion needed + } + + if (version === 'R4') { + // R4 to R5: No major structural changes needed for TerminologyCapabilities + return jsonObj; + } + + if (VersionUtilities.isR3Ver(version)) { + // R3: TerminologyCapabilities doesn't exist - it's a Parameters resource + // Convert from Parameters format to TerminologyCapabilities + return this._convertParametersToR5(jsonObj); + } + + throw new Error(`Unsupported FHIR version: ${version}`); + } + + /** + * Converts R3 Parameters format to R5 TerminologyCapabilities + * @param {Object} params - The Parameters resource + * @returns {Object} TerminologyCapabilities in R5 format + * @private + */ + _convertParametersToR5(params) { + if (params.resourceType !== 'Parameters') { + throw new Error('R3 TerminologyCapabilities must be a Parameters resource'); + } + + const result = { + resourceType: 'TerminologyCapabilities', + id: params.id, + status: 'active', // Default, as Parameters doesn't carry this + kind: 'instance', // Default for terminology server capabilities + codeSystem: [] + }; + + const parameters = params.parameter || []; + let currentSystem = null; + + for (const param of parameters) { + switch (param.name) { + case 'url': + result.url = param.valueUri; + break; + case 'version': + if (currentSystem) { + // This is a code system version + if (param.valueCode) { + currentSystem.version = currentSystem.version || []; + currentSystem.version.push({ code: param.valueCode }); + } + // Empty version parameter means no specific version + } else { + // This is the TerminologyCapabilities version + result.version = param.valueCode || param.valueString; + } + break; + case 'date': + result.date = param.valueDateTime; + break; + case 'system': + // Start a new code system + currentSystem = { uri: param.valueUri }; + result.codeSystem.push(currentSystem); + break; + case 'expansion.parameter': + result.expansion = result.expansion || { parameter: [] }; + result.expansion.parameter.push({ name: param.valueCode }); + break; + } + } + + return result; + } + + /** + * Converts R5 TerminologyCapabilities to target version format (clones object first) + * @param {Object} r5Obj - The R5 format TerminologyCapabilities object + * @param {string} targetVersion - Target FHIR version + * @returns {Object} New object in target version format + * @private + */ + _convertFromR5(r5Obj, targetVersion) { + if (VersionUtilities.isR5Ver(targetVersion)) { + return r5Obj; // No conversion needed + } + + // Clone the object to avoid modifying the original + const cloned = JSON.parse(JSON.stringify(r5Obj)); + + if (VersionUtilities.isR4Ver(targetVersion)) { + return this._convertR5ToR4(cloned); + } else if (VersionUtilities.isR3Ver(targetVersion)) { + return this._convertR5ToR3(cloned); + } + + throw new Error(`Unsupported target FHIR version: ${targetVersion}`); + } + + /** + * Converts R5 TerminologyCapabilities to R4 format + * @param {Object} r5Obj - Cloned R5 TerminologyCapabilities object + * @returns {Object} R4 format TerminologyCapabilities + * @private + */ + _convertR5ToR4(r5Obj) { + // Remove R5-specific elements + if (r5Obj.versionAlgorithmString) { + delete r5Obj.versionAlgorithmString; + } + if (r5Obj.versionAlgorithmCoding) { + delete r5Obj.versionAlgorithmCoding; + } + + // Convert valueCanonical to valueUri throughout the object + this._convertCanonicalToUri(r5Obj); + + return r5Obj; + } + + /** + * Converts R5 TerminologyCapabilities to R3 format (Parameters resource) + * In R3, TerminologyCapabilities didn't exist - we represent it as a Parameters resource + * @param {Object} r5Obj - Cloned R5 TerminologyCapabilities object + * @returns {Object} R3 format Parameters resource + * @private + */ + _convertR5ToR3(r5Obj) { + const params = { + resourceType: 'Parameters', + id: r5Obj.id, + parameter: [] + }; + + // Add url parameter + if (r5Obj.url) { + params.parameter.push({ + name: 'url', + valueUri: r5Obj.url + }); + } + + // Add version parameter + if (r5Obj.version) { + params.parameter.push({ + name: 'version', + valueCode: r5Obj.version + }); + } + + // Add date parameter + if (r5Obj.date) { + params.parameter.push({ + name: 'date', + valueDateTime: r5Obj.date + }); + } + + // Add code systems with their versions + for (const codeSystem of r5Obj.codeSystem || []) { + // Add system parameter + params.parameter.push({ + name: 'system', + valueUri: codeSystem.uri + }); + + // Add version parameter(s) for this code system + if (codeSystem.version && codeSystem.version.length > 0) { + for (const ver of codeSystem.version) { + if (ver.code) { + params.parameter.push({ + name: 'version', + valueCode: ver.code + }); + } else { + // Empty version parameter when no specific version + params.parameter.push({ + name: 'version' + }); + } + } + } else { + // No version specified for this code system + params.parameter.push({ + name: 'version' + }); + } + } + + // Add expansion parameters + if (r5Obj.expansion && r5Obj.expansion.parameter) { + for (const expParam of r5Obj.expansion.parameter) { + params.parameter.push({ + name: 'expansion.parameter', + valueCode: expParam.name + }); + } + } + + return params; + } + + /** + * Recursively converts valueCanonical to valueUri in an object + * R3/R4 doesn't have canonical type in the same way, so valueCanonical must become valueUri + * @param {Object} obj - Object to convert + * @private + */ + _convertCanonicalToUri(obj) { + if (!obj || typeof obj !== 'object') { + return; + } + + if (Array.isArray(obj)) { + obj.forEach(item => this._convertCanonicalToUri(item)); + return; + } + + // Convert valueCanonical to valueUri + if (obj.valueCanonical !== undefined) { + obj.valueUri = obj.valueCanonical; + delete obj.valueCanonical; + } + + // Recurse into all properties + for (const key of Object.keys(obj)) { + if (typeof obj[key] === 'object') { + this._convertCanonicalToUri(obj[key]); + } + } + } + + /** + * Validates that this is a proper TerminologyCapabilities resource + * @throws {Error} If validation fails + */ + validate() { + if (!this.jsonObj || typeof this.jsonObj !== 'object') { + throw new Error('Invalid TerminologyCapabilities: expected object'); + } + + if (this.jsonObj.resourceType !== 'TerminologyCapabilities') { + throw new Error(`Invalid TerminologyCapabilities: resourceType must be "TerminologyCapabilities", got "${this.jsonObj.resourceType}"`); + } + + if (!this.jsonObj.status || typeof this.jsonObj.status !== 'string') { + throw new Error('Invalid TerminologyCapabilities: status is required and must be a string'); + } + + const validStatuses = ['draft', 'active', 'retired', 'unknown']; + if (!validStatuses.includes(this.jsonObj.status)) { + throw new Error(`Invalid TerminologyCapabilities: status must be one of ${validStatuses.join(', ')}, got "${this.jsonObj.status}"`); + } + + if (!this.jsonObj.kind || typeof this.jsonObj.kind !== 'string') { + throw new Error('Invalid TerminologyCapabilities: kind is required and must be a string'); + } + + const validKinds = ['instance', 'capability', 'requirements']; + if (!validKinds.includes(this.jsonObj.kind)) { + throw new Error(`Invalid TerminologyCapabilities: kind must be one of ${validKinds.join(', ')}, got "${this.jsonObj.kind}"`); + } + } + + /** + * Gets the code systems supported by this terminology server + * @returns {Object[]} Array of code system capability objects + */ + getCodeSystems() { + return this.jsonObj.codeSystem || []; + } + + /** + * Gets the expansion capabilities + * @returns {Object|undefined} Expansion capability object + */ + getExpansion() { + return this.jsonObj.expansion; + } + + /** + * Gets the validate-code capabilities + * @returns {Object|undefined} ValidateCode capability object + */ + getValidateCode() { + return this.jsonObj.validateCode; + } + + /** + * Gets the translation capabilities + * @returns {Object|undefined} Translation capability object + */ + getTranslation() { + return this.jsonObj.translation; + } + + /** + * Gets the closure capabilities + * @returns {Object|undefined} Closure capability object + */ + getClosure() { + return this.jsonObj.closure; + } + + /** + * Gets the list of supported expansion parameters + * @returns {string[]} Array of parameter names + */ + getExpansionParameters() { + const expansion = this.getExpansion(); + if (!expansion || !expansion.parameter) { + return []; + } + return expansion.parameter.map(p => p.name); + } + + /** + * Checks if a specific code system is supported + * @param {string} uri - The code system URI to check + * @returns {boolean} True if the code system is supported + */ + supportsCodeSystem(uri) { + return this.getCodeSystems().some(cs => cs.uri === uri); + } + + /** + * Gets version information for a specific code system + * @param {string} uri - The code system URI + * @returns {Object[]|undefined} Array of version objects or undefined if not found + */ + getCodeSystemVersions(uri) { + const codeSystem = this.getCodeSystems().find(cs => cs.uri === uri); + return codeSystem?.version; + } + + /** + * Gets basic info about this terminology capabilities statement + * @returns {Object} Basic information object + */ + getInfo() { + return { + resourceType: this.jsonObj.resourceType, + url: this.jsonObj.url, + version: this.jsonObj.version, + name: this.jsonObj.name, + title: this.jsonObj.title, + status: this.jsonObj.status, + kind: this.jsonObj.kind, + date: this.jsonObj.date, + codeSystemCount: this.getCodeSystems().length, + expansionParameters: this.getExpansionParameters() + }; + } +} + +module.exports = { TerminologyCapabilities }; \ No newline at end of file diff --git a/tx/library/ucum-parsers.js b/tx/library/ucum-parsers.js index b38f201..ef7b765 100644 --- a/tx/library/ucum-parsers.js +++ b/tx/library/ucum-parsers.js @@ -43,7 +43,7 @@ class Lexer { this._checkAnnotation(ch) || this._checkNumber(ch) || this._checkNumberOrSymbol(ch))) { - throw new UcumException(`Error processing unit '${this.source}': unexpected character '${ch}' at position ${this.start}`); + throw new UcumException(`Error processing unit '${this.source}': unexpected character '${ch}' at character ${this.start+1}`); } } } @@ -60,7 +60,7 @@ class Lexer { } if (this.token.length === 1) { - throw new UcumException(`Error processing unit '${this.source}': unexpected character '${ch}' at position ${this.start}: a + or - must be followed by at least one digit`); + throw new UcumException(`Error processing unit '${this.source}': unexpected character '${ch}' at character ${this.start+1}: a + or - must be followed by at least one digit`); } this.type = TokenType.NUMBER; @@ -171,7 +171,7 @@ class Lexer { } error(errMsg) { - throw new UcumException(`Error processing unit '${this.source}': ${errMsg}' at position ${this.start}`); + throw new UcumException(`Error processing unit '${this.source}': ${errMsg} at character ${this.start+1}`); } getTokenAsInt() { @@ -766,8 +766,7 @@ class Search { const regex = new RegExp(text); return regex.test(value); } catch (e) { - console.error('Error message:', e.message); - console.error('Stack trace:', e.stack); + this.log.error(e); return false; } } else { @@ -1010,8 +1009,7 @@ class UcumValidator { } } } catch (e) { - console.error('Error message:', e.message); - console.error('Stack trace:', e.stack); + this.log.error(e); this.result.push(e.message); } } diff --git a/tx/library/valueset.js b/tx/library/valueset.js index 0066bc3..bc2d8f1 100644 --- a/tx/library/valueset.js +++ b/tx/library/valueset.js @@ -1,5 +1,6 @@ const {CanonicalResource} = require("./canonical-resource"); const {getValueName} = require("../../library/utilities"); +const {VersionUtilities} = require("../../library/version-utilities"); /** * Represents a FHIR ValueSet resource with version conversion support @@ -91,16 +92,16 @@ class ValueSet extends CanonicalResource { * @private */ convertFromR5(r5Obj, targetVersion) { - if (targetVersion === 'R5') { + if (VersionUtilities.isR5Ver(targetVersion)) { return r5Obj; // No conversion needed } // Clone the object to avoid modifying the original const cloned = JSON.parse(JSON.stringify(r5Obj)); - if (targetVersion === 'R4') { + if (VersionUtilities.isR4Ver(targetVersion)) { return this._convertR5ToR4(cloned); - } else if (targetVersion === 'R3') { + } else if (VersionUtilities.isR3Ver(targetVersion)) { return this._convertR5ToR3(cloned); } @@ -306,8 +307,8 @@ class ValueSet extends CanonicalResource { throw new Error(`Invalid ValueSet: resourceType must be "ValueSet", got "${this.jsonObj.resourceType}"`); } - if (!this.jsonObj.url || typeof this.jsonObj.url !== 'string') { - throw new Error('Invalid ValueSet: url is required and must be a string'); + if (this.jsonObj.url && typeof this.jsonObj.url !== 'string') { + throw new Error('Invalid ValueSet: url must be a string if present'); } if (this.jsonObj.name && typeof this.jsonObj.name !== 'string') { diff --git a/tx/params.js b/tx/params.js index 928f800..94d4d63 100644 --- a/tx/params.js +++ b/tx/params.js @@ -545,13 +545,12 @@ class TxParameters { } clone() { - let result = new TxParameters(); + let result = new TxParameters(this.languageDefinitions, this.i18n, this.validating); result.assign(this); return result; } assign(other) { - this.languageDefinitions = other.languageDefinitions; if (other.FVersionRules !== null) { this.FVersionRules = [...other.FVersionRules]; } @@ -594,13 +593,16 @@ class TxParameters { } if (other.FHTTPLanguages !== null) { - this.FHTTPLanguages = other.FHTTPLanguages.clone(); + this.FHTTPLanguages = other.FHTTPLanguages; } if (other.FDisplayLanguages !== null) { - this.FDisplayLanguages = other.FDisplayLanguages.clone(); + this.FDisplayLanguages = other.FDisplayLanguages; } } + logInfo() { + return ""; // any parameters worth logging + } } module.exports = { TxParameters, VersionRule }; \ No newline at end of file diff --git a/tx/provider.js b/tx/provider.js index 3a7d454..f7b0a1f 100644 --- a/tx/provider.js +++ b/tx/provider.js @@ -204,7 +204,7 @@ class Provider { async listCodeSystemVersions(url) { let result = new Set(); - for (let cs of this.codeSystems) { + for (let cs of this.codeSystems.values()) { if (cs.url == url) { result.add(cs.version); } diff --git a/tx/sct/expressions.js b/tx/sct/expressions.js index 05cfe91..1b7e47b 100644 --- a/tx/sct/expressions.js +++ b/tx/sct/expressions.js @@ -1549,7 +1549,7 @@ class SnomedExpressionServices { } } catch (error) { // If we can't read the concept descriptions, return empty list - console.warn(`Warning: Could not read descriptions for concept ${conceptIndex}: ${error.message}`); + this.log.warn(`Warning: Could not read descriptions for concept ${conceptIndex}: ${error.message}`); } return designations; @@ -1633,7 +1633,7 @@ class SnomedExpressionServices { return (concept.flags & 1) !== 0; } catch (error) { // If we can't read the concept, assume it's primitive for safety - console.warn(`Warning: Could not check primitive status for concept ${reference}: ${error.message}`); + this.log.warn(`Warning: Could not check primitive status for concept ${reference}: ${error.message}`); return true; } } @@ -1644,7 +1644,7 @@ class SnomedExpressionServices { const concept = this.concepts.getConcept(reference); return concept.identity.toString(); } catch (error) { - console.warn(`Warning: Could not get concept ID for reference ${reference}: ${error.message}`); + this.log.warn(`Warning: Could not get concept ID for reference ${reference}: ${error.message}`); return reference.toString(); // Fall back to using the reference itself } } @@ -1707,7 +1707,7 @@ class SnomedExpressionServices { return descendants.includes(b); } catch (error) { // If we can't read closure data, fall back to simple equality check - console.warn(`Warning: Could not check subsumption for ${a} -> ${b}: ${error.message}`); + this.log.warn(`Warning: Could not check subsumption for ${a} -> ${b}: ${error.message}`); return false; } } @@ -1913,7 +1913,7 @@ class SnomedExpressionServicesExtended extends SnomedExpressionServices { } catch (error) { // Skip problematic relationships if (this.building) { - console.warn(`Warning: Could not read relationship ${relIndex}: ${error.message}`); + this.log.warn(`Warning: Could not read relationship ${relIndex}: ${error.message}`); } } } @@ -1921,7 +1921,7 @@ class SnomedExpressionServicesExtended extends SnomedExpressionServices { return result; } catch (error) { if (this.building) { - console.warn(`Warning: Could not get defining relationships for concept ${conceptIndex}: ${error.message}`); + this.log.warn(`Warning: Could not get defining relationships for concept ${conceptIndex}: ${error.message}`); } return []; } @@ -1943,7 +1943,7 @@ class SnomedExpressionServicesExtended extends SnomedExpressionServices { return parents || []; } catch (error) { if (this.building) { - console.warn(`Warning: Could not get parents for concept ${reference}: ${error.message}`); + this.log.warn(`Warning: Could not get parents for concept ${reference}: ${error.message}`); } return []; } @@ -1962,7 +1962,7 @@ class SnomedExpressionServicesExtended extends SnomedExpressionServices { } catch (error) { // If we can't read the concept, assume it's primitive for safety if (this.building) { - console.warn(`Warning: Could not check primitive status for concept ${reference}, assuming primitive: ${error.message}`); + this.log.warn(`Warning: Could not check primitive status for concept ${reference}, assuming primitive: ${error.message}`); } return true; } @@ -2022,7 +2022,7 @@ class SnomedExpressionServicesExtended extends SnomedExpressionServices { } catch (error) { // Skip problematic relationships but continue if (this.building) { - console.warn(`Warning: Could not process relationship ${relIndex}: ${error.message}`); + this.log.warn(`Warning: Could not process relationship ${relIndex}: ${error.message}`); } } } @@ -2036,7 +2036,7 @@ class SnomedExpressionServicesExtended extends SnomedExpressionServices { } } catch (error) { if (this.building) { - console.warn(`Warning: Could not create defined expression for concept ${reference}: ${error.message}`); + this.log.warn(`Warning: Could not create defined expression for concept ${reference}: ${error.message}`); } // Add as primitive concept as fallback if (!exp.hasConcept(reference)) { diff --git a/tx/tx.fhir.org.yml b/tx/tx.fhir.org.yml index 9c4f526..a754513 100644 --- a/tx/tx.fhir.org.yml +++ b/tx/tx.fhir.org.yml @@ -15,8 +15,7 @@ sources: - ndc:ndc-20211101.db - unii:unii_20240622.db - snomed!:sct_intl_20250201.cache - - snomed:sct_intl_20240201.cache - - snomed:sct_intl_20240801.cache + - snomed:sct_intl_20240201.cache - snomed:sct_se_20231130.cache - snomed:sct_au_20230731.cache - snomed:sct_be_20231115.cache diff --git a/tx/tx.js b/tx/tx.js index da9a5ab..10bdc0f 100644 --- a/tx/tx.js +++ b/tx/tx.js @@ -35,7 +35,6 @@ const {ConceptMapXML} = require("./xml/conceptmap-xml"); class TXModule { constructor() { - this.log = Logger.getInstance().child({ module: 'tx' }); this.config = null; this.library = null; this.endpoints = []; @@ -68,6 +67,13 @@ class TXModule { */ async initialize(config, app) { this.config = config; + // Initialize logger with config settings + this.log = Logger.getInstance().child({ + module: 'tx', + consoleErrors: true, // config.consoleErrors, + telnetErrors: config.telnetErrors + }); + this.log.info('Initializing TX module'); // Load HTML template @@ -195,8 +201,6 @@ class TXModule { res.json = (data) => { const duration = Date.now() - req.txStartTime; - const operation = `${req.method} ${req.baseUrl}${req.path}`; - const params = req.method === 'POST' ? req.body : req.query; const isHtml = txHtml.acceptsHtml(req); const isXml = this.acceptsXml(req); @@ -213,14 +217,13 @@ class TXModule { } else if (isXml) { try { const xml = this.convertResourceToXml(data); - this.logToFile('/Users/grahamegrieve/temp/res-out.xml', xml); - this.logToFile('/Users/grahamegrieve/temp/res-out.json', JSON.stringify(data)); responseSize = Buffer.byteLength(xml, 'utf8'); res.setHeader('Content-Type', 'application/fhir+xml'); result = res.send(xml); } catch (err) { + console.error(err); // Fall back to JSON if XML conversion not supported - log.warn(`XML conversion failed for ${data.resourceType}: ${err.message}, falling back to JSON`); + this.log.warn(`XML conversion failed for ${data.resourceType}: ${err.message}, falling back to JSON`); const jsonStr = JSON.stringify(data); responseSize = Buffer.byteLength(jsonStr, 'utf8'); result = originalJson(data); @@ -232,9 +235,9 @@ class TXModule { } // Log the request with request ID - const paramStr = Object.keys(params).length > 0 ? ` params=${JSON.stringify(this.trimParameters(params))}` : ''; const format = isHtml ? 'html' : (isXml ? 'xml' : 'json'); - log.info(`[${requestId}] ${operation}${paramStr} - ${res.statusCode} - ${format} - ${responseSize} bytes - ${duration}ms`); + let li = req.logInfo ? "("+req.logInfo+")" : ""; + this.log.info(`[${requestId}] ${req.method} ${format} ${res.statusCode} ${duration}ms ${responseSize}: ${req.originalUrl} ${li})`); return result; }; @@ -618,8 +621,9 @@ class TXModule { convertResourceToXml(res) { switch (res.resourceType) { case "CodeSystem" : return CodeSystemXML._jsonToXml(res); - case "CapabilityStatement" : return new CapabilityStatementXML(res, "R5").toXml(); - case "TerminologyCapabilities" : return new TerminologyCapabilitiesXML(res, "R5").toXml(); + case "ValueSet" : return ValueSetXML.toXml(res); + case "CapabilityStatement" : return CapabilityStatementXML.toXml(res, "R5"); + case "TerminologyCapabilities" : return TerminologyCapabilitiesXML.toXml(res, "R5"); case "Parameters": return ParametersXML.toXml(res, this.fhirVersion); case "OperationOutcome": return OperationOutcomeXML.toXml(res, this.fhirVersion); } @@ -633,8 +637,6 @@ class TXModule { throw new Error('Could not detect resource type from XML'); } - this.logToFile('/Users/grahamegrieve/temp/res-in.xml', xml); - const resourceType = rootMatch[1]; let data; @@ -655,15 +657,9 @@ class TXModule { throw new Error(`Resource type ${resourceType} not supported for XML input`); } - this.logToFile('/Users/grahamegrieve/temp/res-in.json', JSON.stringify(data)); return data; } - logToFile(fn, cnt) { - fs.writeFile(fn, cnt, (err) => { - if (err) console.error('Error writing log file:', err); - }); - } } module.exports = TXModule; \ No newline at end of file diff --git a/tx/vs/vs-database.js b/tx/vs/vs-database.js index 7fd92e1..3fd7522 100644 --- a/tx/vs/vs-database.js +++ b/tx/vs/vs-database.js @@ -330,7 +330,7 @@ class ValueSetDatabase { * Load all ValueSets from the database * @returns {Promise<Map<string, Object>>} Map of all ValueSets keyed by various combinations */ - async loadAllValueSets() { + async loadAllValueSets(source) { return new Promise((resolve, reject) => { const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY, (err) => { if (err) { @@ -351,6 +351,7 @@ class ValueSetDatabase { for (const row of rows) { const valueSet = new ValueSet(JSON.parse(row.content)); + valueSet.sourcePackage = source; // Store by URL and id alone valueSetMap.set(row.url, valueSet); @@ -635,8 +636,8 @@ class ValueSetDatabase { switch (name.toLowerCase()) { case 'url': - conditions.push('v.url LIKE ?'); - params.push(`%${value}%`); + conditions.push('v.url = ?'); + params.push(value); break; case 'version': @@ -688,8 +689,8 @@ class ValueSetDatabase { case 'system': joins.add('JOIN valueset_systems vs ON v.id = vs.valueset_id'); - conditions.push('vs.system LIKE ?'); - params.push(`%${value}%`); + conditions.push('vs.system = ?'); + params.push(value); break; default: diff --git a/tx/vs/vs-package.js b/tx/vs/vs-package.js index 3fc7514..83299df 100644 --- a/tx/vs/vs-package.js +++ b/tx/vs/vs-package.js @@ -33,6 +33,7 @@ class PackageValueSetProvider extends AbstractValueSetProvider { if (this.initialized) { return; } + await this.packageLoader.initialize(); const dbExists = await this.database.exists(); @@ -41,7 +42,7 @@ class PackageValueSetProvider extends AbstractValueSetProvider { await this._populateDatabase(); } - this.valueSetMap = await this.database.loadAllValueSets(); + this.valueSetMap = await this.database.loadAllValueSets(this.packageLoader.pid()); this.initialized = true; } @@ -174,6 +175,12 @@ class PackageValueSetProvider extends AbstractValueSetProvider { isMatch = false; break; } + } else if (param === 'url') { + const propValue = json[param]; + if (!this._matchValueFull(propValue, searchValue)) { + isMatch = false; + break; + } } else { // Standard partial text match on property const propValue = json[param]; @@ -207,6 +214,17 @@ class PackageValueSetProvider extends AbstractValueSetProvider { return strValue.includes(searchValue); } + /** + * Check if a value matches the search term (partial, case-insensitive) + */ + _matchValueFull(propValue, searchValue) { + if (propValue === undefined || propValue === null) { + return false; + } + const strValue = String(propValue).toLowerCase(); + return strValue === searchValue; + } + /** * Check if system matches any compose.include[].system */ diff --git a/tx/vs/vs-vsac.js b/tx/vs/vs-vsac.js index 609c030..cc38f1e 100644 --- a/tx/vs/vs-vsac.js +++ b/tx/vs/vs-vsac.js @@ -90,7 +90,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider { try { await this.refreshValueSets(); } catch (error) { - console.error('Error during scheduled refresh:', error.message); + this.log.error(error, 'Error during scheduled refresh:'); } }, intervalMs); } @@ -164,7 +164,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider { console.log(`VSAC refresh completed. Total: ${totalFetched} ValueSets, Deleted: ${deletedCount}`); } catch (error) { - console.error('Error during VSAC refresh:', error.message); + console.log(error, 'Error during VSAC refresh:'); throw error; } finally { this.isRefreshing = false; @@ -225,7 +225,7 @@ class VSACValueSetProvider extends AbstractValueSetProvider { * @private */ async _reloadMap() { - const newMap = await this.database.loadAllValueSets(); + const newMap = await this.database.loadAllValueSets("vsac"); // Atomic replacement of the map this.valueSetMap = newMap; diff --git a/tx/workers/batch-validate.js b/tx/workers/batch-validate.js index e90f3cc..b989fcc 100644 --- a/tx/workers/batch-validate.js +++ b/tx/workers/batch-validate.js @@ -73,10 +73,15 @@ class BatchValidateWorker extends TerminologyWorker { let worker = new ValidateWorker(this.opContext.copy(), this.log, this.provider, this.languages, this.i18n); try { - const p = await worker.handleValueSetInner(op.jsonObj); + let p; + if (this.hasValueSet(op.jsonObj.parameter)) { + p = await worker.handleValueSetInner(op.jsonObj); + } else { + p = await worker.handleCodeSystemInner(op.jsonObj); + } output.push({name: "validation", resource : p}); } catch (error) { - console.log(error); + this.log.error(error); if (error instanceof Issue) { let op = new OperationOutcome(); op.addIssue(error); @@ -88,9 +93,10 @@ class BatchValidateWorker extends TerminologyWorker { } } let result = { resourceType : "Parameters", parameter: output} + req.logInfo = `${output.length} validations`; return res.json(result); } catch (error) { - console.log(error); + this.log.error(error); return res.status(error.statusCode || 500).json(this.operationOutcome( 'error', error.issueCode || 'exception', error.message)); } @@ -113,6 +119,9 @@ class BatchValidateWorker extends TerminologyWorker { }; } + hasValueSet(parameter) { + return parameter.find(p => p.name == 'url' || p.name == 'valueSet'); + } } module.exports = { diff --git a/tx/workers/batch.js b/tx/workers/batch.js index 797bf85..b1a19bf 100644 --- a/tx/workers/batch.js +++ b/tx/workers/batch.js @@ -7,8 +7,6 @@ const { TerminologyWorker } = require('./worker'); const { Issue, OperationOutcome } = require('../library/operation-outcome'); -const DEBUG_LOGGING = false; - class BatchWorker extends TerminologyWorker { /** * @param {OperationContext} opContext - Operation context @@ -41,10 +39,7 @@ class BatchWorker extends TerminologyWorker { try { await this.handleBatch(req, res); } catch (error) { - this.log.error(`Error in batch operation: ${error.message}`); - if (DEBUG_LOGGING) { - console.log('Batch operation error:', error); - } + this.log.error(error); if (error instanceof Issue) { const oo = new OperationOutcome(); oo.addIssue(error); @@ -163,11 +158,7 @@ class BatchWorker extends TerminologyWorker { }; } catch (error) { - this.log.error(`Error processing batch entry ${index}: ${error.message}`); - if (DEBUG_LOGGING) { - console.log(`Batch entry ${index} error:`, error); - } - + this.log.error(error); const statusCode = error.statusCode || 500; const issueCode = error.issueCode || 'exception'; diff --git a/tx/workers/closure.js b/tx/workers/closure.js index 6e40a67..b1c87d3 100644 --- a/tx/workers/closure.js +++ b/tx/workers/closure.js @@ -15,7 +15,7 @@ class ClosureWorker { static handle(req, res, log) { const params = req.method === 'POST' ? req.body : req.query; - log.debug('ConceptMap $closure with params:', params); + this.log.debug('ConceptMap $closure with params:', params); // TODO: Implement closure logic using provider res.status(501).json({ diff --git a/tx/workers/expand.js b/tx/workers/expand.js index 6ed2984..71332b4 100644 --- a/tx/workers/expand.js +++ b/tx/workers/expand.js @@ -252,13 +252,6 @@ class ValueSetExpander { } } - logDisplays(cds) { - console.log('Designations: ' + cds.present); - for (const cd of cds.designations) { - console.log(' ' + cd.present); - } - } - passesImport(imp, system, code) { imp.buildMap(); return imp.hasCode(system, code); @@ -314,9 +307,6 @@ class ValueSetExpander { let result = null; this.worker.deadCheck('processCode'); - if (code == '42806-0') { - console.log("?"); - } if (!this.passesImports(imports, system, code, 0)) { return null; } @@ -524,6 +514,8 @@ class ValueSetExpander { } else { throw new Issue('error', 'not-found', null, 'VS_EXP_IMPORT_UNK_PINNED', this.worker.i18n.translate('VS_EXP_IMPORT_UNK_PINNED', this.params.httpLanguages, [uri, version]), 'not-found', 400); } + } else { + this.worker.seeSourceVS(vs, uri); } } @@ -622,6 +614,7 @@ class ValueSetExpander { if (cset.system) { const cs = await this.worker.findCodeSystem(cset.system, cset.version, this.params, ['complete', 'fragment'], false, true, true, null); + this.worker.seeSourceProvider(cs, cset.system); if (cs == null) { // nothing } else { @@ -729,8 +722,8 @@ class ValueSetExpander { } const iter = await cs.iterator(null); - if (valueSets.length === 0 && this.limitCount > 0 && iter.count > this.limitCount && !this.params.limitedExpansion) { - throw new Issue("error", "too-costly", null, 'VALUESET_TOO_COSTLY', this.worker.i18n.translate('VALUESET_TOO_COSTLY', this.params.httpLanguages, [vsSrc.url, '>' + this.limitCount]), null, 400).withDiagnostics(this.worker.opContext.diagnostics()); + if (valueSets.length === 0 && this.limitCount > 0 && (iter && iter.total > this.limitCount) && !this.params.limitedExpansion && this.offset < 0) { + throw new Issue("error", "too-costly", null, 'VALUESET_TOO_COSTLY', this.worker.i18n.translate('VALUESET_TOO_COSTLY', this.params.httpLanguages, [vsSrc.vurl, '>' + this.limitCount]), null, 400).withDiagnostics(this.worker.opContext.diagnostics()); } let tcount = 0; @@ -744,7 +737,7 @@ class ValueSetExpander { } this.addToTotal(tcount); } else { - this.opContext.log('prepare filters'); + this.worker.opContext.log('prepare filters'); this.noTotal(); if (cs.isNotClosed(filter)) { notClosed.value = true; @@ -752,7 +745,7 @@ class ValueSetExpander { const prep = await cs.getPrepContext(true); const ctxt = await cs.searchFilter(filter, prep, false); await cs.prepare(prep); - this.opContext.log('iterate filters'); + this.worker.opContext.log('iterate filters'); while (await cs.filterMore(ctxt)) { this.worker.deadCheck('processCodes#4'); const c = await cs.filterConcept(ctxt); @@ -763,7 +756,7 @@ class ValueSetExpander { cds, await cs.definition(c), await cs.itemWeight(c), expansion, valueSets, await cs.getExtensions(c), null, await cs.getProperties(c), null, excludeInactive, vsSrc.url); } } - this.opContext.log('iterate filters done'); + this.worker.opContext.log('iterate filters done'); } } @@ -1065,11 +1058,13 @@ class ValueSetExpander { } const iter = await cs.iterator(context); - let c = await cs.nextContext(iter); - while (c) { - this.worker.deadCheck('processCodeAndDescendants#3'); - result += await this.includeCodeAndDescendants(cs, c, expansion, imports, n, excludeInactive, srcUrl); - c = await cs.nextContext(iter); + if (iter) { + let c = await cs.nextContext(iter); + while (c) { + this.worker.deadCheck('processCodeAndDescendants#3'); + result += await this.includeCodeAndDescendants(cs, c, expansion, imports, n, excludeInactive, srcUrl); + c = await cs.nextContext(iter); + } } return result; } @@ -1325,7 +1320,7 @@ class ValueSetExpander { } if (this.offset + this.count < 0 && this.fullList.length > this.limit) { - console.log('Operation took too long @ expand (' + this.constructor.name + ')'); + this.log.log('Operation took too long @ expand (' + this.constructor.name + ')'); throw new Issue("error", "too-costly", null, 'VALUESET_TOO_COSTLY', this.worker.i18n.translate('VALUESET_TOO_COSTLY', this.params.httpLanguages, [source.vurl, '>' + this.limit]), null, 400).withDiagnostics(this.worker.opContext.diagnostics()); } else { let t = 0; @@ -1516,6 +1511,8 @@ class ValueSetExpander { } return undefined; } + + } class ExpandWorker extends TerminologyWorker { @@ -1548,10 +1545,9 @@ class ExpandWorker extends TerminologyWorker { try { await this.handleTypeLevelExpand(req, res); } catch (error) { - this.log.error(`Error in $expand: ${error.message}`); - console.error('$expand error:', error); // Full stack trace to console for debugging + req.logInfo = this.usedSources.join("|")+" - error"+(error.msgId ? " "+error.msgId : ""); + this.log.error(error); const statusCode = error.statusCode || 500; - if (error instanceof Issue) { let oo = new OperationOutcome(); oo.addIssue(error); @@ -1583,8 +1579,8 @@ class ExpandWorker extends TerminologyWorker { try { await this.handleInstanceLevelExpand(req, res); } catch (error) { - this.log.error(`Error in $expand: ${error.message}`); - console.error('$expand error:', error); // Full stack trace to console for debugging + req.logInfo = this.usedSources.join("|")+" - error"+(error.msgId ? " "+error.msgId : ""); + this.log.error(error); const statusCode = error.statusCode || 500; const issueCode = error.issueCode || 'exception'; return res.status(statusCode).json(this.fixForVersion({ @@ -1617,6 +1613,7 @@ class ExpandWorker extends TerminologyWorker { // Body is directly a ValueSet resource valueSet = new ValueSet(req.body); params = this.queryToParameters(req.query); + this.seeSourceVS(valueSet); } else if (req.body.resourceType === 'Parameters') { // Body is a Parameters resource @@ -1626,6 +1623,7 @@ class ExpandWorker extends TerminologyWorker { const valueSetParam = this.findParameter(params, 'valueSet'); if (valueSetParam && valueSetParam.resource) { valueSet = new ValueSet(valueSetParam.resource); + this.seeSourceVS(valueSet); } } else { @@ -1665,7 +1663,7 @@ class ExpandWorker extends TerminologyWorker { const version = versionParam ? this.getParameterValue(versionParam) : null; valueSet = await this.findValueSet(url, version); - + this.seeSourceVS(valueSet, url); if (!valueSet) { return res.status(404).json(this.operationOutcome('error', 'not-found', version ? `ValueSet not found: ${url} version ${version}` : `ValueSet not found: ${url}`)); @@ -1674,9 +1672,10 @@ class ExpandWorker extends TerminologyWorker { // Perform the expansion const result = await this.doExpand(valueSet, txp); + req.logInfo = this.usedSources.join("|")+txp.logInfo(); return res.json(this.fixForVersion(result)); } - + /** * Handle instance-level expand: /ValueSet/{id}/$expand * ValueSet identified by resource ID @@ -1722,6 +1721,7 @@ class ExpandWorker extends TerminologyWorker { // Perform the expansion const result = await this.doExpand(valueSet, txp); + req.logInfo = this.usedSources.join("|")+txp.logInfo(); return res.json(this.fixForVersion(result)); } diff --git a/tx/workers/lookup.js b/tx/workers/lookup.js index 21e836f..a9a3cd7 100644 --- a/tx/workers/lookup.js +++ b/tx/workers/lookup.js @@ -14,8 +14,6 @@ const {TxParameters} = require("../params"); const {Parameters} = require("../library/parameters"); const {Issue, OperationOutcome} = require("../library/operation-outcome"); -const DEBUG_LOGGING = false; - class LookupWorker extends TerminologyWorker { /** * @param {OperationContext} opContext - Operation context @@ -46,8 +44,9 @@ class LookupWorker extends TerminologyWorker { try { await this.handleTypeLevelLookup(req, res); } catch (error) { - this.log.error(`Error in $lookup: ${error.message}`); - console.error('$lookup error:', error); // Full stack trace for debugging + console.log(error); + req.logInfo = this.usedSources.join("|")+" - error"+(error.msgId ? " "+error.msgId : ""); + this.log.error(error); const statusCode = error.statusCode || 500; const issueCode = error.issueCode || 'exception'; return res.status(statusCode).json({ @@ -72,9 +71,8 @@ class LookupWorker extends TerminologyWorker { try { await this.handleInstanceLevelLookup(req, res); } catch (error) { - this.log.error(`Error in $lookup: ${error.message}`); - console.error('$lookup error:', error); // Full stack trace for debugging - // const statusCode = error.statusCode || 500; + req.logInfo = this.usedSources.join("|")+" - error"+(error.msgId ? " "+error.msgId : ""); + this.log.error(error); const issueCode = error.issueCode || 'exception'; return res.status(400).json({ resourceType: 'OperationOutcome', @@ -123,11 +121,13 @@ class LookupWorker extends TerminologyWorker { // Allow complete or fragment content modes, nullOk = true to handle not-found ourselves csProvider = await this.findCodeSystem(coding.system, coding.version || '', txp, ['complete', 'fragment'], true); + this.seeSourceProvider(csProvider, coding.system); code = coding.code; } else if (params.has('system') && params.has('code')) { // system + code parameters csProvider = await this.findCodeSystem(params.get('system'), params.get('version') || '', txp, ['complete', 'fragment'], true); + this.seeSourceProvider(csProvider, params.get('system')); code = params.get('code'); } else { @@ -146,14 +146,10 @@ class LookupWorker extends TerminologyWorker { // Perform the lookup const result = await this.doLookup(csProvider, code, txp); - console.dir(result, {depth: null}); return res.status(200).json(result); } catch (error) { - this.log.error(`Error in CodeSystem $validate-code: ${error.message}`); - if (DEBUG_LOGGING) { - console.log('CodeSystem $validate-code error:', error); - console.log(error); - } + req.logInfo = this.usedSources.join("|")+" - error"+(error.msgId ? " "+error.msgId : ""); + this.log.error(error); if (error instanceof Issue) { let oo = new OperationOutcome(); oo.addIssue(error); @@ -177,6 +173,7 @@ class LookupWorker extends TerminologyWorker { // Find the CodeSystem by ID let codeSystem = this.provider.getCodeSystemById(this.opContext, id); + this.seeSourceProvider(codeSystem, id); if (!codeSystem) { return res.status(404).json(this.operationOutcome('error', 'not-found', @@ -212,14 +209,10 @@ class LookupWorker extends TerminologyWorker { // Perform the lookup const result = await this.doLookup(csProvider, code, txp); - console.dir(result, {depth: null}); return res.status(200).json(result); } catch (error) { - this.log.error(`Error in CodeSystem $validate-code: ${error.message}`); - if (DEBUG_LOGGING) { - console.log('CodeSystem $validate-code error:', error); - console.log(error); - } + req.logInfo = this.usedSources.join("|")+" - error"+(error.msgId ? " "+error.msgId : ""); + this.log.error(error); if (error instanceof Issue) { let oo = new OperationOutcome(); oo.addIssue(error); diff --git a/tx/workers/metadata.js b/tx/workers/metadata.js index aa23fe4..027cb02 100644 --- a/tx/workers/metadata.js +++ b/tx/workers/metadata.js @@ -6,6 +6,9 @@ // GET /$versions - Returns supported FHIR versions // +const {CapabilityStatement} = require("../library/capabilitystatement"); +const {TerminologyCapabilities} = require("../library/terminologycapabilities"); + /** * Metadata handler for FHIR terminology server * Used by TXModule to handle /metadata requests @@ -29,13 +32,15 @@ class MetadataHandler { const provider = req.txProvider; if (mode === 'terminology') { - const tc = await this.buildTerminologyCapabilities(endpoint, provider); - return res.json(tc); + this.logInfo = 'termcaps'; + const tc = new TerminologyCapabilities(await this.buildTerminologyCapabilities(endpoint, provider)); + return res.json(tc.toJSON(endpoint.fhirVersion)); } + this.logInfo = 'metadata'; // Default: return CapabilityStatement - const cs = this.buildCapabilityStatement(endpoint, provider); - return res.json(cs); + const cs = new CapabilityStatement(this.buildCapabilityStatement(endpoint, provider)); + return res.json(cs.toJSON(endpoint.fhirVersion)); } /** diff --git a/tx/workers/read.js b/tx/workers/read.js index a319ef7..3659bd1 100644 --- a/tx/workers/read.js +++ b/tx/workers/read.js @@ -5,7 +5,6 @@ // const { TerminologyWorker } = require('./worker'); - class ReadWorker extends TerminologyWorker { /** * @param {OperationContext} opContext - Operation context @@ -66,8 +65,8 @@ class ReadWorker extends TerminologyWorker { }); } } catch (error) { - this.log.error(`Error reading ${resourceType}/${id}:`, error); - console.error('$lookup error:', error); // Full stack trace for debugging + req.logInfo = this.usedSources.join("|")+" - error"+(error.msgId ? " "+error.msgId : ""); + this.log.error(error); return res.status(500).json({ resourceType: 'OperationOutcome', issue: [{ diff --git a/tx/workers/search.js b/tx/workers/search.js index 0eb53d0..c0e3e99 100644 --- a/tx/workers/search.js +++ b/tx/workers/search.js @@ -85,12 +85,12 @@ class SearchWorker extends TerminologyWorker { const bundle = this.buildSearchBundle( req, resourceType, matches, offset, count, elements ); - + req.logInfo = `${bundle.entry.length} matches`; return res.json(bundle); } catch (error) { - this.log.error(`Error searching ${resourceType}:`, error); - console.error('$lookup error:', error); // Full stack trace for debugging + req.logInfo = "error "+(error.msgId || error.className); + this.log.error(error); return res.status(500).json({ resourceType: 'OperationOutcome', issue: [{ diff --git a/tx/workers/subsumes.js b/tx/workers/subsumes.js index 1f4f7d3..7004e4c 100644 --- a/tx/workers/subsumes.js +++ b/tx/workers/subsumes.js @@ -12,8 +12,7 @@ const { FhirCodeSystemProvider } = require('../cs/cs-cs'); const {TxParameters} = require("../params"); const {Parameters} = require("../library/parameters"); const {Issue, OperationOutcome} = require("../library/operation-outcome"); - -const DEBUG_LOGGING = true; +const {csp} = require("lusca"); class SubsumesWorker extends TerminologyWorker { /** @@ -45,11 +44,8 @@ class SubsumesWorker extends TerminologyWorker { try { await this.handleTypeLevelSubsumes(req, res); } catch (error) { - this.log.error(`Error in CodeSystem $validate-code: ${error.message}`); - if (DEBUG_LOGGING) { - console.log('CodeSystem $validate-code error:', error); - console.log(error); - } + req.logInfo = "error "+(error.msgId || error.className); + this.log.error(error); if (error instanceof Issue) { let oo = new OperationOutcome(); oo.addIssue(error); @@ -71,11 +67,7 @@ class SubsumesWorker extends TerminologyWorker { try { await this.handleInstanceLevelSubsumes(req, res); } catch (error) { - this.log.error(`Error in CodeSystem $validate-code: ${error.message}`); - if (DEBUG_LOGGING) { - console.log('CodeSystem $validate-code error:', error); - console.log(error); - } + this.log.error(error); if (error instanceof Issue) { let oo = new OperationOutcome(); oo.addIssue(error); @@ -119,6 +111,7 @@ class SubsumesWorker extends TerminologyWorker { } // Get the code system provider from the coding's system csProvider = await this.findCodeSystem(codingA.system, codingA.version || '', txp, ['complete'], null, false); + this.seeSourceProvider(csProvider, codingA.system); } else if (params.has('codeA') && params.has('codeB')) { // Using codeA, codeB - system is required if (!params.has('system')) { @@ -126,6 +119,7 @@ class SubsumesWorker extends TerminologyWorker { } csProvider = await this.findCodeSystem(params.get('system'), params.get('version') || '', txp, ['complete'], null, false); + this.seeSourceProvider(csProvider, params.get('system')); // Create codings from the codes codingA = { system: csProvider.system(), @@ -144,6 +138,7 @@ class SubsumesWorker extends TerminologyWorker { // Perform the subsumes check const result = await this.doSubsumes(csProvider, codingA, codingB); + req.logInfo = this.usedSources.join("|")+txp.logInfo(); return res.status(200).json(result); } @@ -203,6 +198,7 @@ class SubsumesWorker extends TerminologyWorker { // Perform the subsumes check const result = await this.doSubsumes(csProvider, codingA, codingB); + req.logInfo = this.usedSources.join("|")+txp.logInfo(); return res.json(result); } /** diff --git a/tx/workers/translate.js b/tx/workers/translate.js index a64a289..9c25b26 100644 --- a/tx/workers/translate.js +++ b/tx/workers/translate.js @@ -13,8 +13,6 @@ const { Parameters } = require('../library/parameters'); const { Issue, OperationOutcome } = require('../library/operation-outcome'); const {ConceptMap} = require("../library/conceptmap"); -const DEBUG_LOGGING = true; - class TranslateWorker extends TerminologyWorker { /** * @param {OperationContext} opContext - Operation context @@ -45,10 +43,7 @@ class TranslateWorker extends TerminologyWorker { try { await this.handleTypeLevelTranslate(req, res); } catch (error) { - this.log.error(`Error in ConceptMap $translate: ${error.message}`); - if (DEBUG_LOGGING) { - console.log('ConceptMap $translate error:', error); - } + this.log.error(error); if (error instanceof Issue) { const oo = new OperationOutcome(); oo.addIssue(error); @@ -70,10 +65,7 @@ class TranslateWorker extends TerminologyWorker { try { await this.handleInstanceLevelTranslate(req, res); } catch (error) { - this.log.error(`Error in ConceptMap $translate: ${error.message}`); - if (DEBUG_LOGGING) { - console.log('ConceptMap $translate error:', error); - } + this.log.error(error); if (error instanceof Issue) { const oo = new OperationOutcome(); oo.addIssue(error); diff --git a/tx/workers/validate.js b/tx/workers/validate.js index f949db8..e31502f 100644 --- a/tx/workers/validate.js +++ b/tx/workers/validate.js @@ -19,13 +19,13 @@ const {validateParameter, isAbsoluteUrl, validateOptionalParameter, getValuePrim const {TxParameters} = require("../params"); const {OperationOutcome, Issue} = require("../library/operation-outcome"); const {Parameters} = require("../library/parameters"); -const {Designations, DisplayCheckingStyle, DisplayDifference} = require("../library/designations"); +const {Designations, DisplayCheckingStyle, DisplayDifference, SearchFilterText} = require("../library/designations"); const ValueSet = require("../library/valueset"); const {ValueSetExpander} = require("./expand"); const {FhirCodeSystemProvider} = require("../cs/cs-cs"); const {CodeSystem} = require("../library/codesystem"); +const {VersionUtilities} = require("../../library/version-utilities"); -const DEBUG_LOGGING = true; const DEV_IGNORE_VALUESET = false; // todo: what's going on with this (ported from pascal) /** @@ -106,15 +106,16 @@ class ValueSetChecker { async determineSystemFromExpansion(code, systems) { let result; try { - let exp = new ValueSetExpander(this.params); - let dep = []; - let vse = await exp.expand(this.valueSet, this.params, '', dep, 10000, 10000, 0, this.FNoCacheThisOne); + let txpe = this.params.clone(); + txpe.limit = 10000; + let exp = new ValueSetExpander(this.worker, txpe); + let vse = await exp.expand(this.valueSet, new SearchFilterText(''), true); result = ''; - for (let c of vse.expansion.contain) { + for (let c of vse.expansion.contains || []) { this.worker.deadCheck('determineSystemFromExpansion'); if (c.code === code) { - systems.push(c.system); - if (result === '') { + systems.add(c.system); + if (!result) { result = c.system; } else { return ''; @@ -122,6 +123,7 @@ class ValueSetChecker { } } } catch (e) { + console.error(e); throw new Error('Exception expanding value set in order to infer system: ' + e.message); } return result; @@ -176,7 +178,7 @@ class ValueSetChecker { this.worker.deadCheck('determineSystem#2'); let match = cc.code === code; if (match) { - systems.push(vsi.system); + systems.add(vsi.system); if (result === '') { result = vsi.system; } else if (result !== vsi.system) { @@ -187,7 +189,7 @@ class ValueSetChecker { } else { let loc = await cs.locate(code); if (loc.context !== null) { - systems.push(vsi.system); + systems.add(vsi.system); if (result === '') { result = vsi.system; } else if (result !== vsi.system) { @@ -218,18 +220,24 @@ class ValueSetChecker { if (versionCoding) { if (result) { if (csa !== null) { - if (csa.versionIsMoreDetailed(result, versionCoding)) { + if (versionCoding != result && csa.versionIsMoreDetailed(result, versionCoding)) { result = versionCoding; } } } + if (!result) { + let vl = await this.worker.listVersions(system); + if (vl.find(v => v == versionCoding || (csa && csa.versionIsMoreDetailed(versionCoding, v)))) { + result = versionCoding; + } + } let cs = await this.worker.findCodeSystem(system, result, this.params, ['complete', 'fragment'], op,true, false, false); if (cs !== null && cs.version() !== versionCoding && !cs.versionIsMoreDetailed(versionCoding, cs.version())) { let errLvl = 'error'; let msg, mid; if (!result) { - if (!cs.versionNeeded) { + if (!cs.versionNeeded()) { errLvl = 'warning'; } msg = this.worker.i18n.translate('VALUESET_VALUE_MISMATCH_DEFAULT', this.params.HTTPLanguages, [system, cs.version(), versionVS, versionCoding]); @@ -449,6 +457,7 @@ class ValueSetChecker { return false; } let cs = await this.worker.findCodeSystem(system, version, this.params, ['complete', 'fragment'], op,true); + this.seeSourceProvider(cs, system); if (cs === null) { this.worker.opContext.addNote(this.valueSet, 'Didn\'t find CodeSystem "' + Renderer.renderCoded(system, version) + '"', this.indentCount); result = null; @@ -547,6 +556,7 @@ class ValueSetChecker { op.addIssueNoId('information', 'informational', addToPath(path, 'code'), msg, 'process-note'); } inactive.value = await cs.isInactive(ctxt.context); + inactive.path = path; vstatus.value = await cs.getStatus(ctxt.context); } if (displays !== null) { @@ -556,7 +566,7 @@ class ValueSetChecker { } } else if (DEV_IGNORE_VALUESET) { // anyhow, we ignore the value set (at least for now) - let cs = this.findCodeSystem(system, version, this.params, ['complete', 'fragment'], op, true, true, false); + let cs = await this.worker.findCodeSystem(system, version, this.params, ['complete', 'fragment'], op, true, true, false); if (cs === null) { result = null; cause.value = 'not-found'; @@ -632,13 +642,12 @@ class ValueSetChecker { } } else { if (!system && inferSystem) { - let systems = []; - systems.duplicates = 'ignore'; + let systems = new Set(); system = await this.determineSystem(this.worker.opContext, code, systems, op); if (system === '') { let msg; - if (systems.length > 1) { - msg = this.worker.i18n.translate('Unable_to_resolve_system__value_set_has_multiple_matches', this.params.HTTPLanguages, [code, this.valueSet.vurl, systems.join(',')]); + if (systems.size > 1) { + msg = this.worker.i18n.translate('Unable_to_resolve_system__value_set_has_multiple_matches', this.params.HTTPLanguages, [code, this.valueSet.vurl, Array.from(systems).join(',')]); messages.push(msg); op.addIssue(new Issue('error', 'not-found', 'code', 'Unable_to_resolve_system__value_set_has_multiple_matches', msg, 'cannot-infer')); } else { @@ -791,7 +800,7 @@ class ValueSetChecker { op.addIssueNoId('error', 'not-found', addToPath(path, 'version'), msg, 'vs-invalid'); return false; } - let cs = await this.findCodeSystem(system, v, this.params, ['complete', 'fragment'], op, true, true, false); + let cs = await this.worker.findCodeSystem(system, v, this.params, ['complete', 'fragment'], op, true, true, false); if (cs === null) { if (!this.params.membershipOnly) { let bAdd = true; @@ -1016,6 +1025,9 @@ class ValueSetChecker { let i = 0; let impliedSystem = { value: '' }; for (let c of code.coding) { + const csd = await this.worker.findCodeSystem(c.system, null, this.params, ['complete', 'fragment'], false, true); + this.worker.seeSourceProvider(csd, c.system); + this.worker.deadCheck('check-b#1'); let path; if (issuePath === 'CodeableConcept') { @@ -1153,7 +1165,7 @@ class ValueSetChecker { } } } else { - this.checkCanonicalStatus(path, op, prov, this.valueSet); + this.checkCanonicalStatusCS(path, op, prov, this.valueSet); let ctxt = await prov.locate(c.code); if (!ctxt.context) { // message can never be populated in pascal? @@ -1206,8 +1218,9 @@ class ValueSetChecker { let m; if (dc === 0) { severity = 'warning'; - m = this.worker.i18n.translate(baseMsg + '_other', this.params.HTTPLanguages, - ['', prov.system, c.code, list.present(this.params.workingLanguages(), defLang.value, true), c.display, this.params.langSummary()]); + baseMsg = 'NO_VALID_DISPLAY_AT_ALL'; + m = this.worker.i18n.translate('NO_VALID_DISPLAY_AT_ALL', this.params.HTTPLanguages, + [c.display, prov.system(), c.code, this.params.langSummary()]); } else if (dc === 1) { m = this.worker.i18n.translate(baseMsg + '_one', this.params.HTTPLanguages, ['', prov.system(), c.code, list.present(this.params.workingLanguages(), defLang.value, true), c.display, this.params.langSummary()]); @@ -1298,7 +1311,7 @@ class ValueSetChecker { } let m = this.worker.i18n.translate('INACTIVE_CONCEPT_FOUND', this.params.HTTPLanguages, [vstatus.value, tcode]); msg(m); - op.addIssue(new Issue('warning', 'business-rule', issuePath, 'INACTIVE_CONCEPT_FOUND', m, 'code-comment')); + op.addIssue(new Issue('warning', 'business-rule', inactive.path, 'INACTIVE_CONCEPT_FOUND', m, 'code-comment')); } else if (vstatus.value && vstatus.value.toLowerCase() === 'deprecated') { result.addParamStr('status', 'deprecated'); let m = this.worker.i18n.translate('DEPRECATED_CONCEPT_FOUND', this.params.HTTPLanguages, [vstatus.value, tcode]); @@ -1327,7 +1340,7 @@ class ValueSetChecker { } async checkDisplays(list, defLang, c, msg, op, path) { - let hd = list.hasDisplay(this.params.workingLanguages(), defLang.value, c.display, false, DisplayCheckingStyle.CASE_INSENSITIVE) + let hd = list.hasDisplay(this.params.workingLanguages(), null, c.display, false, DisplayCheckingStyle.CASE_INSENSITIVE) if (!hd.found) { let baseMsg; if (hd.difference === DisplayDifference.Normalized) { @@ -1480,7 +1493,7 @@ class ValueSetChecker { let loc = await cs.locate(code); result = false; if (loc.context == null) { - this.worker.opContext.addNote(this.valueSet, 'Code "' + code + '" not found in ' + Renderer.renderCoded(cs), this.indentCount); + this.worker.opContext.addNote(this.valueSet, 'Code "' + code + '" not found in ' + Renderer.renderCoded(cs)+": "+loc.mesage, this.indentCount); if (!this.params.membershipOnly) { if (cs.contentMode() !== 'complete') { op.addIssue(new Issue('warning', 'code-invalid', addToPath(path, 'code'), 'UNKNOWN_CODE_IN_FRAGMENT', this.worker.i18n.translate('UNKNOWN_CODE_IN_FRAGMENT', this.params.HTTPLanguages, [code, cs.system(), cs.version()]), 'invalid-code')); @@ -1489,6 +1502,9 @@ class ValueSetChecker { this.worker.i18n.translate(Unknown_Code_in_VersionSCT(cs.system()), this.params.HTTPLanguages, [code, cs.system(), cs.version(), SCTVersion(cs.system(), cs.version())]), 'invalid-code')); } } + if (loc.message && op) { + op.addIssue(new Issue('information', 'code-invalid', addToPath(path, 'code'), null, loc.message, 'invalid-code')); + } } else if (!(abstractOk || !cs.IsAbstract(loc.context))) { this.worker.opContext.addNote(this.valueSet, 'Code "' + code + '" found in ' + Renderer.renderCoded(cs) + ' but is abstract', this.indentCount); if (!this.params.membershipOnly) { @@ -1502,6 +1518,7 @@ class ValueSetChecker { messages.push(msg); if (!this.params.membershipOnly) { inactive.value = true; + inactive.path = path; if (inactive.value) { vstatus.value = await cs.getStatus(loc.context); } @@ -1510,6 +1527,7 @@ class ValueSetChecker { this.worker.opContext.addNote(this.valueSet, 'Code "' + code + '" found in ' + Renderer.renderCoded(cs) + ' but is inactive', this.indentCount); result = false; inactive.value = true; + inactive.path = path; vstatus.value = await cs.getStatus(loc.context); let msg = this.worker.i18n.translate('STATUS_CODE_WARNING_CODE', this.params.HTTPLanguages, ['not active', code]); messages.push(msg); @@ -1533,6 +1551,7 @@ class ValueSetChecker { } await this.worker.listDisplaysFromCodeSystem(displays, cs, loc.context); inactive.value = await cs.isInactive(loc.context); + inactive.path = path; vstatus.value = await cs.getStatus(loc.context); if (vcc !== null) { @@ -1568,6 +1587,7 @@ class ValueSetChecker { result = false; if (!this.params.membershipOnly) { inactive.value = true; + inactive.path = path; vstatus.value = await cs.getStatus(loc); } } else { @@ -1582,6 +1602,7 @@ class ValueSetChecker { op.addIssue(new Issue('warning', 'business-rule', addToPath(path, 'code'), 'CONCEPT_DEPRECATED_IN_VALUESET', this.worker.i18n.translate('CONCEPT_DEPRECATED_IN_VALUESET', this.params.HTTPLanguages, [cs.system(), code, 'deprecated', vs.vurl]), 'code-comment')); } inactive.value = await cs.isInactive(loc); + inactive.path = path; vstatus.value = await cs.getStatus(loc); result = true; return result; @@ -1621,6 +1642,7 @@ class ValueSetChecker { this.worker.opContext.addNote(this.valueSet, 'Filter ' + ctxt.summary + ': Code "' + code + '" found in ' + Renderer.renderCoded(cs) + ' but is inactive', this.indentCount); if (!this.params.membershipOnly) { inactive.value = true; + inactive.path = path; vstatus.value = await cs.getStatus(loc); } } else { @@ -1682,6 +1704,7 @@ class ValueSetChecker { result = false; if (!this.params.membershipOnly) { inactive.value = true; + inactive.path = path; vstatus.value = await cs.getStatus(loc); } } else { @@ -1712,6 +1735,7 @@ class ValueSetChecker { result = false; if (!this.params.membershipOnly) { inactive.value = true; + inactive.path = path; vstatus.value = await cs.getStatus(loc); } } else { @@ -1740,20 +1764,21 @@ class ValueSetChecker { let result = false; let loc = await cs.locate(code, null, message); result = false; - if (loc === null) { + if (loc === null || loc.context == null) { if (!this.params.membershipOnly) { op.addIssue(new Issue('error', 'code-invalid', addToPath(path, 'code'), 'Unknown_Code_in_Version', this.worker.i18n.translate(Unknown_Code_in_VersionSCT(cs.system()), this.params.HTTPLanguages, [code, cs.system(), cs.version(), SCTVersion(cs.system(), cs.version())]), 'invalid-code')); } - } else if (!(abstractOk || !cs.IsAbstract(loc))) { + } else if (!(abstractOk || !cs.IsAbstract(loc.context))) { if (!this.params.membershipOnly) { op.addIssue(new Issue('error', 'business-rule', addToPath(path, 'code'), 'ABSTRACT_CODE_NOT_ALLOWED', this.worker.i18n.translate('ABSTRACT_CODE_NOT_ALLOWED', this.params.HTTPLanguages, [cs.system(), code]), 'code-rule')); } } else { result = true; - inactive.value = await cs.isInactive(loc); - vstatus.value = await cs.getStatus(loc); - await this.worker.listDisplaysFromCodeSystem(displays, cs, loc); + inactive.value = await cs.isInactive(loc.context); + inactive.path = path; + vstatus.value = await cs.getStatus(loc.context); + await this.worker.listDisplaysFromCodeSystem(displays, cs, loc.context); return result; } return result; @@ -1864,13 +1889,39 @@ class ValidateWorker extends TerminologyWorker { * GET/POST /CodeSystem/$validate-code */ async handleCodeSystem(req, res) { - let coded; - let mode; try { const params = this.buildParameters(req); this.addHttpParams(req, params); this.log.debug('CodeSystem $validate-code with params:', params); + let result = await this.handleCodeSystemInner(params); + + return res.status(200).json(result); + + } catch (error) { + this.log.error(error); + console.error(error); + if (error instanceof Issue) { + if (error.isHandleAsOO()) { + let oo = new OperationOutcome(); + oo.addIssue(error); + return res.status(error.statusCode || 500).json(oo.jsonObj); + } else { + return res.status(200).json(this.handlePrepareError(error, coded, mode.mode)); + } + } else { + return res.status(error.statusCode || 500).json(this.operationOutcome( + 'error', error.issueCode || 'exception', error.message)); + } + + } + } + + async handleCodeSystemInner(params, req) { + let coded; + let mode; + + try { // Handle tx-resource and cache-id parameters this.setupAdditionalResources(params); @@ -1878,18 +1929,16 @@ class ValidateWorker extends TerminologyWorker { txp.readParams(params); // Extract coded value - mode = { mode : null }; - coded = this.extractCodedValue(params, true, mode); + mode = {mode: null}; + coded = this.extractCodedValue(params, true, mode); if (!coded) { - return res.status(400).json(this.operationOutcome('error', 'invalid', - 'Unable to find code to validate (looked for coding | codeableConcept | code in parameters =codingX:Coding)')); + throw new Issue('error', 'invalid', null, null, 'Unable to find code to validate (looked for coding | codeableConcept | code in parameters =codingX:Coding)', null, 400); } // Get the CodeSystem - from parameter or by url const codeSystem = await this.resolveCodeSystem(params, txp, coded?.coding?.[0] ?? null, mode); if (!codeSystem) { - return res.status(400).json(this.operationOutcome('error', 'invalid', - 'No CodeSystem specified - provide url parameter or codeSystem resource')); + throw new Issue('error', 'invalid', null, null, 'No CodeSystem specified - provide url parameter or codeSystem resource', null, 400); } if (codeSystem.contentMode() == 'supplement') { throw new Issue('error', 'invalid', this.systemPath(mode), 'CODESYSTEM_CS_NO_SUPPLEMENT', this.opContext.i18n.translate('CODESYSTEM_CS_NO_SUPPLEMENT', txp.HTTPLanguages, [codeSystem.vurl()]), "invalid-data"); @@ -1897,33 +1946,22 @@ class ValidateWorker extends TerminologyWorker { // Perform validation const result = await this.doValidationCS(coded, codeSystem, txp, mode); - if (DEBUG_LOGGING) { - console.dir(result, {depth: null}); + if (req) { + req.logInfo = this.usedSources.join("|") + txp.logInfo(); } - return res.json(result); - + return result; } catch (error) { - this.log.error(`Error in CodeSystem $validate-code: ${error.message}`); - if (DEBUG_LOGGING) { - console.log('CodeSystem $validate-code error:', error); - console.log(error); - } - if (error instanceof Issue) { - if (error.isHandleAsOO()) { - let oo = new OperationOutcome(); - oo.addIssue(error); - return res.status(error.statusCode || 500).json(oo.jsonObj); - } else { - return res.status(200).json(this.handlePrepareError(error, coded, mode.mode)); - } + this.log.error(error); + if (error instanceof Issue && !error.isHandleAsOO()) { + return this.handlePrepareError(error, coded, mode.mode); } else { - return res.status(error.statusCode || 500).json(this.operationOutcome( - 'error', error.issueCode || 'exception', error.message)); + throw error; } } - } + + } systemPath(mode) { switch (mode.mode) { case 'code': return 'system'; @@ -1966,16 +2004,11 @@ class ValidateWorker extends TerminologyWorker { // Perform validation const result = await this.doValidationCS(coded, csp, txp, mode); - if (this.DEBUG_LOGGING) { - console.dir(result, {depth: null}); - } + req.logInfo = this.usedSources.join("|") + txp.logInfo(); return res.json(result); } catch (error) { - this.log.error(`Error in CodeSystem $validate-code: ${error.message}`); - if (DEBUG_LOGGING) { - console.error('CodeSystem $validate-code error:', error); - } + this.log.error(error); return res.status(error.statusCode || 500).json(this.operationOutcome( 'error', error.issueCode || 'exception', error.message)); } @@ -1991,17 +2024,11 @@ class ValidateWorker extends TerminologyWorker { this.addHttpParams(req, params); this.log.debug('ValueSet $validate-code with params:', params); - const result = await this.handleValueSetInner(params); - if (DEBUG_LOGGING) { - console.dir(result, {depth: null}); - } + const result = await this.handleValueSetInner(params, req); return res.json(result); } catch (error) { - this.log.error(`Error in ValueSet $validate-code: ${error.message}`); - if (DEBUG_LOGGING) { - console.error('ValueSet $validate-code error:', error); - } + this.log.error(error); if (error instanceof Issue) { let op = new OperationOutcome(); op.addIssue(error); @@ -2013,7 +2040,7 @@ class ValidateWorker extends TerminologyWorker { } } - async handleValueSetInner(params) { + async handleValueSetInner(params, req) { // Handle tx-resource and cache-id parameters this.setupAdditionalResources(params); @@ -2025,7 +2052,6 @@ class ValidateWorker extends TerminologyWorker { if (!valueSet) { throw new Issue("error", "invalid", null, null, 'No ValueSet specified - provide url parameter or valueSet resource', null, 400); } - // Extract coded value let mode = { mode : null }; @@ -2035,7 +2061,11 @@ class ValidateWorker extends TerminologyWorker { } // Perform validation - return await this.doValidationVS(coded, valueSet, txp, mode.mode, mode.issuePath); + let res = await this.doValidationVS(coded, valueSet, txp, mode.mode, mode.issuePath); + if (req) { + req.logInfo = this.usedSources.join("|") + txp.logInfo(); + } + return res; } /** * Handle an instance-level ValueSet $validate-code request @@ -2055,6 +2085,7 @@ class ValidateWorker extends TerminologyWorker { // Get the ValueSet by id const valueSet = await this.provider.getValueSetById(this.opContext, id); + this.seeSourceVS(valueSet, id); if (!valueSet) { return res.status(404).json(this.operationOutcome('error', 'not-found', `ValueSet/${id} not found`)); @@ -2070,16 +2101,11 @@ class ValidateWorker extends TerminologyWorker { // Perform validation const result = await this.doValidationVS(coded, valueSet, txp, mode.mode, mode.issuePath); - if (DEBUG_LOGGING) { - console.dir(result, {depth: null}); - } + req.logInfo = this.usedSources.join("|")+txp.logInfo(); return res.json(result); } catch (error) { - this.log.error(`Error in ValueSet $validate-code: ${error.message}`); - if (DEBUG_LOGGING) { - console.error('ValueSet $validate-code error:', error); - } + this.log.error(error); return res.status(error.statusCode || 500).json(this.operationOutcome( 'error', error.issueCode || 'exception', error.message)); } @@ -2099,7 +2125,7 @@ class ValidateWorker extends TerminologyWorker { if (csResource) { return csResource; } - + let path = coded == null ? null : mode.issuePath+".system"; let fromCoded = false; // Check for url parameter let url = this.getStringParam(params, 'url'); @@ -2110,6 +2136,13 @@ class ValidateWorker extends TerminologyWorker { if (!url) { return null; } + + let issue = null; + if (!isAbsoluteUrl(url)) { + let m = this.i18n.translate('Terminology_TX_System_Relative', txParams.HTTPLanguages, [url]); + issue = new Issue('error', 'invalid', path, 'Terminology_TX_System_Relative', m, 'invalid-data'); + } + let version = this.getStringParam(params, 'version'); if (!version && fromCoded) { version = coded.version; @@ -2123,18 +2156,25 @@ class ValidateWorker extends TerminologyWorker { if (fromAdditional) { return this.provider.createCodeSystemProvider(this.opContext, fromAdditional, supplements); } else { + let csp = await this.provider.getCodeSystemProvider(this.opContext, url, version, supplements); if (csp) { return csp; - } else if (version) { - let vl = await this.listVersions(url); - if (vl.length == 0) { - throw new Issue("error", "not-found", this.systemPath(mode), 'UNKNOWN_CODESYSTEM_VERSION_NONE', this.opContext.i18n.translate('UNKNOWN_CODESYSTEM_VERSION_NONE', this.opContext.HTTPLanguages, [url, version]), 'not-found').setUnknownSystem(url); + } else { + let vs = await this.findValueSet(url, version); + if (vs) { + let msg = this.i18n.translate('Terminology_TX_System_ValueSet2', txParams.HTTPLanguages, [url]); + throw new Issue('error', 'invalid', path, 'Terminology_TX_System_ValueSet2', msg, 'invalid-data'); + } else if (version) { + let vl = await this.listVersions(url); + if (vl.length == 0) { + throw new Issue("error", "not-found", this.systemPath(mode), 'UNKNOWN_CODESYSTEM_VERSION_NONE', this.opContext.i18n.translate('UNKNOWN_CODESYSTEM_VERSION_NONE', this.opContext.HTTPLanguages, [url, version]), 'not-found').setUnknownSystem(url).addIssue(issue); + } else { + throw new Issue("error", "not-found", this.systemPath(mode), 'UNKNOWN_CODESYSTEM_VERSION', this.opContext.i18n.translate('UNKNOWN_CODESYSTEM_VERSION', this.opContext.HTTPLanguages, [url, version, vl]), 'not-found').setUnknownSystem(url + "|" + version).addIssue(issue); + } } else { - throw new Issue("error", "not-found", this.systemPath(mode), 'UNKNOWN_CODESYSTEM_VERSION', this.opContext.i18n.translate('UNKNOWN_CODESYSTEM_VERSION', this.opContext.HTTPLanguages, [url, version, vl]), 'not-found').setUnknownSystem(url + "|" + version); + throw new Issue("error", "not-found", this.systemPath(mode), 'UNKNOWN_CODESYSTEM', this.opContext.i18n.translate('UNKNOWN_CODESYSTEM', this.opContext.HTTPLanguages, [url]), 'not-found').setUnknownSystem(url).addIssue(issue); } - } else { - throw new Issue("error", "not-found", this.systemPath(mode), 'UNKNOWN_CODESYSTEM', this.opContext.i18n.translate('UNKNOWN_CODESYSTEM', this.opContext.HTTPLanguages, [url]), 'not-found').setUnknownSystem(url); } } } @@ -2149,7 +2189,8 @@ class ValidateWorker extends TerminologyWorker { // Check for valueSet resource parameter const vsResource = this.getResourceParam(params, 'valueSet'); if (vsResource) { - return vsResource; + this.seeSourceVS(vsResource); + return new ValueSet(vsResource); } // Check for url parameter @@ -2164,6 +2205,7 @@ class ValidateWorker extends TerminologyWorker { } let vs = await this.provider.findValueSet(this.opContext, url, version); + this.seeSourceVS(vs, url); if (vs == null) { throw new Issue('error', 'not-found', null, 'Unable_to_resolve_value_Set_', this.i18n.translate('Unable_to_resolve_value_Set_', params.HTTPLanguages, [url+(version ? "|"+version : "")]), 'not-found', 400); } else { @@ -2257,7 +2299,7 @@ class ValidateWorker extends TerminologyWorker { // Add diagnostics if requested if (params.diagnostics) { - result.parameter.push({name: 'diagnostics', valueString: this.worker.opContext.diagnostics()}); + result.jsonObj.parameter.push({name: 'diagnostics', valueString: this.opContext.diagnostics()}); } return result.jsonObj; @@ -2302,9 +2344,7 @@ class ValidateWorker extends TerminologyWorker { try { await checker.prepare(); } catch (error) { - if (DEBUG_LOGGING) { - console.log(error); - } + this.log.error(error); if (!(error instanceof Issue) || error.isHandleAsOO()) { throw error; } else { @@ -2317,7 +2357,7 @@ class ValidateWorker extends TerminologyWorker { // Add diagnostics if requested if (params.diagnostics) { - result.parameter.push({name: 'diagnostics', valueString: this.worker.opContext.diagnostics()}); + result.jsonObj.parameter.push({name: 'diagnostics', valueString: this.opContext.diagnostics()}); } return result.jsonObj; diff --git a/tx/workers/worker.js b/tx/workers/worker.js index f504f4f..0066544 100644 --- a/tx/workers/worker.js +++ b/tx/workers/worker.js @@ -21,8 +21,10 @@ class TerminologySetupError extends Error { * Abstract base class for terminology operations */ class TerminologyWorker { + usedSources = []; additionalResources = []; // Resources provided via tx-resource parameter or cache foundParameters = []; + /** * @param {OperationContext} opContext - Operation context * @param {Logger} log - Provider for code systems and resources @@ -113,7 +115,7 @@ class TerminologyWorker { // Find the latest version let latest = 0; for (let i = 1; i < matches.length; i++) { - if (VersionUtilities.isThisOrLater(matches[latest].version, matches[i].version)) { + if (VersionUtilities.isSemVer(matches[latest].version) && VersionUtilities.isSemVer(matches[i].version) && VersionUtilities.isThisOrLater(matches[latest].version, matches[i].version)) { latest = i; } } @@ -180,6 +182,9 @@ class TerminologyWorker { this.checkVersion(url, provider.version(), params, provider.versionAlgorithm(), op); } } + if (provider == null) { + console.log("!"); + } return provider; } @@ -228,8 +233,8 @@ class TerminologyWorker { listDisplaysFromIncludeConcept(displays, c, vs) { if (c.display && c.display !== '') { - displays.baseLang = this.FLanguages.parse(vs.language); - displays.addDesignation(true, "active", '', '', c.displayElement); + displays.baseLang = this.languages.parse(vs.language); + displays.addDesignation(true, "active", '', '', c.display.trim()); } for (let cd of c.designations || []) { // see https://chat.fhir.org/#narrow/stream/179202-terminology/topic/ValueSet.20designations.20and.20languages @@ -856,6 +861,33 @@ class TerminologyWorker { return resource; } } + + + seeSourceVS(vs, url) { + let s = url; + if (vs) { + if (vs.jsonObj) vs = vs.jsonObj; + s = vs.name || vs.title || vs.id || vs.url; + } + if (!this.usedSources.find(u => u == s)) { + this.usedSources.push(s); + } + } + + seeSourceProvider(cs, url) { + let s = url; + if (cs) { + if (cs instanceof CodeSystem) { + cs = cs.jsonObj; + s = cs.name || cs.title || cs.id || cs.url; + } else { + s = cs.name() || cs.system(); + } + } + if (!this.usedSources.find(u => u == s)) { + this.usedSources.push(s); + } + } } module.exports = {