Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0ce9817
Update release.yml
costateixeira Jan 27, 2026
88af0ef
Update release.yml
costateixeira Jan 27, 2026
011dc63
Update release.yml
costateixeira Jan 27, 2026
6e7336b
Update release.yml
costateixeira Jan 27, 2026
bd4d08a
Update release.yml
costateixeira Jan 27, 2026
5b8cf17
Merge branch 'HealthIntersections:main' into main
costateixeira Jan 27, 2026
ac0e967
add dockerignore to allow building on windows
costateixeira Jan 28, 2026
1b0a4be
heap chart starts at 0
costateixeira Jan 28, 2026
391e17c
Update dockerfile syntax (remove warning)
costateixeira Jan 28, 2026
e8e1848
allow build on windows
costateixeira Jan 28, 2026
33b41d3
Fix const stats declaration
costateixeira Jan 28, 2026
90ce777
Merge branch 'HealthIntersections:main' into main
costateixeira Jan 28, 2026
7c592d0
restore release workflow
costateixeira Jan 28, 2026
e356540
Merge branch 'main' of https://github.com/costateixeira/nodeserver
costateixeira Jan 28, 2026
cff8731
better support for repo names that have uppercase characters
costateixeira Jan 28, 2026
3abdf41
Fix npm package cache lookup by parsing version from details string
costateixeira Jan 29, 2026
530002e
Revert "heap chart starts at 0"
costateixeira Jan 30, 2026
4b95e1b
Merge branch 'main' into main
costateixeira Jan 30, 2026
7b14971
fix build
costateixeira Jan 30, 2026
c1bdc8d
add search parameters
costateixeira Jan 30, 2026
540fcab
add tests for new search parameters
costateixeira Jan 30, 2026
bee243c
Merge branch 'HealthIntersections:main' into main
costateixeira Feb 2, 2026
4fabddb
Merge branch 'HealthIntersections:main' into main
costateixeira Feb 4, 2026
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
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules
npm-debug.log
.git
.gitignore
logs
data
package-cache
*.md
.env
.env.*
15 changes: 11 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "VERSION_NO_V=${VERSION#v}" >> $GITHUB_OUTPUT


- name: Set image name (lowercase)
id: image
run: |
IMAGE="ghcr.io/${GITHUB_REPOSITORY}"
echo "IMAGE_LC=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

Expand All @@ -116,10 +123,10 @@ jobs:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.VERSION }}
ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.VERSION_NO_V }}
${{ steps.image.outputs.IMAGE_LC }}:latest
${{ steps.image.outputs.IMAGE_LC }}:${{ steps.get_version.outputs.VERSION }}
${{ steps.image.outputs.IMAGE_LC }}:${{ steps.get_version.outputs.VERSION_NO_V }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ steps.get_version.outputs.VERSION_NO_V }}
VERSION=${{ steps.get_version.outputs.VERSION_NO_V }}
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
FROM node:20-alpine

# Install build tools for native modules (sqlite3, bcrypt)
RUN apk add --no-cache python3 make g++

# Create app directory
WORKDIR /app

# Install app dependencies
COPY package*.json ./
RUN npm ci --only=production
RUN npm ci --omit=dev

