Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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/
7 changes: 5 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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 . .
Expand Down
4 changes: 3 additions & 1 deletion library/package-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
11 changes: 11 additions & 0 deletions tx/tx.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
Expand Down
56 changes: 44 additions & 12 deletions tx/workers/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand All @@ -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 = [];
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand All @@ -327,20 +345,26 @@ 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({
relation: 'last',
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 {
Expand All @@ -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;
}

/**
Expand Down
Loading