From 182ac3b36c112be08b8c41c4d0ceb985f088398f Mon Sep 17 00:00:00 2001 From: JP Date: Thu, 6 Apr 2023 13:53:29 -0600 Subject: [PATCH] Initial stub out of the CR repo API (#4627) * Initial stub out of the CR repo API * Pagination first pass * cleanup * add transaction tests * add search param conversion working logic * add result parameter conversion * add test for keymap * add type checking logic and tests * remove and / or logic from test and cleanup * Initial stub out of the CR repo API * Pagination first pass * cleanup * add transaction tests * add search param conversion working logic * add result parameter conversion * add test for keymap * add type checking logic and tests * Fix repository tests to use parameters * Cleanup after latest merge * Cleanup * Cleanup * Fix Msg codes * Fix Msg codes * Rename variable for clinical reasoning module * Update clinical-reasoning version * Fix version * review comments --------- Co-authored-by: Brenin Rhodes Co-authored-by: Rosie Elphick --- .../changelog/6_6_0/4695-add-cr-repo-api.yaml | 4 + .../r4/BaseResourceProviderR4Test.java | 2 +- .../fhir/rest/api/server/RequestDetails.java | 2 +- .../rest/api/server/SystemRequestDetails.java | 13 +- .../uhn/fhir/rest/server/RestfulServer.java | 2 +- hapi-fhir-storage-cr/pom.xml | 17 +- .../config/BaseClinicalReasoningConfig.java | 1 + .../uhn/fhir/cr/repo/BundleProviderUtil.java | 263 ++++++++++++++ .../uhn/fhir/cr/repo/HapiFhirRepository.java | 340 ++++++++++++++++++ .../fhir/cr/repo/RequestDetailsCloner.java | 109 ++++++ .../ca/uhn/fhir/cr/repo/SearchConverter.java | 110 ++++++ .../java/ca/uhn/fhir/cr/BaseCrR4Test.java | 4 +- .../fhir/cr/r4/HapiFhirRepositoryR4Test.java | 165 +++++++++ .../uhn/fhir/cr/r4/SearchConverterTest.java | 193 ++++++++++ pom.xml | 4 +- 15 files changed, 1216 insertions(+), 13 deletions(-) create mode 100644 hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_6_0/4695-add-cr-repo-api.yaml create mode 100644 hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/BundleProviderUtil.java create mode 100644 hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/HapiFhirRepository.java create mode 100644 hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/RequestDetailsCloner.java create mode 100644 hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/SearchConverter.java create mode 100644 hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/r4/HapiFhirRepositoryR4Test.java create mode 100644 hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/r4/SearchConverterTest.java diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_6_0/4695-add-cr-repo-api.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_6_0/4695-add-cr-repo-api.yaml new file mode 100644 index 00000000000..42eee91a615 --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_6_0/4695-add-cr-repo-api.yaml @@ -0,0 +1,4 @@ +--- +type: add +issue: 4695 +title: "Add an implementation of the Clinical Reasoning FHIR Repository to allow calling the available CR operations in hapi-fhir." diff --git a/hapi-fhir-jpaserver-test-utilities/src/main/java/ca/uhn/fhir/jpa/provider/r4/BaseResourceProviderR4Test.java b/hapi-fhir-jpaserver-test-utilities/src/main/java/ca/uhn/fhir/jpa/provider/r4/BaseResourceProviderR4Test.java index 48cd0ac4022..10bbdf44e3c 100644 --- a/hapi-fhir-jpaserver-test-utilities/src/main/java/ca/uhn/fhir/jpa/provider/r4/BaseResourceProviderR4Test.java +++ b/hapi-fhir-jpaserver-test-utilities/src/main/java/ca/uhn/fhir/jpa/provider/r4/BaseResourceProviderR4Test.java @@ -87,7 +87,7 @@ public abstract class BaseResourceProviderR4Test extends BaseJpaR4Test { protected static ISearchCoordinatorSvc mySearchCoordinatorSvc; protected static Server ourServer; protected static JpaCapabilityStatementProvider ourCapabilityStatementProvider; - private static DatabaseBackedPagingProvider ourPagingProvider; + protected static DatabaseBackedPagingProvider ourPagingProvider; private static GenericWebApplicationContext ourWebApplicationContext; protected IGenericClient myClient; @Autowired diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/api/server/RequestDetails.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/api/server/RequestDetails.java index f3d62beaecb..80d62cc7e35 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/api/server/RequestDetails.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/api/server/RequestDetails.java @@ -91,7 +91,7 @@ public abstract class RequestDetails { /** * Copy constructor */ - public RequestDetails(ServletRequestDetails theRequestDetails) { + public RequestDetails(RequestDetails theRequestDetails) { myInterceptorBroadcaster = theRequestDetails.getInterceptorBroadcaster(); myRequestStopwatch = theRequestDetails.getRequestStopwatch(); myTenantId = theRequestDetails.getTenantId(); diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/api/server/SystemRequestDetails.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/api/server/SystemRequestDetails.java index 4020cd64e80..225cf7298a4 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/api/server/SystemRequestDetails.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/api/server/SystemRequestDetails.java @@ -42,6 +42,8 @@ import java.io.Reader; import java.nio.charset.Charset; import java.util.List; +import static java.util.Objects.nonNull; + /** * A default RequestDetails implementation that can be used for system calls to * Resource DAO methods when partitioning is enabled. Using a SystemRequestDetails @@ -56,14 +58,21 @@ public class SystemRequestDetails extends RequestDetails { */ private RequestPartitionId myRequestPartitionId; + private IRestfulServerDefaults myServer = new MyRestfulServerDefaults(); + public SystemRequestDetails() { - super(new MyInterceptorBroadcaster()); + this(new MyInterceptorBroadcaster()); } public SystemRequestDetails(IInterceptorBroadcaster theInterceptorBroadcaster) { super(theInterceptorBroadcaster); } + public SystemRequestDetails(RequestDetails theDetails) { + super(theDetails); + if (nonNull(theDetails.getServer())) { myServer = theDetails.getServer(); } + } + public RequestPartitionId getRequestPartitionId() { return myRequestPartitionId; } @@ -140,7 +149,7 @@ public class SystemRequestDetails extends RequestDetails { @Override public IRestfulServerDefaults getServer() { - return new MyRestfulServerDefaults(); + return myServer; } @Override diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/RestfulServer.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/RestfulServer.java index 106c9351ec1..6dbfbad7ccf 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/RestfulServer.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/RestfulServer.java @@ -201,7 +201,7 @@ public class RestfulServer extends HttpServlet implements IRestfulServerhapi-fhir-converter ${project.version} + + org.opencds.cqf.fhir + cqf-fhir-api + ${clinical-reasoning.version} + org.opencds.cqf.cql evaluator.fhir - ${cql-evaluator.version} + ${clinical-reasoning.version} ca.uhn.hapi.fhir @@ -114,7 +119,7 @@ org.opencds.cqf.cql evaluator.spring - ${cql-evaluator.version} + ${clinical-reasoning.version} xpp3 @@ -129,7 +134,7 @@ org.opencds.cqf.cql evaluator.jackson-deps - ${cql-evaluator.version} + ${clinical-reasoning.version} pom @@ -208,6 +213,10 @@ ${project.version} test - + + org.apache.poi + poi + + diff --git a/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/config/BaseClinicalReasoningConfig.java b/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/config/BaseClinicalReasoningConfig.java index 9d8d6453926..235c50c5c06 100644 --- a/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/config/BaseClinicalReasoningConfig.java +++ b/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/config/BaseClinicalReasoningConfig.java @@ -69,6 +69,7 @@ import org.opencds.cqf.cql.evaluator.engine.model.CachingModelResolverDecorator; import org.opencds.cqf.cql.evaluator.engine.retrieve.BundleRetrieveProvider; import org.opencds.cqf.cql.evaluator.fhir.Constants; import org.opencds.cqf.cql.evaluator.fhir.adapter.AdapterFactory; +import org.opencds.cqf.cql.evaluator.fhir.Constants; import org.opencds.cqf.cql.evaluator.measure.MeasureEvaluationOptions; import org.opencds.cqf.cql.evaluator.spring.fhir.adapter.AdapterConfiguration; import org.slf4j.Logger; diff --git a/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/BundleProviderUtil.java b/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/BundleProviderUtil.java new file mode 100644 index 00000000000..538a0c0dc14 --- /dev/null +++ b/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/BundleProviderUtil.java @@ -0,0 +1,263 @@ +package ca.uhn.fhir.cr.repo; + +/*- + * #%L + * HAPI FHIR - Clinical Reasoning + * %% + * Copyright (C) 2014 - 2023 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import ca.uhn.fhir.i18n.Msg; +import ca.uhn.fhir.model.api.Include; +import ca.uhn.fhir.model.valueset.BundleTypeEnum; +import ca.uhn.fhir.rest.api.BundleLinks; +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.EncodingEnum; +import ca.uhn.fhir.rest.api.IVersionSpecificBundleFactory; +import ca.uhn.fhir.rest.api.RestOperationTypeEnum; +import ca.uhn.fhir.rest.api.server.IBundleProvider; +import ca.uhn.fhir.rest.api.server.IRestfulServer; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.server.IPagingProvider; +import ca.uhn.fhir.rest.server.RestfulServerUtils; +import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; +import ca.uhn.fhir.rest.server.method.BaseResourceReturningMethodBinding; +import org.apache.commons.lang3.Validate; +import org.hl7.fhir.instance.model.api.IBaseOperationOutcome; +import org.hl7.fhir.instance.model.api.IBaseResource; + +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; +import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +/** + * This class pulls existing methods from the BaseResourceReturningMethodBinding class used for taking + * the results of a BundleProvider and turning it into a Bundle. It is intended to be used only by the + * HapiFhirRepository. + */ +public class BundleProviderUtil { + private static final org.slf4j.Logger ourLog = + org.slf4j.LoggerFactory.getLogger(BaseResourceReturningMethodBinding.class); + + public static IBaseResource createBundleFromBundleProvider(IRestfulServer theServer, + RequestDetails theRequest, Integer theLimit, String theLinkSelf, Set theIncludes, + IBundleProvider theResult, int theOffset, BundleTypeEnum theBundleType, + EncodingEnum theLinkEncoding, String theSearchId) { + IVersionSpecificBundleFactory bundleFactory = theServer.getFhirContext().newBundleFactory(); + final Integer offset; + Integer limit = theLimit; + + if (theResult.getCurrentPageOffset() != null) { + offset = theResult.getCurrentPageOffset(); + limit = theResult.getCurrentPageSize(); + Validate.notNull(limit, + "IBundleProvider returned a non-null offset, but did not return a non-null page size"); + } else { + offset = RestfulServerUtils.tryToExtractNamedParameter(theRequest, Constants.PARAM_OFFSET); + } + + int numToReturn; + String searchId = null; + List resourceList; + Integer numTotalResults = theResult.size(); + + int pageSize; + if (offset != null || !theServer.canStoreSearchResults()) { + if (limit != null) { + pageSize = limit; + } else { + if (theServer.getDefaultPageSize() != null) { + pageSize = theServer.getDefaultPageSize(); + } else { + pageSize = numTotalResults != null ? numTotalResults : Integer.MAX_VALUE; + } + } + numToReturn = pageSize; + + if (offset != null || theResult.getCurrentPageOffset() != null) { + // When offset query is done theResult already contains correct amount (+ their includes + // etc.) so return everything + resourceList = theResult.getResources(0, Integer.MAX_VALUE); + } else if (numToReturn > 0) { + resourceList = theResult.getResources(0, numToReturn); + } else { + resourceList = Collections.emptyList(); + } + RestfulServerUtils.validateResourceListNotNull(resourceList); + + } else { + IPagingProvider pagingProvider = theServer.getPagingProvider(); + if (limit == null || ((Integer) limit).equals(0)) { + pageSize = pagingProvider.getDefaultPageSize(); + } else { + pageSize = Math.min(pagingProvider.getMaximumPageSize(), limit); + } + numToReturn = pageSize; + + if (numTotalResults != null) { + numToReturn = Math.min(numToReturn, numTotalResults - theOffset); + } + + if (numToReturn > 0 || theResult.getCurrentPageId() != null) { + resourceList = theResult.getResources(theOffset, numToReturn + theOffset); + } else { + resourceList = Collections.emptyList(); + } + RestfulServerUtils.validateResourceListNotNull(resourceList); + + if (numTotalResults == null) { + numTotalResults = theResult.size(); + } + + if (theSearchId != null) { + searchId = theSearchId; + } else { + if (numTotalResults == null || numTotalResults > numToReturn) { + searchId = pagingProvider.storeResultList(theRequest, theResult); + if (isBlank(searchId)) { + ourLog.info( + "Found {} results but paging provider did not provide an ID to use for paging", + numTotalResults); + searchId = null; + } + } + } + } + + /* + * Remove any null entries in the list - This generally shouldn't happen but can if data has + * been manually purged from the JPA database + */ + boolean hasNull = false; + for (IBaseResource next : resourceList) { + if (next == null) { + hasNull = true; + break; + } + } + if (hasNull) { + resourceList.removeIf(Objects::isNull); + } + + /* + * Make sure all returned resources have an ID (if not, this is a bug in the user server code) + */ + for (IBaseResource next : resourceList) { + if (next.getIdElement() == null || next.getIdElement().isEmpty()) { + if (!(next instanceof IBaseOperationOutcome)) { + throw new InternalErrorException(Msg.code(2311) + + "Server method returned resource of type[" + next.getClass().getSimpleName() + + "] with no ID specified (IResource#setId(IdDt) must be called)"); + } + } + } + + BundleLinks links = new BundleLinks(theRequest.getFhirServerBase(), theIncludes, + RestfulServerUtils.prettyPrintResponse(theServer, theRequest), theBundleType); + links.setSelf(theLinkSelf); + + if (theResult.getCurrentPageOffset() != null) { + + if (isNotBlank(theResult.getNextPageId())) { + links.setNext(RestfulServerUtils.createOffsetPagingLink(links, + theRequest.getRequestPath(), theRequest.getTenantId(), offset + limit, limit, + theRequest.getParameters())); + } + if (isNotBlank(theResult.getPreviousPageId())) { + links.setNext(RestfulServerUtils.createOffsetPagingLink(links, + theRequest.getRequestPath(), theRequest.getTenantId(), + Math.max(offset - limit, 0), limit, theRequest.getParameters())); + } + + } + + if (offset != null + || (!theServer.canStoreSearchResults() && !isEverythingOperation(theRequest))) { + // Paging without caching + // We're doing offset pages + int requestedToReturn = numToReturn; + if (theServer.getPagingProvider() == null && offset != null) { + // There is no paging provider at all, so assume we're querying up to all the results we + // need every time + requestedToReturn += offset; + } + if (numTotalResults == null || requestedToReturn < numTotalResults) { + if (!resourceList.isEmpty()) { + links.setNext( + RestfulServerUtils.createOffsetPagingLink(links, theRequest.getRequestPath(), + theRequest.getTenantId(), defaultIfNull(offset, 0) + numToReturn, + numToReturn, theRequest.getParameters())); + } + } + if (offset != null && offset > 0) { + int start = Math.max(0, theOffset - pageSize); + links.setPrev( + RestfulServerUtils.createOffsetPagingLink(links, theRequest.getRequestPath(), + theRequest.getTenantId(), start, pageSize, theRequest.getParameters())); + } + } else if (isNotBlank(theResult.getCurrentPageId())) { + // We're doing named pages + searchId = theResult.getUuid(); + if (isNotBlank(theResult.getNextPageId())) { + links.setNext(RestfulServerUtils.createPagingLink(links, theRequest, searchId, + theResult.getNextPageId(), theRequest.getParameters())); + } + if (isNotBlank(theResult.getPreviousPageId())) { + links.setPrev(RestfulServerUtils.createPagingLink(links, theRequest, searchId, + theResult.getPreviousPageId(), theRequest.getParameters())); + } + } else if (searchId != null) { + /* + * We're doing offset pages - Note that we only return paging links if we actually included + * some results in the response. We do this to avoid situations where people have faked the + * offset number to some huge number to avoid them getting back paging links that don't + * make sense. + */ + if (resourceList.size() > 0) { + if (numTotalResults == null || theOffset + numToReturn < numTotalResults) { + links.setNext((RestfulServerUtils.createPagingLink(links, theRequest, searchId, + theOffset + numToReturn, numToReturn, theRequest.getParameters()))); + } + if (theOffset > 0) { + int start = Math.max(0, theOffset - pageSize); + links.setPrev(RestfulServerUtils.createPagingLink(links, theRequest, searchId, start, + pageSize, theRequest.getParameters())); + } + } + } + + bundleFactory.addRootPropertiesToBundle(theResult.getUuid(), links, theResult.size(), + theResult.getPublished()); + bundleFactory.addResourcesToBundle(new ArrayList<>(resourceList), theBundleType, + links.serverBase, theServer.getBundleInclusionRule(), theIncludes); + + return bundleFactory.getResourceBundle(); + + } + + private static boolean isEverythingOperation(RequestDetails theRequest) { + return (theRequest.getRestOperationType() == RestOperationTypeEnum.EXTENDED_OPERATION_TYPE + || theRequest + .getRestOperationType() == RestOperationTypeEnum.EXTENDED_OPERATION_INSTANCE) + && theRequest.getOperation() != null && theRequest.getOperation().equals("$everything"); + } +} diff --git a/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/HapiFhirRepository.java b/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/HapiFhirRepository.java new file mode 100644 index 00000000000..abe04c3a71e --- /dev/null +++ b/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/HapiFhirRepository.java @@ -0,0 +1,340 @@ +package ca.uhn.fhir.cr.repo; + +/*- + * #%L + * HAPI FHIR - Clinical Reasoning + * %% + * Copyright (C) 2014 - 2023 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import static ca.uhn.fhir.cr.repo.RequestDetailsCloner.startWith; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +import java.io.IOException; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.hl7.fhir.instance.model.api.IBaseBundle; +import org.hl7.fhir.instance.model.api.IBaseConformance; +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.opencds.cqf.fhir.api.Repository; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.i18n.Msg; +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.model.api.Include; +import ca.uhn.fhir.model.valueset.BundleTypeEnum; +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.api.server.IBundleProvider; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.server.RestfulServer; +import ca.uhn.fhir.rest.server.RestfulServerUtils; +import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; +import ca.uhn.fhir.rest.server.exceptions.NotImplementedOperationException; +import ca.uhn.fhir.rest.server.exceptions.ResourceGoneException; +import ca.uhn.fhir.rest.server.method.PageMethodBinding; +import ca.uhn.fhir.util.UrlUtil; + +/** + * This class leverages DaoRegistry from Hapi-fhir to implement CRUD FHIR API operations constrained to provide only the operations necessary for the cql-evaluator modules to function. + **/ +public class HapiFhirRepository implements Repository { + private static final org.slf4j.Logger ourLog = + org.slf4j.LoggerFactory.getLogger(HapiFhirRepository.class); + private final DaoRegistry myDaoRegistry; + private final RequestDetails myRequestDetails; + private final RestfulServer myRestfulServer; + + public HapiFhirRepository(DaoRegistry theDaoRegistry, RequestDetails theRequestDetails, + RestfulServer theRestfulServer) { + myDaoRegistry = theDaoRegistry; + myRequestDetails = theRequestDetails; + myRestfulServer = theRestfulServer; + } + + @Override + public T read(Class theResourceType, I theId, + Map theHeaders) { + var details = startWith(myRequestDetails).addHeaders(theHeaders).create(); + return myDaoRegistry.getResourceDao(theResourceType).read(theId, details); + } + + @Override + public MethodOutcome create(T theResource, + Map theHeaders) { + var details = startWith(myRequestDetails).addHeaders(theHeaders).create(); + return myDaoRegistry.getResourceDao(theResource).create(theResource, details); + } + + @Override + public MethodOutcome patch(I theId, + P thePatchParameters, Map theHeaders) { + var details = startWith(myRequestDetails).addHeaders(theHeaders).create(); + // TODO: conditional url, patch type, patch body? + return myDaoRegistry.getResourceDao(theId.getResourceType()).patch(theId, null, null, + null, thePatchParameters, details); + } + + @Override + public MethodOutcome update(T theResource, + Map theHeaders) { + var details = startWith(myRequestDetails).addHeaders(theHeaders).create(); + + return myDaoRegistry.getResourceDao(theResource).update(theResource, details); + } + + @Override + public MethodOutcome delete( + Class theResourceType, I theId, Map theHeaders) { + var details = startWith(myRequestDetails).addHeaders(theHeaders).create(); + + return myDaoRegistry.getResourceDao(theResourceType).delete(theId, details); + } + + @Override + public B search(Class theBundleType, + Class theResourceType, Map> theSearchParameters, + Map theHeaders) { + var details = startWith(myRequestDetails).addHeaders(theHeaders).create(); + SearchConverter converter = new SearchConverter(); + converter.convertParameters(theSearchParameters, fhirContext()); + details.setParameters(converter.resultParameters); + var bundleProvider = myDaoRegistry.getResourceDao(theResourceType) + .search(converter.searchParameterMap, details); + + if (bundleProvider == null) { + return null; + } + + return createBundle(details, bundleProvider, null); + } + + private B createBundle(RequestDetails theRequestDetails, + IBundleProvider theBundleProvider, String thePagingAction) { + var count = RestfulServerUtils.extractCountParameter(theRequestDetails); + var linkSelf = RestfulServerUtils.createLinkSelf(theRequestDetails.getFhirServerBase(), + theRequestDetails); + + Set includes = new HashSet<>(); + var reqIncludes = theRequestDetails.getParameters().get(Constants.PARAM_INCLUDE); + if (reqIncludes != null) { + for (String nextInclude : reqIncludes) { + includes.add(new Include(nextInclude)); + } + } + + var offset = RestfulServerUtils.tryToExtractNamedParameter(theRequestDetails, + Constants.PARAM_PAGINGOFFSET); + if (offset == null || offset < 0) { + offset = 0; + } + var start = offset; + if (theBundleProvider.size() != null) { + start = Math.max(0, Math.min(offset, theBundleProvider.size())); + } + + BundleTypeEnum bundleType = null; + var bundleTypeValues = theRequestDetails.getParameters().get(Constants.PARAM_BUNDLETYPE); + if (bundleTypeValues != null) { + bundleType = BundleTypeEnum.VALUESET_BINDER.fromCodeString(bundleTypeValues[0]); + } else { + bundleType = BundleTypeEnum.SEARCHSET; + } + + var responseEncoding = RestfulServerUtils.determineResponseEncodingNoDefault( + theRequestDetails, myRestfulServer.getDefaultResponseEncoding()); + var linkEncoding = theRequestDetails.getParameters().containsKey(Constants.PARAM_FORMAT) + && responseEncoding != null ? responseEncoding.getEncoding() : null; + + return (B) BundleProviderUtil.createBundleFromBundleProvider(myRestfulServer, + theRequestDetails, count, linkSelf, includes, theBundleProvider, start, bundleType, + linkEncoding, thePagingAction); + } + + // TODO: The main use case for this is paging through Bundles, but I suppose that technically + // we ought to handle any old link. Maybe this is also an escape hatch for "custom non-FHIR + // repository action"? + @Override + public B link(Class theBundleType, String theUrl, + Map theHeaders) { + var details = startWith(myRequestDetails).addHeaders(theHeaders).create(); + var urlParts = UrlUtil.parseUrl(theUrl); + details.setCompleteUrl(theUrl); + details.setParameters(UrlUtil.parseQueryStrings(urlParts.getParams())); + + var pagingProvider = myRestfulServer.getPagingProvider(); + if (pagingProvider == null) { + throw new InvalidRequestException(Msg.code(2312) + "This server does not support paging"); + } + + var thePagingAction = details.getParameters().get(Constants.PARAM_PAGINGACTION)[0]; + + IBundleProvider bundleProvider; + + String pageId = null; + String[] pageIdParams = details.getParameters().get(Constants.PARAM_PAGEID); + if (pageIdParams != null && pageIdParams.length > 0 && isNotBlank(pageIdParams[0])) { + pageId = pageIdParams[0]; + } + + if (pageId != null) { + // This is a page request by Search ID and Page ID + bundleProvider = pagingProvider.retrieveResultList(details, thePagingAction, pageId); + validateHaveBundleProvider(thePagingAction, bundleProvider); + } else { + // This is a page request by Search ID and Offset + bundleProvider = pagingProvider.retrieveResultList(details, thePagingAction); + validateHaveBundleProvider(thePagingAction, bundleProvider); + } + + return createBundle(details, bundleProvider, thePagingAction); + } + + private void validateHaveBundleProvider(String thePagingAction, + IBundleProvider theBundleProvider) { + // Return an HTTP 410 if the search is not known + if (theBundleProvider == null) { + ourLog.info("Client requested unknown paging ID[{}]", thePagingAction); + String msg = fhirContext().getLocalizer().getMessage(PageMethodBinding.class, + "unknownSearchId", thePagingAction); + throw new ResourceGoneException(Msg.code(2313) + msg); + } + } + + @Override + public C capabilities(Class theCapabilityStatementType, + Map theHeaders) { + var method = myRestfulServer.getServerConformanceMethod(); + if (method == null) { + return null; + } + var details = startWith(myRequestDetails).addHeaders(theHeaders).create(); + return (C) method.provideCapabilityStatement(myRestfulServer, details); + } + + @Override + public B transaction(B theBundle, Map theHeaders) { + var details = startWith(myRequestDetails).addHeaders(theHeaders).create(); + return (B) myDaoRegistry.getSystemDao().transaction(details, theBundle); + } + + @Override + public R invoke(String theName, + P theParameters, Class theReturnType, Map theHeaders) { + var details = startWith(myRequestDetails).addHeaders(theHeaders).setOperation(theName) + .setParameters(theParameters).create(); + + return invoke(details); + } + + @Override + public

MethodOutcome invoke(String theName, P theParameters, + Map theHeaders) { + var details = startWith(myRequestDetails).addHeaders(theHeaders).setOperation(theName) + .setParameters(theParameters).create(); + + return invoke(details); + } + + @Override + public R invoke( + Class theResourceType, String theName, P theParameters, Class theReturnType, + Map theHeaders) { + var details = startWith(myRequestDetails).addHeaders(theHeaders).setOperation(theName) + .setResourceType(theResourceType.getSimpleName()).setParameters(theParameters).create(); + + return invoke(details); + } + + @Override + public

MethodOutcome invoke( + Class theResourceType, String theName, P theParameters, + Map theHeaders) { + var details = startWith(myRequestDetails).addHeaders(theHeaders).setOperation(theName) + .setResourceType(theResourceType.getSimpleName()).setParameters(theParameters).create(); + + return invoke(details); + } + + @Override + public R invoke(I theId, + String theName, P theParameters, Class theReturnType, Map theHeaders) { + var details = startWith(myRequestDetails).addHeaders(theHeaders).setOperation(theName) + .setResourceType(theId.getResourceType()).setId(theId).setParameters(theParameters) + .create(); + + return invoke(details); + } + + @Override + public

MethodOutcome invoke(I theId, + String theName, P theParameters, Map theHeaders) { + var details = startWith(myRequestDetails).addHeaders(theHeaders).setOperation(theName) + .setResourceType(theId.getResourceType()).setId(theId).setParameters(theParameters) + .create(); + + return invoke(details); + } + + private void notImplemented() { + throw new NotImplementedOperationException(Msg.code(2314) + "history not yet implemented"); + } + + @Override + public B history(P theParameters, + Class theReturnBundleType, Map theHeaders) { + notImplemented(); + + return null; + } + + @Override + public B history( + Class theResourceType, P theParameters, Class theReturnBundleType, + Map theHeaders) { + notImplemented(); + + return null; + } + + @Override + public B history(I theId, + P theParameters, Class theReturnBundleType, Map theHeaders) { + notImplemented(); + + return null; + } + + @Override + public FhirContext fhirContext() { + return myRestfulServer.getFhirContext(); + } + + protected R invoke(RequestDetails theDetails) { + try { + return (R) myRestfulServer.determineResourceMethod(theDetails, null) + .invokeServer(myRestfulServer, theDetails); + } catch (IOException e) { + throw new RuntimeException(Msg.code(2315) + e); + } + } +} diff --git a/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/RequestDetailsCloner.java b/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/RequestDetailsCloner.java new file mode 100644 index 00000000000..559c611da9b --- /dev/null +++ b/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/RequestDetailsCloner.java @@ -0,0 +1,109 @@ +package ca.uhn.fhir.cr.repo; + +import java.util.HashMap; +import java.util.Map; + +import org.hl7.fhir.instance.model.api.IBaseParameters; +import org.hl7.fhir.instance.model.api.IIdType; + +/*- + * #%L + * HAPI FHIR - Clinical Reasoning + * %% + * Copyright (C) 2014 - 2023 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.rest.api.RequestTypeEnum; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.api.server.SystemRequestDetails; + +/** + * This class produces partial clones of RequestDetails, the intent being to reuse the context of a + * RequestDetails object for reentrant calls. It retains header and tenancy information while + * scrapping everything else. + */ +class RequestDetailsCloner { + + static DetailsBuilder startWith(RequestDetails theDetails) { + var newDetails = new SystemRequestDetails(theDetails); + newDetails.setRequestType(RequestTypeEnum.POST); + newDetails.setOperation(null); + newDetails.setResource(null); + newDetails.setParameters(new HashMap<>()); + newDetails.setResourceName(null); + newDetails.setCompartmentName(null); + + return new DetailsBuilder(newDetails); + } + + static class DetailsBuilder { + private final SystemRequestDetails myDetails; + + DetailsBuilder(SystemRequestDetails theDetails) { + myDetails = theDetails; + } + + DetailsBuilder addHeaders(Map theHeaders) { + if (theHeaders != null) { + for (var entry : theHeaders.entrySet()) { + myDetails.addHeader(entry.getKey(), entry.getValue()); + } + } + + return this; + } + + DetailsBuilder setParameters(IBaseParameters theParameters) { + myDetails.setResource(theParameters); + + return this; + } + + DetailsBuilder setParameters(Map theParameters) { + myDetails.setParameters(theParameters); + + return this; + } + + DetailsBuilder withRestOperationType(RequestTypeEnum theType) { + myDetails.setRequestType(theType); + + return this; + } + + DetailsBuilder setOperation(String theOperation) { + myDetails.setOperation(theOperation); + + return this; + } + + DetailsBuilder setResourceType(String theResourceName) { + myDetails.setResourceName(theResourceName); + + return this; + } + + DetailsBuilder setId(IIdType theId) { + myDetails.setId(theId); + + return this; + } + + SystemRequestDetails create() { + return myDetails; + } + } +} diff --git a/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/SearchConverter.java b/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/SearchConverter.java new file mode 100644 index 00000000000..4be10410d29 --- /dev/null +++ b/hapi-fhir-storage-cr/src/main/java/ca/uhn/fhir/cr/repo/SearchConverter.java @@ -0,0 +1,110 @@ +package ca.uhn.fhir.cr.repo; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; + +/*- + * #%L + * HAPI FHIR - Clinical Reasoning + * %% + * Copyright (C) 2014 - 2023 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; +import ca.uhn.fhir.model.api.IQueryParameterAnd; +import ca.uhn.fhir.model.api.IQueryParameterOr; +import ca.uhn.fhir.model.api.IQueryParameterType; + +/** + * The IGenericClient API represents searches with OrLists, while the FhirRepository API uses nested + * lists. This class (will eventually) convert between them + */ +public class SearchConverter { + // hardcoded list from FHIR specs: https://www.hl7.org/fhir/search.html + private final List searchResultParameters = Arrays.asList("_sort", "_count", "_include", + "_revinclude", "_summary", "_total", "_elements", "_contained", "_containedType"); + public final Map> separatedSearchParameters = new HashMap<>(); + public final Map> separatedResultParameters = new HashMap<>(); + public final SearchParameterMap searchParameterMap = new SearchParameterMap(); + public final Map resultParameters = new HashMap<>(); + + void convertParameters(Map> theParameters, + FhirContext theFhirContext) { + separateParameterTypes(theParameters); + convertToSearchParameterMap(separatedSearchParameters); + convertToStringMap(separatedResultParameters, theFhirContext); + } + + public void convertToStringMap(@Nonnull Map> theParameters, + @Nonnull FhirContext theFhirContext) { + for (var entry : theParameters.entrySet()) { + String[] values = new String[entry.getValue().size()]; + for (int i = 0; i < entry.getValue().size(); i++) { + values[i] = entry.getValue().get(i).getValueAsQueryToken(theFhirContext); + } + resultParameters.put(entry.getKey(), values); + } + } + + public void convertToSearchParameterMap(Map> theSearchMap) { + if (theSearchMap == null) { + return; + } + for (var entry : theSearchMap.entrySet()) { + for (IQueryParameterType value : entry.getValue()) { + setParameterTypeValue(entry.getKey(), value); + } + } + } + + public void setParameterTypeValue(@Nonnull String theKey, @Nonnull T theParameterType) { + if (isOrList(theParameterType)) { + searchParameterMap.add(theKey, (IQueryParameterOr) theParameterType); + } else if (isAndList(theParameterType)) { + searchParameterMap.add(theKey, (IQueryParameterAnd) theParameterType); + } else { + searchParameterMap.add(theKey, (IQueryParameterType) theParameterType); + } + } + + public void separateParameterTypes( + @Nonnull Map> theParameters) { + for (var entry : theParameters.entrySet()) { + if (isSearchResultParameter(entry.getKey())) { + separatedResultParameters.put(entry.getKey(), entry.getValue()); + } else { + separatedSearchParameters.put(entry.getKey(), entry.getValue()); + } + } + } + + public boolean isSearchResultParameter(String theParameterName) { + return searchResultParameters.contains(theParameterName); + } + + public boolean isOrList(@Nonnull T theParameterType) { + return IQueryParameterOr.class.isAssignableFrom(theParameterType.getClass()); + } + + public boolean isAndList(@Nonnull T theParameterType) { + return IQueryParameterAnd.class.isAssignableFrom(theParameterType.getClass()); + } +} diff --git a/hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/BaseCrR4Test.java b/hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/BaseCrR4Test.java index 405640ec73e..719d3c60b32 100644 --- a/hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/BaseCrR4Test.java +++ b/hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/BaseCrR4Test.java @@ -3,8 +3,10 @@ package ca.uhn.fhir.cr; import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.cr.config.CrR4Config; import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.provider.r4.BaseResourceProviderR4Test; import ca.uhn.fhir.jpa.test.BaseJpaR4Test; import ca.uhn.fhir.parser.IParser; +import ca.uhn.fhir.rest.server.RestfulServer; import io.specto.hoverfly.junit.dsl.HoverflyDsl; import io.specto.hoverfly.junit.dsl.StubServiceBuilder; import io.specto.hoverfly.junit.rule.HoverflyRule; @@ -27,7 +29,7 @@ import static io.specto.hoverfly.junit.dsl.ResponseCreators.success; @ContextConfiguration(classes = {TestCrConfig.class, CrR4Config.class}) -public abstract class BaseCrR4Test extends BaseJpaR4Test implements IResourceLoader { +public abstract class BaseCrR4Test extends BaseResourceProviderR4Test implements IResourceLoader { protected static final FhirContext ourFhirContext = FhirContext.forR4Cached(); private static final IParser ourParser = ourFhirContext.newJsonParser().setPrettyPrint(true); private static final String TEST_ADDRESS = "test-address.com"; diff --git a/hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/r4/HapiFhirRepositoryR4Test.java b/hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/r4/HapiFhirRepositoryR4Test.java new file mode 100644 index 00000000000..874c99b1d14 --- /dev/null +++ b/hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/r4/HapiFhirRepositoryR4Test.java @@ -0,0 +1,165 @@ +package ca.uhn.fhir.cr.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; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Encounter; +import org.hl7.fhir.r4.model.HumanName; +import org.hl7.fhir.r4.model.IdType; +import org.hl7.fhir.r4.model.Immunization; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import ca.uhn.fhir.cr.BaseCrR4Test; +import ca.uhn.fhir.cr.repo.HapiFhirRepository; +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.param.NumberParam; +import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; + +public class HapiFhirRepositoryR4Test extends BaseCrR4Test { + private static final String MY_TEST_DATA = + "ca/uhn/fhir/cr/r4/immunization/Patients_Encounters_Immunizations_Practitioners.json"; + + private RequestDetails setupRequestDetails() { + var requestDetails = new ServletRequestDetails(); + requestDetails.setServletRequest(new MockHttpServletRequest()); + requestDetails.setServer(ourRestServer); + requestDetails.setFhirServerBase(ourServerBase); + return requestDetails; + } + + @Test + void crudTest() { + var requestDetails = setupRequestDetails(); + var repository = new HapiFhirRepository(myDaoRegistry, requestDetails, ourRestServer); + var result = repository + .create(new Patient().addName(new HumanName().setFamily("Test").addGiven("Name1"))); + assertEquals(true, result.getCreated()); + var patient = (Patient) result.getResource(); + assertEquals(1, patient.getName().size()); + assertEquals("Test", patient.getName().get(0).getFamily()); + assertEquals(1, patient.getName().get(0).getGiven().size()); + patient.getName().get(0).addGiven("Name2"); + repository.update(patient); + var updatedPatient = repository.read(Patient.class, patient.getIdElement()); + assertEquals(2, updatedPatient.getName().get(0).getGiven().size()); + repository.delete(Patient.class, patient.getIdElement()); + var ex = assertThrows(Exception.class, + () -> repository.read(Patient.class, new IdType(patient.getIdElement().getIdPart()))); + assertTrue(ex.getMessage().contains("Resource was deleted")); + } + + @Test + void canSearchMoreThan50Patients() { + loadBundle(MY_TEST_DATA); + var expectedPatientCount = 63; + ourPagingProvider.setMaximumPageSize(100); + var repository = new HapiFhirRepository(myDaoRegistry, setupRequestDetails(), ourRestServer); + // get all patient resources posted + var result = repository.search(Bundle.class, Patient.class, withCountParam(100)); + assertEquals(expectedPatientCount, result.getTotal()); + // count all resources in result + int counter = 0; + for (var e : result.getEntry()) { + counter++; + } + // verify all patient resources captured + assertEquals(expectedPatientCount, counter, + "Patient search results don't match available resources"); + } + + @Test + void canSearchWithPagination() { + loadBundle(MY_TEST_DATA); + + var requestDetails = setupRequestDetails(); + requestDetails.setCompleteUrl("http://localhost:44465/fhir/context/Patient?_count=20"); + var repository = new HapiFhirRepository(myDaoRegistry, requestDetails, ourRestServer); + var result = repository.search(Bundle.class, Patient.class, withCountParam(20)); + assertEquals(20, result.getEntry().size()); + var next = result.getLink().get(1); + assertEquals("next", next.getRelation()); + var nextUrl = next.getUrl(); + var nextResult = repository.link(Bundle.class, nextUrl); + assertEquals(20, nextResult.getEntry().size()); + assertEquals(false, + result.getEntry().stream().map(e -> e.getResource().getIdPart()).anyMatch( + i -> nextResult.getEntry().stream().map(e -> e.getResource().getIdPart()) + .collect(Collectors.toList()).contains(i))); + } + + @Test + void transactionReadsPatientResources() { + var expectedPatientCount = 63; + var theBundle = readResource(Bundle.class, MY_TEST_DATA); + ourPagingProvider.setMaximumPageSize(100); + var repository = new HapiFhirRepository(myDaoRegistry, setupRequestDetails(), ourRestServer); + repository.transaction(theBundle); + var result = repository.search(Bundle.class, Patient.class, withCountParam(100)); + // count all resources in result + int counter = 0; + for (Object i : result.getEntry()) { + counter++; + } + // verify all patient resources captured + assertEquals(expectedPatientCount, counter, + "Patient search results don't match available resources"); + } + + @Test + void transactionReadsEncounterResources() { + var expectedEncounterCount = 652; + var theBundle = readResource(Bundle.class, MY_TEST_DATA); + ourPagingProvider.setMaximumPageSize(1000); + var repository = new HapiFhirRepository(myDaoRegistry, setupRequestDetails(), ourRestServer); + repository.transaction(theBundle); + var result = repository.search(Bundle.class, Encounter.class, withCountParam(1000)); + // count all resources in result + int counter = 0; + for (Object i : result.getEntry()) { + counter++; + } + // verify all encounter resources captured + assertEquals(expectedEncounterCount, counter, + "Encounter search results don't match available resources"); + } + + @Test + void transactionReadsImmunizationResources() { + var expectedEncounterCount = 638; + var theBundle = readResource(Bundle.class, MY_TEST_DATA); + ourPagingProvider.setMaximumPageSize(1000); + var repository = new HapiFhirRepository(myDaoRegistry, setupRequestDetails(), ourRestServer); + repository.transaction(theBundle); + var result = repository.search(Bundle.class, Immunization.class, withCountParam(1000)); + // count all resources in result + int counter = 0; + for (Object i : result.getEntry()) { + counter++; + } + // verify all immunization resources captured + assertEquals(expectedEncounterCount, counter, + "Immunization search results don't match available resources"); + } + + Map> withEmptySearchParams() { + return new HashMap<>(); + } + + Map> withCountParam(int theCount) { + var params = withEmptySearchParams(); + params.put(Constants.PARAM_COUNT, Collections.singletonList(new NumberParam(theCount))); + return params; + } +} diff --git a/hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/r4/SearchConverterTest.java b/hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/r4/SearchConverterTest.java new file mode 100644 index 00000000000..160eaf82c9b --- /dev/null +++ b/hapi-fhir-storage-cr/src/test/java/ca/uhn/fhir/cr/r4/SearchConverterTest.java @@ -0,0 +1,193 @@ +package ca.uhn.fhir.cr.r4; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.cr.repo.SearchConverter; +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.rest.param.NumberAndListParam; +import ca.uhn.fhir.rest.param.NumberOrListParam; +import ca.uhn.fhir.rest.param.SpecialAndListParam; +import ca.uhn.fhir.rest.param.SpecialOrListParam; +import ca.uhn.fhir.rest.param.TokenAndListParam; +import ca.uhn.fhir.rest.param.TokenOrListParam; +import ca.uhn.fhir.rest.param.UriAndListParam; +import ca.uhn.fhir.rest.param.UriOrListParam; +import ca.uhn.fhir.rest.param.UriParam; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SearchConverterTest { + private SearchConverter myFixture; + + @BeforeEach + public void setupFixture() { + myFixture = new SearchConverter(); + } + + @Test + void isSearchParameterShouldReturnTrue() { + boolean result = myFixture.isSearchResultParameter("_elements"); + assertTrue(result); + } + + @Test + void isSearchParameterShouldReturnFalse() { + boolean result = myFixture.isSearchResultParameter("_id"); + assertFalse(result); + } + + @Test + void isOrListShouldReturnTrue() { + boolean uriOrList = myFixture.isOrList(new UriOrListParam()); + boolean numberOrList = myFixture.isOrList(new NumberOrListParam()); + boolean specialOrList = myFixture.isOrList(new SpecialOrListParam()); + boolean tokenOrList = myFixture.isOrList(new TokenOrListParam()); + assertTrue(uriOrList); + assertTrue(numberOrList); + assertTrue(specialOrList); + assertTrue(tokenOrList); + } + + @Test + void isAndListShouldReturnTrue() { + boolean uriAndList = myFixture.isAndList(new UriAndListParam()); + boolean numberAndList = myFixture.isAndList(new NumberAndListParam()); + boolean specialAndList = myFixture.isAndList(new SpecialAndListParam()); + boolean tokenAndList = myFixture.isAndList(new TokenAndListParam()); + assertTrue(uriAndList); + assertTrue(numberAndList); + assertTrue(specialAndList); + assertTrue(tokenAndList); + } + + @Test + void isOrListShouldReturnFalse() { + boolean uriAndList = myFixture.isOrList(new UriAndListParam()); + assertFalse(uriAndList); + } + + @Test + void isAndListShouldReturnFalse() { + boolean uriAndList = myFixture.isAndList(new UriOrListParam()); + assertFalse(uriAndList); + } + + @Test + void setParameterTypeValueShouldSetWithOrValue() { + String theKey = "theOrKey"; + UriOrListParam theValue = withUriOrListParam(); + myFixture.setParameterTypeValue(theKey, theValue); + String result = myFixture.searchParameterMap.toNormalizedQueryString(withFhirContext()); + String expected = "?theOrKey=theSecondValue,theValue"; + assertEquals(expected, result); + } + + @Test + void setParameterTypeValueShouldSetWithAndValue() { + String theKey = "theAndKey"; + UriAndListParam theValue = withUriAndListParam(); + myFixture.setParameterTypeValue(theKey, theValue); + String result = myFixture.searchParameterMap.toNormalizedQueryString(withFhirContext()); + String expected = + "?theAndKey=theSecondValue,theValue&theAndKey=theSecondValueAgain,theValueAgain"; + assertEquals(expected, result); + } + + @Test + void setParameterTypeValueShouldSetWithBaseValue() { + String expected = "?theKey=theValue"; + UriParam theValue = new UriParam("theValue"); + String theKey = "theKey"; + myFixture.setParameterTypeValue(theKey, theValue); + String result = myFixture.searchParameterMap.toNormalizedQueryString(withFhirContext()); + assertEquals(expected, result); + } + + @Test + void separateParameterTypesShouldSeparateSearchAndResultParams() { + myFixture.separateParameterTypes(withParamList()); + assertEquals(2, myFixture.separatedSearchParameters.size()); + assertEquals(3, myFixture.separatedResultParameters.size()); + } + + @Test + void convertToStringMapShouldConvert() { + Map expected = withParamListAsStrings(); + myFixture.convertToStringMap(withParamList(), withFhirContext()); + Map result = myFixture.resultParameters; + assertEquals(result.keySet(), expected.keySet()); + assertTrue(result.entrySet().stream() + .allMatch(e -> Arrays.equals(e.getValue(), expected.get(e.getKey())))); + } + + Map> withParamList() { + Map> paramList = new HashMap<>(); + paramList.put("_id", withUriParam(1)); + paramList.put("_elements", withUriParam(2)); + paramList.put("_lastUpdated", withUriParam(1)); + paramList.put("_total", withUriParam(1)); + paramList.put("_count", withUriParam(3)); + return paramList; + } + + Map withParamListAsStrings() { + Map paramList = new HashMap<>(); + paramList.put("_id", withStringParam(1)); + paramList.put("_elements", withStringParam(2)); + paramList.put("_lastUpdated", withStringParam(1)); + paramList.put("_total", withStringParam(1)); + paramList.put("_count", withStringParam(3)); + return paramList; + } + + List withUriParam(int theNumberOfParams) { + List paramList = new ArrayList<>(); + for (int i = 0; i < theNumberOfParams; i++) { + paramList.add(new UriParam(Integer.toString(i))); + } + return paramList; + } + + UriOrListParam withUriOrListParam() { + UriOrListParam orList = new UriOrListParam(); + orList.add(new UriParam("theValue")); + orList.add(new UriParam("theSecondValue")); + return orList; + } + + UriOrListParam withUriOrListParamSecond() { + UriOrListParam orList = new UriOrListParam(); + orList.add(new UriParam("theValueAgain")); + orList.add(new UriParam("theSecondValueAgain")); + return orList; + } + + UriAndListParam withUriAndListParam() { + UriAndListParam andList = new UriAndListParam(); + andList.addAnd(withUriOrListParam()); + andList.addAnd(withUriOrListParamSecond()); + return andList; + } + + String[] withStringParam(int theNumberOfParams) { + String[] paramList = new String[theNumberOfParams]; + for (int i = 0; i < theNumberOfParams; i++) { + paramList[i] = Integer.toString(i); + } + return paramList; + } + + FhirContext withFhirContext() { + return new FhirContext(); + } +} diff --git a/pom.xml b/pom.xml index 276c7632cbb..647a95cbc7c 100644 --- a/pom.xml +++ b/pom.xml @@ -962,9 +962,7 @@ 1.28.4 - 2.7.0-SNAPSHOT - 3.0.0-SNAPSHOT - 3.0.0-SNAPSHOT + 3.0.0.PRE-1 5.4.1