# Bundle app source
COPY . .
Expand Down
2 changes: 1 addition & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ let stats = null;
// Initialize modules based on configuration
async function initializeModules() {
stats = new ServerStats();

// Initialize SHL module
if (config.modules?.shl?.enabled) {
try {
Expand Down
119 changes: 119 additions & 0 deletions tests/tx/search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,123 @@ describe('Search Worker', () => {
}
});
});
describe('_summary parameter', () => {
test('should return only summary elements with _summary=true', async () => {
const response = await request(app)
.get('/tx/r5/CodeSystem')
.query({ _summary: 'true', _count: 5 })
.set('Accept', 'application/json');

expect(response.status).toBe(200);
expect(response.body.resourceType).toBe('Bundle');

if (response.body.entry && response.body.entry.length > 0) {
const resource = response.body.entry[0].resource;
// Summary elements should be present
expect(resource.resourceType).toBe('CodeSystem');
expect(resource.id).toBeDefined();
// Non-summary elements should be absent
expect(resource.concept).toBeUndefined();
expect(resource.property).toBeUndefined();
}
});

test('should return only count with _summary=count', async () => {
const response = await request(app)
.get('/tx/r5/CodeSystem')
.query({ _summary: 'count' })
.set('Accept', 'application/json');

expect(response.status).toBe(200);
expect(response.body.resourceType).toBe('Bundle');
expect(response.body.type).toBe('searchset');
expect(response.body.total).toBeGreaterThan(0);
// No entries when _summary=count
expect(response.body.entry).toBeUndefined();
expect(response.body.link).toBeUndefined();
});

test('should return full resources with _summary=false', async () => {
const response = await request(app)
.get('/tx/r5/CodeSystem')
.query({ _summary: 'false', url: 'http://hl7.org/fhir/administrative-gender' })
.set('Accept', 'application/json');

expect(response.status).toBe(200);

if (response.body.entry && response.body.entry.length > 0) {
const resource = response.body.entry[0].resource;
expect(resource.resourceType).toBe('CodeSystem');
// Full resource should include concept
expect(resource.concept).toBeDefined();
}
});
});

describe('_total parameter', () => {
test('should include total with _total=accurate (default)', async () => {
const response = await request(app)
.get('/tx/r5/CodeSystem')
.query({ _count: 5 })
.set('Accept', 'application/json');

expect(response.status).toBe(200);
expect(response.body.total).toBeDefined();
expect(typeof response.body.total).toBe('number');
});

test('should not include total with _total=none', async () => {
const response = await request(app)
.get('/tx/r5/CodeSystem')
.query({ _total: 'none', _count: 5 })
.set('Accept', 'application/json');

expect(response.status).toBe(200);
expect(response.body.resourceType).toBe('Bundle');
expect(response.body.total).toBeUndefined();
});
});

