Skip to content

Commit e39fe80

Browse files
Merge pull request #265 from HealthIntersections/2026-07-gg-validation-cleanup
2026 07 gg validation cleanup
2 parents ce53354 + f3ddd3a commit e39fe80

4 files changed

Lines changed: 319 additions & 34 deletions

File tree

tests/tx/cache-control.test.js

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,4 +422,174 @@ describe('$cache-control routing (scaffolding)', () => {
422422
expect(codes).toContain('b1');
423423
});
424424
});
425+
426+
// ---- batch front-loading (two-pass) ----
427+
//
428+
// A batch against an unsealed cache front-loads every resource it supplies
429+
// (tx-resource + primary valueSet/codeSystem) into the cache before any entry is
430+
// evaluated. So the batch is order-independent (an entry may reference by url a
431+
// resource a later entry supplies) and a failing entry does not withhold what it
432+
// carried. A sealed cache does not grow, so there is no cross-entry sharing.
433+
describe('batch front-loading (two-pass)', () => {
434+
const BATCH = '/tx/r5/ValueSet/$batch-validate-code';
435+
436+
const entry = (parameter) => ({
437+
name: 'validation',
438+
resource: { resourceType: 'Parameters', parameter }
439+
});
440+
const results = (body) => (body.parameter || []).filter(x => x.name === 'validation');
441+
442+
async function startCache(sealed) {
443+
const parameter = [];
444+
if (sealed !== undefined) parameter.push({ name: 'sealed', valueBoolean: sealed });
445+
const started = await request(app).post(BASE).query({ mode: 'start' })
446+
.set('Content-Type', 'application/json')
447+
.send({ resourceType: 'Parameters', parameter });
448+
return cacheIdFrom(started.body);
449+
}
450+
451+
const mkCS = (tag) => ({
452+
resourceType: 'CodeSystem', url: `http://example.org/batch/${tag}-cs`,
453+
version: '1.0.0', status: 'active', content: 'complete',
454+
concept: [{ code: `${tag}1`, display: `${tag} one` }]
455+
});
456+
const mkVS = (tag, cs) => ({
457+
resourceType: 'ValueSet', url: `http://example.org/batch/${tag}-vs`,
458+
version: '1.0.0', status: 'active',
459+
compose: { include: [{ system: cs.url }] }
460+
});
461+
462+
test('an entry resolves a url supplied only by a LATER entry (unsealed)', async () => {
463+
const cacheId = await startCache(false);
464+
const cs = mkCS('fwd'); const vs = mkVS('fwd', cs);
465+
const res = await request(app).post(BATCH)
466+
.set('Content-Type', 'application/json')
467+
.set('x-cache-id', cacheId)
468+
.send({ resourceType: 'Parameters', parameter: [
469+
// entry 0: references vs by url only (forward reference)
470+
entry([
471+
{ name: 'url', valueString: vs.url },
472+
{ name: 'coding', valueCoding: { system: cs.url, code: 'fwd1' } }
473+
]),
474+
// entry 1: supplies vs + cs inline, AFTER the entry that references them
475+
entry([
476+
{ name: 'tx-resource', resource: cs },
477+
{ name: 'valueSet', resource: vs },
478+
{ name: 'coding', valueCoding: { system: cs.url, code: 'fwd1' } }
479+
])
480+
] });
481+
expect(res.status).toBe(200);
482+
const rs = results(res.body);
483+
expect(rs.length).toBe(2);
484+
// the forward-referencing entry validated true because pass 1 pooled the
485+
// resources from the later entry before any entry ran.
486+
const r0 = (rs[0].resource.parameter || []).find(x => x.name === 'result');
487+
expect(r0 && r0.valueBoolean).toBe(true);
488+
});
489+
490+
test('resources are front-loaded even when the carrying entry fails, and persist (unsealed)', async () => {
491+
const cacheId = await startCache(false);
492+
const cs = mkCS('fail'); const vs = mkVS('fail', cs);
493+
const batch = await request(app).post(BATCH)
494+
.set('Content-Type', 'application/json')
495+
.set('x-cache-id', cacheId)
496+
.send({ resourceType: 'Parameters', parameter: [
497+
// this entry supplies vs+cs but validates a code that isn't in the system
498+
entry([
499+
{ name: 'tx-resource', resource: cs },
500+
{ name: 'valueSet', resource: vs },
501+
{ name: 'coding', valueCoding: { system: cs.url, code: 'NOPE' } }
502+
])
503+
] });
504+
expect(batch.status).toBe(200);
505+
506+
// Despite that entry not validating cleanly, vs was populated: a separate
507+
// by-reference $expand on the same cache now resolves it.
508+
const exp = await request(app).post('/tx/r5/ValueSet/$expand')
509+
.set('Content-Type', 'application/json')
510+
.set('x-cache-id', cacheId)
511+
.send({ resourceType: 'Parameters', parameter: [{ name: 'url', valueUri: vs.url }] });
512+
expect(exp.status).toBe(200);
513+
const codes = ((exp.body.expansion || {}).contains || []).map(c => c.code);
514+
expect(codes).toContain('fail1');
515+
});
516+
517+
test('a sealed batch does NOT share resources across entries', async () => {
518+
const cacheId = await startCache(true);
519+
const cs = mkCS('seal'); const vs = mkVS('seal', cs);
520+
const res = await request(app).post(BATCH)
521+
.set('Content-Type', 'application/json')
522+
.set('x-cache-id', cacheId)
523+
.send({ resourceType: 'Parameters', parameter: [
524+
// entry 0 references vs by url only
525+
entry([
526+
{ name: 'url', valueString: vs.url },
527+
{ name: 'coding', valueCoding: { system: cs.url, code: 'seal1' } }
528+
]),
529+
// entry 1 supplies vs - but a sealed cache does not share it to entry 0
530+
entry([
531+
{ name: 'tx-resource', resource: cs },
532+
{ name: 'valueSet', resource: vs },
533+
{ name: 'coding', valueCoding: { system: cs.url, code: 'seal1' } }
534+
])
535+
] });
536+
expect(res.status).toBe(200);
537+
const rs = results(res.body);
538+
// entry 0 could not resolve vs (no cross-entry sharing when sealed):
539+
// either an OperationOutcome or result=false, but not a clean true.
540+
const r0res = rs[0].resource;
541+
const r0 = (r0res.parameter || []).find(x => x.name === 'result');
542+
const unresolved = r0res.resourceType === 'OperationOutcome' || (r0 && r0.valueBoolean === false);
543+
expect(unresolved).toBe(true);
544+
// entry 1, which carried the resource itself, still validates true.
545+
const r1 = (rs[1].resource.parameter || []).find(x => x.name === 'result');
546+
expect(r1 && r1.valueBoolean).toBe(true);
547+
});
548+
549+
test('an unknown cache-id fails the whole batch with a coded 404', async () => {
550+
const cs = mkCS('unk'); const vs = mkVS('unk', cs);
551+
const res = await request(app).post(BATCH)
552+
.set('Content-Type', 'application/json')
553+
.set('x-cache-id', 'never-issued-this-id')
554+
.send({ resourceType: 'Parameters', parameter: [
555+
entry([
556+
{ name: 'valueSet', resource: vs },
557+
{ name: 'coding', valueCoding: { system: cs.url, code: 'unk1' } }
558+
])
559+
] });
560+
expect(res.status).toBe(404);
561+
expect(res.body.resourceType).toBe('OperationOutcome');
562+
const coding = (((res.body.issue || [])[0] || {}).details || {}).coding || [];
563+
expect(coding.some(c => c.code === 'cache-id-unknown')).toBe(true);
564+
});
565+
566+
// CodeSystem batch: same front-loading, but the primary being validated is a
567+
// code system (system+code), not a value set.
568+
test('a CodeSystem batch front-loads and resolves a system supplied by a later entry (unsealed)', async () => {
569+
const CS_BATCH = '/tx/r5/CodeSystem/$batch-validate-code';
570+
const cacheId = await startCache(false);
571+
const cs = mkCS('csbatch');
572+
const res = await request(app).post(CS_BATCH)
573+
.set('Content-Type', 'application/json')
574+
.set('x-cache-id', cacheId)
575+
.send({ resourceType: 'Parameters', parameter: [
576+
// entry 0: validates a code against cs by system url only (forward ref)
577+
entry([
578+
{ name: 'system', valueUri: cs.url },
579+
{ name: 'code', valueCode: 'csbatch1' }
580+
]),
581+
// entry 1: supplies cs inline, AFTER the entry that references it
582+
entry([
583+
{ name: 'tx-resource', resource: cs },
584+
{ name: 'system', valueUri: cs.url },
585+
{ name: 'code', valueCode: 'csbatch1' }
586+
])
587+
] });
588+
expect(res.status).toBe(200);
589+
const rs = results(res.body);
590+
expect(rs.length).toBe(2);
591+
const r0 = (rs[0].resource.parameter || []).find(x => x.name === 'result');
592+
expect(r0 && r0.valueBoolean).toBe(true);
593+
});
594+
});
425595
});

