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}${resourceType}>\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}${elementName}>\n`;
+ return content;
+ }
+}
+
+module.exports = { BundleXML };