4888 Add validation for composite SearchParameter components types (#4909)

* Added unit tests for composite SearchParameter validator

* Added validation for composite SearchParameter components

* validation for composite SearchParameter components - fixes

* Modified tests

* fixed string in setDefinition

* validation for composite SearchParameter components - fixed validation and Unit tests

* validation for composite SearchParameter components - added method getActiveSearchParameterByComponentDefinition and unit tests

* validation for composite SearchParameter components - minor fixes

* validation for composite SearchParameter components - remove getActiveSearchParameterByComponentDefinition method

* validation for composite SearchParameter components - optimise import

* validation for composite SearchParameter components - fix changelog

* validation for composite SearchParameter components - improved validation logic

* validation for composite SearchParameter components - improved validation logic (remove unused lines)

* validation for composite SearchParameter components - improved validation logic

* validation for composite SearchParameter components - improved validation logic

* validation for composite SearchParameter components - fixed validation logic

* validation for composite SearchParameter components - added test for uri and number combo search

* validation for composite SearchParameter components - added test for uri and number combo search

* validation for composite SearchParameter components - validation logic fix

* validation for composite SearchParameter components - fixes

* validation for composite SearchParameter components - fixes

* validation for composite SearchParameter components - test fixes

---------

Co-authored-by: peartree <etienne.poirier@smilecdr.com>
This commit is contained in:
volodymyr-korzh 2023-06-08 09:00:27 -06:00 committed by GitHub
parent e37edfcf84
commit e28398fc4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 429 additions and 80 deletions

View File

@ -0,0 +1,6 @@
---
type: fix
issue: 4888
jira: SMILE-6047
title: "Previously, it was possible to create composite SP with any types of SP as components.
This has been fixed by limiting the component SP types to String, Token, Date, or Quantity."

View File

@ -103,6 +103,10 @@ public class ExtendedHSearchIndexExtractor {
.filter(nextParam -> !nextParam.isMissing()) .filter(nextParam -> !nextParam.isMissing())
.forEach(nextParam -> retVal.addQuantityIndexData(nextParam.getParamName(), convertQuantity(nextParam))); .forEach(nextParam -> retVal.addQuantityIndexData(nextParam.getParamName(), convertQuantity(nextParam)));
theNewParams.myUriParams.stream()
.filter(nextParam -> !nextParam.isMissing())
.forEach(nextParam -> retVal.addUriIndexData(nextParam.getParamName(), nextParam.getUri()));
theResource.getMeta().getTag().forEach(tag -> theResource.getMeta().getTag().forEach(tag ->
retVal.addTokenIndexData("_tag", tag)); retVal.addTokenIndexData("_tag", tag));

View File

@ -239,6 +239,19 @@ public class SearchParamRegistryImplTest {
assertThat(mySearchParamRegistry.getActiveComboSearchParams("Patient"), is(empty())); assertThat(mySearchParamRegistry.getActiveComboSearchParams("Patient"), is(empty()));
} }
@Test
public void testGetActiveSearchParamByUrl_whenSPExists_returnsActiveSp() {
RuntimeSearchParam patientLanguageSp = mySearchParamRegistry.getActiveSearchParamByUrl("SearchParameter/Patient-language");
assertNotNull(patientLanguageSp);
assertEquals(patientLanguageSp.getId().getIdPart(), "Patient-language");
}
@Test
public void testGetActiveSearchParamByUrl_whenSPNotExist_returnsNull() {
RuntimeSearchParam nonExistingSp = mySearchParamRegistry.getActiveSearchParamByUrl("SearchParameter/nonExistingSp");
assertNull(nonExistingSp);
}
@Test @Test
public void testGetActiveSearchParamsRetries() { public void testGetActiveSearchParamsRetries() {
AtomicBoolean retried = new AtomicBoolean(false); AtomicBoolean retried = new AtomicBoolean(false);

View File

@ -19,15 +19,28 @@
*/ */
package ca.uhn.fhir.jpa.search; package ca.uhn.fhir.jpa.search;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.jpa.dao.TestDaoSearch; import ca.uhn.fhir.jpa.dao.TestDaoSearch;
import ca.uhn.fhir.test.utilities.ITestDataBuilder; import ca.uhn.fhir.test.utilities.ITestDataBuilder;
import org.hl7.fhir.instance.model.api.IBaseResource; import ca.uhn.fhir.util.HapiExtensions;
import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.r4.model.BooleanType;
import org.hl7.fhir.r4.model.CodeableConcept;
import org.hl7.fhir.r4.model.Coding;
import org.hl7.fhir.r4.model.DecimalType;
import org.hl7.fhir.r4.model.Device;
import org.hl7.fhir.r4.model.Enumerations;
import org.hl7.fhir.r4.model.Extension;
import org.hl7.fhir.r4.model.Meta;
import org.hl7.fhir.r4.model.RiskAssessment;
import org.hl7.fhir.r4.model.SearchParameter;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf; import org.junit.jupiter.api.condition.EnabledIf;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.List; import java.util.List;
import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.empty;
@ -167,43 +180,18 @@ public abstract class CompositeSearchParameterTestCases implements ITestDataBuil
@Test @Test
void searchUriNumber_onSameResource_found() { void searchUriNumber_onSameResource_found() {
// Combine existing SPs to test uri + number // Combine existing SPs to test uri + number
createResourceFromJson(""" SearchParameter searchParameter = createCompositeSearchParameter("uri-number-compound-test", "RiskAssessment");
{ searchParameter.addComponent(componentFrom("http://hl7.org/fhir/SearchParameter/Resource-source", "meta.source"));
"resourceType": "SearchParameter", searchParameter.addComponent(componentFrom("http://hl7.org/fhir/SearchParameter/RiskAssessment-probability", "prediction.probability"));
"name": "uri-number-compound-test", doCreateResource(searchParameter);
"status": "active",
"description": "dummy to exercise uri + number",
"code": "uri-number-compound-test",
"base": [ "RiskAssessment" ],
"type": "composite",
"expression": "RiskAssessment",
"component": [ {
"definition": "http://hl7.org/fhir/SearchParameter/Resource-source",
"expression": "meta.source"
}, {
"definition": "http://hl7.org/fhir/SearchParameter/RiskAssessment-probability",
"expression": "prediction.probability"
} ]
}""");
// enable this sp. // enable this sp.
myTestDaoSearch.getSearchParamRegistry().forceRefresh(); myTestDaoSearch.getSearchParamRegistry().forceRefresh();
IIdType raId = createResourceFromJson(""" RiskAssessment riskAssessment = new RiskAssessment();
{ riskAssessment.setMeta(new Meta().setSource("https://example.com/ourSource"));
"resourceType": "RiskAssessment", riskAssessment.addPrediction(new RiskAssessment.RiskAssessmentPredictionComponent().setProbability(new DecimalType(0.02)));
"meta": { IIdType raId = doCreateResource(riskAssessment);
"source": "https://example.com/ourSource"
},
"prediction": [
{
"outcome": {
"text": "Heart Attack"
},
"probabilityDecimal": 0.02
}
]
}
""");
// verify config // verify config
myTestDaoSearch.assertSearchFinds("simple uri search works", "RiskAssessment?_source=https://example.com/ourSource", raId); myTestDaoSearch.assertSearchFinds("simple uri search works", "RiskAssessment?_source=https://example.com/ourSource", raId);
@ -212,6 +200,91 @@ public abstract class CompositeSearchParameterTestCases implements ITestDataBuil
myTestDaoSearch.assertSearchFinds("composite uri + number", "RiskAssessment?uri-number-compound-test=https://example.com/ourSource$0.02", raId); myTestDaoSearch.assertSearchFinds("composite uri + number", "RiskAssessment?uri-number-compound-test=https://example.com/ourSource$0.02", raId);
myTestDaoSearch.assertSearchNotFound("both params must match ", "RiskAssessment?uri-number-compound-test=https://example.com/ourSource$0.08", raId); myTestDaoSearch.assertSearchNotFound("both params must match ", "RiskAssessment?uri-number-compound-test=https://example.com/ourSource$0.08", raId);
myTestDaoSearch.assertSearchNotFound("both params must match ", "RiskAssessment?uri-number-compound-test=https://example.com/otherUrI$0.02", raId); myTestDaoSearch.assertSearchNotFound("both params must match ", "RiskAssessment?uri-number-compound-test=https://example.com/otherUrI$0.02", raId);
//verify combo query
myTestDaoSearch.assertSearchFinds("combo uri + number", "RiskAssessment?_source=https://example.com/ourSource&probability=0.02", raId);
} }
@ParameterizedTest
@MethodSource("extensionProvider")
void testComboSearch_withTokenAndNumber_returnsMatchingResources(Extension theExtension) {
// Combine existing SPs to test Token + number
SearchParameter searchParameter = createCompositeSearchParameter("token-number-combo-test", "RiskAssessment");
searchParameter.addComponent(componentFrom("http://hl7.org/fhir/SearchParameter/RiskAssessment-method", "RiskAssessment"));
searchParameter.addComponent(componentFrom("http://hl7.org/fhir/SearchParameter/RiskAssessment-probability", "RiskAssessment"));
searchParameter.setExtension(List.of(theExtension));
doCreateResource(searchParameter);
// enable this sp.
myTestDaoSearch.getSearchParamRegistry().forceRefresh();
RiskAssessment riskAssessment = new RiskAssessment();
riskAssessment.setMethod(new CodeableConcept(new Coding(null, "BRCAPRO", null)));
riskAssessment.addPrediction(new RiskAssessment.RiskAssessmentPredictionComponent().setProbability(new DecimalType(0.02)));
IIdType raId = doCreateResource(riskAssessment);
RiskAssessment riskAssessmentNonMatch = new RiskAssessment();
riskAssessmentNonMatch.setMethod(new CodeableConcept(new Coding(null, "NOT_FOUND_CODE", null)));
riskAssessmentNonMatch.addPrediction(new RiskAssessment.RiskAssessmentPredictionComponent().setProbability(new DecimalType(0.03)));
doCreateResource(riskAssessmentNonMatch);
// verify combo query
myTestDaoSearch.assertSearchFinds("combo uri + number", "RiskAssessment?method=BRCAPRO&probability=0.02", raId);
myTestDaoSearch.assertSearchNotFound("both params must match", "RiskAssessment?method=CODE&probability=0.02", raId);
myTestDaoSearch.assertSearchNotFound("both params must match", "RiskAssessment?method=BRCAPRO&probability=0.09", raId);
}
@ParameterizedTest
@MethodSource("extensionProvider")
void testComboSearch_withUriAndString_returnsMatchingResources(Extension theExtension) {
//Combine existing SPs to test URI + String
SearchParameter searchParameter = createCompositeSearchParameter("uri-string-combo-test", "Device");
searchParameter.addComponent(componentFrom("http://hl7.org/fhir/SearchParameter/Device-url", "Device"));
searchParameter.addComponent(componentFrom("http://hl7.org/fhir/SearchParameter/Device-model", "Device"));
searchParameter.setExtension(List.of(theExtension));
doCreateResource(searchParameter);
// enable this sp.
myTestDaoSearch.getSearchParamRegistry().forceRefresh();
Device device = new Device();
device.setUrl("http://deviceUrl");
device.setModelNumber("modelNumber");
IIdType deviceId = doCreateResource(device);
Device deviceNonMatch = new Device();
deviceNonMatch.setUrl("http://someurl");
deviceNonMatch.setModelNumber("someModelNumber");
// verify combo query
myTestDaoSearch.assertSearchFinds("combo uri + string", "Device?url=http://deviceUrl&model=modelNumber", deviceId);
myTestDaoSearch.assertSearchNotFound("both params must match", "Device?url=http://wrongUrl&model=modelNumber", deviceId);
myTestDaoSearch.assertSearchNotFound("both params must match", "Device?url=http://deviceUrl&model=wrongModel", deviceId);
}
private static SearchParameter createCompositeSearchParameter(String theCodeValue, String theBase) {
SearchParameter retVal = new SearchParameter();
retVal.setId(theCodeValue);
retVal.setUrl("http://example.org/" + theCodeValue);
retVal.addBase(theBase);
retVal.setCode(theCodeValue);
retVal.setType(Enumerations.SearchParamType.COMPOSITE);
retVal.setStatus(Enumerations.PublicationStatus.ACTIVE);
retVal.setExpression(theBase);
return retVal;
}
private SearchParameter.SearchParameterComponentComponent componentFrom(String theDefinition, String theExpression) {
return new SearchParameter.SearchParameterComponentComponent().setDefinition(theDefinition).setExpression(theExpression);
}
static Stream<Arguments> extensionProvider() {
return Stream.of(
Arguments.of(
new Extension(HapiExtensions.EXT_SP_UNIQUE, new BooleanType(false))), // composite SP of type combo with non-unique index
Arguments.of(
new Extension(HapiExtensions.EXT_SP_UNIQUE, new BooleanType(true))) // composite SP of type combo with non-unique index
);
}
} }

View File

@ -1,25 +1,53 @@
package ca.uhn.fhir.jpa.dao.validation; package ca.uhn.fhir.jpa.dao.validation;
import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.RuntimeSearchParam;
import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
import ca.uhn.fhir.jpa.searchparam.registry.SearchParameterCanonicalizer;
import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException;
import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; import ca.uhn.fhir.rest.server.util.ISearchParamRegistry;
import ca.uhn.fhir.util.HapiExtensions;
import ca.uhn.hapi.converters.canonical.VersionCanonicalizer; import ca.uhn.hapi.converters.canonical.VersionCanonicalizer;
import org.hl7.fhir.r4.model.Enumerations; import org.hl7.fhir.r5.model.BooleanType;
import org.hl7.fhir.r4.model.SearchParameter; import org.hl7.fhir.r5.model.Enumerations;
import org.hl7.fhir.r5.model.Extension;
import org.hl7.fhir.r5.model.SearchParameter;
import org.hl7.fhir.r5.model.SearchParameter.SearchParameterComponentComponent;
import org.hl7.fhir.r5.model.StringType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Spy; import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.mock; import java.util.stream.Stream;
import static org.hl7.fhir.r5.model.Enumerations.PublicationStatus.ACTIVE;
import static org.hl7.fhir.r5.model.Enumerations.SearchParamType.COMPOSITE;
import static org.hl7.fhir.r5.model.Enumerations.SearchParamType.DATE;
import static org.hl7.fhir.r5.model.Enumerations.SearchParamType.NUMBER;
import static org.hl7.fhir.r5.model.Enumerations.SearchParamType.QUANTITY;
import static org.hl7.fhir.r5.model.Enumerations.SearchParamType.REFERENCE;
import static org.hl7.fhir.r5.model.Enumerations.SearchParamType.STRING;
import static org.hl7.fhir.r5.model.Enumerations.SearchParamType.TOKEN;
import static org.hl7.fhir.r5.model.Enumerations.SearchParamType.URI;
import static org.hl7.fhir.r5.model.Enumerations.VersionIndependentResourceTypesAll.OBSERVATION;
import static org.hl7.fhir.r5.model.Enumerations.VersionIndependentResourceTypesAll.PATIENT;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
public class SearchParameterDaoValidatorTest { public class SearchParameterDaoValidatorTest {
@Spy @Spy
private FhirContext ourCtx = FhirContext.forR4Cached(); private FhirContext myFhirContext = FhirContext.forR5Cached();
@Mock @Mock
private ISearchParamRegistry mySearchParamRegistry; private ISearchParamRegistry mySearchParamRegistry;
@Spy @Spy
@ -27,21 +55,48 @@ public class SearchParameterDaoValidatorTest {
@InjectMocks @InjectMocks
private SearchParameterDaoValidator mySvc; private SearchParameterDaoValidator mySvc;
private VersionCanonicalizer myVersionCanonicalizer = new VersionCanonicalizer(ourCtx); private final VersionCanonicalizer myVersionCanonicalizer = new VersionCanonicalizer(myFhirContext);
private final SearchParameterCanonicalizer mySearchParameterCanonicalizer = new SearchParameterCanonicalizer(myFhirContext);
private static final String SP_COMPONENT_DEFINITION_OF_TYPE_TOKEN = "SearchParameter/observation-code";
private static final String SP_COMPONENT_DEFINITION_OF_TYPE_REFERENCE = "SearchParameter/observation-patient";
private static final String SP_COMPONENT_DEFINITION_OF_TYPE_STRING = "SearchParameter/observation-markdown";
private static final String SP_COMPONENT_DEFINITION_OF_TYPE_DATE = "SearchParameter/observation-date";
private static final String SP_COMPONENT_DEFINITION_OF_TYPE_QUANTITY = "SearchParameter/observation-code";
private static final String SP_COMPONENT_DEFINITION_OF_TYPE_URI = "SearchParameter/component-value-canonical";
private static final String SP_COMPONENT_DEFINITION_OF_TYPE_NUMBER = "SearchParameter/component-value-number";
@BeforeEach
public void before() {
createAndMockSearchParameter(TOKEN, SP_COMPONENT_DEFINITION_OF_TYPE_TOKEN, "observation-code", "Observation.code");
createAndMockSearchParameter(REFERENCE, SP_COMPONENT_DEFINITION_OF_TYPE_REFERENCE, "observation-patient", "Observation.subject.where(resolve() is Patient");
createAndMockSearchParameter(STRING, SP_COMPONENT_DEFINITION_OF_TYPE_DATE, "observation-category", "Observation.value.ofType(markdown)");
createAndMockSearchParameter(DATE, SP_COMPONENT_DEFINITION_OF_TYPE_STRING, "observation-date", "Observation.value.ofType(dateTime)");
createAndMockSearchParameter(QUANTITY, SP_COMPONENT_DEFINITION_OF_TYPE_QUANTITY, "observation-quantity", "Observation.value.ofType(Quantity)");
createAndMockSearchParameter(URI, SP_COMPONENT_DEFINITION_OF_TYPE_URI, "observation-component-value-canonical", "Observation.component.value.ofType(canonical)");
createAndMockSearchParameter(NUMBER, SP_COMPONENT_DEFINITION_OF_TYPE_NUMBER, "observation-component-value-number", "Observation.component.valueInteger");
}
private void createAndMockSearchParameter(Enumerations.SearchParamType theType, String theDefinition, String theCodeValue, String theExpression) {
SearchParameter observationCodeSp = createSearchParameter(theType, theDefinition, theCodeValue, theExpression);
RuntimeSearchParam observationCodeRuntimeSearchParam = mySearchParameterCanonicalizer.canonicalizeSearchParameter(observationCodeSp);
lenient().when(mySearchParamRegistry.getActiveSearchParamByUrl(eq(theDefinition))).thenReturn(observationCodeRuntimeSearchParam);
}
@Test @Test
public void testValidateSubscription() { public void testValidateSubscription() {
SearchParameter sp = new SearchParameter(); SearchParameter sp = new SearchParameter();
sp.setId("SearchParameter/patient-eyecolour"); sp.setId("SearchParameter/patient-eyecolour");
sp.setUrl("http://example.org/SearchParameter/patient-eyecolour"); sp.setUrl("http://example.org/SearchParameter/patient-eyecolour");
sp.addBase("Patient"); sp.addBase(PATIENT);
sp.setCode("eyecolour"); sp.setCode("eyecolour");
sp.setType(Enumerations.SearchParamType.TOKEN); sp.setType(TOKEN);
sp.setStatus(Enumerations.PublicationStatus.ACTIVE); sp.setStatus(ACTIVE);
sp.setExpression("Patient.extension('http://foo')"); sp.setExpression("Patient.extension('http://foo')");
sp.addTarget("Patient"); sp.addTarget(PATIENT);
org.hl7.fhir.r5.model.SearchParameter canonicalSp = myVersionCanonicalizer.searchParameterToCanonical(sp); SearchParameter canonicalSp = myVersionCanonicalizer.searchParameterToCanonical(sp);
mySvc.validate(canonicalSp); mySvc.validate(canonicalSp);
} }
@ -50,15 +105,130 @@ public class SearchParameterDaoValidatorTest {
SearchParameter sp = new SearchParameter(); SearchParameter sp = new SearchParameter();
sp.setId("SearchParameter/meal-chef"); sp.setId("SearchParameter/meal-chef");
sp.setUrl("http://example.org/SearchParameter/meal-chef"); sp.setUrl("http://example.org/SearchParameter/meal-chef");
sp.addBase("Meal"); sp.addExtension(new Extension(HapiExtensions.EXTENSION_SEARCHPARAM_CUSTOM_BASE_RESOURCE).setValue(new StringType("Meal")));
sp.addExtension(new Extension(HapiExtensions.EXTENSION_SEARCHPARAM_CUSTOM_TARGET_RESOURCE).setValue(new StringType("Chef")));
sp.setCode("chef"); sp.setCode("chef");
sp.setType(Enumerations.SearchParamType.REFERENCE); sp.setType(REFERENCE);
sp.setStatus(Enumerations.PublicationStatus.ACTIVE); sp.setStatus(ACTIVE);
sp.setExpression("Meal.chef"); sp.setExpression("Meal.chef");
sp.addTarget("Chef");
org.hl7.fhir.r5.model.SearchParameter canonicalSp = myVersionCanonicalizer.searchParameterToCanonical(sp); SearchParameter canonicalSp = myVersionCanonicalizer.searchParameterToCanonical(sp);
mySvc.validate(canonicalSp); mySvc.validate(canonicalSp);
} }
@ParameterizedTest
@MethodSource("extensionProvider")
public void testMethodValidate_nonUniqueComboAndCompositeSearchParamWithComponentOfTypeReference_isNotAllowed(Extension theExtension) {
SearchParameter sp = createSearchParameter(COMPOSITE, "SearchParameter/patient-code", "patient-code", "Observation");
sp.addExtension(theExtension);
sp.addComponent(new SearchParameterComponentComponent().setDefinition(SP_COMPONENT_DEFINITION_OF_TYPE_TOKEN));
sp.addComponent(new SearchParameterComponentComponent().setDefinition(SP_COMPONENT_DEFINITION_OF_TYPE_REFERENCE));
try {
mySvc.validate(sp);
fail();
} catch (UnprocessableEntityException ex) {
assertTrue(ex.getMessage().startsWith("HAPI-2347: "));
assertTrue(ex.getMessage().contains("Invalid component search parameter type: REFERENCE in component.definition: http://example.org/SearchParameter/observation-patient"));
}
}
@Test
public void testMethodValidate_uniqueComboSearchParamWithComponentOfTypeReference_isValid() {
SearchParameter sp = createSearchParameter(COMPOSITE, "SearchParameter/patient-code", "patient-code", "Observation");
sp.addExtension(new Extension(HapiExtensions.EXT_SP_UNIQUE, new BooleanType(true)));
sp.addComponent(new SearchParameterComponentComponent()
.setDefinition(SP_COMPONENT_DEFINITION_OF_TYPE_TOKEN));
sp.addComponent(new SearchParameterComponentComponent()
.setDefinition(SP_COMPONENT_DEFINITION_OF_TYPE_REFERENCE));
mySvc.validate(sp);
}
@ParameterizedTest
@MethodSource("comboSpProvider")
public void testMethodValidate_comboSearchParamsWithNumberUriComponents_isValid(SearchParameter theSearchParameter) {
theSearchParameter.addComponent(new SearchParameterComponentComponent()
.setDefinition(SP_COMPONENT_DEFINITION_OF_TYPE_URI));
theSearchParameter.addComponent(new SearchParameterComponentComponent()
.setDefinition(SP_COMPONENT_DEFINITION_OF_TYPE_NUMBER));
mySvc.validate(theSearchParameter);
}
@Test
public void testMethodValidate_compositeSearchParamsWithNumberUriComponents_isNotAllowed() {
SearchParameter sp = createSearchParameter(COMPOSITE, "SearchParameter/component-value-uri-number", "component-value-uri-number", "Observation");
sp.addComponent(new SearchParameterComponentComponent().setDefinition(SP_COMPONENT_DEFINITION_OF_TYPE_URI));
sp.addComponent(new SearchParameterComponentComponent().setDefinition(SP_COMPONENT_DEFINITION_OF_TYPE_NUMBER));
try {
mySvc.validate(sp);
fail();
} catch (UnprocessableEntityException ex) {
assertTrue(ex.getMessage().startsWith("HAPI-2347: "));
assertTrue(ex.getMessage().contains("Invalid component search parameter type: URI in component.definition: http://example.org/SearchParameter/component-value-canonical"));
}
}
@ParameterizedTest
@MethodSource("compositeSpProvider")
// we're testing for:
// SP of type composite,
// SP of type combo composite non-unique,
// SP of type combo composite unique,
public void testMethodValidate_allCompositeSpTypesWithComponentOfValidType_isValid(SearchParameter theSearchParameter) {
theSearchParameter.addComponent(new SearchParameter.SearchParameterComponentComponent()
.setDefinition(SP_COMPONENT_DEFINITION_OF_TYPE_TOKEN).setExpression("Observation"));
theSearchParameter.addComponent(new SearchParameter.SearchParameterComponentComponent()
.setDefinition(SP_COMPONENT_DEFINITION_OF_TYPE_QUANTITY).setExpression("Observation"));
theSearchParameter.addComponent(new SearchParameter.SearchParameterComponentComponent()
.setDefinition(SP_COMPONENT_DEFINITION_OF_TYPE_STRING).setExpression("Observation"));
theSearchParameter.addComponent(new SearchParameter.SearchParameterComponentComponent()
.setDefinition(SP_COMPONENT_DEFINITION_OF_TYPE_DATE).setExpression("Observation"));
mySvc.validate(theSearchParameter);
}
private static SearchParameter createSearchParameter(Enumerations.SearchParamType theType, String theId, String theCodeValue, String theExpression) {
SearchParameter retVal = new SearchParameter();
retVal.setId(theId);
retVal.setUrl("http://example.org/" + theId);
retVal.addBase(OBSERVATION);
retVal.setCode(theCodeValue);
retVal.setType(theType);
retVal.setStatus(ACTIVE);
retVal.setExpression(theExpression);
return retVal;
}
static Stream<Arguments> extensionProvider() {
return Stream.of(
Arguments.of(
new Extension(HapiExtensions.EXT_SP_UNIQUE, new BooleanType(false))), // composite SP of type combo with non-unique index
Arguments.of((Object) null) // composite SP
);
}
static Stream<Arguments> comboSpProvider() {
return Stream.of(
Arguments.of(createSearchParameter(Enumerations.SearchParamType.COMPOSITE, "SearchParameter/any-type", "any-type", "Observation")
.addExtension(new Extension(HapiExtensions.EXT_SP_UNIQUE, new BooleanType(false)))), // composite SP of type combo with non-unique index
Arguments.of(createSearchParameter(Enumerations.SearchParamType.COMPOSITE, "SearchParameter/any-type", "any-type", "Observation")
.addExtension(new Extension(HapiExtensions.EXT_SP_UNIQUE, new BooleanType(true)))) // composite SP of type combo with unique index
);
}
static Stream<Arguments> compositeSpProvider() {
return Stream.concat(comboSpProvider(), Stream.of(
Arguments.of(createSearchParameter(Enumerations.SearchParamType.COMPOSITE, "SearchParameter/any-type", "any-type", "Observation")) // composite SP
));
}
} }

View File

@ -24,6 +24,7 @@ import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.context.RuntimeSearchParam; import ca.uhn.fhir.context.RuntimeSearchParam;
import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum;
import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException;
import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; import ca.uhn.fhir.rest.server.util.ISearchParamRegistry;
import ca.uhn.fhir.util.ElementUtil; import ca.uhn.fhir.util.ElementUtil;
@ -32,8 +33,19 @@ import org.hl7.fhir.instance.model.api.IPrimitiveType;
import org.hl7.fhir.r5.model.Enumerations; import org.hl7.fhir.r5.model.Enumerations;
import org.hl7.fhir.r5.model.SearchParameter; import org.hl7.fhir.r5.model.SearchParameter;
import java.util.Collection;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum.DATE;
import static ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum.NUMBER;
import static ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum.QUANTITY;
import static ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum.REFERENCE;
import static ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum.STRING;
import static ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum.TOKEN;
import static ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum.URI;
import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.isBlank;
public class SearchParameterDaoValidator { public class SearchParameterDaoValidator {
@ -84,8 +96,6 @@ public class SearchParameterDaoValidator {
throw new UnprocessableEntityException(Msg.code(1113) + "SearchParameter.base is missing"); throw new UnprocessableEntityException(Msg.code(1113) + "SearchParameter.base is missing");
} }
boolean isUnique = hasAnyExtensionUniqueSetTo(searchParameter, true);
if (isCompositeWithoutExpression(searchParameter)) { if (isCompositeWithoutExpression(searchParameter)) {
// this is ok // this is ok
@ -96,47 +106,58 @@ public class SearchParameterDaoValidator {
} else { } else {
if (isUnique) {
if (searchParameter.getComponent().size() == 0) {
throw new UnprocessableEntityException(Msg.code(1115) + "SearchParameter is marked as unique but has no components");
}
for (SearchParameter.SearchParameterComponentComponent next : searchParameter.getComponent()) {
if (isBlank(next.getDefinition())) {
throw new UnprocessableEntityException(Msg.code(1116) + "SearchParameter is marked as unique but is missing component.definition");
}
}
}
FhirVersionEnum fhirVersion = myFhirContext.getVersion().getVersion(); FhirVersionEnum fhirVersion = myFhirContext.getVersion().getVersion();
if (fhirVersion.isOlderThan(FhirVersionEnum.DSTU3)) { if (fhirVersion.isOlderThan(FhirVersionEnum.DSTU3)) {
// omitting validation for DSTU2_HL7ORG, DSTU2_1 and DSTU2 // omitting validation for DSTU2_HL7ORG, DSTU2_1 and DSTU2
} else { } else {
maybeValidateCompositeSpForUniqueIndexing(searchParameter);
if (myStorageSettings.isValidateSearchParameterExpressionsOnSave()) { maybeValidateSearchParameterExpressionsOnSave(searchParameter);
maybeValidateCompositeWithComponent(searchParameter);
validateExpressionPath(searchParameter);
String expression = getExpression(searchParameter);
try {
myFhirContext.newFhirPath().parse(expression);
} catch (Exception exception) {
throw new UnprocessableEntityException(Msg.code(1121) + "Invalid FHIRPath format for SearchParameter.expression \"" + expression + "\": " + exception.getMessage());
}
} }
} }
} }
private boolean isCompositeSp(SearchParameter theSearchParameter) {
return theSearchParameter.getType() != null && theSearchParameter.getType().equals(Enumerations.SearchParamType.COMPOSITE);
} }
private boolean isCompositeWithoutBase(SearchParameter searchParameter) { private boolean isCompositeWithoutBase(SearchParameter searchParameter) {
return return
ElementUtil.isEmpty(searchParameter.getBase()) && ElementUtil.isEmpty(searchParameter.getBase()) &&
ElementUtil.isEmpty(searchParameter.getExtensionsByUrl(HapiExtensions.EXTENSION_SEARCHPARAM_CUSTOM_BASE_RESOURCE)) && ElementUtil.isEmpty(searchParameter.getExtensionsByUrl(HapiExtensions.EXTENSION_SEARCHPARAM_CUSTOM_BASE_RESOURCE)) &&
(searchParameter.getType() == null || !Enumerations.SearchParamType.COMPOSITE.name().equals(searchParameter.getType().name())); !isCompositeSp(searchParameter);
} }
private boolean isCompositeWithoutExpression(SearchParameter searchParameter) { private boolean isCompositeWithoutExpression(SearchParameter searchParameter) {
return searchParameter.getType() != null && searchParameter.getType().name().equals(Enumerations.SearchParamType.COMPOSITE.name()) && isBlank(searchParameter.getExpression()); return isCompositeSp(searchParameter) && isBlank(searchParameter.getExpression());
}
private boolean isCompositeWithComponent(SearchParameter theSearchParameter) {
return isCompositeSp(theSearchParameter) && theSearchParameter.hasComponent();
}
private boolean isCompositeSpForUniqueIndexing(SearchParameter theSearchParameter) {
return isCompositeSp(theSearchParameter) && hasAnyExtensionUniqueSetTo(theSearchParameter, true);
}
private void maybeValidateCompositeSpForUniqueIndexing(SearchParameter theSearchParameter) {
if (isCompositeSpForUniqueIndexing(theSearchParameter)) {
if (!theSearchParameter.hasComponent()) {
throw new UnprocessableEntityException(Msg.code(1115) + "SearchParameter is marked as unique but has no components");
}
for (SearchParameter.SearchParameterComponentComponent next : theSearchParameter.getComponent()) {
if (isBlank(next.getDefinition())) {
throw new UnprocessableEntityException(Msg.code(1116) + "SearchParameter is marked as unique but is missing component.definition");
}
}
}
}
private void maybeValidateSearchParameterExpressionsOnSave(SearchParameter theSearchParameter) {
if (myStorageSettings.isValidateSearchParameterExpressionsOnSave()) {
validateExpressionPath(theSearchParameter);
validateExpressionIsParsable(theSearchParameter);
}
} }
private void validateExpressionPath(SearchParameter theSearchParameter) { private void validateExpressionPath(SearchParameter theSearchParameter) {
@ -153,6 +174,16 @@ public class SearchParameterDaoValidator {
} }
} }
private void validateExpressionIsParsable(SearchParameter theSearchParameter) {
String expression = getExpression(theSearchParameter);
try {
myFhirContext.newFhirPath().parse(expression);
} catch (Exception exception) {
throw new UnprocessableEntityException(Msg.code(1121) + "Invalid FHIRPath format for SearchParameter.expression \"" + expression + "\": " + exception.getMessage());
}
}
private String getExpression(SearchParameter theSearchParameter) { private String getExpression(SearchParameter theSearchParameter) {
return theSearchParameter.getExpression().trim(); return theSearchParameter.getExpression().trim();
} }
@ -165,4 +196,56 @@ public class SearchParameterDaoValidator {
.stream() .stream()
.anyMatch(t -> theValueAsString.equals(t.getValueAsPrimitive().getValueAsString())); .anyMatch(t -> theValueAsString.equals(t.getValueAsPrimitive().getValueAsString()));
} }
private void maybeValidateCompositeWithComponent(SearchParameter theSearchParameter) {
if (isCompositeWithComponent(theSearchParameter)) {
validateCompositeSearchParameterComponents(theSearchParameter);
}
}
private void validateCompositeSearchParameterComponents(SearchParameter theSearchParameter) {
theSearchParameter.getComponent().stream()
.filter(SearchParameter.SearchParameterComponentComponent::hasDefinition)
.map(SearchParameter.SearchParameterComponentComponent::getDefinition)
.filter(Objects::nonNull)
.map(mySearchParamRegistry::getActiveSearchParamByUrl)
.filter(Objects::nonNull)
.forEach(theRuntimeSp -> validateComponentSpTypeAgainstWhiteList(theRuntimeSp, getAllowedSearchParameterTypes(theSearchParameter)));
}
private void validateComponentSpTypeAgainstWhiteList(RuntimeSearchParam theRuntimeSearchParam,
Collection<RestSearchParameterTypeEnum> theAllowedSearchParamTypes) {
if (!theAllowedSearchParamTypes.contains(theRuntimeSearchParam.getParamType())) {
throw new UnprocessableEntityException(String.format("%sInvalid component search parameter type: %s in component.definition: %s, supported types: %s",
Msg.code(2347), theRuntimeSearchParam.getParamType().name(), theRuntimeSearchParam.getUri(),
theAllowedSearchParamTypes.stream().map(Enum::name).collect(Collectors.joining(", "))));
}
}
/*
* Returns allowed Search Parameter Types for a given composite or combo search parameter
* This prevents the creation of search parameters that would fail during runtime (during a GET request)
* Below you can find references to runtime usage for each parameter type:
*
* For Composite Search Parameters without HSearch indexing enabled (JPA only):
* @see QueryStack#createPredicateCompositePart() and SearchBuilder#createCompositeSort()
*
* For Composite Search Parameters with HSearch indexing enabled:
* @see HSearchCompositeSearchIndexDataImpl#writeIndexEntry()
*
* For Combo Search Parameters:
* @see BaseSearchParamExtractor.extractParameterCombinationsForComboParam()
*/
private Set<RestSearchParameterTypeEnum> getAllowedSearchParameterTypes(SearchParameter theSearchParameter) {
// combo unique search parameter
if (hasAnyExtensionUniqueSetTo(theSearchParameter, true)) {
return Set.of(STRING, TOKEN, DATE, QUANTITY, URI, NUMBER, REFERENCE);
// combo non-unique search parameter or composite Search Parameter with HSearch indexing
} else if (hasAnyExtensionUniqueSetTo(theSearchParameter, false) || // combo non-unique search parameter
myStorageSettings.isAdvancedHSearchIndexing()) { // composite Search Parameter with HSearch indexing
return Set.of(STRING, TOKEN, DATE, QUANTITY, URI, NUMBER);
} else { // composite Search Parameter (JPA only)
return Set.of(STRING, TOKEN, DATE, QUANTITY);
}
}
} }