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, Exception> buildEitherLeft(@Nullable String theId) { return Eithers.forLeft(Optional.ofNullable(theId).map(Reference::new)); } + + @Test + void resolveByUrl_noMatchingMeasures_throws() { + var url = "http://example.org/Measure/does-not-exist"; + when(repository.search(eq(Bundle.class), eq(Measure.class), any(Map.class))) + .thenReturn(new Bundle()); + + var actual = assertThrows(MeasureLookupException.class, () -> testSubject.resolveByUrl(url)); + + assertEquals("Measure URL: %s, found no matching measure resources".formatted(url), actual.getMessage()); + } + + @Test + void resolveByUrl_multipleMatches_throws() { + var url = "http://example.org/Measure/ambiguous"; + var bundle = new Bundle(); + bundle.addEntry().setResource(new Measure().setUrl(url)); + bundle.addEntry().setResource(new Measure().setUrl(url)); + when(repository.search(eq(Bundle.class), eq(Measure.class), any(Map.class))) + .thenReturn(bundle); + + var actual = assertThrows(MeasureLookupException.class, () -> testSubject.resolveByUrl(url)); + + assertEquals( + "Measure URL: %s, found more than one matching measure resource".formatted(url), actual.getMessage()); + } + + @Test + void resolveByUrl_singleMatch_returns() { + var url = "http://example.org/Measure/found"; + var measure = new Measure().setUrl(url); + var bundle = new Bundle(); + bundle.addEntry().setResource(measure); + when(repository.search(eq(Bundle.class), eq(Measure.class), any(Map.class))) + .thenReturn(bundle); + + assertSame(measure, testSubject.resolveByUrl(url)); + } }