translations/Messages.properties

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1492,7 +1492,8 @@ RESOURCE_INTERNAL_USE_ONLY = This {0} comes from the package {1} which has been
14921492
TYPE_SPECIFIC_CHECKS_DT_SID_INCORRECT = The URL {0} is not a correct URL to use (in spite of being in the standard, and/or accepted in the past)
14931493
INACTIVE_DISPLAY_FOUND_one = ''{1}'' is no longer considered a correct display for code ''{2}'' (status = {4}). The correct display is ''{3}''
14941494
INACTIVE_DISPLAY_FOUND_other = ''{1}'' is no longer considered a correct display for code ''{2}'' (status = {4}). The correct display is one of {3}
1495-
INACTIVE_CONCEPT_FOUND = The concept ''{1}'' has a status of {0} and its use should be reviewed
1495+
INACTIVE_CONCEPT_FOUND = The concept ''{1}'' has a status of {0} and its use should be reviewed
1496+
INACTIVE_CONCEPT_FOUND_ADD = The concept ''{1}'' has a status of {0} and {2} and its use should be reviewed
14961497
DEPRECATED_CONCEPT_FOUND = The concept ''{1}'' is deprecated and its use should be reviewed
14971498
CONCEPT_DEPRECATED_IN_VALUESET = The presence of the concept ''{1}'' in the system ''{0}'' in the value set {3} is marked with a status of {2} and its use should be reviewed
14981499
SYSTEM_VERSION_MULTIPLE_OVERRIDE = Multiple version overrides found for system {0}: ''{1}'', ''{2}'');

