diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..39a3205 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +npm-debug.log +.git +.gitignore +logs +data +package-cache +*.md +.env +.env.* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fbc643..4286201 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b5e93f2..1d8895d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 . . diff --git a/server.js b/server.js index 0a87604..9705cfd 100644 --- a/server.js +++ b/server.js @@ -66,7 +66,7 @@ let stats = null; // Initialize modules based on configuration async function initializeModules() { stats = new ServerStats(); - + // Initialize SHL module if (config.modules.shl.enabled) { try { diff --git a/tests/tx/search.test.js b/tests/tx/search.test.js index 82e1be2..0a7af62 100644 --- a/tests/tx/search.test.js +++ b/tests/tx/search.test.js @@ -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(' { + 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'); + }); + }); }); diff --git a/tx/library.js b/tx/library.js index 5fcdf3c..c71eb41 100644 --- a/tx/library.js +++ b/tx/library.js @@ -391,7 +391,15 @@ class Library { } async loadNpm(packageManager, details, isDefault, mode) { - const packagePath = await packageManager.fetch(details, null); + // Parse packageId and version from details (e.g., "hl7.terminology.r4#6.0.2") + let packageId = details; + let version = null; + if (details.includes('#')) { + const parts = details.split('#'); + packageId = parts[0]; + version = parts[1]; + } + const packagePath = await packageManager.fetch(packageId, version); if (mode === "fetch" || mode === "cs") { return; } diff --git a/tx/tx-html.js b/tx/tx-html.js index cde4074..ef5453a 100644 --- a/tx/tx-html.js +++ b/tx/tx-html.js @@ -103,6 +103,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'); } diff --git a/tx/tx.js b/tx/tx.js index abbd8a4..ded17e3 100644 --- a/tx/tx.js +++ b/tx/tx.js @@ -66,10 +66,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 diff --git a/tx/workers/search.js b/tx/workers/search.js index 9f661d2..4855051 100644 --- a/tx/workers/search.js +++ b/tx/workers/search.js @@ -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']; @@ -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 @@ -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) { @@ -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') { 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); @@ -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; } /**