Skip to content

Commit a198b96

Browse files
Merge branch 'bugfix/LF-3330/use-GET-for-memberOf-when-possible' into 'master'
Use GET for the memberOf() function whenever possible See merge request lfor/fhirpath.js!43
2 parents f17107e + bcf6bda commit a198b96

5 files changed

Lines changed: 195 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
This log documents significant changes for each release. This project follows
44
[Semantic Versioning](http://semver.org/).
55

6-
## [4.5.1] - 2025-07-07
6+
## [4.5.1] - 2025-07-09
77
### Added
88
- tests for the R5 model.
9+
### Changed
10+
- Updated the "%terminologies.validateVS()" method and "memberOf()" function to
11+
use GET for CodeableConcept with a single Coding when using a ValueSet
12+
reference instead of an actual ValueSet.
913
### Fixed
1014
- updated tests for the R4 model.
1115

src/additional.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ engine.memberOf = function (coll, valueSetColl ) {
3636
}
3737
return Terminologies.validateVS.call(this,
3838
[terminologies], valueSetColl, coll, ''
39-
).then(params => {
39+
)?.then(params => {
4040
return util.valData(params)?.parameter.find((p) => p.name === "result").valueBoolean;
4141
}, () => []);
4242
}

src/terminologies.js

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -172,23 +172,28 @@ class Terminologies {
172172
const ctx = this;
173173
util.checkAllowAsync(ctx, 'validateVS');
174174

175-
if(valueSetColl.length === 1 && codedColl.length === 1 &&
176-
checkParams(params)) {
175+
const valueSet = valueSetColl.length === 1 && util.valData(valueSetColl[0]);
176+
let coded = codedColl.length === 1 && util.valData(codedColl[0]);
177+
178+
// If valueSet or coded are empty, we can predict that the $validate-code
179+
// operation will return an error.
180+
if(valueSet && coded && checkParams(params)) {
177181
const vsTypeInfo = TypeInfo.fromValue(valueSetColl[0]);
178182
const isActualValueSet = vsTypeInfo.is(TypeInfo.FhirValueSet, ctx.model);
179183
const isValueSetUrl = vsTypeInfo.is(TypeInfo.FhirUri, ctx.model) ||
180184
vsTypeInfo.is(TypeInfo.SystemString, ctx.model);
181185
if (isActualValueSet || isValueSetUrl) {
182186
const {isCodeableConcept, isCoding, isCode} = getCodedType(ctx, codedColl);
183187
if (isCodeableConcept || isCoding || isCode) {
184-
const valueSet = util.valData(valueSetColl[0]);
185-
const coded = util.valData(codedColl[0]);
186188
const requestUrl =
187189
`${self[0].terminologyUrl}/ValueSet/$validate-code`;
188190

189-
if (isActualValueSet || isCodeableConcept) {
190-
// Workaround for the case where we don't have a system.
191-
// See discussion here:
191+
// Use a POST request if the passed valueSet is an actual ValueSet or
192+
// the passed coded value is a CodeableConcept with more than one
193+
// coding or no coding.
194+
if (isActualValueSet || isCodeableConcept && coded.coding?.length !== 1) {
195+
// getSystemFromVS() is a workaround for the case where we don't
196+
// have a system. See discussion here:
192197
// https://chat.fhir.org/#narrow/stream/179266-fhirpath/topic/Problem.20with.20the.20.22memberOf.22.20function.20and.20R4.20servers
193198
response = (isCode ?
194199
getSystemFromVS(ctx, self[0].terminologyUrl, valueSet)
@@ -218,7 +223,7 @@ class Terminologies {
218223
}
219224
);
220225
});
221-
} else {
226+
} else { // Otherwise use a GET request.
222227
if (isCode) {
223228
// Workaround for the case where we don't have a system.
224229
// See discussion here:
@@ -237,18 +242,27 @@ class Terminologies {
237242
}
238243
);
239244
});
240-
} else if (isCoding) {
241-
const queryParams = new URLSearchParams({
242-
url: valueSet ?? '',
243-
system: coded.system ?? '',
244-
code: coded.code
245-
});
246-
response = util.fetchWithCache(
247-
`${requestUrl}?${queryParams.toString() + (params ? '&' + params : '')}`,
248-
{
249-
...(ctx.signal ? {signal: ctx.signal} : {})
250-
}
251-
);
245+
} else {
246+
// If the coded value is a CodeableConcept with only one Coding
247+
if (isCodeableConcept) {
248+
coded = coded.coding[0];
249+
}
250+
// If the coded value is Coding and has system and code, we can
251+
// use it in the request URL; otherwise, the $validate-code
252+
// operation will return an error.
253+
if (coded?.system && coded?.code) {
254+
const queryParams = new URLSearchParams({
255+
url: valueSet,
256+
system: coded.system,
257+
code: coded.code
258+
});
259+
response = util.fetchWithCache(
260+
`${requestUrl}?${queryParams.toString() + (params ? '&' + params : '')}`,
261+
{
262+
...(ctx.signal ? {signal: ctx.signal} : {})
263+
}
264+
);
265+
}
252266
}
253267
}
254268
}
@@ -779,19 +793,19 @@ function getCodedType(ctx, codedColl) {
779793
* object or null.
780794
* @param {string} resourceType - The expected FHIR resource type (e.g.,
781795
* "ValueSet", "Parameters").
782-
* @returns {Promise<ResourceNode|null>} - A promise resolving to a ResourceNode
783-
* if the resource type matches, or null if an error occurs or the resource
784-
* type does not match.
796+
* @returns {Promise<ResourceNode|null>|null} - A promise resolving to a ResourceNode
797+
* if the resource type matches, or to null if an error occurs or the resource
798+
* type does not match; or null if the given response object is falsy.
785799
*/
786800
function transformResponseToResource(ctx, response, resourceType) {
787-
return response && response.then(obj => {
801+
return response?.then(obj => {
788802
if (obj?.resourceType === resourceType) {
789803
return ResourceNode.makeResNode(obj, null, null, null, null, ctx.model);
790804
}
791805
// Throw an error if the resource type does not match - will cause the catch
792806
// function to be called.
793807
throw new Error('Unexpected resourceType in response: ' + obj?.resourceType);
794-
}).catch(() => null);
808+
}).catch(() => null) || null;
795809
}
796810

797811
module.exports = Terminologies;

test/async-functions.test.js

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,52 @@ describe('Async functions', () => {
412412
expect(result).toEqual([]);
413413
});
414414

415+
it('should return an empty collection when a code does not have a value', () => {
416+
mockFetchResults([
417+
['ValueSet?url=http%3A%2F%2Fhl7.org%2Ffhir%2FValueSet%2Fobservation-vitalsignresult', {
418+
"resourceType": "Bundle",
419+
"entry": [{
420+
"resource": {
421+
"resourceType": "ValueSet",
422+
"url": "http://hl7.org/fhir/ValueSet/observation-vitalsignresult",
423+
"compose": {
424+
"include": [{
425+
"system": "http://loinc.org",
426+
"concept": [
427+
{"code": "85353-1"}, {"code": "9279-1"}, {"code": "8867-4"},
428+
{"code": "2708-6"}, {"code": "8310-5"}, {"code": "8302-2"},
429+
{"code": "9843-4"}, {"code": "29463-7"},
430+
{"code": "39156-5"}, {"code": "85354-9"},
431+
{"code": "8480-6"}, {"code": "8462-4"}, {"code": "8478-0"}
432+
]
433+
}]
434+
}
435+
}
436+
}]
437+
}]
438+
]);
439+
440+
let result = fhirpath.evaluate(
441+
{
442+
"resourceType": "Observation",
443+
"code": {
444+
"coding":
445+
[{
446+
"_code": {
447+
"id": "someCodeId"
448+
}
449+
}]
450+
}
451+
},
452+
"%terminologies.validateVS('http://hl7.org/fhir/ValueSet/observation-vitalsignresult', Observation.code.coding.code)",
453+
{},
454+
modelR4,
455+
{ async: true, terminologyUrl: "https://lforms-fhir.nlm.nih.gov/baseR4" }
456+
);
457+
458+
expect(result).toEqual([]);
459+
});
460+
415461

416462
it('should throw an error when the async function is not allowed', () => {
417463
let result = () => fhirpath.evaluate(
@@ -682,7 +728,11 @@ describe('Async functions', () => {
682728

683729
it('should work with CodeableConcept when async functions are enabled', (done) => {
684730
mockFetchResults([
685-
[/ValueSet\/\$validate-code/, {
731+
[{
732+
url: '/ValueSet/$validate-code',
733+
body: '"system":"http://loinc.org"',
734+
method: 'POST'
735+
}, {
686736
"resourceType": "Parameters",
687737
"parameter": [
688738
{
@@ -707,6 +757,79 @@ describe('Async functions', () => {
707757
});
708758

709759

760+
it('should use GET for CodeableConcept with a single Coding', (done) => {
761+
mockFetchResults([
762+
[{
763+
url: /ValueSet\/\$validate-code.*&system=system1/,
764+
method: 'GET'
765+
}, {
766+
"resourceType": "Parameters",
767+
"parameter": [
768+
{
769+
"name": "result",
770+
"valueBoolean": true
771+
}
772+
]
773+
}]
774+
]);
775+
let result = fhirpath.evaluate(
776+
observationResource,
777+
"%factory.CodeableConcept(%factory.Coding('system1', '1')).memberOf('http://some-valueset')",
778+
{},
779+
modelR4,
780+
{ async: true, terminologyUrl: "https://lforms-fhir.nlm.nih.gov/baseR4" }
781+
);
782+
expect(result instanceof Promise).toBe(true);
783+
result.then((r) => {
784+
expect(r).toEqual([true]);
785+
done();
786+
})
787+
});
788+
789+
it('should work when Coding in a CodeableConcept has no system and code', () => {
790+
mockFetchResults([]);
791+
let result = fhirpath.evaluate(
792+
{},
793+
"%factory.CodeableConcept(%factory.Coding('', '', 'some display text')).memberOf('http://some-valueset')",
794+
{},
795+
modelR4,
796+
{ async: true, terminologyUrl: "https://lforms-fhir.nlm.nih.gov/baseR4" }
797+
);
798+
expect(result).toEqual([]);
799+
});
800+
801+
802+
it('should work when no Coding in a CodeableConcept', (done) => {
803+
mockFetchResults([
804+
[{
805+
url: '/ValueSet/$validate-code',
806+
body: '"valueUri":"http://some-valueset"',
807+
method: 'POST'
808+
}, null, {
809+
"resourceType": "OperationOutcome",
810+
"issue": [ {
811+
"severity": "error",
812+
"code": "processing",
813+
"diagnostics": "HAPI-0899: No code, coding, or codeableConcept provided to validate"
814+
} ]
815+
}]
816+
]);
817+
let result = fhirpath.evaluate(
818+
{},
819+
"%factory.CodeableConcept(Observation.code.coding).memberOf('http://some-valueset')",
820+
{},
821+
modelR4,
822+
{ async: true, terminologyUrl: "https://lforms-fhir.nlm.nih.gov/baseR4" }
823+
);
824+
expect(result instanceof Promise).toBe(true);
825+
result.then((r) => {
826+
expect(r).toEqual([]);
827+
done();
828+
});
829+
});
830+
831+
832+
710833
it('should work with "code" when async functions are enabled', (done) => {
711834
mockFetchResults([
712835
[/ValueSet\?url=http%3A%2F%2Fhl7\.org%2Ffhir%2FValueSet%2Fobservation-vitalsignresult/, {

test/mock-fetch-results.js

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,26 @@ let fetchSpy;
33

44
/**
55
* Checks whether a string (in the first parameter) matches a regular expression,
6-
* substring or test function (in the second parameter).
6+
* contains the substring or satisfies the test function (in the second parameter).
77
* If the second parameter is not passed (no condition), returns true.
88
* @param {string} str - string to check
99
* @param {RegExp|string|Function|undefined} condition - regular expression,
1010
* substring, or test function.
11+
* @param {Object} [options] - apply condition options:
12+
* @param {Object} [options.fullStringMatch] - whether to perform a full string
13+
* comparison on the strings in the "str" and "condition" parameters, defaults
14+
* to false.
1115
* @returns {boolean}
1216
*/
13-
function checkString(str, condition) {
17+
function checkString(str, condition, options= {}) {
1418
if (condition === undefined) {
1519
return true;
1620
} else if (condition instanceof RegExp) {
1721
return condition.test(str);
1822
} else if (typeof condition === 'string') {
19-
return str && (str.indexOf(condition) !== -1)
23+
return options?.fullStringMatch ?
24+
str === condition
25+
: (str !== null && str !== undefined && str.indexOf(condition) !== -1);
2026
} else if (condition instanceof Function) {
2127
return condition(str);
2228
} else {
@@ -36,19 +42,26 @@ function checkString(str, condition) {
3642
* response as the third item.
3743
* For mocking POST requests the first item of a response description with a URL
3844
* condition could be replaced with an object:
39-
* {url: string|Regexp, body: string|RegExp},
45+
* {url: string|Regexp, body: string|RegExp, method: string},
4046
* where "url" is a RegExp for a URL or a URL substring,
41-
* "body" is a RegExp for the body content or a substring of the body content.
47+
* "body" is a RegExp for the body content or a substring of the body content,
48+
* and "method" is a RegExp for the HTTP method (e.g. "POST") or a substring of
49+
* the HTTP method.
50+
*
4251
* @param {Object} options - options for the mock.
4352
* @param {number} [options.timeout=0] - timeout for the mock response.
4453
*/
4554
function mockFetchResults(results, {timeout = 0} = {}) {
4655
fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(
47-
(url, options) => new Promise((resolve, reject) => {
56+
(url, options) => new Promise((resolve) => {
4857
const mockedItem = results?.find(
4958
(r) => {
50-
if (typeof r[0] === 'object' && (r[0].url || r[0].body)) {
51-
return checkString(url, r[0]?.url) && checkString(options.body, r[0]?.body);
59+
if (typeof r[0] === 'object' && r[0] !== null && (r[0].url ||
60+
r[0].body || r[0].method)) {
61+
return checkString(url, r[0]?.url) &&
62+
checkString(options.body, r[0]?.body) &&
63+
checkString(options.method ?? 'GET', r[0]?.method,
64+
{fullStringMatch: true});
5265
} else {
5366
return checkString(url, r[0]);
5467
}
@@ -60,7 +73,7 @@ function mockFetchResults(results, {timeout = 0} = {}) {
6073
options?.method === 'POST' &&
6174
optionHeaders.get('Content-Type') !== 'application/fhir+json; charset=utf-8'
6275
) {
63-
reportError('Unexpected request header.', reject);
76+
reportError('Unexpected request header.');
6477
}
6578

6679
const okResult = mockedItem?.[1];
@@ -84,7 +97,7 @@ function mockFetchResults(results, {timeout = 0} = {}) {
8497
ok: false
8598
});
8699
} else {
87-
reportError(`"${url}" is not mocked.`, reject);
100+
reportError(`"${url}" is not mocked.`);
88101
}
89102
}, timeout);
90103
})
@@ -94,13 +107,12 @@ function mockFetchResults(results, {timeout = 0} = {}) {
94107
/**
95108
* Report an error message.
96109
* @param {string} msg - error message to report.
97-
* @param {Function} rejectFn - function to call to reject the promise.
98110
*/
99-
function reportError(msg, rejectFn) {
111+
function reportError(msg) {
100112
// Show error message in the console:
101113
console.error(msg);
102-
// Reject the promise so that the request to the server fails:
103-
rejectFn(msg);
114+
// Throw an error so that the test fails:
115+
throw new Error(msg);
104116
}
105117

106118

0 commit comments

Comments
 (0)