tx/workers/batch-validate.js

Lines changed: 125 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
//
2-
// Validate Worker - Handles $validate-code operations
2+
// Batch Validate Worker - Handles $batch-validate-code operations
33
//
4-
// GET /CodeSystem/$validate-code?{params}
5-
// POST /CodeSystem/$validate-code
6-
// GET /CodeSystem/{id}/$validate-code?{params}
7-
// POST /CodeSystem/{id}/$validate-code
8-
// GET /ValueSet/$validate-code?{params}
9-
// POST /ValueSet/$validate-code
10-
// GET /ValueSet/{id}/$validate-code?{params}
11-
// POST /ValueSet/{id}/$validate-code
4+
// GET/POST /ValueSet/$batch-validate-code - batch of ValueSet $validate-code
5+
// GET/POST /CodeSystem/$batch-validate-code - batch of CodeSystem $validate-code
6+
//
7+
// A batch is a Parameters whose `validation` entries each carry a single
8+
// $validate-code request (as a nested Parameters). Shared inputs may be supplied
9+
// once at the top level ("globals") and are applied to every entry. Entries are
10+
// dispatched per-shape (a url/valueSet entry validates against a value set; a
11+
// system/codeSystem entry validates against a code system), so both batch routes
12+
// share one implementation and even a mixed batch is handled correctly.
1213
//
1314

