diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/i18n/Msg.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/i18n/Msg.java index 0ce9994d40d..cf1d6135a87 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/i18n/Msg.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/i18n/Msg.java @@ -25,7 +25,7 @@ public final class Msg { /** * IMPORTANT: Please update the following comment after you add a new code - * Last code value: 2131 + * Last code value: 2132 */ private Msg() {} diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/api/model/Batch2JobOperationResult.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/api/model/Batch2JobOperationResult.java index 4fea22a9175..17c19ff6017 100644 --- a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/api/model/Batch2JobOperationResult.java +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/api/model/Batch2JobOperationResult.java @@ -1,5 +1,25 @@ package ca.uhn.fhir.jpa.api.model; +/*- + * #%L + * HAPI FHIR Storage api + * %% + * Copyright (C) 2014 - 2022 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% + */ + public class Batch2JobOperationResult { // operation name private String myOperation; diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/searchparam/submit/interceptor/SearchParamSubmitInterceptorLoader.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/searchparam/submit/interceptor/SearchParamSubmitInterceptorLoader.java index 1f7abd4267d..8aa5aac3049 100644 --- a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/searchparam/submit/interceptor/SearchParamSubmitInterceptorLoader.java +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/searchparam/submit/interceptor/SearchParamSubmitInterceptorLoader.java @@ -52,4 +52,8 @@ public class SearchParamSubmitInterceptorLoader { public void setInterceptorRegistry(IInterceptorService theInterceptorRegistry) { myInterceptorRegistry = theInterceptorRegistry; } + + protected SearchParamValidatingInterceptor getSearchParamValidatingInterceptor() { + return mySearchParamValidatingInterceptor; + } } diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/searchparam/submit/interceptor/SearchParamValidatingInterceptor.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/searchparam/submit/interceptor/SearchParamValidatingInterceptor.java index 6cc01b58e7d..1009c7b767f 100644 --- a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/searchparam/submit/interceptor/SearchParamValidatingInterceptor.java +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/searchparam/submit/interceptor/SearchParamValidatingInterceptor.java @@ -38,9 +38,14 @@ import ca.uhn.fhir.rest.param.TokenAndListParam; import ca.uhn.fhir.rest.param.TokenOrListParam; import ca.uhn.fhir.rest.param.TokenParam; import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; +import org.apache.commons.lang3.Validate; +import org.hl7.fhir.instance.model.api.IBaseExtension; +import org.hl7.fhir.instance.model.api.IBaseHasExtensions; import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IPrimitiveType; import org.springframework.beans.factory.annotation.Autowired; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -53,6 +58,7 @@ import static org.apache.commons.lang3.StringUtils.isNotBlank; public class SearchParamValidatingInterceptor { public static final String SEARCH_PARAM = "SearchParameter"; + public List myUpliftExtensions; private FhirContext myFhirContext; @@ -73,10 +79,9 @@ public class SearchParamValidatingInterceptor { } public void validateSearchParamOnCreate(IBaseResource theResource, RequestDetails theRequestDetails){ - if( isNotSearchParameterResource(theResource) ){ + if(isNotSearchParameterResource(theResource)){ return; } - RuntimeSearchParam runtimeSearchParam = mySearchParameterCanonicalizer.canonicalizeSearchParameter(theResource); if (runtimeSearchParam == null) { return; @@ -84,38 +89,113 @@ public class SearchParamValidatingInterceptor { SearchParameterMap searchParameterMap = extractSearchParameterMap(runtimeSearchParam); - List persistedIdList = getDao().searchForIds(searchParameterMap, theRequestDetails); + if (isUpliftSearchParam(theResource)) { + validateUpliftSp(theRequestDetails, runtimeSearchParam, searchParameterMap); + } else { + validateStandardSpOnCreate(theRequestDetails, searchParameterMap); + } + } + private void validateStandardSpOnCreate(RequestDetails theRequestDetails, SearchParameterMap searchParameterMap) { + List persistedIdList = getDao().searchForIds(searchParameterMap, theRequestDetails); if( isNotEmpty(persistedIdList) ) { throw new UnprocessableEntityException(Msg.code(2131) + "Can't process submitted SearchParameter as it is overlapping an existing one."); } } public void validateSearchParamOnUpdate(IBaseResource theResource, RequestDetails theRequestDetails){ - if( isNotSearchParameterResource(theResource) ){ + if(isNotSearchParameterResource(theResource)){ + return; + } + RuntimeSearchParam runtimeSearchParam = mySearchParameterCanonicalizer.canonicalizeSearchParameter(theResource); + if (runtimeSearchParam == null) { return; } - RuntimeSearchParam runtimeSearchParam = mySearchParameterCanonicalizer.canonicalizeSearchParameter(theResource); - SearchParameterMap searchParameterMap = extractSearchParameterMap(runtimeSearchParam); - List pidList = getDao().searchForIds(searchParameterMap, theRequestDetails); + if (isUpliftSearchParam(theResource)) { + validateUpliftSp(theRequestDetails, runtimeSearchParam, searchParameterMap); + } else { + validateStandardSpOnUpdate(theRequestDetails, runtimeSearchParam, searchParameterMap); + } + } + private void validateUpliftSp(RequestDetails theRequestDetails, RuntimeSearchParam theRuntimeSearchParam, SearchParameterMap theSearchParameterMap) { + Validate.notEmpty(getUpliftExtensions(), "You are attempting to validate an Uplift Search Parameter, but have not defined which URLs correspond to uplifted search parameter extensions."); + + IBundleProvider bundleProvider = getDao().search(theSearchParameterMap, theRequestDetails); + List allResources = bundleProvider.getAllResources(); + if(isNotEmpty(allResources)) { + Set existingIds = allResources.stream().map(resource -> resource.getIdElement().getIdPart()).collect(Collectors.toSet()); + if (isNewSearchParam(theRuntimeSearchParam, existingIds)) { + for (String upliftExtensionUrl: getUpliftExtensions()) { + boolean matchesExistingUplift = allResources.stream() + .map(sp -> mySearchParameterCanonicalizer.canonicalizeSearchParameter(sp)) + .filter(sp -> !sp.getExtensions(upliftExtensionUrl).isEmpty()) + .anyMatch(sp -> isDuplicateUpliftParameter(theRuntimeSearchParam, sp, upliftExtensionUrl)); + + if (matchesExistingUplift) { + throwDuplicateError(); + } + } + } + } + } + + private boolean isDuplicateUpliftParameter(RuntimeSearchParam theRuntimeSearchParam, RuntimeSearchParam theSp, String theUpliftUrl) { + String firstCode = getUpliftChildExtensionValueByUrl(theRuntimeSearchParam, "code", theUpliftUrl); + String secondCode = getUpliftChildExtensionValueByUrl(theSp, "code", theUpliftUrl); + String firstElementName = getUpliftChildExtensionValueByUrl(theRuntimeSearchParam, "element-name", theUpliftUrl); + String secondElementName = getUpliftChildExtensionValueByUrl(theSp, "element-name", theUpliftUrl); + return firstCode.equals(secondCode) && firstElementName.equals(secondElementName); + } + + + private String getUpliftChildExtensionValueByUrl(RuntimeSearchParam theSp, String theUrl, String theUpliftUrl) { + List> extensions = theSp.getExtensions(theUpliftUrl); + Validate.isTrue(extensions.size() == 1); + IBaseExtension topLevelExtension = extensions.get(0); + List extension = (List) topLevelExtension.getExtension(); + String subExtensionValue = extension.stream().filter(ext -> ext.getUrl().equals(theUrl)).map(IBaseExtension::getValue) + .map(IPrimitiveType.class::cast) + .map(IPrimitiveType::getValueAsString) + .findFirst() + .orElseThrow(() -> new UnprocessableEntityException(Msg.code(2132), "Unable to process Uplift SP addition as the SearchParameter is malformed.")); + return subExtensionValue; + } + + private boolean isNewSearchParam(RuntimeSearchParam theSearchParam, Set theExistingIds) { + return theExistingIds + .stream() + .noneMatch(resId -> resId.equals(theSearchParam.getId().getIdPart())); + } + + private void validateStandardSpOnUpdate(RequestDetails theRequestDetails, RuntimeSearchParam runtimeSearchParam, SearchParameterMap searchParameterMap) { + List pidList = getDao().searchForIds(searchParameterMap, theRequestDetails); if(isNotEmpty(pidList)){ Set resolvedResourceIds = myIdHelperService.translatePidsToFhirResourceIds(new HashSet<>(pidList)); - String incomingResourceId = runtimeSearchParam.getId().getIdPart(); - - boolean isNewSearchParam = resolvedResourceIds - .stream() - .noneMatch(resId -> resId.equals(incomingResourceId)); - - if(isNewSearchParam){ - throw new UnprocessableEntityException(Msg.code(2125) + "Can't process submitted SearchParameter as it is overlapping an existing one."); + if(isNewSearchParam(runtimeSearchParam, resolvedResourceIds)) { + throwDuplicateError(); } } } + private void throwDuplicateError() { + throw new UnprocessableEntityException(Msg.code(2125) + "Can't process submitted SearchParameter as it is overlapping an existing one."); + } + + private boolean isUpliftSearchParam(IBaseResource theResource) { + if (theResource instanceof IBaseHasExtensions) { + IBaseHasExtensions resource = (IBaseHasExtensions) theResource; + return resource.getExtension() + .stream() + .anyMatch(ext -> getUpliftExtensions().contains(ext.getUrl())); + } else { + return false; + } + } + private boolean isNotSearchParameterResource(IBaseResource theResource){ return ! SEARCH_PARAM.equalsIgnoreCase(myFhirContext.getResourceType(theResource)); } @@ -180,4 +260,14 @@ public class SearchParamValidatingInterceptor { return retVal; } + + public List getUpliftExtensions() { + if (myUpliftExtensions == null) { + myUpliftExtensions = new ArrayList<>(); + } + return myUpliftExtensions; + } + public void addUpliftExtension(String theUrl) { + getUpliftExtensions().add(theUrl); + } } diff --git a/hapi-fhir-storage/src/test/java/ca/uhn/fhir/jpa/interceptor/validation/SearchParameterValidatingInterceptorTest.java b/hapi-fhir-storage/src/test/java/ca/uhn/fhir/jpa/interceptor/validation/SearchParameterValidatingInterceptorTest.java index 2ea7e20d879..3202928fc99 100644 --- a/hapi-fhir-storage/src/test/java/ca/uhn/fhir/jpa/interceptor/validation/SearchParameterValidatingInterceptorTest.java +++ b/hapi-fhir-storage/src/test/java/ca/uhn/fhir/jpa/interceptor/validation/SearchParameterValidatingInterceptorTest.java @@ -8,10 +8,14 @@ import ca.uhn.fhir.jpa.searchparam.registry.SearchParameterCanonicalizer; import ca.uhn.fhir.jpa.searchparam.submit.interceptor.SearchParamValidatingInterceptor; import ca.uhn.fhir.rest.api.server.RequestDetails; import ca.uhn.fhir.rest.api.server.storage.ResourcePersistentId; +import ca.uhn.fhir.rest.server.SimpleBundleProvider; import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; +import org.hl7.fhir.r4.model.CodeType; import org.hl7.fhir.r4.model.Enumerations; +import org.hl7.fhir.r4.model.Extension; import org.hl7.fhir.r4.model.Patient; import org.hl7.fhir.r4.model.SearchParameter; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -34,6 +38,7 @@ import static org.mockito.Mockito.when; public class SearchParameterValidatingInterceptorTest { static final FhirContext ourFhirContext = FhirContext.forR4(); + public static final String UPLIFT_URL = "https://some-url"; @Mock RequestDetails myRequestDetails; @@ -48,7 +53,7 @@ public class SearchParameterValidatingInterceptorTest { SearchParamValidatingInterceptor mySearchParamValidatingInterceptor; - SearchParameter mySearchParameterId1; + SearchParameter myExistingSearchParameter; static String ID1 = "ID1"; static String ID2 = "ID2"; @@ -61,8 +66,9 @@ public class SearchParameterValidatingInterceptorTest { mySearchParamValidatingInterceptor.setSearchParameterCanonicalizer(new SearchParameterCanonicalizer(ourFhirContext)); mySearchParamValidatingInterceptor.setIIDHelperService(myIdHelperService); mySearchParamValidatingInterceptor.setDaoRegistry(myDaoRegistry); + mySearchParamValidatingInterceptor.addUpliftExtension(UPLIFT_URL); - mySearchParameterId1 = aSearchParameter(ID1); + myExistingSearchParameter = buildSearchParameterWithId(ID1); } @@ -78,9 +84,9 @@ public class SearchParameterValidatingInterceptorTest { public void whenCreatingNonOverlappingSearchParam_thenIsAllowed(){ when(myDaoRegistry.getResourceDao(eq(SearchParamValidatingInterceptor.SEARCH_PARAM))).thenReturn(myIFhirResourceDao); - setPersistedSearchParameters(emptyList()); + setPersistedSearchParameterIds(emptyList()); - SearchParameter newSearchParam = aSearchParameter(ID1); + SearchParameter newSearchParam = buildSearchParameterWithId(ID1); mySearchParamValidatingInterceptor.resourcePreCreate(newSearchParam, myRequestDetails); @@ -90,9 +96,9 @@ public class SearchParameterValidatingInterceptorTest { public void whenCreatingOverlappingSearchParam_thenExceptionIsThrown(){ when(myDaoRegistry.getResourceDao(eq(SearchParamValidatingInterceptor.SEARCH_PARAM))).thenReturn(myIFhirResourceDao); - setPersistedSearchParameters(asList(mySearchParameterId1)); + setPersistedSearchParameterIds(asList(myExistingSearchParameter)); - SearchParameter newSearchParam = aSearchParameter(ID2); + SearchParameter newSearchParam = buildSearchParameterWithId(ID2); try { mySearchParamValidatingInterceptor.resourcePreCreate(newSearchParam, myRequestDetails); @@ -107,9 +113,9 @@ public class SearchParameterValidatingInterceptorTest { public void whenUsingPutOperationToCreateNonOverlappingSearchParam_thenIsAllowed(){ when(myDaoRegistry.getResourceDao(eq(SearchParamValidatingInterceptor.SEARCH_PARAM))).thenReturn(myIFhirResourceDao); - setPersistedSearchParameters(emptyList()); + setPersistedSearchParameterIds(emptyList()); - SearchParameter newSearchParam = aSearchParameter(ID1); + SearchParameter newSearchParam = buildSearchParameterWithId(ID1); mySearchParamValidatingInterceptor.resourcePreUpdate(null, newSearchParam, myRequestDetails); } @@ -118,9 +124,9 @@ public class SearchParameterValidatingInterceptorTest { public void whenUsingPutOperationToCreateOverlappingSearchParam_thenExceptionIsThrown(){ when(myDaoRegistry.getResourceDao(eq(SearchParamValidatingInterceptor.SEARCH_PARAM))).thenReturn(myIFhirResourceDao); - setPersistedSearchParameters(asList(mySearchParameterId1)); + setPersistedSearchParameterIds(asList(myExistingSearchParameter)); - SearchParameter newSearchParam = aSearchParameter(ID2); + SearchParameter newSearchParam = buildSearchParameterWithId(ID2); try { mySearchParamValidatingInterceptor.resourcePreUpdate(null, newSearchParam, myRequestDetails); @@ -134,28 +140,77 @@ public class SearchParameterValidatingInterceptorTest { public void whenUpdateSearchParam_thenIsAllowed(){ when(myDaoRegistry.getResourceDao(eq(SearchParamValidatingInterceptor.SEARCH_PARAM))).thenReturn(myIFhirResourceDao); - setPersistedSearchParameters(asList(mySearchParameterId1)); - when(myIdHelperService.translatePidsToFhirResourceIds(any())).thenReturn(Set.of(mySearchParameterId1.getId())); + setPersistedSearchParameterIds(asList(myExistingSearchParameter)); + when(myIdHelperService.translatePidsToFhirResourceIds(any())).thenReturn(Set.of(myExistingSearchParameter.getId())); - SearchParameter newSearchParam = aSearchParameter(ID1); + SearchParameter newSearchParam = buildSearchParameterWithId(ID1); mySearchParamValidatingInterceptor.resourcePreUpdate(null, newSearchParam, myRequestDetails); } - private void setPersistedSearchParameters(List theSearchParams){ + @Test + public void whenUpliftSearchParameter_thenMoreGranularComparisonSucceeds() { + when(myDaoRegistry.getResourceDao(eq(SearchParamValidatingInterceptor.SEARCH_PARAM))).thenReturn(myIFhirResourceDao); + + setPersistedSearchParameters(asList(myExistingSearchParameter)); + + SearchParameter newSearchParam = buildSearchParameterWithUpliftExtension(ID2); + + mySearchParamValidatingInterceptor.resourcePreUpdate(null, newSearchParam, myRequestDetails); + } + + @Test + public void whenUpliftSearchParameter_thenMoreGranularComparisonFails() { + when(myDaoRegistry.getResourceDao(eq(SearchParamValidatingInterceptor.SEARCH_PARAM))).thenReturn(myIFhirResourceDao); + SearchParameter existingUpliftSp = buildSearchParameterWithUpliftExtension(ID1); + setPersistedSearchParameters(asList(existingUpliftSp)); + + SearchParameter newSearchParam = buildSearchParameterWithUpliftExtension(ID2); + + try { + mySearchParamValidatingInterceptor.resourcePreUpdate(null, newSearchParam, myRequestDetails); + fail(); + }catch (UnprocessableEntityException e){ + assertTrue(e.getMessage().contains("2125")); + } + } + + @NotNull + private SearchParameter buildSearchParameterWithUpliftExtension(String theID) { + SearchParameter newSearchParam = buildSearchParameterWithId(theID); + + Extension topLevelExtension = new Extension(); + topLevelExtension.setUrl(UPLIFT_URL); + + Extension codeExtension = new Extension(); + codeExtension.setUrl("code"); + codeExtension.setValue(new CodeType("identifier")); + + Extension elementExtension = new Extension(); + elementExtension.setUrl("element-name"); + elementExtension.setValue(new CodeType("patient-identifier")); + + topLevelExtension.addExtension(codeExtension); + topLevelExtension.addExtension(elementExtension); + newSearchParam.addExtension(topLevelExtension); + return newSearchParam; + } + + private void setPersistedSearchParameterIds(List theSearchParams){ List resourcePersistentIds = theSearchParams .stream() .map(SearchParameter::getId) .map(theS -> new ResourcePersistentId(theS)) .collect(Collectors.toList()); - Set ids = theSearchParams.stream().map(sp -> sp.getId()).collect(Collectors.toSet()); - when(myIFhirResourceDao.searchForIds(any(), any())).thenReturn(resourcePersistentIds); } + private void setPersistedSearchParameters(List theSearchParams) { + when(myIFhirResourceDao.search(any(), any())).thenReturn(new SimpleBundleProvider(theSearchParams)); + } - private SearchParameter aSearchParameter(String id) { + private SearchParameter buildSearchParameterWithId(String id) { SearchParameter retVal = new SearchParameter(); retVal.setId(id); retVal.setCode("patient");