diff --git a/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/config/r4/RiskAdjustmentOperationConfig.java b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/config/r4/RiskAdjustmentOperationConfig.java new file mode 100644 index 000000000..998c943f4 --- /dev/null +++ b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/config/r4/RiskAdjustmentOperationConfig.java @@ -0,0 +1,50 @@ +package org.opencds.cqf.fhir.cr.hapi.config.r4; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.FhirVersionEnum; +import ca.uhn.fhir.rest.api.server.IRepositoryFactory; +import ca.uhn.fhir.rest.server.RestfulServer; +import java.util.List; +import java.util.Map; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderLoader; +import org.opencds.cqf.fhir.cr.hapi.config.ProviderSelector; +import org.opencds.cqf.fhir.cr.hapi.config.RepositoryConfig; +import org.opencds.cqf.fhir.cr.hapi.r4.ISubmitDataProcessorFactory; +import org.opencds.cqf.fhir.cr.hapi.r4.ra.SubmitDataProvider; +import org.opencds.cqf.fhir.cr.hapi.r4.ra.SubmitRemarkDataProvider; +import org.opencds.cqf.fhir.cr.measure.r4.R4SubmitDataService; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@Configuration +@Import({RepositoryConfig.class}) +public class RiskAdjustmentOperationConfig { + + @Bean + ISubmitDataProcessorFactory r4SubmitDataProcessorFactory(IRepositoryFactory repositoryFactory) { + return rd -> new R4SubmitDataService(repositoryFactory.create(rd)); + } + + @Bean(name = "riskAdjustmentSubmitDataProvider") + SubmitDataProvider r4RaSubmitDataProvider(ISubmitDataProcessorFactory r4SubmitDataProcessorFactory) { + return new SubmitDataProvider(r4SubmitDataProcessorFactory); + } + + @Bean(name = "riskAdjustmentSubmitRemarkDataProvider") + SubmitRemarkDataProvider r4RaSubmitDataRemarkProvider(ISubmitDataProcessorFactory r4SubmitDataProcessorFactory) { + return new SubmitRemarkDataProvider(r4SubmitDataProcessorFactory); + } + + @Bean(name = "riskAdjustmentOperationLoader") + public ProviderLoader riskAdjustmentOperationLoader( + ApplicationContext applicationContext, FhirContext fhirContext, RestfulServer restfulServer) { + + var selector = new ProviderSelector( + fhirContext, + Map.of(FhirVersionEnum.R4, List.of(SubmitDataProvider.class, SubmitRemarkDataProvider.class))); + + return new ProviderLoader(restfulServer, applicationContext, selector); + } +} diff --git a/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/r4/ra/SubmitDataProvider.java b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/r4/ra/SubmitDataProvider.java new file mode 100644 index 000000000..651b38a79 --- /dev/null +++ b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/r4/ra/SubmitDataProvider.java @@ -0,0 +1,86 @@ +package org.opencds.cqf.fhir.cr.hapi.r4.ra; + +import ca.uhn.fhir.model.api.annotation.Description; +import ca.uhn.fhir.rest.annotation.Operation; +import ca.uhn.fhir.rest.annotation.OperationParam; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.CanonicalType; +import org.hl7.fhir.r4.model.Measure; +import org.hl7.fhir.r4.model.MeasureReport; +import org.opencds.cqf.fhir.cr.hapi.r4.ISubmitDataProcessorFactory; + +public class SubmitDataProvider { + + // Operation canonical name and RA MeasureReport profile canonical + private static final String OPERATION_NAME = "$ra-submit-data"; + private static final String RA_DATAEX_MR_PROFILE = + "http://hl7.org/fhir/us/davinci-ra/StructureDefinition/ra-datax-measurereport"; + + private final ISubmitDataProcessorFactory r4SubmitDataProcessorFactory; + + public SubmitDataProvider(ISubmitDataProcessorFactory r4SubmitDataProcessorFactory) { + this.r4SubmitDataProcessorFactory = + Objects.requireNonNull(r4SubmitDataProcessorFactory, "r4SubmitDataProcessorFactory is required"); + } + + /** + * The $ra-submit-data operation is used to submit a Risk Adjustment Data Exchange + * MeasureReport and related data-of-interest resources. + * Endpoint: [base]/Measure/$ra-submit-data + * Deviation from base $submit-data: + * - The {@code measureReport} input MUST conform to the + * RA Data Exchange MeasureReport profile + * + * @param requestDetails Autopopulated by HAPI. + * @param report The RA Data Exchange MeasureReport (1..1). + * @param resources Related data-of-interest resources (0..*). + * @return A transaction response {@link Bundle}. + */ + @Description( + shortDefinition = "$ra-submit-data", + value = + "Implements the $ra-submit-data operation.") + @Operation(name = OPERATION_NAME, type = Measure.class) + public Bundle submitData( + RequestDetails requestDetails, + @OperationParam(name = "measureReport", min = 1, max = 1) MeasureReport report, + @OperationParam(name = "resource") List resources) { + + // Validate required input + if (report == null) { + throw new InvalidRequestException("Parameter 'measureReport' must not be null."); + } + if (!hasProfile(report)) { + String profiles = report.hasMeta() && report.getMeta().hasProfile() + ? report.getMeta().getProfile().stream() + .map(CanonicalType::asStringValue) + .collect(Collectors.joining(", ")) + : "(none)"; + throw new InvalidRequestException("The 'measureReport' MUST conform to profile: " + RA_DATAEX_MR_PROFILE + + ". Provided profiles: " + profiles + "."); + } + + // Normalize optional inputs + List safeResources = resources == null + ? Collections.emptyList() + : resources.stream().filter(Objects::nonNull).collect(Collectors.toList()); + + // Delegate to implementation + return r4SubmitDataProcessorFactory.create(requestDetails).submitData(report, safeResources); + } + + private static boolean hasProfile(MeasureReport report) { + return report.hasMeta() + && report.getMeta().hasProfile() + && report.getMeta().getProfile().stream() + .map(CanonicalType::asStringValue) + .anyMatch(SubmitDataProvider.RA_DATAEX_MR_PROFILE::equals); + } +} diff --git a/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/r4/ra/SubmitRemarkDataProvider.java b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/r4/ra/SubmitRemarkDataProvider.java new file mode 100644 index 000000000..67269a485 --- /dev/null +++ b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/r4/ra/SubmitRemarkDataProvider.java @@ -0,0 +1,111 @@ +package org.opencds.cqf.fhir.cr.hapi.r4.ra; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.model.api.annotation.Description; +import ca.uhn.fhir.rest.annotation.IdParam; +import ca.uhn.fhir.rest.annotation.Operation; +import ca.uhn.fhir.rest.annotation.OperationParam; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; +import ca.uhn.fhir.util.BundleUtil; +import java.util.Objects; +import java.util.stream.Collectors; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.CanonicalType; +import org.hl7.fhir.r4.model.IdType; +import org.hl7.fhir.r4.model.Measure; +import org.hl7.fhir.r4.model.MeasureReport; +import org.hl7.fhir.r4.model.Resource; +import org.opencds.cqf.fhir.cr.hapi.r4.ISubmitDataProcessorFactory; + +public class SubmitRemarkDataProvider { + + // Operation canonical name and RA MeasureReport profile canonical + private static final String OPERATION_NAME = "$submit-remark-data"; + private static final String RA_MR_REMARK_BUNDLE_PROFILE = + "http://hl7.org/fhir/us/davinci-ra/StructureDefinition/ra-measurereport-remark-bundle"; + private static final String RA_MR_REMARK_PROFILE = + "http://hl7.org/fhir/us/davinci-ra/StructureDefinition/ra-measurereport-with-remark"; + + private final ISubmitDataProcessorFactory r4SubmitDataProcessorFactory; + + public SubmitRemarkDataProvider(ISubmitDataProcessorFactory r4SubmitDataProcessorFactory) { + this.r4SubmitDataProcessorFactory = + Objects.requireNonNull(r4SubmitDataProcessorFactory, "r4SubmitDataProcessorFactory is required"); + } + + /** + * A Condition Category Remark may reference resources such as Practitioner and Condition, using + * Patch to submit the remark may not be feasible. This operation is used to submit a Risk + * Adjustment Coding Gap Report with one or more ccRemarks on at least one of the Condition + * Categories, along with the relevant resources referenced by the ccRemark(s). + * URL: [base]/Measure/$submit-remark-data + * URL: [base]/Measure/[id]/$submit-remark-data + * + * @param requestDetails Autopopulated by HAPI. + * @param id The ID of the Measure to submit remark data + * @param bundle A Bundle that contains a Risk Adjustment Coding Gap Report with + * ccRemark(s) on at least one of the Condition Categories and the + * ccRemark referenced resources + * @return A transaction response {@link Bundle}. + */ + @Description( + shortDefinition = "$submit-remark-data", + value = + "Implements the $submit-remark-data operation.") + @Operation(name = OPERATION_NAME, type = Measure.class) + public Bundle submitRemarkData( + RequestDetails requestDetails, + @IdParam IdType id, + @OperationParam(name = "bundle", min = 1) Bundle bundle) { + + // Validate required input + validateResource(bundle, "bundle", "Bundle", RA_MR_REMARK_BUNDLE_PROFILE); + + // Extract MR from Bundle (should be the first entry in the Bundle) and validate + var firstEntry = bundle.getEntryFirstRep().getResource(); + validateResource(firstEntry, null, "MeasureReport", RA_MR_REMARK_PROFILE); + + // Extract MeasureReport from Bundle and remark resources + var report = (MeasureReport) firstEntry; + var remarkResources = BundleUtil.toListOfResources(FhirContext.forR4Cached(), bundle).stream() + .filter(resource -> !(resource instanceof MeasureReport)) + .toList(); + + // TODO: Deduplicate remarkResources? + + // Delegate to implementation + return r4SubmitDataProcessorFactory.create(requestDetails).submitData(report, remarkResources); + } + + private static boolean hasProfile(Resource resource, String profile) { + return resource.hasMeta() + && resource.getMeta().hasProfile() + && resource.getMeta().getProfile().stream() + .map(CanonicalType::asStringValue) + .anyMatch(profile::equals); + } + + private static void validateResource(Resource resource, String parameterName, String resourceName, String profile) { + if (resource == null) { + throw new InvalidRequestException( + parameterName != null + ? String.format("Parameter '%s' must not be null.", parameterName) + : String.format("Resource '%s' must not be null.", resourceName)); + } + validateProfile(resource, profile); + } + + private static void validateProfile(Resource resource, String profile) { + if (!hasProfile(resource, profile)) { + String profiles = resource.hasMeta() && resource.getMeta().hasProfile() + ? resource.getMeta().getProfile().stream() + .map(CanonicalType::asStringValue) + .collect(Collectors.joining(", ")) + : "(none)"; + throw new InvalidRequestException(String.format( + "The '%s' MUST conform to profile: %s. Provided profiles: %s.", + resource.fhirType(), profile, profiles)); + } + } +} diff --git a/cqf-fhir-cr-hapi/src/test/java/org/opencds/cqf/fhir/cr/hapi/r4/BaseCrR4TestServer.java b/cqf-fhir-cr-hapi/src/test/java/org/opencds/cqf/fhir/cr/hapi/r4/BaseCrR4TestServer.java index a19422955..440e00c26 100644 --- a/cqf-fhir-cr-hapi/src/test/java/org/opencds/cqf/fhir/cr/hapi/r4/BaseCrR4TestServer.java +++ b/cqf-fhir-cr-hapi/src/test/java/org/opencds/cqf/fhir/cr/hapi/r4/BaseCrR4TestServer.java @@ -35,6 +35,7 @@ import org.opencds.cqf.fhir.cr.hapi.config.r4.ExtractOperationConfig; import org.opencds.cqf.fhir.cr.hapi.config.r4.PackageOperationConfig; import org.opencds.cqf.fhir.cr.hapi.config.r4.PopulateOperationConfig; +import org.opencds.cqf.fhir.cr.hapi.config.r4.RiskAdjustmentOperationConfig; import org.opencds.cqf.fhir.cr.hapi.config.test.TestCrStorageSettingsConfigurer; import org.opencds.cqf.fhir.cr.hapi.config.test.r4.TestCrR4Config; import org.springframework.beans.factory.annotation.Autowired; @@ -50,7 +51,8 @@ EvaluateOperationConfig.class, ExtractOperationConfig.class, PackageOperationConfig.class, - PopulateOperationConfig.class + PopulateOperationConfig.class, + RiskAdjustmentOperationConfig.class }) public abstract class BaseCrR4TestServer extends BaseJpaR4Test implements IResourceLoader { diff --git a/cqf-fhir-cr-hapi/src/test/java/org/opencds/cqf/fhir/cr/hapi/r4/RiskAdjustmentOperationProviderIT.java b/cqf-fhir-cr-hapi/src/test/java/org/opencds/cqf/fhir/cr/hapi/r4/RiskAdjustmentOperationProviderIT.java new file mode 100644 index 000000000..562a7bfe2 --- /dev/null +++ b/cqf-fhir-cr-hapi/src/test/java/org/opencds/cqf/fhir/cr/hapi/r4/RiskAdjustmentOperationProviderIT.java @@ -0,0 +1,92 @@ +package org.opencds.cqf.fhir.cr.hapi.r4; + +import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; +import org.hl7.fhir.r4.model.MeasureReport; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Resource; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class RiskAdjustmentOperationProviderIT extends BaseCrR4TestServer { + + @Test + void testRiskAdjustmentSubmitDataOperationInvalidRequest() { + try { + ourClient + .operation() + .onType("Measure") + .named("ra-submit-data") + .withNoParameters(Parameters.class) + .execute(); + Assertions.fail(); + } catch (InvalidRequestException ire) { + // Passes + } + } + + @Test + void testRiskAdjustmentSubmitRemarkDataOperationInvalidRequest() { + try { + ourClient + .operation() + .onType("Measure") + .named("submit-remark-data") + .withNoParameters(Parameters.class) + .execute(); + Assertions.fail(); + } catch (InvalidRequestException ire) { + // Passes + } + } + + @Test + void testRiskAdjustmentSubmitDataOperationInvalidProfile() { + var mr = (MeasureReport) readResource("ra-datax-measurereport01.json"); + mr.getMeta().setProfile(null); + var params = org.opencds.cqf.fhir.utility.r4.Parameters.parameters( + org.opencds.cqf.fhir.utility.r4.Parameters.part("measureReport", mr)); + try { + ourClient + .operation() + .onType("Measure") + .named("ra-submit-data") + .withParameters(params) + .execute(); + Assertions.fail(); + } catch (InvalidRequestException ire) { + // Passes + } + } + + @Test + void testRiskAdjustmentSubmitRemarkDataOperationInvalidProfile() { + var mr = (MeasureReport) readResource("ra-datax-measurereport01.json"); + var params = org.opencds.cqf.fhir.utility.r4.Parameters.parameters( + org.opencds.cqf.fhir.utility.r4.Parameters.part("measureReport", mr)); + try { + ourClient + .operation() + .onType("Measure") + .named("submit-remark-data") + .withParameters(params) + .execute(); + Assertions.fail(); + } catch (InvalidRequestException ire) { + // Passes + } + } + + @Test + void testRiskAdjustmentSubmitDataOperationValidRequest() { + var mr = readResource("ra-datax-measurereport01.json"); + var params = org.opencds.cqf.fhir.utility.r4.Parameters.parameters( + org.opencds.cqf.fhir.utility.r4.Parameters.part("measureReport", (Resource) mr)); + var result = ourClient + .operation() + .onType("Measure") + .named("ra-submit-data") + .withParameters(params) + .execute(); + Assertions.assertNotNull(result); + } +} diff --git a/cqf-fhir-cr-hapi/src/test/resources/ra-datax-measurereport01.json b/cqf-fhir-cr-hapi/src/test/resources/ra-datax-measurereport01.json new file mode 100644 index 000000000..afa165638 --- /dev/null +++ b/cqf-fhir-cr-hapi/src/test/resources/ra-datax-measurereport01.json @@ -0,0 +1,61 @@ +{ + "resourceType" : "MeasureReport", + "id" : "ra-datax-measurereport01", + "meta" : { + "extension" : [ + { + "url" : "http://hl7.org/fhir/StructureDefinition/instance-name", + "valueString" : "Data Exchange MeasureReport Example01" + }, + { + "url" : "http://hl7.org/fhir/StructureDefinition/instance-description", + "valueMarkdown" : "This is an example for the Risk Adjustment Data Exchange MeasureReport profile. It evaluatedResource references an example C-CDA document that is being submitted." + } + ], + "profile" : [ + "http://hl7.org/fhir/us/davinci-ra/StructureDefinition/ra-datax-measurereport" + ] + }, + "extension" : [ + { + "url" : "http://hl7.org/fhir/StructureDefinition/measurereport-category", + "valueCodeableConcept" : { + "coding" : [ + { + "system" : "http://hl7.org/fhir/CodeSystem/measurereport-category", + "code" : "ra" + } + ] + } + }, + { + "url" : "http://hl7.org/fhir/us/davinci-ra/StructureDefinition/ra-payerCodingGapReportId", + "valueId" : "ra-measurereport01" + }, + { + "url" : "http://hl7.org/fhir/us/davinci-ra/StructureDefinition/ra-reportingVendor", + "valueReference" : { + "reference" : "Organization/ra-vendor01" + } + } + ], + "status" : "complete", + "type" : "data-collection", + "measure" : "http://hl7.org/fhir/us/davinci-ra/Measure/RAModelExample01", + "subject" : { + "reference" : "Patient/ra-patient01" + }, + "date" : "2021-11-10", + "reporter" : { + "reference" : "Organization/ra-org02pat02" + }, + "period" : { + "start" : "2021-01-01", + "end" : "2021-09-30" + }, + "evaluatedResource" : [ + { + "reference" : "DocumentReference/ra-documentreference01pat01" + } + ] +} \ No newline at end of file