diff --git a/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/config/CrCdsHooksConfig.java b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/config/CrCdsHooksConfig.java index fb1171dc68..9e63a0f66f 100644 --- a/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/config/CrCdsHooksConfig.java +++ b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/config/CrCdsHooksConfig.java @@ -1,7 +1,9 @@ package org.opencds.cqf.fhir.cr.hapi.config; import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.interceptor.model.RequestPartitionId; import ca.uhn.fhir.jpa.cache.IResourceChangeListenerRegistry; +import ca.uhn.fhir.jpa.model.config.PartitionSettings; import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; import ca.uhn.fhir.repository.IRepository; import ca.uhn.fhir.rest.api.server.IRepositoryFactory; @@ -124,6 +126,7 @@ public CdsServiceInterceptor cdsServiceInterceptor( CdsServiceRegistryImpl cdsServiceRegistry, ICrDiscoveryServiceFactory discoveryServiceFactory, ICdsCrServiceFactory crServiceFactory, + PartitionSettings partitionSettings, Optional resourceChangeListenerRegistry) { if (resourceChangeListenerRegistry.isEmpty()) { return null; @@ -132,7 +135,11 @@ public CdsServiceInterceptor cdsServiceInterceptor( resourceChangeListenerRegistry .get() .registerResourceResourceChangeListener( - PLAN_DEFINITION_RESOURCE_NAME, SearchParameterMap.newSynchronous(), listener, 1000); + PLAN_DEFINITION_RESOURCE_NAME, + RequestPartitionId.defaultPartition(partitionSettings), + SearchParameterMap.newSynchronous(), + listener, + 1000); return listener; } } diff --git a/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/config/test/TestCrConfig.java b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/config/test/TestCrConfig.java index 520959a60b..7c35c349a2 100644 --- a/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/config/test/TestCrConfig.java +++ b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/config/test/TestCrConfig.java @@ -1,11 +1,13 @@ package org.opencds.cqf.fhir.cr.hapi.config.test; -import ca.uhn.fhir.batch2.jobs.reindex.ReindexProvider; +import ca.uhn.fhir.batch2.jobs.bulkmodify.reindex.ReindexProvider; import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.support.IValidationSupport; +import ca.uhn.fhir.interceptor.model.RequestPartitionId; import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; import ca.uhn.fhir.jpa.api.dao.DaoRegistry; import ca.uhn.fhir.jpa.api.dao.IFhirSystemDao; +import ca.uhn.fhir.jpa.cache.IResourceChangeListener; import ca.uhn.fhir.jpa.cache.IResourceChangeListenerCacheRefresher; import ca.uhn.fhir.jpa.cache.IResourceChangeListenerRegistry; import ca.uhn.fhir.jpa.cache.ResourceChangeListenerCacheFactory; @@ -13,6 +15,7 @@ import ca.uhn.fhir.jpa.cache.ResourceChangeListenerRegistryImpl; import ca.uhn.fhir.jpa.cache.ResourceChangeListenerRegistryInterceptor; import ca.uhn.fhir.jpa.graphql.GraphQLProvider; +import ca.uhn.fhir.jpa.model.config.PartitionSettings; import ca.uhn.fhir.jpa.provider.DiffProvider; import ca.uhn.fhir.jpa.provider.IJpaSystemProvider; import ca.uhn.fhir.jpa.provider.TerminologyUploaderProvider; @@ -26,6 +29,7 @@ import ca.uhn.fhir.rest.server.RestfulServer; import ca.uhn.fhir.rest.server.provider.ResourceProviderFactory; import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; +import jakarta.annotation.Nonnull; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -49,6 +53,9 @@ @Configuration @Import({SubscriptionSubmitterConfig.class, SubscriptionChannelConfig.class}) public class TestCrConfig { + public static final String LIBRARY_RESOURCE_NAME = "Library"; + public static final String VALUESET_RESOURCE_NAME = "ValueSet"; + @Bean public RestfulServer restfulServer( IFhirSystemDao fhirSystemDao, @@ -116,12 +123,13 @@ public Map> globalValueSetCache() { @Bean public ElmCacheResourceChangeListener elmCacheResourceChangeListener( IResourceChangeListenerRegistry resourceChangeListenerRegistry, + PartitionSettings partitionSettings, DaoRegistry daoRegistry, EvaluationSettings evaluationSettings) { ElmCacheResourceChangeListener listener = new ElmCacheResourceChangeListener(daoRegistry, evaluationSettings.getLibraryCache()); - resourceChangeListenerRegistry.registerResourceResourceChangeListener( - "Library", SearchParameterMap.newSynchronous(), listener, 1000); + registerResourceResourceChangeListener( + resourceChangeListenerRegistry, partitionSettings, listener, LIBRARY_RESOURCE_NAME); return listener; } @@ -129,13 +137,14 @@ public ElmCacheResourceChangeListener elmCacheResourceChangeListener( public CodeCacheResourceChangeListener codeCacheResourceChangeListener( IResourceChangeListenerRegistry resourceChangeListenerRegistry, EvaluationSettings evaluationSettings, + PartitionSettings partitionSettings, DaoRegistry daoRegistry) { CodeCacheResourceChangeListener listener = new CodeCacheResourceChangeListener(daoRegistry, evaluationSettings.getValueSetCache()); // registry - resourceChangeListenerRegistry.registerResourceResourceChangeListener( - "ValueSet", SearchParameterMap.newSynchronous(), listener, 1000); + registerResourceResourceChangeListener( + resourceChangeListenerRegistry, partitionSettings, listener, VALUESET_RESOURCE_NAME); return listener; } @@ -144,9 +153,10 @@ public CodeCacheResourceChangeListener codeCacheResourceChangeListener( public IResourceChangeListenerRegistry resourceChangeListenerRegistry( InMemoryResourceMatcher inMemoryResourceMatcher, FhirContext fhirContext, + PartitionSettings partitionSettings, ResourceChangeListenerCacheFactory resourceChangeListenerCacheFactory) { return new ResourceChangeListenerRegistryImpl( - fhirContext, resourceChangeListenerCacheFactory, inMemoryResourceMatcher); + fhirContext, partitionSettings, resourceChangeListenerCacheFactory, inMemoryResourceMatcher); } @Bean @@ -158,4 +168,28 @@ IResourceChangeListenerCacheRefresher resourceChangeListenerCacheRefresher() { public ResourceChangeListenerRegistryInterceptor resourceChangeListenerRegistryInterceptor() { return new ResourceChangeListenerRegistryInterceptor(); } + + @Bean + public PartitionSettings partitionSettings() { + return new PartitionSettings(); + } + + private void registerResourceResourceChangeListener( + IResourceChangeListenerRegistry resourceChangeListenerRegistry, + PartitionSettings partitionSettings, + IResourceChangeListener listener, + String resourceType) { + + resourceChangeListenerRegistry.registerResourceResourceChangeListener( + resourceType, + getRequestPartitionId(partitionSettings), + SearchParameterMap.newSynchronous(), + listener, + 1000); + } + + @Nonnull + private RequestPartitionId getRequestPartitionId(PartitionSettings partitionSettings) { + return RequestPartitionId.defaultPartition(partitionSettings); + } } diff --git a/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceHapiFhirRepository.java b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceHapiFhirRepository.java index 885757f5db..b5c1ae5853 100644 --- a/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceHapiFhirRepository.java +++ b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceHapiFhirRepository.java @@ -5,12 +5,15 @@ import static java.util.Objects.nonNull; import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.api.model.DaoMethodOutcome; import ca.uhn.fhir.jpa.repository.HapiFhirRepository; -import ca.uhn.fhir.jpa.repository.SearchConverter; -import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.jpa.repository.searchparam.SearchParameterMapRepositoryRestQueryBuilder; import ca.uhn.fhir.model.api.Include; import ca.uhn.fhir.model.valueset.BundleTypeEnum; +import ca.uhn.fhir.repository.impl.MultiMapRepositoryRestQueryBuilder; import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.api.PatchTypeEnum; import ca.uhn.fhir.rest.api.RestOperationTypeEnum; import ca.uhn.fhir.rest.api.server.IBundleProvider; import ca.uhn.fhir.rest.api.server.RequestDetails; @@ -18,14 +21,14 @@ import ca.uhn.fhir.rest.server.RestfulServer; import ca.uhn.fhir.rest.server.RestfulServerUtils; import ca.uhn.fhir.rest.server.SimpleBundleProvider; -import com.google.common.collect.Multimap; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import org.hl7.fhir.instance.model.api.IBaseBundle; +import org.hl7.fhir.instance.model.api.IBaseParameters; import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,9 +40,10 @@ * Since hapi-fhir is released quarterly, we want to maintain flexibility to change behaviour * within our more flexible release cadence. */ +@SuppressWarnings("UnstableApiUsage") public class ClinicalIntelligenceHapiFhirRepository extends HapiFhirRepository { - private static final Logger ourLog = LoggerFactory.getLogger(ClinicalIntelligenceHapiFhirRepository.class); + private static final Logger logger = LoggerFactory.getLogger(ClinicalIntelligenceHapiFhirRepository.class); private final RequestDetails requestDetails; private final RestfulServer restfulServer; @@ -53,8 +57,20 @@ public ClinicalIntelligenceHapiFhirRepository( this.daoRegistry = daoRegistry; } + @Override + public T read( + Class resourceType, I id, Map headers) { + + var details = ClinicalIntelligenceRequestDetailsCloner.startWith( + requestDetails, daoRegistry.getFhirContext(), resourceType) + .setAction(RestOperationTypeEnum.READ) + .addHeaders(headers) + .create(); + return (T) this.daoRegistry.getResourceDao(resourceType).read(id, details); + } + /** - * Override {@link HapiFhirRepository#search(Class, Class, Map, Map)} to ensure that the + * Override {@link HapiFhirRepository#search(Class, Class, IRepositoryRestQueryContributor, Map)} to ensure that the * _count {@link RequestDetails} parameter is passed through to the DAO layer instead of * dropping it. *

@@ -67,18 +83,19 @@ public ClinicalIntelligenceHapiFhirRepository( public B search( Class bundleType, Class resourceType, - Multimap> searchParameters, + IRepositoryRestQueryContributor queryContributor, Map headers) { - var details = ClinicalIntelligenceRequestDetailsCloner.startWith(requestDetails) + var details = ClinicalIntelligenceRequestDetailsCloner.startWith( + requestDetails, daoRegistry.getFhirContext(), resourceType) .setAction(RestOperationTypeEnum.SEARCH_TYPE) .addHeaders(headers) .create(); - var converter = new SearchConverter(); - converter.convertParameters(searchParameters, fhirContext()); - details.setParameters(converter.myResultParameters); + var searchParameterMap = + SearchParameterMapRepositoryRestQueryBuilder.buildFromQueryContributor(queryContributor); + details.setParameters(MultiMapRepositoryRestQueryBuilder.toFlatMap(searchParameterMap)); details.setResourceName(daoRegistry.getFhirContext().getResourceType(resourceType)); if (details instanceof SystemRequestDetails) { @@ -89,13 +106,55 @@ public B search( } var resourceDao = daoRegistry.getResourceDao(resourceType); - var bundleProvider = resourceDao.search(converter.mySearchParameterMap, details); + var bundleProvider = resourceDao.search(searchParameterMap, details); bundleProvider = sanitizeBundleProvider(bundleProvider); return createBundle(details, bundleProvider); } + public MethodOutcome create(T resource, Map headers) { + return this.daoRegistry + .getResourceDao(resource) + .create(resource, cloneWithAction(resource, headers, RestOperationTypeEnum.CREATE)); + } + + public MethodOutcome update(T resource, Map headers) { + final DaoMethodOutcome update = daoRegistry + .getResourceDao(resource) + .update(resource, cloneWithAction(resource, headers, RestOperationTypeEnum.UPDATE)); + boolean created = update.getCreated() != null && update.getCreated(); + if (created) { + update.setResponseStatusCode(201); + } else { + update.setResponseStatusCode(200); + } + + return update; + } + + public MethodOutcome patch( + I id, P patchParameters, Map headers) { + return this.daoRegistry + .getResourceDao(id.getResourceType()) + .patch( + id, + null, + PatchTypeEnum.FHIR_PATCH_JSON, + null, + patchParameters, + cloneWithAction(patchParameters, headers, RestOperationTypeEnum.PATCH)); + } + + public MethodOutcome delete( + Class resourceType, I id, Map headers) { + return this.daoRegistry + .getResourceDao(resourceType) + .delete(id, cloneWithAction(resourceType, headers, RestOperationTypeEnum.DELETE)); + } + + // N.B. We don't support request details cloning for link + protected IBundleProvider sanitizeBundleProvider(IBundleProvider bundleProvider) { return nonNull(bundleProvider) ? bundleProvider : new SimpleBundleProvider(); } @@ -125,7 +184,7 @@ protected BundleTypeEnum extractBundleTypeFromRequestParameters(Map RequestDetails cloneWithAction( + T resource, Map headers, RestOperationTypeEnum operationTypeEnum) { + return cloneWithAction(resource.getClass(), headers, operationTypeEnum); + } + + private RequestDetails cloneWithAction( + Class resourceType, Map headers, RestOperationTypeEnum operationTypeEnum) { + + return ClinicalIntelligenceRequestDetailsCloner.startWith( + requestDetails, daoRegistry.getFhirContext(), resourceType) + .setAction(operationTypeEnum) + .addHeaders(headers) + .create(); + } + private B createBundle(RequestDetails requestDetails, IBundleProvider bundleProvider) { Integer count = RestfulServerUtils.extractCountParameter(requestDetails); diff --git a/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceRequestDetailsCloner.java b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceRequestDetailsCloner.java index aaf066721e..1bf4f28ba5 100644 --- a/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceRequestDetailsCloner.java +++ b/cqf-fhir-cr-hapi/src/main/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceRequestDetailsCloner.java @@ -1,13 +1,20 @@ package org.opencds.cqf.fhir.cr.hapi.repository; +import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.rest.api.RequestTypeEnum; import ca.uhn.fhir.rest.api.RestOperationTypeEnum; import ca.uhn.fhir.rest.api.server.RequestDetails; import ca.uhn.fhir.rest.api.server.SystemRequestDetails; +import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; import java.util.HashMap; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r4.model.ResourceType; /** * This is an exact copy of the RequestDetailsCloner from hapi-fhir, which is package-private, so @@ -17,24 +24,63 @@ class ClinicalIntelligenceRequestDetailsCloner { private ClinicalIntelligenceRequestDetailsCloner() {} - static DetailsBuilder startWith(RequestDetails details) { - RequestDetails newDetails; - if (details instanceof ServletRequestDetails servletDetails) { + static DetailsBuilder startWith( + RequestDetails origRequestDetails, FhirContext fhirContext, Class resourceType) { + final RequestDetails newDetails; + + final boolean isPartitionableResource = isPartitionableResource(fhirContext, resourceType); + + if (origRequestDetails instanceof ServletRequestDetails servletDetails) { newDetails = new ServletRequestDetails(servletDetails); + } else if (origRequestDetails instanceof SystemRequestDetails systemRequestDetails) { + final SystemRequestDetails clonedSystemRequestDetails = new SystemRequestDetails(origRequestDetails); + if (isPartitionableResource) { + clonedSystemRequestDetails.setRequestPartitionId(systemRequestDetails.getRequestPartitionId()); + } + newDetails = clonedSystemRequestDetails; } else { - newDetails = new SystemRequestDetails(details); + throw new InvalidRequestException("Unsupported request origRequestDetails type: %s" + .formatted(origRequestDetails.getClass().getName())); + } + if (isPartitionableResource) { + newDetails.setTenantId(origRequestDetails.getTenantId()); } newDetails.setRequestType(RequestTypeEnum.POST); newDetails.setOperation(null); newDetails.setResource(null); - newDetails.setParameters(new HashMap<>(details.getParameters())); + newDetails.setParameters(new HashMap<>(origRequestDetails.getParameters())); newDetails.setResourceName(null); newDetails.setCompartmentName(null); - newDetails.setResponse(details.getResponse()); + newDetails.setResponse(origRequestDetails.getResponse()); return new DetailsBuilder(newDetails); } + // LUKETODO: this logic is maintained in BaseRequestPartitionHelperSvc#isResourcePartitionable + private static boolean isPartitionableResource( + FhirContext fhirContext, Class resourceType) { + final Set nonPartitionableResourceTypes = Stream.of( + ResourceType.Library, + ResourceType.ValueSet, + ResourceType.StructureMap, + ResourceType.StructureDefinition, + ResourceType.Questionnaire, + ResourceType.NamingSystem, + ResourceType.CompartmentDefinition, + ResourceType.SearchParameter, + ResourceType.ConceptMap, + ResourceType.OperationDefinition, + ResourceType.CodeSystem) + .map(Enum::name) + .collect(Collectors.toUnmodifiableSet()); + + final String resourceTypeString = fhirContext.getResourceType(resourceType); + + final boolean isResourceNonPartitionable = nonPartitionableResourceTypes.contains(resourceTypeString); + + return !isResourceNonPartitionable; + } + static class DetailsBuilder { private final RequestDetails details; diff --git a/cqf-fhir-cr-hapi/src/test/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceHapiFhirRepositoryDaoOperationsTest.java b/cqf-fhir-cr-hapi/src/test/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceHapiFhirRepositoryDaoOperationsTest.java new file mode 100644 index 0000000000..9d9a1f49f7 --- /dev/null +++ b/cqf-fhir-cr-hapi/src/test/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceHapiFhirRepositoryDaoOperationsTest.java @@ -0,0 +1,284 @@ +package org.opencds.cqf.fhir.cr.hapi.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.interceptor.model.RequestPartitionId; +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; +import ca.uhn.fhir.jpa.api.model.DaoMethodOutcome; +import ca.uhn.fhir.rest.api.PatchTypeEnum; +import ca.uhn.fhir.rest.api.RequestTypeEnum; +import ca.uhn.fhir.rest.api.RestOperationTypeEnum; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.api.server.SystemRequestDetails; +import ca.uhn.fhir.rest.server.RestfulServer; +import java.util.Map; +import org.hl7.fhir.r4.model.IdType; +import org.hl7.fhir.r4.model.Library; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +class ClinicalIntelligenceHapiFhirRepositoryDaoOperationsTest { + + private static final FhirContext FHIR_CONTEXT = FhirContext.forR4Cached(); + private static final String TENANT_ID = "test-tenant"; + private static final RequestPartitionId PARTITION_ID = RequestPartitionId.fromPartitionId(123); + + @Mock + private DaoRegistry daoRegistry; + + @Mock + private IFhirResourceDao patientDao; + + @Mock + private IFhirResourceDao libraryDao; + + private AutoCloseable mocks; + + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + lenient().when(daoRegistry.getFhirContext()).thenReturn(FHIR_CONTEXT); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + // ===== READ ===== + + @Test + void read_PartitionedResource_PreservesPartitionAndTenant() { + when(daoRegistry.getResourceDao(Patient.class)).thenReturn(patientDao); + when(patientDao.read(any(), any(RequestDetails.class))).thenReturn(new Patient()); + + var repo = createRepository(createPartitionedRequestDetails()); + repo.read(Patient.class, new IdType("Patient/123"), Map.of()); + + var captor = ArgumentCaptor.forClass(RequestDetails.class); + verify(patientDao).read(any(), captor.capture()); + assertPartitionPreserved(captor.getValue(), RestOperationTypeEnum.READ); + } + + @Test + void read_NonPartitionedResource_ClearsPartitionId() { + when(daoRegistry.getResourceDao(Library.class)).thenReturn(libraryDao); + when(libraryDao.read(any(), any(RequestDetails.class))).thenReturn(new Library()); + + var repo = createRepository(createPartitionedRequestDetails()); + repo.read(Library.class, new IdType("Library/456"), Map.of()); + + var captor = ArgumentCaptor.forClass(RequestDetails.class); + verify(libraryDao).read(any(), captor.capture()); + assertPartitionCleared(captor.getValue(), RestOperationTypeEnum.READ); + } + + @Test + void read_PassesHeadersToClonedRequestDetails() { + when(daoRegistry.getResourceDao(Patient.class)).thenReturn(patientDao); + when(patientDao.read(any(), any(RequestDetails.class))).thenReturn(new Patient()); + + var repo = createRepository(createPartitionedRequestDetails()); + repo.read(Patient.class, new IdType("Patient/123"), Map.of("X-Custom", "header-value")); + + var captor = ArgumentCaptor.forClass(RequestDetails.class); + verify(patientDao).read(any(), captor.capture()); + assertEquals("header-value", captor.getValue().getHeader("X-Custom")); + } + + @Test + void read_WithoutPartitionInOriginalRequestDetails_DoesNotSetPartition() { + when(daoRegistry.getResourceDao(Patient.class)).thenReturn(patientDao); + when(patientDao.read(any(), any(RequestDetails.class))).thenReturn(new Patient()); + + var repo = createRepository(new SystemRequestDetails()); + repo.read(Patient.class, new IdType("Patient/123"), Map.of()); + + var captor = ArgumentCaptor.forClass(RequestDetails.class); + verify(patientDao).read(any(), captor.capture()); + assertInstanceOf(SystemRequestDetails.class, captor.getValue()); + assertNull(((SystemRequestDetails) captor.getValue()).getRequestPartitionId()); + } + + // ===== CREATE ===== + + @Test + void create_PartitionedResource_PreservesPartitionAndTenant() { + doReturn(patientDao).when(daoRegistry).getResourceDao(any(Patient.class)); + when(patientDao.create(any(Patient.class), any(RequestDetails.class))).thenReturn(new DaoMethodOutcome()); + + var repo = createRepository(createPartitionedRequestDetails()); + repo.create(new Patient(), Map.of()); + + var captor = ArgumentCaptor.forClass(RequestDetails.class); + verify(patientDao).create(any(Patient.class), captor.capture()); + assertPartitionPreserved(captor.getValue(), RestOperationTypeEnum.CREATE); + } + + @Test + void create_NonPartitionedResource_ClearsPartitionId() { + doReturn(libraryDao).when(daoRegistry).getResourceDao(any(Library.class)); + when(libraryDao.create(any(Library.class), any(RequestDetails.class))).thenReturn(new DaoMethodOutcome()); + + var repo = createRepository(createPartitionedRequestDetails()); + repo.create(new Library(), Map.of()); + + var captor = ArgumentCaptor.forClass(RequestDetails.class); + verify(libraryDao).create(any(Library.class), captor.capture()); + assertPartitionCleared(captor.getValue(), RestOperationTypeEnum.CREATE); + } + + // ===== UPDATE ===== + + @Test + void update_PartitionedResource_PreservesPartitionAndTenant_WhenCreated() { + doReturn(patientDao).when(daoRegistry).getResourceDao(any(Patient.class)); + var outcome = new DaoMethodOutcome(); + outcome.setCreated(true); + when(patientDao.update(any(Patient.class), any(RequestDetails.class))).thenReturn(outcome); + + var repo = createRepository(createPartitionedRequestDetails()); + var result = repo.update(new Patient(), Map.of()); + + var captor = ArgumentCaptor.forClass(RequestDetails.class); + verify(patientDao).update(any(Patient.class), captor.capture()); + assertPartitionPreserved(captor.getValue(), RestOperationTypeEnum.UPDATE); + assertEquals(201, result.getResponseStatusCode()); + } + + @Test + void update_SetsStatus200_WhenNotCreated() { + doReturn(patientDao).when(daoRegistry).getResourceDao(any(Patient.class)); + var outcome = new DaoMethodOutcome(); + outcome.setCreated(false); + when(patientDao.update(any(Patient.class), any(RequestDetails.class))).thenReturn(outcome); + + var repo = createRepository(createPartitionedRequestDetails()); + var result = repo.update(new Patient(), Map.of()); + + assertEquals(200, result.getResponseStatusCode()); + } + + @Test + void update_SetsStatus200_WhenCreatedIsNull() { + doReturn(patientDao).when(daoRegistry).getResourceDao(any(Patient.class)); + when(patientDao.update(any(Patient.class), any(RequestDetails.class))).thenReturn(new DaoMethodOutcome()); + + var repo = createRepository(createPartitionedRequestDetails()); + var result = repo.update(new Patient(), Map.of()); + + assertEquals(200, result.getResponseStatusCode()); + } + + @Test + void update_NonPartitionedResource_ClearsPartitionId() { + doReturn(libraryDao).when(daoRegistry).getResourceDao(any(Library.class)); + var outcome = new DaoMethodOutcome(); + outcome.setCreated(false); + when(libraryDao.update(any(Library.class), any(RequestDetails.class))).thenReturn(outcome); + + var repo = createRepository(createPartitionedRequestDetails()); + repo.update(new Library(), Map.of()); + + var captor = ArgumentCaptor.forClass(RequestDetails.class); + verify(libraryDao).update(any(Library.class), captor.capture()); + assertPartitionCleared(captor.getValue(), RestOperationTypeEnum.UPDATE); + } + + // ===== DELETE ===== + + @Test + void delete_PartitionedResource_PreservesPartitionAndTenant() { + when(daoRegistry.getResourceDao(Patient.class)).thenReturn(patientDao); + when(patientDao.delete(any(), any(RequestDetails.class))).thenReturn(new DaoMethodOutcome()); + + var repo = createRepository(createPartitionedRequestDetails()); + repo.delete(Patient.class, new IdType("Patient/123"), Map.of()); + + var captor = ArgumentCaptor.forClass(RequestDetails.class); + verify(patientDao).delete(any(), captor.capture()); + assertPartitionPreserved(captor.getValue(), RestOperationTypeEnum.DELETE); + } + + @Test + void delete_NonPartitionedResource_ClearsPartitionId() { + when(daoRegistry.getResourceDao(Library.class)).thenReturn(libraryDao); + when(libraryDao.delete(any(), any(RequestDetails.class))).thenReturn(new DaoMethodOutcome()); + + var repo = createRepository(createPartitionedRequestDetails()); + repo.delete(Library.class, new IdType("Library/456"), Map.of()); + + var captor = ArgumentCaptor.forClass(RequestDetails.class); + verify(libraryDao).delete(any(), captor.capture()); + assertPartitionCleared(captor.getValue(), RestOperationTypeEnum.DELETE); + } + + // ===== PATCH ===== + + @Test + void patch_PreservesPartition_BecauseParametersIsPartitionable() { + // patch uses getResourceDao(String) for DAO lookup but cloneWithAction uses + // patchParameters.getClass() (Parameters) for the partition check. + // Parameters is not in NON_PARTITIONABLE_RESOURCE_TYPES, so partition is preserved. + doReturn(patientDao).when(daoRegistry).getResourceDao("Patient"); + when(patientDao.patch(any(), any(), any(), any(), any(), any(RequestDetails.class))) + .thenReturn(new DaoMethodOutcome()); + + var repo = createRepository(createPartitionedRequestDetails()); + repo.patch(new IdType("Patient/123"), new Parameters(), Map.of()); + + var captor = ArgumentCaptor.forClass(RequestDetails.class); + verify(patientDao).patch(any(), isNull(), eq(PatchTypeEnum.FHIR_PATCH_JSON), isNull(), any(), captor.capture()); + assertPartitionPreserved(captor.getValue(), RestOperationTypeEnum.PATCH); + } + + // ===== Helpers ===== + + private SystemRequestDetails createPartitionedRequestDetails() { + var details = new SystemRequestDetails(); + details.setRequestPartitionId(PARTITION_ID); + details.setTenantId(TENANT_ID); + return details; + } + + private ClinicalIntelligenceHapiFhirRepository createRepository(SystemRequestDetails requestDetails) { + return new ClinicalIntelligenceHapiFhirRepository(daoRegistry, requestDetails, new RestfulServer(FHIR_CONTEXT)); + } + + private void assertPartitionPreserved(RequestDetails clonedDetails, RestOperationTypeEnum expectedOp) { + assertInstanceOf(SystemRequestDetails.class, clonedDetails); + var systemDetails = (SystemRequestDetails) clonedDetails; + assertEquals(expectedOp, clonedDetails.getRestOperationType()); + assertEquals(RequestTypeEnum.POST, clonedDetails.getRequestType()); + assertEquals(TENANT_ID, clonedDetails.getTenantId()); + assertEquals(PARTITION_ID, systemDetails.getRequestPartitionId()); + } + + private void assertPartitionCleared(RequestDetails clonedDetails, RestOperationTypeEnum expectedOp) { + assertInstanceOf(SystemRequestDetails.class, clonedDetails); + var systemDetails = (SystemRequestDetails) clonedDetails; + assertEquals(expectedOp, clonedDetails.getRestOperationType()); + assertEquals(RequestTypeEnum.POST, clonedDetails.getRequestType()); + // Tenant ID is preserved because the SystemRequestDetails constructor copies it + assertEquals(TENANT_ID, clonedDetails.getTenantId()); + // Partition ID is cleared for non-partitionable resources + assertNull(systemDetails.getRequestPartitionId()); + } +} diff --git a/cqf-fhir-cr-hapi/src/test/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceRequestDetailsClonerTest.java b/cqf-fhir-cr-hapi/src/test/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceRequestDetailsClonerTest.java index 98a79282ab..1c3be3b14d 100644 --- a/cqf-fhir-cr-hapi/src/test/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceRequestDetailsClonerTest.java +++ b/cqf-fhir-cr-hapi/src/test/java/org/opencds/cqf/fhir/cr/hapi/repository/ClinicalIntelligenceRequestDetailsClonerTest.java @@ -1,97 +1,290 @@ package org.opencds.cqf.fhir.cr.hapi.repository; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.interceptor.model.RequestPartitionId; import ca.uhn.fhir.rest.api.RequestTypeEnum; import ca.uhn.fhir.rest.api.server.IRestfulResponse; import ca.uhn.fhir.rest.api.server.RequestDetails; import ca.uhn.fhir.rest.api.server.SystemRequestDetails; import ca.uhn.fhir.rest.api.server.SystemRestfulResponse; +import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; +import jakarta.annotation.Nonnull; import java.util.HashMap; import java.util.Map; +import java.util.stream.Stream; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.Measure; import org.junit.jupiter.api.Test; -import org.opencds.cqf.fhir.cr.hapi.repository.ClinicalIntelligenceRequestDetailsCloner.DetailsBuilder; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; class ClinicalIntelligenceRequestDetailsClonerTest { - @Test - void testStartWithServletRequestDetails() { - ServletRequestDetails mockDetails = mock(ServletRequestDetails.class); - Map mockParams = new HashMap<>(); - mockParams.put("key1", new String[] {"value1"}); - when(mockDetails.getParameters()).thenReturn(mockParams); + private static final FhirContext FHIR_CONTEXT = FhirContext.forR4Cached(); + + record RequestDetailsVariant(String label, RequestDetails requestDetails) { + @Override + @Nonnull + public String toString() { + return label; + } + } + + static Stream requestDetailsVariants() { + var system = new SystemRequestDetails(); + system.setParameters(new HashMap<>()); + + var servlet = new ServletRequestDetails(); + servlet.setParameters(new HashMap<>()); + + return Stream.of( + new RequestDetailsVariant("SystemRequestDetails", system), + new RequestDetailsVariant("ServletRequestDetails", servlet)); + } - DetailsBuilder builder = ClinicalIntelligenceRequestDetailsCloner.startWith(mockDetails); - RequestDetails result = builder.create(); + @ParameterizedTest + @MethodSource("requestDetailsVariants") + void startWith_SetsRequestTypeToPost(RequestDetailsVariant variant) { + RequestDetails result = ClinicalIntelligenceRequestDetailsCloner.startWith( + variant.requestDetails(), FHIR_CONTEXT, Measure.class) + .create(); - assertNotNull(result); assertEquals(RequestTypeEnum.POST, result.getRequestType()); - assertNull(result.getResource()); + } + + @ParameterizedTest + @MethodSource("requestDetailsVariants") + void startWith_ResetsOperationResourceAndNames(RequestDetailsVariant variant) { + RequestDetails result = ClinicalIntelligenceRequestDetailsCloner.startWith( + variant.requestDetails(), FHIR_CONTEXT, Measure.class) + .create(); + assertNull(result.getOperation()); - assertEquals(mockParams, result.getParameters()); + assertNull(result.getResource()); + assertNull(result.getResourceName()); + assertNull(result.getCompartmentName()); } - @Test - void testStartWithSystemRequestDetails() { - RequestDetails mockDetails = mock(SystemRequestDetails.class); - Map mockParams = new HashMap<>(); - mockParams.put("key2", new String[] {"value2"}); - when(mockDetails.getParameters()).thenReturn(mockParams); + @ParameterizedTest + @MethodSource("requestDetailsVariants") + void startWith_PreservesTenantId(RequestDetailsVariant variant) { + variant.requestDetails().setTenantId("test-tenant"); - DetailsBuilder builder = ClinicalIntelligenceRequestDetailsCloner.startWith(mockDetails); - RequestDetails result = builder.create(); + RequestDetails result = ClinicalIntelligenceRequestDetailsCloner.startWith( + variant.requestDetails(), FHIR_CONTEXT, Measure.class) + .create(); - assertNotNull(result); - assertEquals(RequestTypeEnum.POST, result.getRequestType()); - assertNull(result.getResource()); - assertNull(result.getOperation()); - assertEquals(mockParams, result.getParameters()); + assertEquals("test-tenant", result.getTenantId()); } - @Test - void testStartWithSetsResponseFromOriginalRequest() { - RequestDetails mockDetails = mock(RequestDetails.class); + @ParameterizedTest + @MethodSource("requestDetailsVariants") + void startWith_PreservesResponse(RequestDetailsVariant variant) { IRestfulResponse expectedResponse = new SystemRestfulResponse(null); - when(mockDetails.getResponse()).thenReturn(expectedResponse); + variant.requestDetails().setResponse(expectedResponse); - DetailsBuilder builder = ClinicalIntelligenceRequestDetailsCloner.startWith(mockDetails); - RequestDetails result = builder.create(); + RequestDetails result = ClinicalIntelligenceRequestDetailsCloner.startWith( + variant.requestDetails(), FHIR_CONTEXT, Measure.class) + .create(); - assertNotNull(result); assertEquals(expectedResponse, result.getResponse()); } + @ParameterizedTest + @MethodSource("requestDetailsVariants") + void startWith_ClonesParameters(RequestDetailsVariant variant) { + Map originalParams = new HashMap<>(); + originalParams.put("param1", new String[] {"value1", "value2"}); + variant.requestDetails().setParameters(originalParams); + + RequestDetails result = ClinicalIntelligenceRequestDetailsCloner.startWith( + variant.requestDetails(), FHIR_CONTEXT, Measure.class) + .create(); + + assertNotSame(originalParams, result.getParameters()); + assertEquals(originalParams, result.getParameters()); + } + + @ParameterizedTest + @MethodSource("requestDetailsVariants") + void startWith_PreservesRequestDetailsType(RequestDetailsVariant variant) { + RequestDetails result = ClinicalIntelligenceRequestDetailsCloner.startWith( + variant.requestDetails(), FHIR_CONTEXT, Measure.class) + .create(); + + assertInstanceOf(variant.requestDetails().getClass(), result); + } + @Test - void testStartWithResetsResourceNameAndCompartmentName() { - RequestDetails mockDetails = mock(RequestDetails.class); + void startWith_SystemRequestDetails_PreservesRequestPartitionId() { + var partitionId = RequestPartitionId.fromPartitionId(123); + var system = new SystemRequestDetails(); + system.setParameters(new HashMap<>()); + system.setRequestPartitionId(partitionId); - DetailsBuilder builder = ClinicalIntelligenceRequestDetailsCloner.startWith(mockDetails); - RequestDetails result = builder.create(); + RequestDetails result = ClinicalIntelligenceRequestDetailsCloner.startWith(system, FHIR_CONTEXT, Measure.class) + .create(); - assertNotNull(result); - assertNull(result.getResourceName()); - assertNull(result.getCompartmentName()); + assertInstanceOf(SystemRequestDetails.class, result); + assertEquals(partitionId, ((SystemRequestDetails) result).getRequestPartitionId()); } @Test - void testStartWithClonesParametersCorrectly() { - RequestDetails mockDetails = mock(RequestDetails.class); - Map originalParams = new HashMap<>(); - originalParams.put("param1", new String[] {"value1", "value2"}); - when(mockDetails.getParameters()).thenReturn(originalParams); + void startWith_SystemRequestDetails_PreservesNullRequestPartitionId() { + var system = new SystemRequestDetails(); + system.setParameters(new HashMap<>()); + + RequestDetails result = ClinicalIntelligenceRequestDetailsCloner.startWith(system, FHIR_CONTEXT, Measure.class) + .create(); + + assertInstanceOf(SystemRequestDetails.class, result); + assertNull(((SystemRequestDetails) result).getRequestPartitionId()); + } + + @Test + void startWith_UnsupportedType_ThrowsInvalidRequestException() { + // Mock creates a proxy that is neither SystemRequestDetails nor ServletRequestDetails + RequestDetails unsupported = mock(RequestDetails.class); + + assertThrows( + InvalidRequestException.class, + () -> ClinicalIntelligenceRequestDetailsCloner.startWith(unsupported, FHIR_CONTEXT, Measure.class)); + } + + record PartitionableResourceVariant( + String label, + FhirContext fhirContext, + Class resourceType, + boolean expectedPartitionable) { + @Override + public String toString() { + return label; + } + } + + static Stream partitionableResourceVariants() { + return Stream.of( + // R4 partitionable resources + new PartitionableResourceVariant( + "R4 Patient", FhirContext.forR4Cached(), org.hl7.fhir.r4.model.Patient.class, true), + new PartitionableResourceVariant( + "R4 Observation", FhirContext.forR4Cached(), org.hl7.fhir.r4.model.Observation.class, true), + new PartitionableResourceVariant("R4 Measure", FhirContext.forR4Cached(), Measure.class, true), + new PartitionableResourceVariant( + "R4 Encounter", FhirContext.forR4Cached(), org.hl7.fhir.r4.model.Encounter.class, true), + // R4 non-partitionable resources (all 11 types) + new PartitionableResourceVariant( + "R4 Library", FhirContext.forR4Cached(), org.hl7.fhir.r4.model.Library.class, false), + new PartitionableResourceVariant( + "R4 ValueSet", FhirContext.forR4Cached(), org.hl7.fhir.r4.model.ValueSet.class, false), + new PartitionableResourceVariant( + "R4 StructureMap", FhirContext.forR4Cached(), org.hl7.fhir.r4.model.StructureMap.class, false), + new PartitionableResourceVariant( + "R4 StructureDefinition", + FhirContext.forR4Cached(), + org.hl7.fhir.r4.model.StructureDefinition.class, + false), + new PartitionableResourceVariant( + "R4 Questionnaire", + FhirContext.forR4Cached(), + org.hl7.fhir.r4.model.Questionnaire.class, + false), + new PartitionableResourceVariant( + "R4 NamingSystem", FhirContext.forR4Cached(), org.hl7.fhir.r4.model.NamingSystem.class, false), + new PartitionableResourceVariant( + "R4 CompartmentDefinition", + FhirContext.forR4Cached(), + org.hl7.fhir.r4.model.CompartmentDefinition.class, + false), + new PartitionableResourceVariant( + "R4 SearchParameter", + FhirContext.forR4Cached(), + org.hl7.fhir.r4.model.SearchParameter.class, + false), + new PartitionableResourceVariant( + "R4 ConceptMap", FhirContext.forR4Cached(), org.hl7.fhir.r4.model.ConceptMap.class, false), + new PartitionableResourceVariant( + "R4 OperationDefinition", + FhirContext.forR4Cached(), + org.hl7.fhir.r4.model.OperationDefinition.class, + false), + new PartitionableResourceVariant( + "R4 CodeSystem", FhirContext.forR4Cached(), org.hl7.fhir.r4.model.CodeSystem.class, false), + // DSTU3 partitionable + new PartitionableResourceVariant( + "DSTU3 Patient", FhirContext.forDstu3Cached(), org.hl7.fhir.dstu3.model.Patient.class, true), + new PartitionableResourceVariant( + "DSTU3 Observation", + FhirContext.forDstu3Cached(), + org.hl7.fhir.dstu3.model.Observation.class, + true), + // DSTU3 non-partitionable + new PartitionableResourceVariant( + "DSTU3 Library", FhirContext.forDstu3Cached(), org.hl7.fhir.dstu3.model.Library.class, false), + new PartitionableResourceVariant( + "DSTU3 ValueSet", FhirContext.forDstu3Cached(), org.hl7.fhir.dstu3.model.ValueSet.class, false), + new PartitionableResourceVariant( + "DSTU3 CodeSystem", + FhirContext.forDstu3Cached(), + org.hl7.fhir.dstu3.model.CodeSystem.class, + false), + // R5 partitionable + new PartitionableResourceVariant( + "R5 Patient", FhirContext.forR5Cached(), org.hl7.fhir.r5.model.Patient.class, true), + new PartitionableResourceVariant( + "R5 Observation", FhirContext.forR5Cached(), org.hl7.fhir.r5.model.Observation.class, true), + // R5 non-partitionable + new PartitionableResourceVariant( + "R5 Library", FhirContext.forR5Cached(), org.hl7.fhir.r5.model.Library.class, false), + new PartitionableResourceVariant( + "R5 ValueSet", FhirContext.forR5Cached(), org.hl7.fhir.r5.model.ValueSet.class, false), + new PartitionableResourceVariant( + "R5 CodeSystem", FhirContext.forR5Cached(), org.hl7.fhir.r5.model.CodeSystem.class, false)); + } + + @ParameterizedTest + @MethodSource("partitionableResourceVariants") + void startWith_SystemRequestDetails_PreservesPartitionIdOnlyForPartitionableResource( + PartitionableResourceVariant variant) { + var partitionId = RequestPartitionId.fromPartitionId(123); + var system = new SystemRequestDetails(); + system.setParameters(new HashMap<>()); + system.setRequestPartitionId(partitionId); + + RequestDetails result = ClinicalIntelligenceRequestDetailsCloner.startWith( + system, variant.fhirContext(), variant.resourceType()) + .create(); + + assertInstanceOf(SystemRequestDetails.class, result); + if (variant.expectedPartitionable()) { + assertEquals(partitionId, ((SystemRequestDetails) result).getRequestPartitionId()); + } else { + assertNull(((SystemRequestDetails) result).getRequestPartitionId()); + } + } + + @ParameterizedTest + @MethodSource("partitionableResourceVariants") + void startWith_SystemRequestDetails_PreservesTenantIdRegardlessOfPartitionability( + PartitionableResourceVariant variant) { + // Tenant ID is always preserved for SystemRequestDetails because the + // constructor copies it, regardless of whether the resource is partitionable + var system = new SystemRequestDetails(); + system.setParameters(new HashMap<>()); + system.setTenantId("test-tenant"); - DetailsBuilder builder = ClinicalIntelligenceRequestDetailsCloner.startWith(mockDetails); - RequestDetails result = builder.create(); + RequestDetails result = ClinicalIntelligenceRequestDetailsCloner.startWith( + system, variant.fhirContext(), variant.resourceType()) + .create(); - assertNotNull(result); - Map clonedParams = result.getParameters(); - assertNotSame(originalParams, clonedParams); - assertEquals(originalParams, clonedParams); + assertEquals("test-tenant", result.getTenantId()); } } diff --git a/pom.xml b/pom.xml index 1a658981d8..878d800400 100644 --- a/pom.xml +++ b/pom.xml @@ -25,7 +25,7 @@ 4.3.0 1.36 6.2.12 - 8.6.0 + 8.7.13-SNAPSHOT 4.6.1 33.2.1-jre 2.35.1