diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9e5da02 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# Dependencies - must be rebuilt for container architecture +node_modules/ + +# Development/test files +.git/ +.gitignore +*.md +tests/ +test-results/ +coverage/ + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo + +# Local data and logs +data/ +logs/ +*.log + +# Local config that shouldn't be in container +.env +.env.* +config/config.json + +# Build artifacts +*.tgz + +# Claude Code +.claude/ diff --git a/Dockerfile b/Dockerfile index b5e93f2..c4c6d1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,14 @@ -FROM node:20-alpine +FROM node:24-alpine # Create app directory WORKDIR /app +# Install build dependencies for native modules (bcrypt, sqlite3) +RUN apk add --no-cache python3 make g++ + # Install app dependencies COPY package*.json ./ -RUN npm ci --only=production +RUN npm install --omit=dev # Bundle app source COPY . . diff --git a/library/package-manager.js b/library/package-manager.js index 8d61758..fd02cf3 100644 --- a/library/package-manager.js +++ b/library/package-manager.js @@ -856,7 +856,9 @@ class PackageContentLoader { } fhirVersion() { - return this.package.fhirVersions[0]; + // Handle both modern 'fhirVersions' and older 'fhir-version-list' formats + const versions = this.package.fhirVersions || this.package['fhir-version-list']; + return versions ? versions[0] : undefined; } id() { diff --git a/tests/data/snomed-test-expectations-json b/tests/data/snomed-test-expectations.json similarity index 100% rename from tests/data/snomed-test-expectations-json rename to tests/data/snomed-test-expectations.json diff --git a/tx/tx.js b/tx/tx.js index fcd2d6d..f2753ae 100644 --- a/tx/tx.js +++ b/tx/tx.js @@ -33,6 +33,7 @@ const {ParametersXML} = require("./xml/parameters-xml"); const {OperationOutcomeXML} = require("./xml/operationoutcome-xml"); const {ValueSetXML} = require("./xml/valueset-xml"); const {ConceptMapXML} = require("./xml/conceptmap-xml"); +const {BundleXML} = require("./xml/bundle-xml"); const {TxHtmlRenderer} = require("./tx-html"); const {Renderer} = require("./library/renderer"); const {OperationsWorker} = require("./workers/operations"); @@ -69,6 +70,13 @@ class TXModule { } acceptsXml(req) { + // Check _format query parameter first (takes precedence per FHIR spec) + const format = req.query._format || req.query.format || req.body?._format; + if (format) { + const f = format.toLowerCase(); + return f === 'xml' || f.includes('fhir+xml') || f.includes('xml+fhir'); + } + // Fall back to Accept header const accept = req.headers.accept || ''; return accept.includes('application/fhir+xml') || accept.includes('application/xml+fhir'); } @@ -273,6 +281,8 @@ class TXModule { } else { const jsonStr = JSON.stringify(data); responseSize = Buffer.byteLength(jsonStr, 'utf8'); + // Set proper FHIR content-type for JSON responses + res.setHeader('Content-Type', 'application/fhir+json; charset=utf-8'); result = originalJson(data); } @@ -871,6 +881,7 @@ class TXModule { switch (res.resourceType) { case "CodeSystem" : return CodeSystemXML._jsonToXml(res); case "ValueSet" : return ValueSetXML.toXml(res); + case "Bundle" : return BundleXML.toXml(res, this.fhirVersion); case "CapabilityStatement" : return CapabilityStatementXML.toXml(res, "R5"); case "TerminologyCapabilities" : return TerminologyCapabilitiesXML.toXml(res, "R5"); case "Parameters": return ParametersXML.toXml(res, this.fhirVersion); diff --git a/tx/workers/search.js b/tx/workers/search.js index 9f661d2..cdfb3cc 100644 --- a/tx/workers/search.js +++ b/tx/workers/search.js @@ -30,12 +30,19 @@ class SearchWorker extends TerminologyWorker { // Allowed search parameters static ALLOWED_PARAMS = [ - '_offset', '_count', '_elements', '_sort', + '_offset', '_count', '_elements', '_sort', '_summary', '_total', '_format', 'url', 'version', 'content-mode', 'date', 'description', 'supplements', 'identifier', 'jurisdiction', 'name', 'publisher', 'status', 'system', 'title', 'text' ]; + // Summary element fields for each resource type (FHIR summary elements) + static SUMMARY_ELEMENTS = { + CodeSystem: ['url', 'version', 'name', 'title', 'status', 'experimental', 'date', 'publisher', 'description', 'jurisdiction', 'content'], + ValueSet: ['url', 'version', 'name', 'title', 'status', 'experimental', 'date', 'publisher', 'description', 'jurisdiction'], + ConceptMap: ['url', 'version', 'name', 'title', 'status', 'experimental', 'date', 'publisher', 'description', 'jurisdiction'] + }; + // Sortable fields static SORT_FIELDS = ['id', 'url', 'version', 'date', 'name', 'vurl']; @@ -57,6 +64,8 @@ class SearchWorker extends TerminologyWorker { const elements = params._elements ? decodeURIComponent(params._elements).split(',').map(e => e.trim()) : null; const count = Math.min(elements ? 2000 : 200, Math.max(1, parseInt(params._count) || 20)); const sort = params._sort || "id"; + const summary = params._summary; // true, text, data, count, false + const total = params._total; // none, estimate, accurate // Get matching resources let matches = []; @@ -83,9 +92,9 @@ class SearchWorker extends TerminologyWorker { // Build and return the bundle const bundle = this.buildSearchBundle( - req, resourceType, matches, offset, count, elements + req, resourceType, matches, offset, count, elements, summary, total ); - req.logInfo = `${bundle.entry.length} matches`; + req.logInfo = `${bundle.entry ? bundle.entry.length : 0} matches`; return res.json(bundle); } catch (error) { @@ -266,8 +275,17 @@ class SearchWorker extends TerminologyWorker { /** * Build a FHIR search Bundle with pagination */ - buildSearchBundle(req, resourceType, allMatches, offset, count, elements) { - const total = allMatches.length; + buildSearchBundle(req, resourceType, allMatches, offset, count, elements, summary, totalParam) { + const totalCount = allMatches.length; + + // Handle _summary=count - only return total, no entries + if (summary === 'count') { + return { + resourceType: 'Bundle', + type: 'searchset', + total: totalCount + }; + } // Get the slice for this page const pageResults = allMatches.slice(offset, offset + count); @@ -317,7 +335,7 @@ class SearchWorker extends TerminologyWorker { } // Next link (if more results) - if (offset + count < total) { + if (offset + count < totalCount) { const nextParams = new URLSearchParams(searchParams); nextParams.set('_offset', offset + count); links.push({ @@ -327,7 +345,7 @@ class SearchWorker extends TerminologyWorker { } // Last link - const lastOffset = Math.max(0, Math.floor((total - 1) / count) * count); + const lastOffset = Math.max(0, Math.floor((totalCount - 1) / count) * count); const lastParams = new URLSearchParams(searchParams); lastParams.set('_offset', lastOffset); links.push({ @@ -335,12 +353,18 @@ class SearchWorker extends TerminologyWorker { url: `${baseUrl}?${lastParams.toString()}` }); + // Determine which elements to include based on _summary + let effectiveElements = elements; + if (summary === 'true' && !elements) { + effectiveElements = SearchWorker.SUMMARY_ELEMENTS[resourceType] || []; + } + // Build entries const entries = pageResults.map(resource => { - // Apply _elements filter if specified + // Apply _elements or _summary filter if specified let filteredResource = resource; - if (elements) { - filteredResource = this.filterElements(resource, elements); + if (effectiveElements) { + filteredResource = this.filterElements(resource, effectiveElements); } return { @@ -352,13 +376,21 @@ class SearchWorker extends TerminologyWorker { }; }); - return { + // Build the bundle + const bundle = { resourceType: 'Bundle', type: 'searchset', - total: total, + total: totalCount, link: links, entry: entries }; + + // Add total unless _total=none + if (totalParam !== 'none') { + bundle.total = totalCount; + } + + return bundle; } /** diff --git a/tx/xml/bundle-xml.js b/tx/xml/bundle-xml.js new file mode 100644 index 0000000..b92d9e0 --- /dev/null +++ b/tx/xml/bundle-xml.js @@ -0,0 +1,237 @@ +// +// Bundle XML Serialization +// + +const { FhirXmlBase } = require('./xml-base'); + +/** + * XML support for FHIR Bundle resources + */ +class BundleXML extends FhirXmlBase { + + /** + * Element order for Bundle (FHIR requires specific order) + */ + static _elementOrder = [ + 'id', 'meta', 'implicitRules', 'language', + 'identifier', 'type', 'timestamp', 'total', + 'link', 'entry', 'signature', 'issues' + ]; + + /** + * Element order for Bundle.entry + */ + static _entryElementOrder = [ + 'link', 'fullUrl', 'resource', 'search', 'request', 'response' + ]; + + /** + * Element order for Bundle.link + */ + static _linkElementOrder = [ + 'relation', 'url' + ]; + + /** + * Element order for Bundle.entry.search + */ + static _searchElementOrder = [ + 'mode', 'score' + ]; + + /** + * Convert Bundle JSON to XML string + * @param {Object} json - Bundle as JSON + * @param {number} fhirVersion - FHIR version (3, 4, or 5) + * @returns {string} XML string + */ + static toXml(json, fhirVersion) { + let content = ''; + const indent = ' '; + + // Render simple elements first + for (const key of this._elementOrder) { + if (json[key] === undefined) continue; + + if (key === 'link') { + // Handle link array + for (const link of json.link || []) { + content += `${indent}\n`; + content += this.renderElementsInOrder(link, 2, this._linkElementOrder); + content += `${indent}\n`; + } + } else if (key === 'entry') { + // Handle entry array with nested resources + for (const entry of json.entry || []) { + content += `${indent}\n`; + + // fullUrl + if (entry.fullUrl) { + content += `${indent}${indent}\n`; + } + + // resource - needs special handling to embed full resource + if (entry.resource) { + content += `${indent}${indent}\n`; + content += this.renderResource(entry.resource, 3, fhirVersion); + content += `${indent}${indent}\n`; + } + + // search + if (entry.search) { + content += `${indent}${indent}\n`; + if (entry.search.mode) { + content += `${indent}${indent}${indent}\n`; + } + if (entry.search.score !== undefined) { + content += `${indent}${indent}${indent}\n`; + } + content += `${indent}${indent}\n`; + } + + content += `${indent}\n`; + } + } else if (key === 'total') { + content += `${indent}\n`; + } else if (key === 'type') { + content += `${indent}\n`; + } else if (key === 'timestamp') { + content += `${indent}\n`; + } else if (key === 'id') { + content += `${indent}\n`; + } else if (key === 'meta') { + content += this.renderMeta(json.meta, 1); + } else { + // Generic element handling + content += this.renderElement(key, json[key], 1); + } + } + + return this.wrapInRootElement('Bundle', content); + } + + /** + * Render a nested resource as XML + */ + static renderResource(resource, indentLevel, _fhirVersion) { + void _fhirVersion; // reserved for future version-specific rendering + + const indent = ' '.repeat(indentLevel); + const resourceType = resource.resourceType; + + // For known resource types, delegate to their specific converters + // For unknown types, render generically + let innerContent = ''; + + // Get element order based on resource type + const elementOrder = this.getElementOrderForResource(resourceType); + + innerContent = this.renderElementsInOrder(resource, indentLevel + 1, elementOrder); + + return `${indent}<${resourceType} xmlns="http://hl7.org/fhir">\n${innerContent}${indent}\n`; + } + + /** + * Get element order for a resource type + */ + static getElementOrderForResource(resourceType) { + // Common elements that most resources have + const commonElements = [ + 'id', 'meta', 'implicitRules', 'language', 'text', 'contained', + 'extension', 'modifierExtension' + ]; + + switch (resourceType) { + case 'CodeSystem': + return [ + ...commonElements, + 'url', 'identifier', 'version', 'versionAlgorithmString', 'versionAlgorithmCoding', + 'name', 'title', 'status', 'experimental', 'date', 'publisher', + 'contact', 'description', 'useContext', 'jurisdiction', 'purpose', 'copyright', + 'copyrightLabel', 'approvalDate', 'lastReviewDate', + 'caseSensitive', 'valueSet', 'hierarchyMeaning', 'compositional', + 'versionNeeded', 'content', 'supplements', 'count', 'filter', 'property', 'concept' + ]; + case 'ValueSet': + return [ + ...commonElements, + 'url', 'identifier', 'version', 'versionAlgorithmString', 'versionAlgorithmCoding', + 'name', 'title', 'status', 'experimental', 'date', 'publisher', + 'contact', 'description', 'useContext', 'jurisdiction', 'purpose', 'copyright', + 'copyrightLabel', 'approvalDate', 'lastReviewDate', 'effectivePeriod', + 'immutable', 'compose', 'expansion' + ]; + case 'ConceptMap': + return [ + ...commonElements, + 'url', 'identifier', 'version', 'name', 'title', 'status', 'experimental', + 'date', 'publisher', 'contact', 'description', 'useContext', 'jurisdiction', + 'purpose', 'copyright', 'sourceUri', 'sourceCanonical', 'targetUri', 'targetCanonical', + 'group' + ]; + default: + // Return common elements plus all other keys from the resource + return commonElements; + } + } + + /** + * Render meta element + */ + static renderMeta(meta, indentLevel) { + if (!meta) return ''; + const indent = ' '.repeat(indentLevel); + let content = `${indent}\n`; + + if (meta.versionId) { + content += `${indent} \n`; + } + if (meta.lastUpdated) { + content += `${indent} \n`; + } + if (meta.source) { + content += `${indent} \n`; + } + for (const profile of meta.profile || []) { + content += `${indent} \n`; + } + for (const security of meta.security || []) { + content += this.renderCoding(security, indentLevel + 1, 'security'); + } + for (const tag of meta.tag || []) { + content += this.renderCoding(tag, indentLevel + 1, 'tag'); + } + + content += `${indent}\n`; + return content; + } + + /** + * Render a Coding element + */ + static renderCoding(coding, indentLevel, elementName) { + const indent = ' '.repeat(indentLevel); + let content = `${indent}<${elementName}>\n`; + + if (coding.system) { + content += `${indent} \n`; + } + if (coding.version) { + content += `${indent} \n`; + } + if (coding.code) { + content += `${indent} \n`; + } + if (coding.display) { + content += `${indent} \n`; + } + if (coding.userSelected !== undefined) { + content += `${indent} \n`; + } + + content += `${indent}\n`; + return content; + } +} + +module.exports = { BundleXML };