From 7f6f31bbcfba892952bd437351702346945ebe57 Mon Sep 17 00:00:00 2001 From: Jose Costa Teixeira <16153168+costateixeira@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:08:34 +0100 Subject: [PATCH 1/7] fix minor issues --- library/package-manager.js | 4 +++- ...d-test-expectations-json => snomed-test-expectations.json} | 0 2 files changed, 3 insertions(+), 1 deletion(-) rename tests/data/{snomed-test-expectations-json => snomed-test-expectations.json} (100%) 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 From d5df25ac3f69fa4269eae7367fc587537105c9a3 Mon Sep 17 00:00:00 2001 From: Jose Costa Teixeira <16153168+costateixeira@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:17:48 +0100 Subject: [PATCH 2/7] Add FHIR search parameters _summary, _total, _format and Bundle XML support Implement FHIR search parameters: - _summary: supports 'true' (summary elements only) and 'count' (total only) - _total: supports 'none' to omit total count from bundle - _format: query parameter for content negotiation (takes precedence over Accept header per FHIR spec) Add XML serialization for search result bundles to support _format=xml responses with proper FHIR element ordering. Files changed: - tx/workers/search.js: search parameter handling and bundle building - tx/tx.js: _format parameter in acceptsXml/acceptsJson - tx/xml/bundle-xml.js: new Bundle XML serializer Co-Authored-By Claude --- tx/tx.js | 14 +++ tx/workers/search.js | 56 ++++++++--- tx/xml/bundle-xml.js | 235 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+), 12 deletions(-) create mode 100644 tx/xml/bundle-xml.js diff --git a/tx/tx.js b/tx/tx.js index fcd2d6d..5e871c3 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,16 @@ class TXModule { } acceptsXml(req) { +<<<<<<< Updated upstream +======= + // 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 +>>>>>>> Stashed changes const accept = req.headers.accept || ''; return accept.includes('application/fhir+xml') || accept.includes('application/xml+fhir'); } @@ -273,6 +284,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 +884,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..9e8ad19 --- /dev/null +++ b/tx/xml/bundle-xml.js @@ -0,0 +1,235 @@ +// +// 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) { + 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 }; From 37f578d6b2a39c3f5492d48df6b27b5ac50a4a05 Mon Sep 17 00:00:00 2001 From: Jose Costa Teixeira <16153168+costateixeira@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:29:49 +0100 Subject: [PATCH 3/7] update dockerfile --- Dockerfile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 . . From a0702fa6857435c6868d70f65a99beb20f344b18 Mon Sep 17 00:00:00 2001 From: Jose Costa Teixeira <16153168+costateixeira@users.noreply.github.com> Date: Wed, 4 Feb 2026 23:14:24 +0100 Subject: [PATCH 4/7] support crosscompiling --- .dockerignore | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .dockerignore 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/ From f40d92b0575123b3bdcfa21e74afd8672b6e76d1 Mon Sep 17 00:00:00 2001 From: Jose Costa Teixeira <16153168+costateixeira@users.noreply.github.com> Date: Wed, 4 Feb 2026 23:15:31 +0100 Subject: [PATCH 5/7] oops --- tx/tx.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/tx/tx.js b/tx/tx.js index 5e871c3..f2753ae 100644 --- a/tx/tx.js +++ b/tx/tx.js @@ -70,8 +70,6 @@ class TXModule { } acceptsXml(req) { -<<<<<<< Updated upstream -======= // Check _format query parameter first (takes precedence per FHIR spec) const format = req.query._format || req.query.format || req.body?._format; if (format) { @@ -79,7 +77,6 @@ class TXModule { return f === 'xml' || f.includes('fhir+xml') || f.includes('xml+fhir'); } // Fall back to Accept header ->>>>>>> Stashed changes const accept = req.headers.accept || ''; return accept.includes('application/fhir+xml') || accept.includes('application/xml+fhir'); } From 2506a3a4e00a27654d258e4959a67125d8b018d7 Mon Sep 17 00:00:00 2001 From: Jose Costa Teixeira <16153168+costateixeira@users.noreply.github.com> Date: Thu, 5 Feb 2026 00:01:38 +0100 Subject: [PATCH 6/7] fix code quality issue --- tx/xml/bundle-xml.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tx/xml/bundle-xml.js b/tx/xml/bundle-xml.js index 9e8ad19..47a61ac 100644 --- a/tx/xml/bundle-xml.js +++ b/tx/xml/bundle-xml.js @@ -113,7 +113,7 @@ class BundleXML extends FhirXmlBase { /** * Render a nested resource as XML */ - static renderResource(resource, indentLevel, fhirVersion) { + static renderResource(resource, indentLevel, _fhirVersion) { const indent = ' '.repeat(indentLevel); const resourceType = resource.resourceType; From 4d895541893e86454fda1f3cbe22beb8bf231119 Mon Sep 17 00:00:00 2001 From: Jose Costa Teixeira Date: Thu, 5 Feb 2026 21:07:39 +0100 Subject: [PATCH 7/7] Update bundle-xml.js --- tx/xml/bundle-xml.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tx/xml/bundle-xml.js b/tx/xml/bundle-xml.js index 47a61ac..b92d9e0 100644 --- a/tx/xml/bundle-xml.js +++ b/tx/xml/bundle-xml.js @@ -114,6 +114,8 @@ class BundleXML extends FhirXmlBase { * 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;