diff --git a/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/r4/measure/MeasureOperationsProvider.java b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/r4/measure/MeasureOperationsProvider.java index 3e1012f889..a914bd4fb8 100644 --- a/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/r4/measure/MeasureOperationsProvider.java +++ b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/r4/measure/MeasureOperationsProvider.java @@ -8,6 +8,7 @@ import ca.uhn.fhir.rest.annotation.OperationParam; import ca.uhn.fhir.rest.api.server.RequestDetails; import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; +import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; import ca.uhn.fhir.rest.server.provider.ProviderConstants; import java.util.List; import org.hl7.fhir.exceptions.FHIRException; @@ -20,6 +21,7 @@ import org.opencds.cqf.fhir.cr.hapi.common.StringTimePeriodHandler; import org.opencds.cqf.fhir.cr.hapi.r4.R4MeasureEvaluatorMultipleFactory; import org.opencds.cqf.fhir.cr.hapi.r4.R4MeasureEvaluatorSingleFactory; +import org.opencds.cqf.fhir.cr.measure.common.InvalidMeasureRequestException; import org.opencds.cqf.fhir.cr.measure.common.MeasureEnvironment; import org.opencds.cqf.fhir.cr.measure.common.MeasureReference; @@ -90,18 +92,22 @@ public MeasureReport evaluateMeasure( var dataEndpointParam = (Endpoint) getEndpoint(fhirVersion, dataEndpoint); var environment = new MeasureEnvironment( contentEndpointParam, terminologyEndpointParam, dataEndpointParam, additionalData); - return r4MeasureServiceFactory - .create(requestDetails, environment) - .evaluate( - new MeasureReference.ById(id), - stringTimePeriodHandler.getStartZonedDateTime(periodStart, requestDetails), - stringTimePeriodHandler.getEndZonedDateTime(periodEnd, requestDetails), - reportType, - subject, - lastReceivedOn, - parameters, - productLine, - practitioner); + try { + return r4MeasureServiceFactory + .create(requestDetails, environment) + .evaluate( + new MeasureReference.ById(id), + stringTimePeriodHandler.getStartZonedDateTime(periodStart, requestDetails), + stringTimePeriodHandler.getEndZonedDateTime(periodEnd, requestDetails), + reportType, + subject, + lastReceivedOn, + parameters, + productLine, + practitioner); + } catch (InvalidMeasureRequestException exception) { + throw new InvalidRequestException(exception.getMessage(), exception); + } } /** @@ -158,16 +164,20 @@ public Parameters evaluate( var measureRefs = MeasureReference.fromOperationParams(measureId, measureIdentifier, measureUrl); var environment = new MeasureEnvironment( contentEndpointParam, terminologyEndpointParam, dataEndpointParam, additionalData); - return r4MultiMeasureServiceFactory - .create(requestDetails, environment) - .evaluate( - measureRefs, - stringTimePeriodHandler.getStartZonedDateTime(periodStart, requestDetails), - stringTimePeriodHandler.getEndZonedDateTime(periodEnd, requestDetails), - reportType, - subject, - parameters, - productLine, - reporter); + try { + return r4MultiMeasureServiceFactory + .create(requestDetails, environment) + .evaluate( + measureRefs, + stringTimePeriodHandler.getStartZonedDateTime(periodStart, requestDetails), + stringTimePeriodHandler.getEndZonedDateTime(periodEnd, requestDetails), + reportType, + subject, + parameters, + productLine, + reporter); + } catch (InvalidMeasureRequestException exception) { + throw new InvalidRequestException(exception.getMessage(), exception); + } } } diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/InvalidMeasureDefinitionException.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/InvalidMeasureDefinitionException.java index a87c7e5714..c117ed2f31 100644 --- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/InvalidMeasureDefinitionException.java +++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/InvalidMeasureDefinitionException.java @@ -5,7 +5,7 @@ * stratifier with no {@code criteria.expression} and no components, or other shape errors that make * the Measure un-evaluable. */ -public class InvalidMeasureDefinitionException extends RuntimeException { +public class InvalidMeasureDefinitionException extends InvalidMeasureRequestException { public InvalidMeasureDefinitionException(String message) { super(message); } diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/InvalidMeasureRequestException.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/InvalidMeasureRequestException.java new file mode 100644 index 0000000000..09cb5e804e --- /dev/null +++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/InvalidMeasureRequestException.java @@ -0,0 +1,20 @@ +package org.opencds.cqf.fhir.cr.measure.common; + +/** + * Base class for measure evaluation errors caused by invalid client input — the request itself is + * malformed or refers to resources that cannot be resolved. Subclasses are translated to {@code + * ca.uhn.fhir.rest.server.exceptions.InvalidRequestException} (HTTP 400) at the HAPI provider + * boundary so that a single {@code catch} can cover the whole family. + * + *
Service-layer code in {@code cqf-fhir-cr} should throw a subclass of this type rather than a
+ * HAPI exception directly, keeping the service layer independent of HAPI's HTTP bindings.
+ */
+public class InvalidMeasureRequestException extends RuntimeException {
+ public InvalidMeasureRequestException(String message) {
+ super(message);
+ }
+
+ public InvalidMeasureRequestException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureLookupException.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureLookupException.java
new file mode 100644
index 0000000000..3e35188887
--- /dev/null
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureLookupException.java
@@ -0,0 +1,12 @@
+package org.opencds.cqf.fhir.cr.measure.common;
+
+/**
+ * Thrown when the request cannot be resolved to a unique Measure to evaluate — e.g. the measure
+ * URL matches no resources, matches multiple resources, or no measure reference was supplied at
+ * all.
+ */
+public class MeasureLookupException extends InvalidMeasureRequestException {
+ public MeasureLookupException(String message) {
+ super(message);
+ }
+}
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4MultiMeasureService.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4MultiMeasureService.java
index 0624942db8..6cb67f8d5f 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4MultiMeasureService.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/R4MultiMeasureService.java
@@ -31,6 +31,7 @@
import org.opencds.cqf.fhir.cr.measure.common.CompositeEvaluationResultsPerMeasure;
import org.opencds.cqf.fhir.cr.measure.common.MeasureDef;
import org.opencds.cqf.fhir.cr.measure.common.MeasureEvalType;
+import org.opencds.cqf.fhir.cr.measure.common.MeasureLookupException;
import org.opencds.cqf.fhir.cr.measure.common.MeasurePeriodValidator;
import org.opencds.cqf.fhir.cr.measure.common.MeasureReference;
import org.opencds.cqf.fhir.cr.measure.r4.utils.R4MeasureServiceUtils;
@@ -228,6 +229,11 @@ private List> evaluateToListOfList(
String reporter,
@Nullable String practitioner) {
+ if (measureRefs == null || measureRefs.isEmpty()) {
+ throw new MeasureLookupException(
+ "At least one of measureId, measureIdentifier, or measureUrl must be supplied");
+ }
+
measurePeriodValidator.validatePeriodStartAndEnd(periodStart, periodEnd);
var r4ProcessorToUse = new R4MeasureProcessor(resolvedRepo, this.measureEvaluationOptions);
diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/utils/R4MeasureServiceUtils.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/utils/R4MeasureServiceUtils.java
index eff1208d9c..fbdb78c3ee 100644
--- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/utils/R4MeasureServiceUtils.java
+++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/r4/utils/R4MeasureServiceUtils.java
@@ -51,6 +51,7 @@
import org.hl7.fhir.r4.model.SearchParameter;
import org.hl7.fhir.r4.model.StringType;
import org.opencds.cqf.fhir.cr.measure.common.MeasureEvalType;
+import org.opencds.cqf.fhir.cr.measure.common.MeasureLookupException;
import org.opencds.cqf.fhir.cr.measure.common.MeasureReference;
import org.opencds.cqf.fhir.cr.measure.common.MeasureReportType;
import org.opencds.cqf.fhir.cr.measure.common.MeasureScoring;
@@ -203,6 +204,14 @@ public Measure resolveByUrl(String url) {
}
Bundle result = this.repository.search(Bundle.class, Measure.class, searchParameters);
+
+ if (result == null || result.getEntry().isEmpty()) {
+ throw new MeasureLookupException("Measure URL: %s, found no matching measure resources".formatted(url));
+ }
+ if (result.getEntry().size() > 1) {
+ throw new MeasureLookupException(
+ "Measure URL: %s, found more than one matching measure resource".formatted(url));
+ }
return (Measure) result.getEntryFirstRep().getResource();
}
diff --git a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MultiMeasureServiceTest.java b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MultiMeasureServiceTest.java
index dae351a5ee..196716405a 100644
--- a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MultiMeasureServiceTest.java
+++ b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MultiMeasureServiceTest.java
@@ -1,5 +1,6 @@
package org.opencds.cqf.fhir.cr.measure.r4;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -10,6 +11,7 @@
import java.util.Date;
import org.hl7.fhir.r4.model.MeasureReport.MeasureReportStatus;
import org.junit.jupiter.api.Test;
+import org.opencds.cqf.fhir.cr.measure.common.MeasureLookupException;
import org.opencds.cqf.fhir.cr.measure.common.MeasurePopulationType;
import org.opencds.cqf.fhir.cr.measure.r4.MultiMeasure.Given;
@@ -1103,4 +1105,17 @@ void MultiMeasure_ThrowsErrorWithDuplicatePopulationIds() {
assertTrue(e.getMessage().contains("Duplicate population ID"));
assertTrue(e.getMessage().contains("initial-population"));
}
+
+ @Test
+ void MultiMeasure_NoMeasureReferencesSupplied_throws() {
+ var when = GIVEN_REPO
+ .when()
+ .periodStart("2024-01-01")
+ .periodEnd("2024-12-31")
+ .reportType("population")
+ .evaluate();
+
+ var e = assertThrows(MeasureLookupException.class, when::then);
+ assertEquals("At least one of measureId, measureIdentifier, or measureUrl must be supplied", e.getMessage());
+ }
}
diff --git a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/utils/R4MeasureServiceUtilsTest.java b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/utils/R4MeasureServiceUtilsTest.java
index 7f0b6cb8fb..e01f2114fe 100644
--- a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/utils/R4MeasureServiceUtilsTest.java
+++ b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/utils/R4MeasureServiceUtilsTest.java
@@ -5,26 +5,35 @@
import static org.hamcrest.Matchers.equalTo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
import ca.uhn.fhir.repository.IRepository;
import jakarta.annotation.Nullable;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
+import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Extension;
+import org.hl7.fhir.r4.model.Measure;
import org.hl7.fhir.r4.model.MeasureReport;
import org.hl7.fhir.r4.model.Reference;
import org.hl7.fhir.r4.model.StringType;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.opencds.cqf.fhir.cr.measure.common.MeasureEvalType;
+import org.opencds.cqf.fhir.cr.measure.common.MeasureLookupException;
import org.opencds.cqf.fhir.cr.measure.constant.MeasureReportConstants;
import org.opencds.cqf.fhir.cr.measure.r4.R4MeasureEvalType;
import org.opencds.cqf.fhir.utility.monad.Either;
@@ -266,4 +275,42 @@ private static Extension buildExtensionForProductLine(String productLine) {
private static Either