Permit uplift (#3978)

* Add test and implementation for permitting uplifts

* Add new code paths, error code

* Add license

* Modify to support arbitrary urls

* Modify to support arbitrary urls
This commit is contained in:
Tadgh 2022-08-31 18:03:43 -07:00 committed by GitHub
parent 26ca950bce
commit d9134fc553
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 202 additions and 33 deletions

View File

@ -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() {}

View File

@ -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;

View File

@ -52,4 +52,8 @@ public class SearchParamSubmitInterceptorLoader {
public void setInterceptorRegistry(IInterceptorService theInterceptorRegistry) {
myInterceptorRegistry = theInterceptorRegistry;
}
protected SearchParamValidatingInterceptor getSearchParamValidatingInterceptor() {
return mySearchParamValidatingInterceptor;
}
}

View File

@ -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<String> 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<ResourcePersistentId> persistedIdList = getDao().searchForIds(searchParameterMap, theRequestDetails);
if (isUpliftSearchParam(theResource)) {
validateUpliftSp(theRequestDetails, runtimeSearchParam, searchParameterMap);
} else {
validateStandardSpOnCreate(theRequestDetails, searchParameterMap);
}
}
private void validateStandardSpOnCreate(RequestDetails theRequestDetails, SearchParameterMap searchParameterMap) {
List<ResourcePersistentId> 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<ResourcePersistentId> 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<IBaseResource> allResources = bundleProvider.getAllResources();
if(isNotEmpty(allResources)) {
Set<String> 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<IBaseExtension<?, ?>> extensions = theSp.getExtensions(theUpliftUrl);
Validate.isTrue(extensions.size() == 1);
IBaseExtension<?, ?> topLevelExtension = extensions.get(0);
List<IBaseExtension> extension = (List<IBaseExtension>) 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<String> theExistingIds) {
return theExistingIds
.stream()
.noneMatch(resId -> resId.equals(theSearchParam.getId().getIdPart()));
}
private void validateStandardSpOnUpdate(RequestDetails theRequestDetails, RuntimeSearchParam runtimeSearchParam, SearchParameterMap searchParameterMap) {
List<ResourcePersistentId> pidList = getDao().searchForIds(searchParameterMap, theRequestDetails);
if(isNotEmpty(pidList)){
Set<String> 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<String> getUpliftExtensions() {
if (myUpliftExtensions == null) {
myUpliftExtensions = new ArrayList<>();
}
return myUpliftExtensions;
}
public void addUpliftExtension(String theUrl) {
getUpliftExtensions().add(theUrl);
}
}

View File

@ -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<SearchParameter> 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<SearchParameter> theSearchParams){
List<ResourcePersistentId> resourcePersistentIds = theSearchParams
.stream()
.map(SearchParameter::getId)
.map(theS -> new ResourcePersistentId(theS))
.collect(Collectors.toList());
Set<String> ids = theSearchParams.stream().map(sp -> sp.getId()).collect(Collectors.toSet());
when(myIFhirResourceDao.searchForIds(any(), any())).thenReturn(resourcePersistentIds);
}
private void setPersistedSearchParameters(List<SearchParameter> 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");