describe('_format parameter', () => {
test('should return JSON with _format=json', async () => {
const response = await request(app)
.get('/tx/r5/CodeSystem')
.query({ _format: 'json', _count: 2 });

expect(response.status).toBe(200);
expect(response.headers['content-type']).toContain('application/fhir+json');
expect(response.body.resourceType).toBe('Bundle');
});

test('should return XML with _format=xml', async () => {
const response = await request(app)
.get('/tx/r5/CodeSystem')
.query({ _format: 'xml', _count: 2 });

expect(response.status).toBe(200);
expect(response.headers['content-type']).toContain('application/fhir+xml');
expect(response.text).toContain('<Bundle');
expect(response.text).toContain('xmlns="http://hl7.org/fhir"');
});

test('should return JSON with _format=application/fhir+json', async () => {
const response = await request(app)
.get('/tx/r5/CodeSystem')
.query({ _format: 'application/fhir+json', _count: 2 });

expect(response.status).toBe(200);
expect(response.headers['content-type']).toContain('application/fhir+json');
});

test('_format should override Accept header', async () => {
const response = await request(app)
.get('/tx/r5/CodeSystem')
.query({ _format: 'xml', _count: 2 })
.set('Accept', 'application/fhir+json');

expect(response.status).toBe(200);
// _format=xml should override Accept: application/fhir+json
expect(response.headers['content-type']).toContain('application/fhir+xml');
});
});
});
14 changes: 14 additions & 0 deletions tx/tx-html.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,20 @@ class TxHtmlRenderer {
* Check if request accepts HTML
*/
acceptsHtml(req) {
// Check _format query parameter first (takes precedence)
const format = req.query._format || req.query.format;
if (format) {
const f = format.toLowerCase();
// If _format specifies json or xml, don't return HTML
if (f === 'json' || f === 'xml' || f.includes('fhir+json') || f.includes('fhir+xml')) {
return false;
}
// Check if _format explicitly requests HTML
if (f === 'html' || f.includes('text/html')) {
return true;
}
}
// Fall back to Accept header
const accept = req.headers.accept || '';
return accept.includes('text/html');
}
Expand Down
18 changes: 18 additions & 0 deletions tx/tx.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,28 @@ class TXModule {
}

acceptsXml(req) {
// Check _format query parameter first (takes precedence per FHIR spec)
const format = req.query._format || req.query.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');
}

acceptsJson(req) {
// Check _format query parameter first
const format = req.query._format || req.query.format;
if (format) {
const f = format.toLowerCase();
return f === 'json' || f.includes('fhir+json') || f.includes('json+fhir');
}
// Default to JSON if no specific format requested
return true;
}


/**
* Initialize the TX module
Expand Down
61 changes: 52 additions & 9 deletions tx/workers/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,15 @@ class SearchWorker extends TerminologyWorker {

// Allowed search parameters
static ALLOWED_PARAMS = [
'_offset', '_count', '_elements', '_sort',
'_offset', '_count', '_elements', '_sort', '_summary', '_total',
'url', 'version', 'content-mode', 'date', 'description',
'supplements', 'identifier', 'jurisdiction', 'name',
'publisher', 'status', 'system', 'title', 'text'
];

// Summary elements per FHIR spec for terminology resources
static SUMMARY_ELEMENTS = ['resourceType', 'id', 'meta', 'url', 'version', 'name', 'title', 'status', 'date', 'publisher', 'description'];

// Sortable fields
static SORT_FIELDS = ['id', 'url', 'version', 'date', 'name', 'vurl'];

Expand All @@ -52,10 +55,26 @@ class SearchWorker extends TerminologyWorker {
this.log.debug(`Search ${resourceType} with params:`, params);

try {
// Parse pagination parameters
// Parse pagination and control parameters
const offset = Math.max(0, parseInt(params._offset) || 0);
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 summary = params._summary || 'false';
const totalMode = params._total || 'accurate'; // accurate, estimate, none

// Handle _summary parameter - it overrides _elements
let elements = null;
if (summary === 'true') {
elements = SearchWorker.SUMMARY_ELEMENTS;
} else if (summary === 'data') {
// _summary=data means exclude text/narrative - for terminology resources, same as no filter
elements = null;
} else if (summary === 'text') {
// _summary=text means only text element - not very useful for terminology
elements = ['resourceType', 'id', 'meta', 'text'];
} else if (params._elements) {
elements = decodeURIComponent(params._elements).split(',').map(e => e.trim());
}

const count = summary === 'count' ? 0 : Math.min(elements ? 2000 : 200, Math.max(1, parseInt(params._count) || 20));
const sort = params._sort || "id";

// Get matching resources
Expand Down Expand Up @@ -83,9 +102,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, totalMode
);
req.logInfo = `${bundle.entry.length} matches`;
req.logInfo = summary === 'count' ? `count: ${bundle.total}` : `${bundle.entry.length} matches`;
return res.json(bundle);

} catch (error) {
Expand Down Expand Up @@ -265,10 +284,27 @@ class SearchWorker extends TerminologyWorker {

/**
* Build a FHIR search Bundle with pagination
* @param {Object} req - Express request
* @param {string} resourceType - Resource type
* @param {Array} allMatches - All matching resources
* @param {number} offset - Pagination offset
* @param {number} count - Page size
* @param {Array} elements - Elements to include (or null for all)
* @param {string} summary - _summary parameter value
* @param {string} totalMode - _total parameter value (accurate, estimate, none)
*/
buildSearchBundle(req, resourceType, allMatches, offset, count, elements) {
buildSearchBundle(req, resourceType, allMatches, offset, count, elements, summary = 'false', totalMode = 'accurate') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to add test cases for these changes in search.test.js

const total = allMatches.length;

// Handle _summary=count - return only count, no entries
if (summary === 'count') {
return {
resourceType: 'Bundle',
type: 'searchset',
total: total
};
}

// Get the slice for this page
const pageResults = allMatches.slice(offset, offset + count);

Expand Down Expand Up @@ -352,13 +388,20 @@ class SearchWorker extends TerminologyWorker {
};
});

return {
// Build result bundle
const bundle = {
resourceType: 'Bundle',
type: 'searchset',
total: total,
link: links,
entry: entries
};

// Include total unless _total=none
if (totalMode !== 'none') {
bundle.total = total;
}

return bundle;
}

/**
Expand Down
Loading