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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fumifier",
"version": "2.2.0",
"version": "2.2.1",
"description": "Fumifier is the core FLASH DSL engine inside FUME - It parses, compiles and executes FHIR related transformation logic expressed with FUME's specialized syntax (FLASH) and applies it to JSON data structures.",
"author": "Outburn Ltd.",
"main": "./dist/index.cjs",
Expand Down Expand Up @@ -92,7 +92,7 @@
"lru-cache": "^11.3.5"
},
"devDependencies": {
"@outburn/structure-navigator": "^1.9.4",
"@outburn/structure-navigator": "^1.9.5",
"@outburn/types": "^0.1.0",
"@types/json5": "^0.0.30",
"@types/node": "^25.6.0",
Expand Down Expand Up @@ -123,7 +123,7 @@
},
"peerDependencies": {
"@outburn/fhir-client": "^1.5.0",
"@outburn/structure-navigator": "^1.9.4",
"@outburn/structure-navigator": "^1.9.5",
"@outburn/types": "^0.1.0 || ^0.2.0",
"fhir-package-explorer": "^1.9.3",
"fhir-snapshot-generator": "^2.3.1",
Expand Down
40 changes: 35 additions & 5 deletions src/fumifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ class FumifierError extends Error {
}

/**
* @typedef {import('@outburn/structure-navigator').FhirStructureNavigator} FhirStructureNavigator
* @typedef {import('@outburn/structure-navigator').FhirStructureNavigatorInterface} FhirStructureNavigatorInterface
*/

/**
Expand Down Expand Up @@ -151,7 +151,7 @@ class FumifierError extends Error {
/**
* @typedef FumifierOptions
* @property {boolean} [recover] Attempt to recover on parse error.
* @property {FhirStructureNavigator} [navigator] FHIR structure navigator used to resolve FLASH constructs.
* @property {FhirStructureNavigatorInterface} [navigator] FHIR structure navigator used to resolve FLASH constructs.
* @property {FhirTerminologyRuntime} [terminologyRuntime] FHIR terminology runtime used for valueset expansions.
* @property {AstCacheInterface} [astCache] Optional AST cache implementation for parsed expressions. Defaults to shared LRU cache.
* @property {MappingCacheInterface} [mappingCache] Optional mapping repository for named expressions.
Expand Down Expand Up @@ -2458,6 +2458,10 @@ var fumifier = (function() {
return environment.lookup(Symbol.for('fumifier.__fhirClient'));
}

if (activeConnection.client) {
return activeConnection.client;
}

const connectionResolver = environment.lookup(Symbol.for('fumifier.__connectionResolver'));
if (!connectionResolver) {
return environment.lookup(Symbol.for('fumifier.__fhirClient'));
Expand All @@ -2475,17 +2479,43 @@ var fumifier = (function() {
env.bind('resolve', defineFunction(wrappers.resolve, '<s-o?o?:o>'));
env.bind('literal', defineFunction(wrappers.literal, '<s-o?o?:s>'));
env.bind('useFhirServer', defineFunction(function(target, config) {
const currentFhirServerSymbol = Symbol.for('fumifier.__currentFhirServer');

if (typeof target === 'undefined') {
this.environment.bind(Symbol.for('fumifier.__currentFhirServer'), null);
this.environment.bind(currentFhirServerSymbol, null);
return undefined;
}

const connectionResolver = this.environment.lookup(Symbol.for('fumifier.__connectionResolver'));
if (!connectionResolver) {
throw new FumifierError('D3200', undefined, {
target,
stack: (new Error()).stack
});
}

let client;
try {
client = connectionResolver(target, config);
} catch (err) {
if (typeof err?.code === 'string' && /^[DSTF]/.test(err.code)) {
throw err;
}

throw new FumifierError('D3201', undefined, {
target,
sourceMessage: err?.message || String(err),
sourceError: err,
stack: err?.stack || (new Error()).stack
});
}

if (typeof config === 'undefined') {
this.environment.bind(Symbol.for('fumifier.__currentFhirServer'), { target });
this.environment.bind(currentFhirServerSymbol, { target, client });
return undefined;
}

this.environment.bind(Symbol.for('fumifier.__currentFhirServer'), { target, config });
this.environment.bind(currentFhirServerSymbol, { target, config, client });
return undefined;
}, '<s?o?:u>'));
}
Expand Down
11 changes: 10 additions & 1 deletion src/utils/createFhirFetchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@ License: See the LICENSE file included with this package for the terms that appl
*/



/**
* @typedef {import('@outburn/structure-navigator').FhirStructureNavigatorInterface} FhirStructureNavigatorInterface
*/

/**
* @typedef {import('fhir-terminology-runtime').FhirTerminologyRuntime} FhirTerminologyRuntime
*/

/** Takes a FHIR Structure Navigator and FHIR Terminology Runtime and returns helper functions used to fetch FHIR semantic data
* @param {FhirStructureNavigator} navigator - FHIR structure navigator
* @param {FhirStructureNavigatorInterface} navigator - FHIR structure navigator
* @param {FhirTerminologyRuntime} terminologyRuntime - FHIR terminology runtime for valueset expansions
* @returns {Object} An object containing functions to fetch FHIR data
* @property {Function} getElement - Function to fetch element definitions
Expand Down
2 changes: 2 additions & 0 deletions src/utils/errorCodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ const errorCodes = {
"D3139": "The $single() function expected exactly 1 matching result. Instead it matched 0.",
"D3140": "Malformed URL passed to ${{{functionName}}}(): {{value}}",
"D3141": "{{{message}}}",
"D3200": "Cannot switch FHIR server to {{target}}: connection resolver is not configured.",
"D3201": "Failed to switch FHIR server to {{target}}: {{{sourceMessage}}}",
"F0001": "Failed to extract root FHIR package context. This may hinder AST mobility. FHIR Package Explorer < v1.5.0 doesn't support this operation.",
"F1000": "FLASH blocks are present in the expression, but no FHIR Structure Navigator was provided. Cannot process FHIR conformance.",
"F1003": "Invalid FHIR type/profile identifier after `InstanceOf:`",
Expand Down
10 changes: 9 additions & 1 deletion src/utils/resolveDefinitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ import extractSystemFhirType from './extractSystemFhirType.js';
import { populateMessage } from './errorCodes.js';
import fn from './functions.js';

/**
* @typedef {import('@outburn/structure-navigator').FhirStructureNavigatorInterface} FhirStructureNavigatorInterface
*/

/**
* @typedef {import('fhir-terminology-runtime').FhirTerminologyRuntime} FhirTerminologyRuntime
*/

/**
* Centralized recoverable error handling helper.
* @param {Object} base - base error object without position information
Expand Down Expand Up @@ -70,7 +78,7 @@ function handleRecoverableError(base, positions, recover, errors, errObj) {
* After parsing a Fumifier expression and running it through processAst,
* if the expression has FLASH it will be flagged as such and passed here for FHIR definition resolution and processing.
* @param {Object} expr - Parsed Fumifier expression
* @param {FhirStructureNavigator} navigator - FHIR structure navigator
* @param {FhirStructureNavigatorInterface} navigator - FHIR structure navigator
* @param {FhirTerminologyRuntime} terminologyRuntime - FHIR terminology runtime for valueset expansions
* @param {boolean} recover - If true, will continue processing and collect errors instead of throwing them.
* @param {Array} errors - Array to collect errors if recover is true
Expand Down
67 changes: 62 additions & 5 deletions test/use-fhir-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,20 +139,77 @@ describe('$useFhirServer', function() {
]);
});

it('throws when resolver cannot resolve a named connection', async function() {
it('throws immediately when resolver cannot resolve a named connection', async function() {
const defaultClient = createClient(1);

const expr = await fumifier("($useFhirServer('nonExistent'); $search('Patient', {}))", {
const expr = await fumifier("$useFhirServer('nonExistent')", {
fhirClient: defaultClient,
connectionResolver: (target) => {
throw new Error(`Unknown FHIR connection name: "${target}"`);
}
});

await expect(expr.evaluate({})).to.eventually.be.rejectedWith('Unknown FHIR connection name: "nonExistent"');
try {
await expr.evaluate({});
expect.fail('Expected evaluation to reject');
} catch (err) {
expect(err.code).to.equal('D3201');
expect(err.token).to.equal('useFhirServer');
expect(err.message).to.equal('Failed to switch FHIR server to "nonExistent": Unknown FHIR connection name: "nonExistent"');
}
});

it('keeps $translateCode on terminology runtime and does not use connection resolver', async function() {
it('throws when a target is provided without a connection resolver', async function() {
const expr = await fumifier("$useFhirServer('nonExistent')");

try {
await expr.evaluate({});
expect.fail('Expected evaluation to reject');
} catch (err) {
expect(err.code).to.equal('D3200');
expect(err.token).to.equal('useFhirServer');
expect(err.message).to.equal('Cannot switch FHIR server to "nonExistent": connection resolver is not configured.');
}
});

it('resolves once at $useFhirServer and reuses the stored client for later FHIR helpers', async function() {
const defaultClient = createClient(1);
const namedClient = createClient(2);
const resolverCalls = [];

const expr = await fumifier("($useFhirServer('myConn'); [$search('Patient', {}).total, $capabilities().resourceType])", {
fhirClient: defaultClient,
connectionResolver: (target, config) => {
resolverCalls.push({ target, config });
return namedClient;
}
});

const result = await expr.evaluate({});

expect(result).to.deep.equal([2, 'CapabilityStatement']);
expect(resolverCalls).to.deep.equal([{ target: 'myConn', config: undefined }]);
});

it('wraps plain resolver failures with D3201 details', async function() {
const expr = await fumifier("$useFhirServer('myConn')", {
connectionResolver: () => {
throw new Error('boom');
}
});

try {
await expr.evaluate({});
expect.fail('Expected evaluation to reject');
} catch (err) {
expect(err.code).to.equal('D3201');
expect(err.token).to.equal('useFhirServer');
expect(err.message).to.equal('Failed to switch FHIR server to "myConn": boom');
expect(err.sourceMessage).to.equal('boom');
}
});

it('keeps $translateCode on terminology runtime after switching FHIR servers', async function() {
const defaultClient = createClient(1);
const resolverCalls = [];
const terminologyRuntime = {
Expand All @@ -175,7 +232,7 @@ describe('$useFhirServer', function() {

const result = await expr.evaluate({});
expect(result).to.equal('mapped-code');
expect(resolverCalls).to.deep.equal([]);
expect(resolverCalls).to.deep.equal([{ target: 'myConn', config: undefined }]);
});

it('affects later object entries when the modifier is evaluated inside the object constructor', async function() {
Expand Down
Loading