1415
const { TerminologyWorker } = require('./worker');
@@ -30,12 +31,20 @@ class BatchValidateWorker extends TerminologyWorker {
3031
*/
3132
constructor(opContext, log, provider, languages, i18n) {
3233
super(opContext, log, provider, languages, i18n);
33-
this.globalNames.add("tx-resource");
34-
this.globalNames.add("url");
35-
this.globalNames.add("valueSet");
36-
this.globalNames.add("lenient-display-validation");
37-
this.globalNames.add("__Accept-Language");
38-
this.globalNames.add("__Content-Language");
34+
// "Global" parameters may be supplied once at the top level and are applied to
35+
// every entry that doesn't override them. tx-resource is always shared. The
36+
// primary differs by operation: a ValueSet batch shares url/valueSet, a
37+
// CodeSystem batch shares system/codeSystem.
38+
this.valueSetGlobalNames = new Set([
39+
"tx-resource", "url", "valueSet", "lenient-display-validation",
40+
"__Accept-Language", "__Content-Language"
41+
]);
42+
this.codeSystemGlobalNames = new Set([
43+
"tx-resource", "system", "codeSystem", "lenient-display-validation",
44+
"__Accept-Language", "__Content-Language"
45+
]);
46+
// Back-compat: `globalNames` was the (ValueSet) set before CodeSystem batches.
47+
this.globalNames = this.valueSetGlobalNames;
3948
}
4049

4150
/**
@@ -46,31 +55,70 @@ class BatchValidateWorker extends TerminologyWorker {
4655
return 'batch-validate-code';
4756
}
4857

58+
/** ValueSet/$batch-validate-code: shared globals are url/valueSet. */
4959
async handleValueSet(req, res) {
60+
return this.processBatch(req, res, this.valueSetGlobalNames);
61+
}
62+
63+
/** CodeSystem/$batch-validate-code: shared globals are system/codeSystem. */
64+
async handleCodeSystem(req, res) {
65+
return this.processBatch(req, res, this.codeSystemGlobalNames);
66+
}
67+
68+
/**
69+
* Run a batch of $validate-code requests. Two passes: frontLoadBatch() pools all
70+
* supplied resources into an unsealed cache first (see that method); then each
71+
* `validation` entry is evaluated, inheriting the top-level globals it does not
72+
* override and dispatched by shape to the ValueSet or CodeSystem validator.
73+
*
74+
* @param {express.Request} req
75+
* @param {express.Response} res
76+
* @param {Set<string>} globalNames - which top-level params are shared globals
77+
*/
78+
async processBatch(req, res, globalNames) {
5079
try {
5180
let params = req.body;
5281
this.addHttpParams(req, params);
5382

5483
let globalParams = [];
5584
for (const p of params.parameter) {
56-
if (this.globalNames.has(p.name)) {
85+
if (globalNames.has(p.name)) {
5786
globalParams.push(p);
5887
}
5988
}
6089

90+
// Pass 1: front-load every resource the batch supplies into the (unsealed)
91+
// session cache before any entry is evaluated. When this happens, pass 2
92+
// below drops per-entry tx-resource processing (they're already cached).
93+
const frontLoaded = this.frontLoadBatch(params);
94+
6195
let output = [];
6296

6397
for (const p of params.parameter) {
6498
if (p.name == 'validation') {
6599
let op = new Parameters();
66100
op.jsonObj.parameter = [];
67101
for (const gp of globalParams) {
102+
if (gp.name == 'tx-resource') {
103+
// Pass 2: when the batch was front-loaded into an unsealed cache, the
104+
// tx-resources are already in the cache and every entry resolves them
105+
// by reference - don't re-inject or re-process them per entry. Without
106+
// front-loading (no cache, or a sealed cache) keep the original
107+
// behaviour: the global tx-resources apply to every entry.
108+
if (!frontLoaded) {
109+
op.jsonObj.parameter.push(gp);
110+
}
111+
continue;
112+
}
68113
let exists = p.resource.parameter.find(pp => gp.name == pp.name);
69-
if (gp.name == 'tx-resource' || !exists) {
114+
if (!exists) {
70115
op.jsonObj.parameter.push(gp);
71116
}
72117
}
73-
op.jsonObj.parameter.push(...p.resource.parameter);
118+
const entryParams = frontLoaded
119+
? p.resource.parameter.filter(pp => pp.name !== 'tx-resource')
120+
: p.resource.parameter;
121+
op.jsonObj.parameter.push(...entryParams);
74122

75123
let worker = new ValidateWorker(this.opContext.copy(), this.log, this.provider, this.languages, this.i18n);
76124
try {
@@ -100,11 +148,70 @@ class BatchValidateWorker extends TerminologyWorker {
100148
} catch (error) {
101149
this.log.error(error);
102150
debugLog(error);
151+
// A batch-level failure (e.g. an unknown cache-id from pass 1) applies to the
152+
// whole batch. Preserve a coded Issue (like cache-id-unknown) so the client
153+
// gets the same coded OperationOutcome + status it would on a single op.
154+
if (error instanceof Issue) {
155+
const oo = new OperationOutcome();
156+
oo.addIssue(error);
157+
return res.status(error.statusCode || 500).json(oo.jsonObj);
158+
}
103159
return res.status(error.statusCode || 500).json(this.operationOutcome(
104160
'error', error.issueCode || 'exception', error.message));
105161
}
106162
}
107163

164+
/**
165+
* Pass 1 of batch processing: front-load every resource the batch supplies
166+
* (each `tx-resource`, plus each entry's primary `valueSet`/`codeSystem`) into
167+
* the session cache before any entry is evaluated. This makes the batch
168+
* order-independent - an entry may refer by url to a resource another entry
169+
* supplied - and means a failing entry does not withhold the resources it
170+
* carried, because population happens up front and as one step.
171+
*
172+
* Front-loading is only relevant for an *unsealed* cache: that is what grows, so
173+
* that is how one entry's resources become visible to the others. A sealed cache
174+
* does not grow (each entry stays self-contained), and with no cache there is
175+
* nowhere to pool; in both cases this returns false and pass 2 keeps the original
176+
* per-entry tx-resource handling.
177+
*
178+
* The unknown-cache-id check is done here, once, for the whole batch.
179+
*
180+
* @param {Object} params - the batch Parameters (req.body)
181+
* @returns {boolean} true if resources were front-loaded into an unsealed cache
182+
*/
183+
frontLoadBatch(params) {
184+
const cacheId = this.opContext ? this.opContext.cacheId : null;
185+
const cache = this.opContext ? this.opContext.resourceCache : null;
186+
if (!cacheId || !cache) {
187+
return false;
188+
}
189+
if (!cache.has(cacheId)) {
190+
throw new Issue('error', 'not-found', null, 'CACHE_ID_UNKNOWN',
191+
this.i18n.translate('CACHE_ID_UNKNOWN', this.opContext.langs, [cacheId]),
192+
'cache-id-unknown', 404);
193+
}
194+
if (cache.isSealed(cacheId)) {
195+
return false;
196+
}
197+
// Flatten the top-level params and every entry's params into one list, then let
198+
// collectSuppliedResources pick out the tx-resource + primary valueSet/codeSystem.
199+
const allParams = [];
200+
for (const p of params.parameter) {
201+
if (p.name === 'validation' && p.resource && Array.isArray(p.resource.parameter)) {
202+
allParams.push(...p.resource.parameter);
203+
} else {
204+
allParams.push(p);
205+
}
206+
}
207+
const { txResources, primaryResources } = this.collectSuppliedResources({ parameter: allParams });
208+
const pool = txResources.concat(primaryResources);
209+
if (pool.length > 0) {
210+
cache.add(cacheId, pool);
211+
}
212+
return true;
213+
}
214+
108215
/**
109216
* Build an OperationOutcome
110217
*/

0 commit comments

Comments
 (0)