From eb5d952edff67734c5950f78bb402211affd2564 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Sat, 13 Jun 2026 10:17:10 -0400 Subject: [PATCH] rebuild how caching works - see tools IG for documentation --- server.js | 5 + stats.js | 44 ++-- tests/tx/cache-control.test.js | 207 +++++++++++++++++++ tests/tx/cache-id-params.test.js | 103 ++++++++++ tests/tx/cache-id.test.js | 301 ++++++++++++++++++++++++++++ tests/tx/concept-count.test.js | 109 ++++++++++ tests/tx/expand.test.js | 63 +++++- tests/tx/renderer-resources.test.js | 47 +++++ tests/tx/resource-cache.test.js | 242 ++++++++++++++++++++++ tests/tx/tx-module.test.js | 27 +++ translations/Messages.properties | 1 + tx/html/home-metrics.liquid | 20 +- tx/library/codesystem.js | 31 +++ tx/library/conceptmap.js | 24 +++ tx/library/valueset.js | 46 +++++ tx/operation-context.js | 106 +++++++++- tx/tx-html.js | 27 +++ tx/tx.js | 61 +++++- tx/workers/cache-control.js | 186 +++++++++++++++++ tx/workers/metadata.js | 13 +- tx/workers/worker.js | 167 +++++++++++---- 21 files changed, 1747 insertions(+), 83 deletions(-) create mode 100644 tests/tx/cache-control.test.js create mode 100644 tests/tx/cache-id-params.test.js create mode 100644 tests/tx/cache-id.test.js create mode 100644 tests/tx/concept-count.test.js create mode 100644 tests/tx/resource-cache.test.js create mode 100644 tx/workers/cache-control.js diff --git a/server.js b/server.js index c38ef244..52626e38 100644 --- a/server.js +++ b/server.js @@ -430,6 +430,11 @@ async function buildRootPageContent() { content += `Process Memory: ${rssMB} MB of ${memLimitMB} MB (${processPCT.toFixed(0)}%)`; content += `System Memory: ${usedMemMB} MB of ${totalMemMB} MB (${sysMemPCT.toFixed(0)}%)`; content += ''; + content += ''; + content += `Expansion Cache: ${stats.expansionItems()} items`; + content += `Client Cache: ${stats.clientCaches()} caches / ${stats.clientConcepts()} concepts`; + content += `Max Client Cache: ${stats.maxClientCaches()} caches / ${stats.maxClientConcepts()} concepts`; + content += ''; content += getLogStats(); content += ''; diff --git a/stats.js b/stats.js index fdeedeb7..f450b295 100644 --- a/stats.js +++ b/stats.js @@ -36,24 +36,26 @@ class ServerStats { : (now - this.startTime) / 60000; const requestsPerMin = minutesSinceStart > 0 ? requestsDelta / minutesSinceStart : 0; - const currentCpu = this.readSystemCpu(); - const idleDelta = currentCpu.idle - this.lastUsage.idle; - const totalDelta = currentCpu.total - this.lastUsage.total; - const percent = totalDelta > 0 ? 100 * (1 - idleDelta / totalDelta) : 0; - const loopDelay = this.eventLoopMonitor.mean / 1e6; - let cacheCount = 0; + // Two distinct caches: the expansion cache (count entries) and the client + // (resource) cache (count concepts held - a sense of how much it's carrying). + let expansionItems = 0; + let clientConcepts = 0; for (let m of this.cachingModules) { - cacheCount = cacheCount + m.cacheCount(); + if (typeof m.expansionItemCount === 'function') { + expansionItems = expansionItems + m.expansionItemCount(); + } + if (typeof m.clientConceptCount === 'function') { + clientConcepts = clientConcepts + m.clientConceptCount(); + } } - this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, cpu: percent, block: loopDelay, cache : cacheCount}); + this.history.push({time: now, mem: currentMem - this.startMem, rpm: requestsPerMin, tat: requestsTat, block: loopDelay, expansion: expansionItems, clientConcepts: clientConcepts}); this.eventLoopMonitor.reset(); this.requestCountSnapshot = combinedCount; this.requestTime = 0; this.lastTime = now; - this.lastUsage = currentCpu; // Prune old data (keep 24 hours) const cutoff = now - (24 * 60 * 60 * 1000); // 24 hours ago @@ -65,7 +67,6 @@ class ServerStats { this.started = true; this.startMem = process.memoryUsage().heapUsed; this.startTime = Date.now(); - this.lastUsage = this.readSystemCpu(); this.lastTime = this.startTime; this.eventLoopMonitor = monitorEventLoopDelay({ resolution: 20 }); this.eventLoopMonitor.enable(); @@ -141,17 +142,24 @@ class ServerStats { clearInterval(this.timer); } - readSystemCpu() { - const os = require('os'); - const cpus = os.cpus(); - let idle = 0, total = 0; - for (const cpu of cpus) { - idle += cpu.times.idle; - total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq; + // Live cache aggregates across all registered caching modules (defensive: a + // module that doesn't expose a given metric contributes 0). + _sumCachingModules(method) { + let total = 0; + for (let m of this.cachingModules) { + if (typeof m[method] === 'function') { + total = total + m[method](); + } } - return { idle, total }; + return total; } + expansionItems() { return this._sumCachingModules('expansionItemCount'); } + clientCaches() { return this._sumCachingModules('clientCacheCount'); } + clientConcepts() { return this._sumCachingModules('clientConceptCount'); } + maxClientCaches() { return this._sumCachingModules('maxClientCacheCount'); } + maxClientConcepts() { return this._sumCachingModules('maxClientConceptCount'); } + getTaskColor(status) { switch (status) { case "started": return "LightGrey"; diff --git a/tests/tx/cache-control.test.js b/tests/tx/cache-control.test.js new file mode 100644 index 00000000..ab82324d --- /dev/null +++ b/tests/tx/cache-control.test.js @@ -0,0 +1,207 @@ +/** + * $cache-control routing smoke tests + * + * At this stage the operation is scaffolding only: start/end parse the request but + * do nothing yet. These tests just confirm the route is wired up and dispatches on + * the `mode` query parameter. Behavioural tests come with the implementation. + */ + +const request = require('supertest'); +const { getTestApp, shutdownTestApp } = require('./setup'); + +const BASE = '/tx/r5/$cache-control'; + +describe('$cache-control routing (scaffolding)', () => { + let app; + + beforeAll(async () => { + app = await getTestApp(); + }, 60000); + + afterAll(async () => { + await shutdownTestApp(); + }); + + // An empty Parameters body stands in for "no front-loaded resources". A real + // client always sets a Content-Type on a POST; the endpoint's media-type gate + // rejects a truly bodyless POST with 415 (see note - bodyless POST is a separate + // decision). + const emptyBody = { resourceType: 'Parameters', parameter: [] }; + + test('POST mode=start resolves and returns a Parameters', async () => { + const res = await request(app) + .post(BASE) + .query({ mode: 'start' }) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .send(emptyBody); + expect(res.status).toBe(200); + expect(res.body.resourceType).toBe('Parameters'); + }); + + test('GET mode=start resolves (browsable, like the other operations)', async () => { + const res = await request(app) + .get(BASE) + .query({ mode: 'start' }) + .set('Accept', 'application/json'); + expect(res.status).toBe(200); + expect(res.body.resourceType).toBe('Parameters'); + }); + + test('POST mode=end resolves and returns a Parameters', async () => { + const res = await request(app) + .post(BASE) + .query({ mode: 'end' }) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('x-cache-id', 'some-cache-id') + .send(emptyBody); + expect(res.status).toBe(200); + expect(res.body.resourceType).toBe('Parameters'); + }); + + test('missing mode is a 400 OperationOutcome', async () => { + const res = await request(app) + .post(BASE) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .send(emptyBody); + expect(res.status).toBe(400); + expect(res.body.resourceType).toBe('OperationOutcome'); + }); + + test('unknown mode is a 400 OperationOutcome', async () => { + const res = await request(app) + .post(BASE) + .query({ mode: 'bogus' }) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .send(emptyBody); + expect(res.status).toBe(400); + expect(res.body.resourceType).toBe('OperationOutcome'); + }); + + test('mode supplied as a Parameters body parameter is accepted as a fallback', async () => { + const res = await request(app) + .post(BASE) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .send({ resourceType: 'Parameters', parameter: [{ name: 'mode', valueCode: 'start' }] }); + expect(res.status).toBe(200); + expect(res.body.resourceType).toBe('Parameters'); + }); + + // ---- behaviour ---- + + function cacheIdFrom(body) { + const p = (body.parameter || []).find(x => x.name === 'cache-id'); + return p ? p.valueId : undefined; + } + + test('start returns a server-issued cache-id', async () => { + const res = await request(app) + .post(BASE) + .query({ mode: 'start' }) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .send(emptyBody); + expect(res.status).toBe(200); + const id = cacheIdFrom(res.body); + expect(typeof id).toBe('string'); + expect(id).toMatch(/^[0-9a-f-]{36}$/i); // UUID shape + }); + + test('each start mints a distinct cache-id', async () => { + const a = await request(app).post(BASE).query({ mode: 'start' }) + .set('Content-Type', 'application/json').send(emptyBody); + const b = await request(app).post(BASE).query({ mode: 'start' }) + .set('Content-Type', 'application/json').send(emptyBody); + expect(cacheIdFrom(a.body)).not.toBe(cacheIdFrom(b.body)); + }); + + test('start front-loads supplied resources into the cache, retrievable by url', async () => { + const colorsCS = { + resourceType: 'CodeSystem', + url: 'http://example.org/cc-test/colors', + version: '1.0.0', + status: 'active', + content: 'complete', + concept: [{ code: 'red', display: 'Red' }] + }; + const colorsVS = { + resourceType: 'ValueSet', + url: 'http://example.org/cc-test/colors-vs', + version: '1.0.0', + status: 'active', + compose: { include: [{ system: colorsCS.url }] } + }; + + // Front-load CS + VS via start. + const started = await request(app) + .post(BASE) + .query({ mode: 'start' }) + .set('Content-Type', 'application/json') + .send({ + resourceType: 'Parameters', + parameter: [ + { name: 'tx-resource', resource: colorsCS }, + { name: 'valueSet', resource: colorsVS } + ] + }); + const cacheId = cacheIdFrom(started.body); + expect(cacheId).toBeDefined(); + + // The front-loaded ValueSet should now resolve by url under that cache-id. + // (validate still reads cache-id from the parameter in this step; the header + // read path is a later change.) + const validated = await request(app) + .post('/tx/r5/ValueSet/$validate-code') + .set('Content-Type', 'application/json') + .send({ + resourceType: 'Parameters', + parameter: [ + { name: 'cache-id', valueId: cacheId }, + { name: 'url', valueString: colorsVS.url }, + { name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } } + ] + }); + expect(validated.status).toBe(200); + const result = (validated.body.parameter || []).find(x => x.name === 'result'); + expect(result && result.valueBoolean).toBe(true); + }); + + test('end with the cache-id header releases the cache', async () => { + const started = await request(app).post(BASE).query({ mode: 'start' }) + .set('Content-Type', 'application/json').send(emptyBody); + const cacheId = cacheIdFrom(started.body); + + const ended = await request(app) + .post(BASE) + .query({ mode: 'end' }) + .set('Content-Type', 'application/json') + .set('x-cache-id', cacheId) + .send(emptyBody); + expect(ended.status).toBe(200); + expect(ended.body.resourceType).toBe('Parameters'); + }); + + test('end without the cache-id header is a 400', async () => { + const res = await request(app) + .post(BASE) + .query({ mode: 'end' }) + .set('Content-Type', 'application/json') + .send(emptyBody); + expect(res.status).toBe(400); + expect(res.body.resourceType).toBe('OperationOutcome'); + }); + + test('end of an unknown cache-id is a tolerant no-op (200)', async () => { + const res = await request(app) + .post(BASE) + .query({ mode: 'end' }) + .set('Content-Type', 'application/json') + .set('x-cache-id', 'never-issued-this-id') + .send(emptyBody); + expect(res.status).toBe(200); + }); +}); diff --git a/tests/tx/cache-id-params.test.js b/tests/tx/cache-id-params.test.js new file mode 100644 index 00000000..77d53acb --- /dev/null +++ b/tests/tx/cache-id-params.test.js @@ -0,0 +1,103 @@ +/** + * Cache-id parameter extraction tests + * + * Regression guard for the bug where the cache-id protocol was silently disabled + * because getParameterValue() did not understand the `valueId` element. fhir-core + * sends cache-id as an IdType, i.e. {"name":"cache-id","valueId":"..."}, so if + * getParameterValue() omits valueId it returns null, cacheId becomes null, and the + * server never caches or resolves anything by reference -- every by-reference + * validation then fails with "value set ... could not be found". + * + * These are pure unit tests against the parameter helpers on TerminologyWorker; + * they do not need a running server. + */ + +const { TerminologyWorker } = require('../../tx/workers/worker'); +const { OperationContext } = require('../../tx/operation-context'); +const { TestUtilities } = require('../test-utilities'); + +class MockProvider { + loadSupplements() { return []; } +} + +class TestWorker extends TerminologyWorker { + opName() { return 'test'; } + vsHandle() { return null; } +} + +describe('cache-id parameter extraction', () => { + let worker; + + beforeEach(async () => { + const opContext = new OperationContext('en-US', await TestUtilities.loadTranslations(), 'test-123'); + worker = new TestWorker(opContext, { info: () => {} }, new MockProvider(), {}, { }); + }); + + describe('getParameterValue - primitive value[x] types', () => { + // The one that actually broke production: cache-id arrives as valueId. + test('reads valueId (the real cache-id wire format from fhir-core)', () => { + const param = { name: 'cache-id', valueId: '25180033-f4f5-41e8-8880-6f5252da0dd2' }; + expect(worker.getParameterValue(param)).toBe('25180033-f4f5-41e8-8880-6f5252da0dd2'); + }); + + test.each([ + ['valueString', 'hello'], + ['valueCode', 'active'], + ['valueId', 'abc-123'], + ['valueUri', 'http://example.org/x'], + ['valueCanonical', 'http://example.org/x|1.0.0'], + ['valueUrl', 'http://example.org/x'], + ['valueBoolean', true], + ['valueInteger', 42], + ['valueDecimal', 3.14], + ['valueDateTime', '2026-01-01T00:00:00Z'], + ])('reads %s', (type, value) => { + expect(worker.getParameterValue({ name: 'p', [type]: value })).toBe(value); + }); + + test('reads valueBoolean=false (must not be treated as missing)', () => { + expect(worker.getParameterValue({ name: 'p', valueBoolean: false })).toBe(false); + }); + + test('reads resource over value[x] when both present', () => { + const res = { resourceType: 'ValueSet', url: 'http://x' }; + expect(worker.getParameterValue({ name: 'p', resource: res })).toBe(res); + }); + + test('returns null for a parameter with no value', () => { + expect(worker.getParameterValue({ name: 'p' })).toBeNull(); + }); + + test('returns null for null/undefined param', () => { + expect(worker.getParameterValue(null)).toBeNull(); + expect(worker.getParameterValue(undefined)).toBeNull(); + }); + }); + + describe('findParameter + getParameterValue together (the cache-id read path)', () => { + function params(...parameter) { + return { resourceType: 'Parameters', parameter }; + } + + test('cache-id as valueId is found and read (the regression)', () => { + const p = params( + { name: 'code', valueCode: 'application/pdf' }, + { name: 'cache-id', valueId: 'cid-1' } + ); + const found = worker.findParameter(p, 'cache-id'); + expect(found).not.toBeNull(); + expect(worker.getParameterValue(found)).toBe('cid-1'); + }); + + test('cache-id as valueString is also found and read', () => { + const p = params({ name: 'cache-id', valueString: 'cid-2' }); + expect(worker.getParameterValue(worker.findParameter(p, 'cache-id'))).toBe('cid-2'); + }); + + test('missing cache-id yields null (no false cache-id)', () => { + const p = params({ name: 'code', valueCode: 'x' }); + const found = worker.findParameter(p, 'cache-id'); + expect(found).toBeNull(); + }); + }); +}); diff --git a/tests/tx/cache-id.test.js b/tests/tx/cache-id.test.js new file mode 100644 index 00000000..a7ca4f0b --- /dev/null +++ b/tests/tx/cache-id.test.js @@ -0,0 +1,301 @@ +/** + * cache-id protocol integration tests (HTTP, end-to-end) + * + * The cache-id protocol lets a client send a CodeSystem/ValueSet once under a + * `cache-id`, then refer to it by url on later requests with the same cache-id. + * This is in routine use by every fhir-core consumer, so it must be exercised hard. + * + * Background on the two bugs these tests guard: + * 1. fhir-core sends cache-id as an IdType -> {"name":"cache-id","valueId":"..."}. + * If the server can't read valueId, cacheId is null and the whole protocol + * silently no-ops, so every by-reference call fails "could not be found". + * 2. The server must cache the primary `valueSet` parameter (not only + * `tx-resource`), or the by-reference follow-up can't resolve the main VS. + * + * These run over real HTTP against the test app (R5 core auto-loaded). + */ + +const request = require('supertest'); +const { getTestApp, shutdownTestApp } = require('./setup'); + +const VALIDATE = '/tx/r5/ValueSet/$validate-code'; +const EXPAND = '/tx/r5/ValueSet/$expand'; + +// Self-contained fixtures so we don't depend on what's in the core package. +const colorsCS = { + resourceType: 'CodeSystem', + url: 'http://example.org/cache-id-test/colors', + version: '1.0.0', + status: 'active', + content: 'complete', + concept: [ + { code: 'red', display: 'Red' }, + { code: 'green', display: 'Green' }, + { code: 'blue', display: 'Blue' } + ] +}; + +const colorsVS = { + resourceType: 'ValueSet', + url: 'http://example.org/cache-id-test/colors-vs', + version: '1.0.0', + status: 'active', + compose: { include: [{ system: colorsCS.url }] } +}; + +// Send cache-id either as valueId (fhir-core's real format) or valueString. +function cacheIdParam(value, as) { + return as === 'valueString' + ? { name: 'cache-id', valueString: value } + : { name: 'cache-id', valueId: value }; +} + +function post(app, url, parameter) { + return request(app) + .post(url) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .send({ resourceType: 'Parameters', parameter }); +} + +// Create a real cache via $cache-control and return its server-issued id. Caches +// must now exist before they can be referenced; this is how a client gets one. +async function startCache(app, parameter = []) { + const res = await request(app) + .post('/tx/r5/$cache-control') + .query({ mode: 'start' }) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .send({ resourceType: 'Parameters', parameter }); + const p = (res.body.parameter || []).find(x => x.name === 'cache-id'); + return p ? p.valueId : undefined; +} + +// Pull the boolean `result` out of a $validate-code Parameters response. +function resultOf(body) { + if (!body || body.resourceType !== 'Parameters') return undefined; + const p = (body.parameter || []).find(x => x.name === 'result'); + return p ? p.valueBoolean : undefined; +} + +// True if the response is the coded "unknown cache" error. +function isUnknownCacheError(res) { + if (res.body.resourceType !== 'OperationOutcome') return false; + return (res.body.issue || []).some(i => + (i.details && (i.details.coding || []).some(c => c.code === 'cache-id-unknown'))); +} + +describe('cache-id protocol (integration)', () => { + let app; + + beforeAll(async () => { + app = await getTestApp(); + }, 60000); + + afterAll(async () => { + await shutdownTestApp(); + }); + + // The headline regression: cache-id as valueId, register inline then validate by url. + describe.each([ + ['valueId (fhir-core wire format)', 'valueId'], + ['valueString', 'valueString'], + ])('$validate-code round trip with cache-id as %s', (_label, as) => { + test('register CS+VS under cache-id, then validate a valid code by url', async () => { + const cid = await startCache(app); + + // 1st call: send CS (tx-resource) and the primary VS (inline) under cache-id. + const r1 = await post(app, VALIDATE, [ + cacheIdParam(cid, as), + { name: 'tx-resource', resource: colorsCS }, + { name: 'valueSet', resource: colorsVS }, + { name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } } + ]); + expect(r1.status).toBe(200); + expect(resultOf(r1.body)).toBe(true); + + // 2nd call: refer to the VS by url only, same cache-id, nothing re-sent. + const r2 = await post(app, VALIDATE, [ + cacheIdParam(cid, as), + { name: 'url', valueString: colorsVS.url }, + { name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } } + ]); + expect(r2.status).toBe(200); + expect(resultOf(r2.body)).toBe(true); + }); + + test('by-reference validation of an invalid code returns result=false (really validating)', async () => { + const cid = await startCache(app); + await post(app, VALIDATE, [ + cacheIdParam(cid, as), + { name: 'tx-resource', resource: colorsCS }, + { name: 'valueSet', resource: colorsVS }, + { name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } } + ]); + + const r2 = await post(app, VALIDATE, [ + cacheIdParam(cid, as), + { name: 'url', valueString: colorsVS.url }, + { name: 'coding', valueCoding: { system: colorsCS.url, code: 'magenta' } } + ]); + expect(r2.status).toBe(200); + expect(resultOf(r2.body)).toBe(false); + }); + }); + + describe('unknown cache-id is a specific, coded, server-authoritative error', () => { + test('a started cache resolves by url; a never-issued cache-id is the coded unknown-cache error', async () => { + const cid = await startCache(app); + // Register under the started cache. + const reg = await post(app, VALIDATE, [ + cacheIdParam(cid, 'valueId'), + { name: 'tx-resource', resource: colorsCS }, + { name: 'valueSet', resource: colorsVS }, + { name: 'coding', valueCoding: { system: colorsCS.url, code: 'green' } } + ]); + expect(resultOf(reg.body)).toBe(true); + + // Same cache-id, by url -> resolves from cache. + const same = await post(app, VALIDATE, [ + cacheIdParam(cid, 'valueId'), + { name: 'url', valueString: colorsVS.url }, + { name: 'coding', valueCoding: { system: colorsCS.url, code: 'green' } } + ]); + expect(resultOf(same.body)).toBe(true); + + // A cache-id the server never issued -> coded unknown-cache error, NOT a + // silent success and NOT an obscure "value set not found". + const other = await post(app, VALIDATE, [ + { name: 'cache-id', valueId: 'never-issued-this-cache' }, + { name: 'url', valueString: colorsVS.url }, + { name: 'coding', valueCoding: { system: colorsCS.url, code: 'green' } } + ]); + expect(other.status).toBe(404); + expect(isUnknownCacheError(other)).toBe(true); + }); + + test('unknown cache-id supplied via the x-cache-id header is also the coded error', async () => { + const res = await request(app) + .post(VALIDATE) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('x-cache-id', 'never-issued-this-cache') + .send({ + resourceType: 'Parameters', + parameter: [ + { name: 'url', valueString: colorsVS.url }, + { name: 'coding', valueCoding: { system: colorsCS.url, code: 'green' } } + ] + }); + expect(res.status).toBe(404); + expect(isUnknownCacheError(res)).toBe(true); + }); + + test('$expand with an unknown cache-id is the coded error too', async () => { + const res = await post(app, EXPAND, [ + { name: 'cache-id', valueId: 'never-issued-this-cache' }, + { name: 'url', valueString: colorsVS.url } + ]); + expect(res.status).toBe(404); + expect(isUnknownCacheError(res)).toBe(true); + }); + + test('by url with no cache-id and a VS not in the library fails (not a silent pass)', async () => { + const r = await post(app, VALIDATE, [ + { name: 'url', valueString: colorsVS.url }, + { name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } } + ]); + expect(resultOf(r.body)).not.toBe(true); + }); + }); + + describe('cache-id carried as the x-cache-id header (going-forward transport)', () => { + test('full round trip with the cache-id only ever sent as a header', async () => { + const cid = await startCache(app); + + const reg = await request(app) + .post(VALIDATE) + .set('Content-Type', 'application/json') + .set('x-cache-id', cid) + .send({ + resourceType: 'Parameters', + parameter: [ + { name: 'tx-resource', resource: colorsCS }, + { name: 'valueSet', resource: colorsVS }, + { name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } } + ] + }); + expect(reg.status).toBe(200); + expect(resultOf(reg.body)).toBe(true); + + const byUrl = await request(app) + .post(VALIDATE) + .set('Content-Type', 'application/json') + .set('x-cache-id', cid) + .send({ + resourceType: 'Parameters', + parameter: [ + { name: 'url', valueString: colorsVS.url }, + { name: 'coding', valueCoding: { system: colorsCS.url, code: 'red' } } + ] + }); + expect(byUrl.status).toBe(200); + expect(resultOf(byUrl.body)).toBe(true); + }); + }); + + describe('cached tx-resource CodeSystem is reused by a later inline ValueSet', () => { + test('CS sent once under cache-id; a second VS using it validates without re-sending the CS', async () => { + const cid = await startCache(app); + + // 1st call registers the CS (and a first VS) under the cache-id. + const r1 = await post(app, VALIDATE, [ + cacheIdParam(cid, 'valueId'), + { name: 'tx-resource', resource: colorsCS }, + { name: 'valueSet', resource: colorsVS }, + { name: 'coding', valueCoding: { system: colorsCS.url, code: 'blue' } } + ]); + expect(resultOf(r1.body)).toBe(true); + + // 2nd call: a DIFFERENT inline VS over the same CS, CS not re-sent. + const secondVS = { + resourceType: 'ValueSet', + url: 'http://example.org/cache-id-test/colors-vs-2', + version: '1.0.0', + status: 'active', + compose: { include: [{ system: colorsCS.url, concept: [{ code: 'blue' }] }] } + }; + const r2 = await post(app, VALIDATE, [ + cacheIdParam(cid, 'valueId'), + { name: 'valueSet', resource: secondVS }, + { name: 'coding', valueCoding: { system: colorsCS.url, code: 'blue' } } + ]); + expect(r2.status).toBe(200); + expect(resultOf(r2.body)).toBe(true); + }); + }); + + describe('$expand cache-id round trip', () => { + test('register VS+CS via cache-id (valueId), then expand by url', async () => { + const cid = await startCache(app); + + const r1 = await post(app, EXPAND, [ + cacheIdParam(cid, 'valueId'), + { name: 'tx-resource', resource: colorsCS }, + { name: 'valueSet', resource: colorsVS } + ]); + expect(r1.status).toBe(200); + expect(r1.body.resourceType).toBe('ValueSet'); + + const r2 = await post(app, EXPAND, [ + cacheIdParam(cid, 'valueId'), + { name: 'url', valueString: colorsVS.url } + ]); + expect(r2.status).toBe(200); + expect(r2.body.resourceType).toBe('ValueSet'); + expect(r2.body.expansion).toBeDefined(); + const codes = (r2.body.expansion.contains || []).map(c => c.code).sort(); + expect(codes).toEqual(['blue', 'green', 'red']); + }); + }); +}); diff --git a/tests/tx/concept-count.test.js b/tests/tx/concept-count.test.js new file mode 100644 index 00000000..45f20cd8 --- /dev/null +++ b/tests/tx/concept-count.test.js @@ -0,0 +1,109 @@ +/** + * conceptCount() tests for CodeSystem, ValueSet, ConceptMap. + * + * conceptCount() is precalculated at construction and gives a cheap O(1) sense of + * how large a cached resource is (used by cache sizing/limits). It counts the + * concepts *present in the resource*, not the size of a value set's full expansion. + */ + +const { CodeSystem } = require('../../tx/library/codesystem'); +const ValueSet = require('../../tx/library/valueset'); +const { ConceptMap } = require('../../tx/library/conceptmap'); + +describe('CodeSystem.conceptCount()', () => { + test('counts a flat concept list', () => { + const cs = new CodeSystem({ + resourceType: 'CodeSystem', url: 'http://x/cs', version: '1', status: 'active', content: 'complete', + concept: [{ code: 'a' }, { code: 'b' }, { code: 'c' }] + }); + expect(cs.conceptCount()).toBe(3); + }); + + test('counts nested concepts recursively', () => { + const cs = new CodeSystem({ + resourceType: 'CodeSystem', url: 'http://x/cs', version: '1', status: 'active', content: 'complete', + concept: [ + { code: 'a', concept: [{ code: 'a1' }, { code: 'a2', concept: [{ code: 'a2i' }] }] }, + { code: 'b' } + ] + }); + // a, a1, a2, a2i, b = 5 + expect(cs.conceptCount()).toBe(5); + }); + + test('is 0 when there are no concepts (e.g. not-present / grammar systems)', () => { + const cs = new CodeSystem({ + resourceType: 'CodeSystem', url: 'http://x/cs', version: '1', status: 'active', content: 'not-present' + }); + expect(cs.conceptCount()).toBe(0); + }); +}); + +describe('ValueSet.conceptCount()', () => { + test('counts enumerated concepts in compose include and exclude', () => { + const vs = new ValueSet({ + resourceType: 'ValueSet', url: 'http://x/vs', status: 'active', + compose: { + include: [{ system: 'http://x/cs', concept: [{ code: 'a' }, { code: 'b' }, { code: 'c' }] }], + exclude: [{ system: 'http://x/cs', concept: [{ code: 'c' }] }] + } + }); + expect(vs.conceptCount()).toBe(4); // 3 include + 1 exclude + }); + + test('counts an inline expansion (contains) recursively', () => { + const vs = new ValueSet({ + resourceType: 'ValueSet', url: 'http://x/vs', status: 'active', + expansion: { + contains: [ + { system: 'http://x/cs', code: 'a' }, + { system: 'http://x/cs', code: 'b', contains: [{ system: 'http://x/cs', code: 'b1' }] } + ] + } + }); + expect(vs.conceptCount()).toBe(3); // a, b, b1 + }); + + test('counts compose + inline expansion together', () => { + const vs = new ValueSet({ + resourceType: 'ValueSet', url: 'http://x/vs', status: 'active', + compose: { include: [{ system: 'http://x/cs', concept: [{ code: 'a' }] }] }, + expansion: { contains: [{ system: 'http://x/cs', code: 'a' }, { system: 'http://x/cs', code: 'b' }] } + }); + expect(vs.conceptCount()).toBe(3); // 1 compose + 2 expansion + }); + + test('is 0 for an include-by-system with no enumerated concepts', () => { + const vs = new ValueSet({ + resourceType: 'ValueSet', url: 'http://x/vs', status: 'active', + compose: { include: [{ system: 'urn:ietf:bcp:13' }] } + }); + expect(vs.conceptCount()).toBe(0); + }); +}); + +describe('ConceptMap.conceptCount()', () => { + test('counts elements plus targets across groups', () => { + const cm = new ConceptMap({ + resourceType: 'ConceptMap', url: 'http://x/cm', status: 'active', + group: [ + { + source: 'http://x/cs1', target: 'http://x/cs2', + element: [ + { code: 'a', target: [{ code: 'A', relationship: 'equivalent' }] }, + { code: 'b', target: [{ code: 'B1', relationship: 'equivalent' }, { code: 'B2', relationship: 'equivalent' }] } + ] + } + ] + }); + // elements: a, b (2) + targets: A, B1, B2 (3) = 5 + expect(cm.conceptCount()).toBe(5); + }); + + test('is 0 with no groups', () => { + const cm = new ConceptMap({ + resourceType: 'ConceptMap', url: 'http://x/cm', status: 'active' + }); + expect(cm.conceptCount()).toBe(0); + }); +}); diff --git a/tests/tx/expand.test.js b/tests/tx/expand.test.js index ea2ae56f..82ab9fb4 100644 --- a/tests/tx/expand.test.js +++ b/tests/tx/expand.test.js @@ -7,6 +7,18 @@ const request = require('supertest'); const { getTestApp, shutdownTestApp } = require('./setup'); +// Caches must be created explicitly via $cache-control before a cache-id can be +// referenced. Returns the server-issued id. +async function startCache(app) { + const res = await request(app) + .post('/tx/r5/$cache-control') + .query({ mode: 'start' }) + .set('Content-Type', 'application/json') + .send({ resourceType: 'Parameters', parameter: [] }); + const p = (res.body.parameter || []).find(x => x.name === 'cache-id'); + return p ? p.valueId : undefined; +} + describe('Expand Worker', () => { let app; @@ -288,7 +300,7 @@ describe('Expand Worker', () => { }); test('should cache tx-resource with cache-id and reuse in subsequent request', async () => { - const cacheId = 'test-cache-colors-' + Date.now(); + const cacheId = await startCache(app); // First request: submit CodeSystem with cache-id const response1 = await request(app) @@ -345,6 +357,55 @@ describe('Expand Worker', () => { // expect(response2.body.expansion.contains.map(c => c.code)).toEqual(['red', 'blue']); }); + test('should cache the primary valueSet under cache-id and resolve it by url', async () => { + // Regression for the cache-id protocol gap: fhir-core registers the *primary* + // ValueSet by sending it inline as the `valueSet` parameter on the first call, + // then on later calls refers to it by url alone with the same cache-id. The + // server must retain that primary ValueSet, otherwise the by-url follow-up + // fails with "value set could not be found". + const cacheId = await startCache(app); + + // First request: primary ValueSet sent inline as `valueSet`, plus the + // CodeSystem it needs as tx-resource, both under the cache-id. + // NB: cache-id is sent as `valueId` here, exactly as fhir-core sends it + // (IdType). getParameterValue must understand valueId or the cache-id is + // read as null and the whole protocol silently no-ops. + const response1 = await request(app) + .post('/tx/r5/ValueSet/$expand') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .send({ + resourceType: 'Parameters', + parameter: [ + { name: 'cache-id', valueId: cacheId }, + { name: 'tx-resource', resource: testCodeSystem }, + { name: 'valueSet', resource: testValueSet } + ] + }); + + expect(response1.status).toBe(200); + expect(response1.body.resourceType).toBe('ValueSet'); + + // Second request: refer to the primary ValueSet by url only, same cache-id, + // with neither the valueSet nor the CodeSystem re-sent. Both must come from + // the cache. Before the fix this returned 422 "could not be found". + const response2 = await request(app) + .post('/tx/r5/ValueSet/$expand') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .send({ + resourceType: 'Parameters', + parameter: [ + { name: 'cache-id', valueId: cacheId }, + { name: 'url', valueString: testValueSet.url } + ] + }); + + expect(response2.status).toBe(200); + expect(response2.body.resourceType).toBe('ValueSet'); + expect(response2.body.expansion).toBeDefined(); + }); + test('should fail without cache-id when CodeSystem not provided', async () => { // This request provides a ValueSet that references a CodeSystem // that isn't in the provider and isn't provided via tx-resource diff --git a/tests/tx/renderer-resources.test.js b/tests/tx/renderer-resources.test.js index c549d00a..8809ef5e 100644 --- a/tests/tx/renderer-resources.test.js +++ b/tests/tx/renderer-resources.test.js @@ -236,3 +236,50 @@ describe('displayDate remains lenient (separate from resource validation)', () = expect(renderer.displayDate('2024-13-45')).toBe('2024-13-45'); }); }); + +// ─── Parameters primitive value rendering (valueId etc.) ───────────────────── + +describe('Parameters value rendering covers id-family primitives', () => { + test('valueId is rendered, not shown as (empty) - the cache-id case', async () => { + const html = await txHtml.renderParameters({ + resourceType: 'Parameters', + parameter: [{ name: 'cache-id', valueId: '70995493-fd31-477e-b570-e0a2b3275bb5' }] + }); + expect(html).toContain('70995493-fd31-477e-b570-e0a2b3275bb5'); + expect(html).not.toContain('(empty)'); + }); + + test('renders the other id-family / numeric primitives', async () => { + const cases = [ + { name: 'oid', valueOid: 'urn:oid:1.2.3' , expect: 'urn:oid:1.2.3' }, + { name: 'uuid', valueUuid: 'urn:uuid:abc', expect: 'urn:uuid:abc' }, + { name: 'i64', valueInteger64: '9007199254740993', expect: '9007199254740993' }, + { name: 'pos', valuePositiveInt: 5, expect: '5' }, + { name: 'uns', valueUnsignedInt: 0, expect: '0' } + ]; + for (const c of cases) { + const html = await txHtml.renderParameters({ resourceType: 'Parameters', parameter: [c] }); + expect(html).toContain(c.expect); + expect(html).not.toContain('(empty)'); + } + }); + + test('valueMarkdown is rendered as HTML (consistent with the rest of the server)', async () => { + const html = await txHtml.renderParameters({ + resourceType: 'Parameters', + parameter: [{ name: 'doc', valueMarkdown: 'see **the docs** for details' }] + }); + // The rendered table cell contains real HTML (the JSON-source block below the + // table still echoes the raw markdown, so we only assert the rendered form). + expect(html).toContain('the docs'); + expect(html).not.toContain('(empty)'); + }); + + test('valueMarkdown rendering is XSS-safe (raw HTML escaped)', async () => { + const html = await txHtml.renderParameters({ + resourceType: 'Parameters', + parameter: [{ name: 'doc', valueMarkdown: 'hi ' }] + }); + expect(html).not.toContain(''); + }); +}); diff --git a/tests/tx/resource-cache.test.js b/tests/tx/resource-cache.test.js new file mode 100644 index 00000000..e44cb076 --- /dev/null +++ b/tests/tx/resource-cache.test.js @@ -0,0 +1,242 @@ +/** + * ResourceCache unit tests + * + * ResourceCache backs the cache-id protocol: resources sent under a cache-id on one + * request must be retrievable on later requests with the same cache-id, and kept + * isolated between different cache-ids. + */ + +const { ResourceCache } = require('../../tx/operation-context'); + +const CS = (url, version) => ({ resourceType: 'CodeSystem', url, version }); +const VS = (url, version) => ({ resourceType: 'ValueSet', url, version }); + +// A resource that reports a concept count, like the real wrapper classes do. +const sized = (resource, n) => ({ ...resource, conceptCount: () => n }); + +describe('ResourceCache', () => { + let cache; + + beforeEach(() => { + cache = new ResourceCache(null); + }); + + describe('get / has on empty cache', () => { + test('get returns empty array for unknown cache-id', () => { + expect(cache.get('nope')).toEqual([]); + }); + test('has is false for unknown cache-id', () => { + expect(cache.has('nope')).toBe(false); + }); + }); + + describe('add', () => { + test('stores resources under a cache-id and reports has()', () => { + cache.add('c1', [CS('http://cs', '1.0.0')]); + expect(cache.has('c1')).toBe(true); + expect(cache.get('c1')).toHaveLength(1); + }); + + test('merges across multiple add() calls under the same cache-id', () => { + cache.add('c1', [CS('http://cs', '1.0.0')]); + cache.add('c1', [VS('http://vs', '1.0.0')]); + const got = cache.get('c1'); + expect(got).toHaveLength(2); + expect(got.map(r => r.resourceType).sort()).toEqual(['CodeSystem', 'ValueSet']); + }); + + test('replaces an existing resource with the same url+version+type', () => { + cache.add('c1', [CS('http://cs', '1.0.0')]); + const updated = { ...CS('http://cs', '1.0.0'), title: 'updated' }; + cache.add('c1', [updated]); + const got = cache.get('c1'); + expect(got).toHaveLength(1); + expect(got[0].title).toBe('updated'); + }); + + test('keeps resources with the same url but different versions as distinct', () => { + cache.add('c1', [CS('http://cs', '1.0.0')]); + cache.add('c1', [CS('http://cs', '2.0.0')]); + expect(cache.get('c1')).toHaveLength(2); + }); + + test('treats a CodeSystem and ValueSet at the same url as distinct', () => { + cache.add('c1', [CS('http://same', '1.0.0')]); + cache.add('c1', [VS('http://same', '1.0.0')]); + expect(cache.get('c1')).toHaveLength(2); + }); + + test('ignores empty / null resource lists', () => { + cache.add('c1', []); + cache.add('c1', null); + expect(cache.has('c1')).toBe(false); + }); + }); + + describe('isolation between cache-ids', () => { + test('resources under one cache-id are not visible under another', () => { + cache.add('c1', [CS('http://cs', '1.0.0')]); + expect(cache.get('c2')).toEqual([]); + expect(cache.has('c2')).toBe(false); + }); + + test('two cache-ids hold independent contents', () => { + cache.add('c1', [CS('http://a', '1')]); + cache.add('c2', [CS('http://b', '1')]); + expect(cache.get('c1').map(r => r.url)).toEqual(['http://a']); + expect(cache.get('c2').map(r => r.url)).toEqual(['http://b']); + }); + }); + + describe('get returns a copy', () => { + test('mutating the returned array does not affect the cache', () => { + cache.add('c1', [CS('http://cs', '1.0.0')]); + const got = cache.get('c1'); + got.push(VS('http://injected', '1')); + expect(cache.get('c1')).toHaveLength(1); + }); + }); + + describe('set (replace all)', () => { + test('replaces the entire contents for a cache-id', () => { + cache.add('c1', [CS('http://cs', '1.0.0'), VS('http://vs', '1.0.0')]); + cache.set('c1', [CS('http://only', '1.0.0')]); + const got = cache.get('c1'); + expect(got).toHaveLength(1); + expect(got[0].url).toBe('http://only'); + }); + }); + + describe('clear / clearAll / size', () => { + test('clear removes a single cache-id', () => { + cache.add('c1', [CS('http://a', '1')]); + cache.add('c2', [CS('http://b', '1')]); + cache.clear('c1'); + expect(cache.has('c1')).toBe(false); + expect(cache.has('c2')).toBe(true); + expect(cache.size()).toBe(1); + }); + + test('clearAll empties the cache', () => { + cache.add('c1', [CS('http://a', '1')]); + cache.add('c2', [CS('http://b', '1')]); + cache.clearAll(); + expect(cache.size()).toBe(0); + }); + }); + + describe('prune', () => { + test('removes entries older than maxAge but keeps fresh ones', () => { + cache.add('old', [CS('http://old', '1')]); + // Force the entry to look stale. + cache.cache.get('old').lastUsed = Date.now() - 10000; + cache.add('fresh', [CS('http://fresh', '1')]); + + cache.prune(5000); // maxAge 5s + + expect(cache.has('old')).toBe(false); + expect(cache.has('fresh')).toBe(true); + }); + + test('get() refreshes lastUsed so an active entry is not pruned', () => { + cache.add('c1', [CS('http://a', '1')]); + cache.cache.get('c1').lastUsed = Date.now() - 10000; + cache.get('c1'); // touch -> refreshes lastUsed + cache.prune(5000); + expect(cache.has('c1')).toBe(true); + }); + }); + + describe('running concept count', () => { + test('starts at zero', () => { + expect(cache.conceptCount()).toBe(0); + }); + + test('add accumulates the total and per-cache subtotal', () => { + cache.add('c1', [sized(CS('http://a', '1'), 10), sized(VS('http://b', '1'), 3)]); + expect(cache.conceptCount()).toBe(13); + expect(cache.conceptCountFor('c1')).toBe(13); + }); + + test('resources with no conceptCount() count as zero', () => { + cache.add('c1', [CS('http://a', '1')]); // plain object, no conceptCount + expect(cache.conceptCount()).toBe(0); + }); + + test('replacing a resource adjusts by the difference', () => { + cache.add('c1', [sized(CS('http://a', '1'), 10)]); + expect(cache.conceptCount()).toBe(10); + cache.add('c1', [sized(CS('http://a', '1'), 4)]); // same key, fewer concepts + expect(cache.conceptCount()).toBe(4); + expect(cache.conceptCountFor('c1')).toBe(4); + }); + + test('totals are kept separate per cache-id', () => { + cache.add('c1', [sized(CS('http://a', '1'), 10)]); + cache.add('c2', [sized(CS('http://b', '1'), 5)]); + expect(cache.conceptCount()).toBe(15); + expect(cache.conceptCountFor('c1')).toBe(10); + expect(cache.conceptCountFor('c2')).toBe(5); + }); + + test('set replaces the entry contribution', () => { + cache.add('c1', [sized(CS('http://a', '1'), 10), sized(VS('http://b', '1'), 5)]); + expect(cache.conceptCount()).toBe(15); + cache.set('c1', [sized(CS('http://c', '1'), 2)]); + expect(cache.conceptCount()).toBe(2); + expect(cache.conceptCountFor('c1')).toBe(2); + }); + + test('clear subtracts the cleared entry', () => { + cache.add('c1', [sized(CS('http://a', '1'), 10)]); + cache.add('c2', [sized(CS('http://b', '1'), 5)]); + cache.clear('c1'); + expect(cache.conceptCount()).toBe(5); + expect(cache.conceptCountFor('c1')).toBe(0); + }); + + test('clearAll resets the total to zero', () => { + cache.add('c1', [sized(CS('http://a', '1'), 10)]); + cache.add('c2', [sized(CS('http://b', '1'), 5)]); + cache.clearAll(); + expect(cache.conceptCount()).toBe(0); + }); + + test('prune subtracts evicted entries', () => { + cache.add('old', [sized(CS('http://old', '1'), 7)]); + cache.cache.get('old').lastUsed = Date.now() - 10000; + cache.add('fresh', [sized(CS('http://fresh', '1'), 4)]); + cache.prune(5000); + expect(cache.conceptCount()).toBe(4); + }); + }); + + describe('high-water marks (max)', () => { + test('track the most caches and concepts ever held', () => { + cache.add('c1', [sized(CS('http://a', '1'), 10)]); + cache.add('c2', [sized(CS('http://b', '1'), 5)]); + expect(cache.maxSize()).toBe(2); + expect(cache.maxConceptCount()).toBe(15); + }); + + test('max is not reduced when entries are cleared', () => { + cache.add('c1', [sized(CS('http://a', '1'), 10)]); + cache.add('c2', [sized(CS('http://b', '1'), 5)]); + cache.clear('c1'); + cache.clear('c2'); + expect(cache.size()).toBe(0); + expect(cache.conceptCount()).toBe(0); + // high-water marks persist + expect(cache.maxSize()).toBe(2); + expect(cache.maxConceptCount()).toBe(15); + }); + + test('max grows but never shrinks across set/prune', () => { + cache.set('c1', [sized(CS('http://a', '1'), 20)]); + expect(cache.maxConceptCount()).toBe(20); + cache.set('c1', [sized(CS('http://a', '1'), 3)]); // shrink live count + expect(cache.conceptCount()).toBe(3); + expect(cache.maxConceptCount()).toBe(20); + }); + }); +}); diff --git a/tests/tx/tx-module.test.js b/tests/tx/tx-module.test.js index 27aa1b83..4c6deb06 100644 --- a/tests/tx/tx-module.test.js +++ b/tests/tx/tx-module.test.js @@ -83,6 +83,33 @@ describe('TX Module', () => { expect(opNames).toContain('validate-code'); expect(opNames).toContain('subsumes'); }); + + test('advertises the $cache-control operation at the system level', async () => { + const response = await request(app) + .get('/tx/r5/metadata') + .set('Accept', 'application/json'); + expect(response.status).toBe(200); + + const rest = response.body.rest[0]; + const systemOps = (rest.operation || []).map(o => o.name); + expect(systemOps).toContain('cache-control'); + const cc = rest.operation.find(o => o.name === 'cache-control'); + expect(cc.definition).toBe('http://hl7.org/fhir/tools/OperationDefinition/cache-control'); + }); + + test('does NOT advertise cache-id as an expansion parameter (old implicit protocol retired)', async () => { + const response = await request(app) + .get('/tx/r5/metadata') + .query({ mode: 'terminology' }) + .set('Accept', 'application/json'); + expect(response.status).toBe(200); + expect(response.body.resourceType).toBe('TerminologyCapabilities'); + + const expansionParams = ((response.body.expansion || {}).parameter || []).map(p => p.name); + expect(expansionParams).not.toContain('cache-id'); + // tx-resource is still supported and still advertised + expect(expansionParams).toContain('tx-resource'); + }); }); describe('GET /tx/r5/', () => { diff --git a/translations/Messages.properties b/translations/Messages.properties index 31376529..b5ad9a61 100644 --- a/translations/Messages.properties +++ b/translations/Messages.properties @@ -1100,6 +1100,7 @@ Unable_to_resolve_system__value_set_has_multiple_matches = The System URI could Unable_to_resolve_system__value_set_has_no_includes_or_expansion = The System URI could not be determined for the code ''{0}'' in the ValueSet ''{1}'': value set has no includes or expansion UNABLE_TO_RESOLVE_SYSTEM_SYSTEM_IS_INDETERMINATE = The code system {1} referred to from value set {0} has a grammar, and the code might be valid in it Unable_to_resolve_value_Set_ = A definition for the value Set ''{0}'' could not be found +CACHE_ID_UNKNOWN = The cache ''{0}'' is not known to this server. Caches are created with $cache-control?mode=start; this one was never created, or has expired or been released Unable_to_validate_code_without_using_server = Unable to validate code without using server because: {0} Undefined_attribute__on__for_type__properties__ = Undefined attribute ''@{0}'' on {1} for type {2} Undefined_element_ = Undefined element ''{0}'' at {1} diff --git a/tx/html/home-metrics.liquid b/tx/html/home-metrics.liquid index 57001b05..8816b4d9 100644 --- a/tx/html/home-metrics.liquid +++ b/tx/html/home-metrics.liquid @@ -4,7 +4,7 @@
-
Cache
+
Expansion Cache
@@ -18,8 +18,8 @@
-
CPU Usage (%)
- +
Client Cache
+
Node Blocking
@@ -99,13 +99,13 @@ } }); - // Cache chart + // Expansion cache chart new Chart(document.getElementById('cacheChart'), { type: 'line', data: { labels: historyData.map(d => formatTime(d.time)), datasets: [{ - data: historyData.map(d => d.cache), + data: historyData.map(d => d.expansion), borderColor: '#7b7b7b', backgroundColor: 'rgba(123, 123, 123, 0.1)', fill: true, @@ -121,7 +121,7 @@ ...chartOptions.scales.y, title: { display: true, - text: '# cached items' + text: 'Expansion cache # items' } } } @@ -158,13 +158,13 @@ } }); - // CPU chart - new Chart(document.getElementById('cpuChart'), { + // Client cache concepts chart + new Chart(document.getElementById('clientCacheChart'), { type: 'line', data: { labels: historyData.map(d => formatTime(d.time)), datasets: [{ - data: historyData.map(d => parseFloat(formatMB(d.cpu))), + data: historyData.map(d => d.clientConcepts), borderColor: '#ff7b00', backgroundColor: 'rgba(255, 123, 0, 0.1)', fill: true, @@ -180,7 +180,7 @@ ...chartOptions.scales.y, title: { display: true, - text: '%' + text: 'Client Cache # concepts' } } } diff --git a/tx/library/codesystem.js b/tx/library/codesystem.js index 29354e85..a6240f54 100644 --- a/tx/library/codesystem.js +++ b/tx/library/codesystem.js @@ -42,6 +42,37 @@ class CodeSystem extends CanonicalResource { } this.buildMaps(); } + // Precalculated at construction so callers (e.g. the resource cache) have a + // cheap O(1) sense of how large this resource is. + this._conceptCount = CodeSystem._countConcepts(this.jsonObj.concept); + } + + /** + * Number of concepts defined in this CodeSystem, counted recursively (nested + * concepts included). Precalculated at construction time. + * @returns {number} + */ + conceptCount() { + return this._conceptCount; + } + + /** + * Recursively count concepts (including nested `concept` children). + * @param {Array} concepts + * @returns {number} + */ + static _countConcepts(concepts) { + if (!Array.isArray(concepts)) { + return 0; + } + let n = 0; + for (const c of concepts) { + n++; + if (c && c.concept) { + n += CodeSystem._countConcepts(c.concept); + } + } + return n; } /** diff --git a/tx/library/conceptmap.js b/tx/library/conceptmap.js index ae420235..a6343b34 100644 --- a/tx/library/conceptmap.js +++ b/tx/library/conceptmap.js @@ -20,6 +20,30 @@ class ConceptMap extends CanonicalResource { this.jsonObj = conceptMapToR5(jsonObj, fhirVersion); this.validate(); this.id = this.jsonObj.id; + // Precalculated at construction so callers (e.g. the resource cache) have a + // cheap O(1) sense of how large this resource is. + this._conceptCount = ConceptMap._countConcepts(this.jsonObj); + } + + /** + * Number of concept nodes this ConceptMap carries: every source `element` plus + * every `target` across all groups (each is a coded node, so this approximates + * the resource's size). Precalculated at construction time. + * @returns {number} + */ + conceptCount() { + return this._conceptCount; + } + + static _countConcepts(jsonObj) { + let n = 0; + for (const g of ((jsonObj && jsonObj.group) || [])) { + for (const e of (g.element || [])) { + n++; + n += Array.isArray(e.target) ? e.target.length : 0; + } + } + return n; } /** diff --git a/tx/library/valueset.js b/tx/library/valueset.js index 3b632ce9..3addac55 100644 --- a/tx/library/valueset.js +++ b/tx/library/valueset.js @@ -25,6 +25,52 @@ class ValueSet extends CanonicalResource { this.jsonObj = valueSetToR5(jsonObj, fhirVersion); this.validate(); this.buildMaps(); + // Precalculated at construction so callers (e.g. the resource cache) have a + // cheap O(1) sense of how large this resource is. + this._conceptCount = ValueSet._countConcepts(this.jsonObj); + } + + /** + * Number of concepts this ValueSet carries: the enumerated concepts in its + * compose (include + exclude `concept` lists) plus any concepts in an inline + * expansion (`expansion.contains`, counted recursively). Note this counts the + * concepts *present in the resource*, not the (possibly unbounded) size of the + * value set's full expansion. Precalculated at construction time. + * @returns {number} + */ + conceptCount() { + return this._conceptCount; + } + + static _countConcepts(jsonObj) { + let n = 0; + const compose = jsonObj && jsonObj.compose; + if (compose) { + for (const inc of (compose.include || [])) { + n += Array.isArray(inc.concept) ? inc.concept.length : 0; + } + for (const exc of (compose.exclude || [])) { + n += Array.isArray(exc.concept) ? exc.concept.length : 0; + } + } + if (jsonObj && jsonObj.expansion) { + n += ValueSet._countContains(jsonObj.expansion.contains); + } + return n; + } + + static _countContains(items) { + if (!Array.isArray(items)) { + return 0; + } + let n = 0; + for (const it of items) { + n++; + if (it && it.contains) { + n += ValueSet._countContains(it.contains); + } + } + return n; } /** diff --git a/tx/operation-context.js b/tx/operation-context.js index cccce84d..f42ec8b5 100644 --- a/tx/operation-context.js +++ b/tx/operation-context.js @@ -3,6 +3,7 @@ const inspector = require("inspector"); const crypto = require("crypto"); const {Languages} = require("../library/languages"); const {Issue} = require("./library/operation-outcome"); +const Logger = require("../library/logger"); /** * Check if running under a debugger @@ -59,6 +60,37 @@ class ResourceCache { this.stats = stats; this.cache = new Map(); this.locks = new Map(); // For thread-safety with async operations + this.log = Logger.getInstance().child({module: 'tx-cache'}); + // Running total of concepts across every resource in every entry, maintained + // incrementally on each mutation so cache sizing/limits are O(1) to consult. + // Each entry also carries its own `concepts` subtotal so removal/replacement + // can adjust the total without rescanning. + this.totalConcepts = 0; + // High-water marks (never decremented) - the most caches and the most concepts + // this cache has held at once, for capacity reporting. + this.maxConcepts = 0; + this.maxSizeValue = 0; + } + + /** + * Update the high-water marks after a mutation that may have grown the cache. + */ + _trackMax() { + if (this.totalConcepts > this.maxConcepts) { + this.maxConcepts = this.totalConcepts; + } + if (this.cache.size > this.maxSizeValue) { + this.maxSizeValue = this.cache.size; + } + } + + /** + * Concept count of a single cached resource, or 0 if it doesn't expose one. + * @param {Object} resource + * @returns {number} + */ + _conceptsOf(resource) { + return resource && typeof resource.conceptCount === 'function' ? resource.conceptCount() : 0; } /** @@ -70,8 +102,10 @@ class ResourceCache { const entry = this.cache.get(cacheId); if (entry) { entry.lastUsed = Date.now(); + this.log.info(`cache-id '${cacheId}': hit, returning ${entry.resources.length} resource(s): ${entry.resources.map(r => this._resourceKey(r)).join(', ')}`); return [...entry.resources]; // Return a copy } + this.log.info(`cache-id '${cacheId}': miss (no entry)`); return []; } @@ -92,22 +126,32 @@ class ResourceCache { add(cacheId, resources) { if (!resources || resources.length === 0) return; - const entry = this.cache.get(cacheId) || { resources: [], lastUsed: Date.now() }; + const entry = this.cache.get(cacheId) || { resources: [], lastUsed: Date.now(), concepts: 0 }; - // Merge resources, avoiding duplicates by url+version + // Merge resources, avoiding duplicates by url+version. Keep the entry's concept + // subtotal and the cache-wide total in step with each insertion/replacement. for (const resource of resources) { const key = this._resourceKey(resource); + const newConcepts = this._conceptsOf(resource); const existingIndex = entry.resources.findIndex(r => this._resourceKey(r) === key); if (existingIndex >= 0) { - // Replace existing + // Replace existing: adjust by the difference + const delta = newConcepts - this._conceptsOf(entry.resources[existingIndex]); entry.resources[existingIndex] = resource; + entry.concepts += delta; + this.totalConcepts += delta; + this.log.info(`cache-id '${cacheId}': replaced ${key}`); } else { entry.resources.push(resource); + entry.concepts += newConcepts; + this.totalConcepts += newConcepts; + this.log.info(`cache-id '${cacheId}': added ${key}`); } } entry.lastUsed = Date.now(); this.cache.set(cacheId, entry); + this._trackMax(); } /** @@ -116,10 +160,23 @@ class ResourceCache { * @param {Array} resources - Resources to set */ set(cacheId, resources) { + this.log.info(`cache-id '${cacheId}': set (replace all) with ${resources.length} resource(s): ${resources.map(r => this._resourceKey(r)).join(', ')}`); + // Drop the old entry's contribution, then count the replacement. + const existing = this.cache.get(cacheId); + if (existing) { + this.totalConcepts -= existing.concepts || 0; + } + let concepts = 0; + for (const resource of resources) { + concepts += this._conceptsOf(resource); + } + this.totalConcepts += concepts; this.cache.set(cacheId, { resources: [...resources], - lastUsed: Date.now() + lastUsed: Date.now(), + concepts }); + this._trackMax(); } /** @@ -127,6 +184,10 @@ class ResourceCache { * @param {string} cacheId - The cache identifier */ clear(cacheId) { + const entry = this.cache.get(cacheId); + if (entry) { + this.totalConcepts -= entry.concepts || 0; + } this.cache.delete(cacheId); } @@ -135,6 +196,7 @@ class ResourceCache { */ clearAll() { this.cache.clear(); + this.totalConcepts = 0; } /** @@ -150,6 +212,7 @@ class ResourceCache { for (const [cacheId, entry] of this.cache.entries()) { if (now - entry.lastUsed > maxAge) { i++; + this.totalConcepts -= entry.concepts || 0; this.cache.delete(cacheId); } } @@ -166,6 +229,41 @@ class ResourceCache { return this.cache.size; } + /** + * Total number of concepts held across every entry in the cache. Maintained + * incrementally, so this is O(1). + * @returns {number} + */ + conceptCount() { + return this.totalConcepts; + } + + /** + * Highest number of concepts this cache has held at once (high-water mark). + * @returns {number} + */ + maxConceptCount() { + return this.maxConcepts; + } + + /** + * Highest number of caches (cache-ids) this cache has held at once. + * @returns {number} + */ + maxSize() { + return this.maxSizeValue; + } + + /** + * Number of concepts held under a single cache-id (0 if unknown). + * @param {string} cacheId + * @returns {number} + */ + conceptCountFor(cacheId) { + const entry = this.cache.get(cacheId); + return entry ? (entry.concepts || 0) : 0; + } + /** * Generate a key for a resource based on url and version * @param {Object} resource - The resource diff --git a/tx/tx-html.js b/tx/tx-html.js index f477d19c..8fb5a41d 100644 --- a/tx/tx-html.js +++ b/tx/tx-html.js @@ -471,6 +471,33 @@ class TxHtmlRenderer { if (param.valueCode !== undefined) { return `${escape(param.valueCode)}`; } + if (param.valueId !== undefined) { + return escape(String(param.valueId)); + } + if (param.valueOid !== undefined) { + return escape(String(param.valueOid)); + } + if (param.valueUuid !== undefined) { + return escape(String(param.valueUuid)); + } + if (param.valueMarkdown !== undefined) { + // Render markdown to HTML the same way the rest of the server does, using + // commonmark in safe mode (raw HTML in the markdown is escaped, so this is + // XSS-safe). + const commonmark = require('commonmark'); + const reader = new commonmark.Parser(); + const writer = new commonmark.HtmlRenderer({ safe: true }); + return writer.render(reader.parse(String(param.valueMarkdown))); + } + if (param.valueInteger64 !== undefined) { + return escape(String(param.valueInteger64)); + } + if (param.valuePositiveInt !== undefined) { + return escape(String(param.valuePositiveInt)); + } + if (param.valueUnsignedInt !== undefined) { + return escape(String(param.valueUnsignedInt)); + } if (param.valueDate !== undefined) { return escape(param.valueDate); } diff --git a/tx/tx.js b/tx/tx.js index b46ac2c7..a883861c 100644 --- a/tx/tx.js +++ b/tx/tx.js @@ -27,6 +27,7 @@ const LookupWorker = require('./workers/lookup'); const SubsumesWorker = require('./workers/subsumes'); const { MetadataHandler } = require('./workers/metadata'); const { BatchValidateWorker } = require('./workers/batch-validate'); +const { CacheControlWorker } = require('./workers/cache-control'); const {CapabilityStatementXML} = require("./xml/capabilitystatement-xml"); const {TerminologyCapabilitiesXML} = require("./xml/terminologycapabilities-xml"); const {ParametersXML} = require("./xml/parameters-xml"); @@ -655,6 +656,26 @@ class TXModule { } }); + // $cache-control (GET and POST) - base-level operation: mode=start|end + router.get('/\\$cache-control', async (req, res) => { + const start = Date.now(); + try { + let worker = new CacheControlWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n); + await worker.handle(req, res, this.log); + } finally { + this.countRequest('$cache-control', Date.now() - start); + } + }); + router.post('/\\$cache-control', async (req, res) => { + const start = Date.now(); + try { + let worker = new CacheControlWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n); + await worker.handle(req, res, this.log); + } finally { + this.countRequest('$cache-control', Date.now() - start); + } + }); + // ConceptMap/$translate (GET and POST) router.get('/ConceptMap/\\$translate', async (req, res) => { const start = Date.now(); @@ -1177,10 +1198,46 @@ class TXModule { } } - cacheCount() { + // Number of entries in the expansion cache, summed across endpoints. + expansionItemCount() { + let count = 0; + for (let ep of this.endpoints) { + count = count + ep.expansionCache.size(); + } + return count; + } + + // Number of concepts held in the client (resource) cache, summed across endpoints. + clientConceptCount() { + let count = 0; + for (let ep of this.endpoints) { + count = count + ep.resourceCache.conceptCount(); + } + return count; + } + + // Number of caches (cache-ids) currently held in the client cache, across endpoints. + clientCacheCount() { + let count = 0; + for (let ep of this.endpoints) { + count = count + ep.resourceCache.size(); + } + return count; + } + + // High-water marks for the client cache, summed across endpoints. + maxClientCacheCount() { + let count = 0; + for (let ep of this.endpoints) { + count = count + ep.resourceCache.maxSize(); + } + return count; + } + + maxClientConceptCount() { let count = 0; for (let ep of this.endpoints) { - count = count + ep.resourceCache.size() + ep.expansionCache.size(); + count = count + ep.resourceCache.maxConceptCount(); } return count; } diff --git a/tx/workers/cache-control.js b/tx/workers/cache-control.js new file mode 100644 index 00000000..2171fe3f --- /dev/null +++ b/tx/workers/cache-control.js @@ -0,0 +1,186 @@ +// +// Cache Control Worker - Handles the $cache-control operation +// +// GET /$cache-control?mode=start - create a cache, return its (server-issued) id +// POST /$cache-control?mode=start - as above, optionally front-loading resources +// GET /$cache-control?mode=end - tell the server it can release the cache now +// POST /$cache-control?mode=end +// (mode=check is reserved for later - report whether a cache is still valid + stats) +// +// This is the explicit replacement for the implicit `cache-id` parameter protocol: +// the server owns the cache-id namespace, so it can authoritatively reject an +// unknown/expired cache later instead of failing obscurely deep inside a validation. +// +// NOTE: this is scaffolding. start()/end() currently parse the request into a +// Parameters resource (the same way validate.js does) but do not yet create or +// release anything. The behaviour is filled in by later steps. +// + +const crypto = require('crypto'); +const { TerminologyWorker, CACHE_ID_HEADER } = require('./worker'); +const { Parameters } = require('../library/parameters'); +const { debugLog } = require('../operation-context'); + +class CacheControlWorker extends TerminologyWorker { + /** + * @param {OperationContext} opContext - Operation context + * @param {Logger} log - Logger instance + * @param {Provider} provider - Provider for code systems and resources + * @param {LanguageDefinitions} languages - Language definitions + * @param {I18nSupport} i18n - Internationalization support + */ + constructor(opContext, log, provider, languages, i18n) { + super(opContext, log, provider, languages, i18n); + } + + opName() { + return 'cache-control'; + } + + // Not a value-set operation; the base class requires this to be implemented. + vsHandle() { + return null; + } + + /** + * Express entry point for /$cache-control (GET and POST). + * Dispatches on the `mode` query parameter. + * @param {express.Request} req - Express request + * @param {express.Response} res - Express response + */ + async handle(req, res) { + try { + const mode = this.getMode(req); + // GET is accepted as well as POST, for consistency with the server's other + // operations (which are all browsable) and the convenience of poking + // $cache-control from a browser. The operation is declared affectsState=true + // in its OperationDefinition, so conformant clients POST; an accidental + // GET-created cache is an empty entry that self-expires. + switch (mode) { + case 'start': + return await this.start(req, res); + case 'end': + return await this.end(req, res); + default: + return res.status(400).json(this.operationOutcome('error', 'invalid', + `$cache-control requires a 'mode' of 'start' or 'end'` + + (mode ? ` (got '${mode}')` : ` (none supplied)`))); + } + } catch (error) { + this.log.error(error); + debugLog(error); + req.logInfo = this.usedSources.join('|') + ' - error' + (error.msgId ? ' ' + error.msgId : ''); + const statusCode = error.statusCode || 500; + const issueCode = error.issueCode || 'exception'; + return res.status(statusCode).json(this.operationOutcome('error', issueCode, error.message)); + } + } + + /** + * Determine the requested mode. The cache-control mode travels in the query + * string (?mode=start) so it survives even when a Parameters resource is POSTed + * as the body; a `mode` parameter in the body is accepted as a fallback. + * @param {express.Request} req + * @returns {string|null} + */ + getMode(req) { + if (req.query && req.query.mode) { + return String(req.query.mode); + } + if (req.body && req.body.resourceType === 'Parameters') { + const p = this.findParameter(req.body, 'mode'); + if (p) { + return this.getParameterValue(p); + } + } + return null; + } + + /** + * mode=start: mint a server-issued cache-id, create the (per-endpoint) cache, + * front-load any supplied resources into it, and return the id in the body. + * + * The id is returned in the body rather than a header to keep it readable by + * browser clients without CORS expose-header configuration; subsequent calls + * carry it back as the `${CACHE_ID_HEADER}` request header. + * + * The cache is created even when no resources are front-loaded: an explicitly + * empty cache must still *exist* so the server can later tell "cache I issued, + * currently empty" from "cache-id I never issued". That's why this uses + * ResourceCache.set (which always creates the entry) rather than add (which + * ignores empty resource lists). + * + * @param {express.Request} req + * @param {express.Response} res + */ + async start(req, res) { + const cache = this.opContext.resourceCache; + if (!cache) { + return res.status(500).json(this.operationOutcome('error', 'exception', + 'No resource cache is available on this endpoint')); + } + + const params = new Parameters(this.buildParameters(req)); + const { txResources, primaryResources } = this.collectSuppliedResources(params.jsonObj); + const resources = txResources.concat(primaryResources); + + const cacheId = crypto.randomUUID(); + cache.set(cacheId, resources); + + return res.status(200).json({ + resourceType: 'Parameters', + parameter: [ + { name: 'cache-id', valueId: cacheId } + ] + }); + } + + /** + * mode=end: release the cache named by the `${CACHE_ID_HEADER}` header so the + * server can reclaim it now rather than waiting for the idle timeout. + * + * Releasing is idempotent: ending an id the server doesn't have is not an error + * here (the authoritative "unknown cache" signal belongs on the validation path, + * a later step). A missing header is a client error, though. + * + * @param {express.Request} req + * @param {express.Response} res + */ + async end(req, res) { + const cache = this.opContext.resourceCache; + if (!cache) { + return res.status(500).json(this.operationOutcome('error', 'exception', + 'No resource cache is available on this endpoint')); + } + + const cacheId = req.headers[CACHE_ID_HEADER]; + if (!cacheId) { + return res.status(400).json(this.operationOutcome('error', 'invalid', + `$cache-control mode=end requires the cache-id in the '${CACHE_ID_HEADER}' header`)); + } + + cache.clear(cacheId); + + return res.status(200).json({ resourceType: 'Parameters', parameter: [] }); + } + + /** + * Build an OperationOutcome. + * @param {string} severity - error, warning, information + * @param {string} code - Issue code + * @param {string} message - Diagnostic message + * @returns {Object} OperationOutcome resource + */ + operationOutcome(severity, code, message) { + return { + resourceType: 'OperationOutcome', + issue: [{ + severity, + code, + details: { text: message } + }] + }; + } +} + +module.exports = { CacheControlWorker, CACHE_ID_HEADER }; diff --git a/tx/workers/metadata.js b/tx/workers/metadata.js index 34f94138..7e788f0c 100644 --- a/tx/workers/metadata.js +++ b/tx/workers/metadata.js @@ -267,6 +267,7 @@ class MetadataHandler { { name: 'translate', definition: 'http://hl7.org/fhir/OperationDefinition/ConceptMap-translate' }, { name: 'closure', definition: 'http://hl7.org/fhir/OperationDefinition/ConceptMap-closure' }, { name: 'related', definition: 'https://raw.githubusercontent.com/HealthIntersections/FHIRsmith/refs/heads/main/tx/data/OperationDefinition-ValueSet-related.json' }, + { name: 'cache-control', definition: 'http://hl7.org/fhir/tools/OperationDefinition/cache-control' }, { name: 'versions', definition: 'http://hl7.org/fhir/OperationDefinition/fhir-versions' } ] } @@ -395,10 +396,14 @@ class MetadataHandler { buildExpansionCapabilities() { return { parameter: [ - { - name: 'cache-id', - documentation: 'This server supports caching terminology resources between calls. Clients only need to send value sets and codesystems once; thereafter they are automatically in scope for calls with the same cache-id. The cache is retained for 30 min from last call' - }, + // NOTE: `cache-id` is deliberately not advertised as an expansion parameter. + // Caching is now discovered via the $cache-control operation in the + // CapabilityStatement, not by advertising a cache-id parameter here. The old + // advertisement implied the implicit "auto-create on first sight" protocol, + // which has been replaced: a cache must be created explicitly with + // $cache-control?mode=start before its cache-id can be used. Clients that + // only know the old mechanism will see no cache-id parameter and simply not + // cache (inlining each request), which is correct, just not optimised. { name: 'tx-resource', documentation: 'Additional valuesets needed for evaluation e.g. value sets referred to from the import statement of the value set being expanded' diff --git a/tx/workers/worker.js b/tx/workers/worker.js index f9aa1bf5..ab28d134 100644 --- a/tx/workers/worker.js +++ b/tx/workers/worker.js @@ -8,6 +8,12 @@ const {Languages} = require("../../library/languages"); const {ConceptMap} = require("../library/conceptmap"); const {Renderer} = require("../library/renderer"); +// The cache-id travels as an HTTP header (not an operation parameter) so proxies / +// load-balancers can act on it and the server can reject it before parsing the body. +// Defined here (the base worker) so both the worker and cache-control share one +// source of truth without a circular require. Express lower-cases header names. +const CACHE_ID_HEADER = 'x-cache-id'; + /** * Custom error for terminology setup issues */ @@ -121,7 +127,11 @@ class TerminologyWorker { latest = i; } } - return matches[latest]; + const found = matches[latest]; + if (this.additionalResourcesCacheId && this.log) { + this.log.info(`cache-id '${this.additionalResourcesCacheId}': using cached ${found.resourceType} ${found.url}${found.version ? '|' + found.version : ''} for lookup of ${url}${version ? '|' + version : ''}`); + } + return found; } } @@ -487,50 +497,75 @@ class TerminologyWorker { * @returns {Object} Parameters resource */ buildParameters(req) { + let params; + // If POST with Parameters resource, use directly if (req.method === 'POST' && req.body && req.body.resourceType === 'Parameters') { - return req.body; - } - if (req.method === 'POST' && req.body && req.body.resourceType) { + params = req.body; + } else if (req.method === 'POST' && req.body && req.body.resourceType) { let langs = this.languages.parse(req.headers['accept-language']); throw new Issue('error', 'invalid', null, 'Wrong_type_for_resource_expected', this.i18n.translate('Wrong_type_for_resource_expected', langs, ["Parameters", req.body.resourceType])).handleAsOO(400); - } - - // Convert query params or form body to Parameters - const source = req.method === 'POST' ? {...req.query, ...req.body} : req.query; - const params = { - resourceType: 'Parameters', - parameter: [] - }; + } else { + // Convert query params or form body to Parameters + const source = req.method === 'POST' ? {...req.query, ...req.body} : req.query; + params = { + resourceType: 'Parameters', + parameter: [] + }; - for (const [name, value] of Object.entries(source)) { - if (value === undefined || value === null) continue; + for (const [name, value] of Object.entries(source)) { + if (value === undefined || value === null) continue; - if (Array.isArray(value)) { - // Repeating parameter - for (const v of value) { - params.parameter.push({name, valueString: String(v)}); - } - } else if (typeof value === 'object') { - // Could be a resource or complex type - check resourceType - if (value.resourceType) { - params.parameter.push({name, resource: value}); + if (Array.isArray(value)) { + // Repeating parameter + for (const v of value) { + params.parameter.push({name, valueString: String(v)}); + } + } else if (typeof value === 'object') { + // Could be a resource or complex type - check resourceType + if (value.resourceType) { + params.parameter.push({name, resource: value}); + } else { + // Assume it's a complex type like Coding or CodeableConcept + params.parameter.push(this.buildComplexParameter(name, value)); + } + } else if (value == 'true') { + params.parameter.push({name, valueBoolean: true}); + } else if (value == 'false') { + params.parameter.push({name, valueBoolean: false}); } else { - // Assume it's a complex type like Coding or CodeableConcept - params.parameter.push(this.buildComplexParameter(name, value)); + params.parameter.push({name, valueString: String(value)}); } - } else if (value == 'true') { - params.parameter.push({name, valueBoolean: true}); - } else if (value == 'false') { - params.parameter.push({name, valueBoolean: false}); - } else { - params.parameter.push({name, valueString: String(value)}); } } + this.applyCacheIdHeader(req, params); return params; } + /** + * Normalise the cache-id from the `${CACHE_ID_HEADER}` header into a `cache-id` + * parameter, so all downstream code can read the cache-id the same way whether + * the client sent it as a header (the going-forward mechanism) or, for now, still + * as a parameter. If a cache-id parameter is already present it is left as-is, so + * an explicit parameter wins and there's no surprising override. + * @param {express.Request} req + * @param {Object} params - Parameters resource (mutated in place) + */ + applyCacheIdHeader(req, params) { + const headerVal = req && req.headers ? req.headers[CACHE_ID_HEADER] : null; + if (!headerVal) { + return; + } + if (!params.parameter) { + params.parameter = []; + } + if (params.parameter.some(p => p.name === 'cache-id')) { + return; + } + params.parameter.push({ name: 'cache-id', valueId: String(headerVal) }); + } + /** * Build a parameter for complex types */ @@ -559,36 +594,79 @@ class TerminologyWorker { // ========== Additional Resources Handling ========== /** - * Set up additional resources from tx-resource parameters and cache + * Collect the resources supplied inline in a Parameters resource: the + * `tx-resource` parameters, and the primary `valueSet`/`codeSystem` parameters. + * + * The primary resource is collected separately because the cache-id protocol + * needs it retained too: fhir-core sends the main ValueSet as `valueSet` (not + * `tx-resource`), so if it isn't cached, a later by-reference call can't resolve + * it ("value set ... could not be found"). A primary resource is only usable by + * reference if it has a url to key on, so url-less ones are skipped. + * * @param {Object} params - Parameters resource + * @returns {{txResources: Array, primaryResources: Array}} wrapped resources */ - setupAdditionalResources(params) { - if (!params || !params.parameter) return; - - // Collect tx-resource parameters (resources provided inline) + collectSuppliedResources(params) { const txResources = []; + const primaryResources = []; + if (!params || !params.parameter) { + return { txResources, primaryResources }; + } for (const param of params.parameter) { - this.deadCheck('setupAdditionalResources'); + this.deadCheck('collectSuppliedResources'); if (param.name === 'tx-resource' && param.resource) { - let res = this.wrapRawResource(param.resource); + const res = this.wrapRawResource(param.resource); if (res) { txResources.push(res); } + } else if ((param.name === 'valueSet' || param.name === 'codeSystem') && param.resource && param.resource.url) { + const res = this.wrapRawResource(param.resource); + if (res) { + primaryResources.push(res); + } } } + return { txResources, primaryResources }; + } + + /** + * Set up additional resources from tx-resource parameters and cache + * @param {Object} params - Parameters resource + */ + setupAdditionalResources(params) { + if (!params || !params.parameter) return; + + // Collect the resources supplied inline on this request (tx-resource plus the + // primary valueSet/codeSystem). See collectSuppliedResources for why the + // primary resource is included. + const { txResources, primaryResources } = this.collectSuppliedResources(params); // Check for cache-id const cacheIdParam = this.findParameter(params, 'cache-id'); const cacheId = cacheIdParam ? this.getParameterValue(cacheIdParam) : null; if (cacheId && this.opContext.resourceCache) { - // Merge tx-resources with cached resources - if (txResources.length > 0) { - this.opContext.resourceCache.add(cacheId, txResources); + // The cache must already exist: caches are created explicitly via + // $cache-control?mode=start, which is the only thing that mints a cache-id. + // A cache-id the server doesn't know is an unambiguous, server-authoritative + // error condition (never created, or expired / released) - report it with a + // specific coded issue rather than silently auto-creating a fresh cache and + // then failing obscurely later when a by-reference resource can't be found. + if (!this.opContext.resourceCache.has(cacheId)) { + throw new Issue('error', 'not-found', null, 'CACHE_ID_UNKNOWN', + this.i18n.translate('CACHE_ID_UNKNOWN', this.opContext.langs, [cacheId]), + 'cache-id-unknown', 404); + } + + // The cache exists: merge any resources supplied on this request into it + // (incremental population is allowed), then expose the full cache contents. + const toCache = txResources.concat(primaryResources); + if (toCache.length > 0) { + this.opContext.resourceCache.add(cacheId, toCache); } - // Set additional resources to all resources for this cache-id this.additionalResources = this.opContext.resourceCache.get(cacheId); + this.additionalResourcesCacheId = cacheId; } else { // No cache-id, just use the tx-resources directly this.additionalResources = txResources; @@ -708,7 +786,7 @@ class TerminologyWorker { // Check for various value types const valueTypes = [ 'valueString', 'valueCode', 'valueUri', 'valueCanonical', 'valueUrl', - 'valueBoolean', 'valueInteger', 'valueDecimal', + 'valueBoolean', 'valueInteger', 'valueDecimal', 'valueId', 'valueDateTime', 'valueDate', 'valueTime', 'valueCoding', 'valueCodeableConcept', 'valueIdentifier', 'valueQuantity' @@ -934,5 +1012,6 @@ class TerminologyWorker { module.exports = { TerminologyWorker, - TerminologySetupError + TerminologySetupError, + CACHE_ID_HEADER }; \ No newline at end of file