Add repo validation outcome to response (#2293)

* Add repository validating interceptor outcome to response

* Add a doc

* Add a test and some docs
This commit is contained in:
James Agnew 2021-01-17 12:50:13 -05:00 committed by GitHub
parent 9d8bbaf868
commit 29cf20aac3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 198 additions and 58 deletions

View File

@ -64,6 +64,8 @@ Note that this rule alone does not actually enforce validation against the speci
}
```
<a name="require-validation"/>
# Rules: Require Validation to Declared Profiles
Use the following rule to require that resources of the given type be validated successfully before allowing them to be persisted. For every resource of the given type that is submitted for storage, the `Resource.meta.profile` field will be examined and the resource will be validated against any declarations found there.
@ -101,3 +103,7 @@ Rules can declare that a specific profile is not allowed.
```java
{{snippet:classpath:/ca/uhn/hapi/fhir/docs/RepositoryValidatingInterceptorExamples.java|disallowProfiles}}
```
# Adding Validation Outcome to HTTP Response
If you have included a [Require Validation](#require-validation) rule to your chain, you can add the `ValidationResultEnrichingInterceptor` to your server if you wish to have validation results added to and OperationOutcome objects that are returned by the server.

View File

@ -20,6 +20,7 @@ package ca.uhn.fhir.jpa.interceptor.validation;
* #L%
*/
import ca.uhn.fhir.rest.api.server.RequestDetails;
import org.apache.commons.lang3.Validate;
import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
import org.hl7.fhir.instance.model.api.IBaseResource;
@ -35,7 +36,7 @@ public interface IRepositoryValidatingRule {
String getResourceType();
@Nonnull
RuleEvaluation evaluate(@Nonnull IBaseResource theResource);
RuleEvaluation evaluate(RequestDetails theRequestDetails, @Nonnull IBaseResource theResource);
class RuleEvaluation {

View File

@ -24,6 +24,7 @@ import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.interceptor.api.Hook;
import ca.uhn.fhir.interceptor.api.Interceptor;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException;
import ca.uhn.fhir.util.OperationOutcomeUtil;
import com.google.common.collect.ArrayListMultimap;
@ -47,7 +48,7 @@ import java.util.stream.Collectors;
public class RepositoryValidatingInterceptor {
private static final Logger ourLog = LoggerFactory.getLogger(RepositoryValidatingInterceptor.class);
private Multimap<String, IRepositoryValidatingRule> myRules = ArrayListMultimap.create();
private final Multimap<String, IRepositoryValidatingRule> myRules = ArrayListMultimap.create();
private FhirContext myFhirContext;
/**
@ -113,25 +114,25 @@ public class RepositoryValidatingInterceptor {
* Interceptor hook method. This method should not be called directly.
*/
@Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED)
void create(IBaseResource theResource) {
handle(theResource);
void create(RequestDetails theRequestDetails, IBaseResource theResource) {
handle(theRequestDetails, theResource);
}
/**
* Interceptor hook method. This method should not be called directly.
*/
@Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED)
void update(IBaseResource theOldResource, IBaseResource theNewResource) {
handle(theNewResource);
void update(RequestDetails theRequestDetails, IBaseResource theOldResource, IBaseResource theNewResource) {
handle(theRequestDetails, theNewResource);
}
private void handle(IBaseResource theNewResource) {
private void handle(RequestDetails theRequestDetails, IBaseResource theNewResource) {
Validate.notNull(myFhirContext, "No FhirContext has been set for this interceptor of type: %s", getClass());
String resourceType = myFhirContext.getResourceType(theNewResource);
Collection<IRepositoryValidatingRule> rules = myRules.get(resourceType);
for (IRepositoryValidatingRule nextRule : rules) {
IRepositoryValidatingRule.RuleEvaluation outcome = nextRule.evaluate(theNewResource);
IRepositoryValidatingRule.RuleEvaluation outcome = nextRule.evaluate(theRequestDetails, theNewResource);
if (!outcome.isPasses()) {
handleFailure(outcome);
}

View File

@ -23,6 +23,7 @@ package ca.uhn.fhir.jpa.interceptor.validation;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.support.IValidationSupport;
import ca.uhn.fhir.jpa.validation.ValidatorResourceFetcher;
import ca.uhn.fhir.rest.server.interceptor.ValidationResultEnrichingInterceptor;
import ca.uhn.fhir.validation.ResultSeverityEnum;
import org.apache.commons.lang3.Validate;
import org.apache.commons.text.WordUtils;
@ -148,13 +149,27 @@ public final class RepositoryValidatingRuleBuilder implements IRuleRoot {
}
/**
* @param theProfileUrl
* @return
* If set, any resources that contain a profile declaration in <code>Resource.meta.profile</code>
* matching {@literal theProfileUrl} will be rejected.
*
* @param theProfileUrl The profile canonical URL
*/
public FinalizedTypedRule disallowProfile(String theProfileUrl) {
return disallowProfiles(theProfileUrl);
}
/**
* Perform a resource validation step using the FHIR Instance Validator and reject the
* storage if the validation fails.
*
* <p>
* If the {@link ValidationResultEnrichingInterceptor} is registered against the
* {@link ca.uhn.fhir.rest.server.RestfulServer} interceptor registry, the validation results
* will be appended to any <code>OperationOutcome</code> resource returned by the server.
* </p>
*
* @see ValidationResultEnrichingInterceptor
*/
public FinalizedRequireValidationRule requireValidationToDeclaredProfiles() {
RequireValidationRule rule = new RequireValidationRule(myFhirContext, myType, myValidationSupport, myValidatorResourceFetcher);
myRules.add(rule);
@ -251,13 +266,13 @@ public final class RepositoryValidatingRuleBuilder implements IRuleRoot {
* Specifies that if the validation results in any results with a severity of <code>theSeverity</code> or
* greater, the resource will be tagged with the given tag when it is saved.
*
* @param theSeverity The minimum severity. Must be drawn from values in {@link ResultSeverityEnum} and must not be <code>null</code>
* @param theSeverity The minimum severity. Must be drawn from values in {@link ResultSeverityEnum} and must not be <code>null</code>
* @param theTagSystem The system for the tag to add. Must not be <code>null</code>
* @param theTagCode The code for the tag to add. Must not be <code>null</code>
* @param theTagCode The code for the tag to add. Must not be <code>null</code>
* @return
*/
@Nonnull
public FinalizedRequireValidationRule tagOnSeverity(@Nonnull String theSeverity,@Nonnull String theTagSystem,@Nonnull String theTagCode) {
public FinalizedRequireValidationRule tagOnSeverity(@Nonnull String theSeverity, @Nonnull String theTagSystem, @Nonnull String theTagCode) {
ResultSeverityEnum severity = ResultSeverityEnum.fromCode(toLowerCase(theSeverity));
return tagOnSeverity(severity, theTagSystem, theTagCode);
}
@ -266,13 +281,13 @@ public final class RepositoryValidatingRuleBuilder implements IRuleRoot {
* Specifies that if the validation results in any results with a severity of <code>theSeverity</code> or
* greater, the resource will be tagged with the given tag when it is saved.
*
* @param theSeverity The minimum severity. Must be drawn from values in {@link ResultSeverityEnum} and must not be <code>null</code>
* @param theSeverity The minimum severity. Must be drawn from values in {@link ResultSeverityEnum} and must not be <code>null</code>
* @param theTagSystem The system for the tag to add. Must not be <code>null</code>
* @param theTagCode The code for the tag to add. Must not be <code>null</code>
* @param theTagCode The code for the tag to add. Must not be <code>null</code>
* @return
*/
@Nonnull
public FinalizedRequireValidationRule tagOnSeverity(@Nonnull ResultSeverityEnum theSeverity,@Nonnull String theTagSystem,@Nonnull String theTagCode) {
public FinalizedRequireValidationRule tagOnSeverity(@Nonnull ResultSeverityEnum theSeverity, @Nonnull String theTagSystem, @Nonnull String theTagCode) {
myRule.tagOnSeverity(theSeverity, theTagSystem, theTagCode);
return this;
}

View File

@ -23,6 +23,8 @@ package ca.uhn.fhir.jpa.interceptor.validation;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.support.IValidationSupport;
import ca.uhn.fhir.jpa.validation.ValidatorResourceFetcher;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.server.interceptor.ValidationResultEnrichingInterceptor;
import ca.uhn.fhir.validation.FhirValidator;
import ca.uhn.fhir.validation.ResultSeverityEnum;
import ca.uhn.fhir.validation.SingleValidationMessage;
@ -58,7 +60,7 @@ class RequireValidationRule extends BaseTypedRule {
@Nonnull
@Override
public RuleEvaluation evaluate(@Nonnull IBaseResource theResource) {
public RuleEvaluation evaluate(RequestDetails theRequestDetails, @Nonnull IBaseResource theResource) {
FhirValidator validator = getFhirContext().newValidator();
validator.registerValidatorModule(myValidator);
@ -83,6 +85,8 @@ class RequireValidationRule extends BaseTypedRule {
}
ValidationResultEnrichingInterceptor.addValidationResultToRequestDetails(theRequestDetails, outcome);
return RuleEvaluation.forSuccess(this);
}
@ -104,6 +108,14 @@ class RequireValidationRule extends BaseTypedRule {
myRejectOnSeverity = null;
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
.append("resourceType", getResourceType())
.append("rejectOnSeverity", myRejectOnSeverity)
.append("tagOnSeverity", myTagOnSeverity)
.toString();
}
private static class TagOnSeverity {
private final int mySeverity;
@ -133,13 +145,4 @@ class RequireValidationRule extends BaseTypedRule {
return ResultSeverityEnum.values()[mySeverity].name() + "/" + myTagSystem + "/" + myTagCode;
}
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
.append("resourceType", getResourceType())
.append("rejectOnSeverity", myRejectOnSeverity)
.append("tagOnSeverity", myTagOnSeverity)
.toString();
}
}

View File

@ -21,6 +21,7 @@ package ca.uhn.fhir.jpa.interceptor.validation;
*/
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.util.UrlUtil;
import org.apache.commons.lang3.Validate;
import org.apache.commons.lang3.builder.ToStringBuilder;
@ -47,7 +48,7 @@ class RuleDisallowProfile extends BaseTypedRule {
@Nonnull
@Override
public RuleEvaluation evaluate(@Nonnull IBaseResource theResource) {
public RuleEvaluation evaluate(RequestDetails theRequestDetails, @Nonnull IBaseResource theResource) {
for (IPrimitiveType<String> next : theResource.getMeta().getProfile()) {
String nextUrl = next.getValueAsString();
String nextUrlNormalized = UrlUtil.normalizeCanonicalUrlForComparison(nextUrl);

View File

@ -21,6 +21,7 @@ package ca.uhn.fhir.jpa.interceptor.validation;
*/
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import org.hl7.fhir.instance.model.api.IBaseResource;
@ -39,7 +40,7 @@ class RuleRequireProfileDeclaration extends BaseTypedRule {
@Nonnull
@Override
public RuleEvaluation evaluate(@Nonnull IBaseResource theResource) {
public RuleEvaluation evaluate(RequestDetails theRequestDetails, @Nonnull IBaseResource theResource) {
Optional<String> matchingProfile = theResource
.getMeta()
.getProfile()

View File

@ -64,7 +64,9 @@ import ca.uhn.fhir.jpa.model.entity.ModelConfig;
import ca.uhn.fhir.jpa.model.entity.ResourceIndexedSearchParamString;
import ca.uhn.fhir.jpa.model.entity.ResourceTable;
import ca.uhn.fhir.jpa.partition.IPartitionLookupSvc;
import ca.uhn.fhir.jpa.provider.r4.BaseJpaResourceProviderObservationR4;
import ca.uhn.fhir.jpa.provider.r4.JpaSystemProviderR4;
import ca.uhn.fhir.jpa.rp.r4.ObservationResourceProvider;
import ca.uhn.fhir.jpa.search.DatabaseBackedPagingProvider;
import ca.uhn.fhir.jpa.search.IStaleSearchDeletingSvc;
import ca.uhn.fhir.jpa.search.reindex.IResourceReindexingSvc;

View File

@ -0,0 +1,82 @@
package ca.uhn.fhir.jpa.interceptor.validation;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.jpa.config.BaseConfig;
import ca.uhn.fhir.jpa.dao.r4.BaseJpaR4Test;
import ca.uhn.fhir.jpa.rp.r4.ObservationResourceProvider;
import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.api.PreferReturnEnum;
import ca.uhn.fhir.rest.server.interceptor.ValidationResultEnrichingInterceptor;
import ca.uhn.fhir.test.utilities.server.RestfulServerExtension;
import org.hl7.fhir.r4.model.Observation;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import java.util.List;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
public class RepositoryValidatingInterceptorHttpR4Test extends BaseJpaR4Test {
private static final Logger ourLog = LoggerFactory.getLogger(RepositoryValidatingInterceptorHttpR4Test.class);
@Autowired
protected ObservationResourceProvider myObservationResourceProvider;
private RepositoryValidatingInterceptor myValInterceptor;
@RegisterExtension
protected RestfulServerExtension myRestfulServerExtension = new RestfulServerExtension(FhirVersionEnum.R4);
@Autowired
private ApplicationContext myApplicationContext;
@BeforeEach
public void before() {
myValInterceptor = new RepositoryValidatingInterceptor();
myValInterceptor.setFhirContext(myFhirCtx);
myInterceptorRegistry.registerInterceptor(myValInterceptor);
myRestfulServerExtension.getRestfulServer().registerProvider(myObservationResourceProvider);
myRestfulServerExtension.getRestfulServer().getInterceptorService().registerInterceptor(new ValidationResultEnrichingInterceptor());
}
@AfterEach
public void after() {
myInterceptorRegistry.unregisterInterceptorsIf(t -> t instanceof RepositoryValidatingInterceptor);
}
@Test
public void testValidationOutcomeAddedToRequestResponse() {
List<IRepositoryValidatingRule> rules = newRuleBuilder()
.forResourcesOfType("Observation")
.requireValidationToDeclaredProfiles()
.withBestPracticeWarningLevel("WARNING")
.build();
myValInterceptor.setRules(rules);
Observation obs = new Observation();
obs.getCode().addCoding().setSystem("http://foo").setCode("123").setDisplay("help im a bug");
obs.setStatus(Observation.ObservationStatus.AMENDED);
MethodOutcome outcome = myRestfulServerExtension
.getFhirClient()
.create()
.resource(obs)
.prefer(PreferReturnEnum.OPERATION_OUTCOME)
.execute();
String operationOutcomeEncoded = myFhirCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(outcome.getOperationOutcome());
ourLog.info("Outcome: {}", operationOutcomeEncoded);
assertThat(operationOutcomeEncoded, containsString("All observations should have a subject"));
}
private RepositoryValidatingRuleBuilder newRuleBuilder() {
return myApplicationContext.getBean(BaseConfig.REPOSITORY_VALIDATING_RULE_BUILDER, RepositoryValidatingRuleBuilder.class);
}
}

View File

@ -15,7 +15,6 @@ import org.hl7.fhir.r4.model.OperationOutcome;
import org.hl7.fhir.r4.model.Parameters;
import org.hl7.fhir.r4.model.Patient;
import org.hl7.fhir.r4.model.StringType;
import org.hl7.fhir.r4.model.UrlType;
import org.hl7.fhir.r5.utils.IResourceValidator;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@ -44,6 +43,7 @@ public class RepositoryValidatingInterceptorR4Test extends BaseJpaR4Test {
myValInterceptor = new RepositoryValidatingInterceptor();
myValInterceptor.setFhirContext(myFhirCtx);
myInterceptorRegistry.registerInterceptor(myValInterceptor);
}
@AfterEach

View File

@ -45,7 +45,7 @@ import static org.apache.commons.lang3.StringUtils.isNotBlank;
* request with an {@link UnprocessableEntityException HTTP 422 Unprocessable Entity}.
*/
@Interceptor
public abstract class BaseValidatingInterceptor<T> {
public abstract class BaseValidatingInterceptor<T> extends ValidationResultEnrichingInterceptor {
/**
* Default value:<br/>

View File

@ -31,8 +31,6 @@ import ca.uhn.fhir.rest.server.method.ResourceParameter;
import ca.uhn.fhir.validation.FhirValidator;
import ca.uhn.fhir.validation.ResultSeverityEnum;
import ca.uhn.fhir.validation.ValidationResult;
import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
import org.hl7.fhir.instance.model.api.IBaseResource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@ -51,11 +49,6 @@ public class RequestValidatingInterceptor extends BaseValidatingInterceptor<Stri
* X-HAPI-Request-Validation
*/
public static final String DEFAULT_RESPONSE_HEADER_NAME = "X-FHIR-Request-Validation";
/**
* A {@link RequestDetails#getUserData() user data} entry will be created with this
* key which contains the {@link ValidationResult} from validating the request.
*/
public static final String REQUEST_VALIDATION_RESULT = RequestValidatingInterceptor.class.getName() + "_REQUEST_VALIDATION_RESULT";
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(RequestValidatingInterceptor.class);
private boolean myAddValidationResultsToResponseOperationOutcome = true;
@ -82,8 +75,9 @@ public class RequestValidatingInterceptor extends BaseValidatingInterceptor<Stri
ValidationResult validationResult = validate(requestText, theRequestDetails);
// The JPA server will use this
theRequestDetails.getUserData().put(REQUEST_VALIDATION_RESULT, validationResult);
if (myAddValidationResultsToResponseOperationOutcome) {
addValidationResultToRequestDetails(theRequestDetails, validationResult);
}
return true;
}
@ -110,25 +104,6 @@ public class RequestValidatingInterceptor extends BaseValidatingInterceptor<Stri
myAddValidationResultsToResponseOperationOutcome = theAddValidationResultsToResponseOperationOutcome;
}
@Hook(Pointcut.SERVER_OUTGOING_RESPONSE)
public boolean outgoingResponse(RequestDetails theRequestDetails, IBaseResource theResponseObject) {
if (myAddValidationResultsToResponseOperationOutcome) {
if (theResponseObject instanceof IBaseOperationOutcome) {
IBaseOperationOutcome oo = (IBaseOperationOutcome) theResponseObject;
if (theRequestDetails != null) {
ValidationResult validationResult = (ValidationResult) theRequestDetails.getUserData().get(RequestValidatingInterceptor.REQUEST_VALIDATION_RESULT);
if (validationResult != null) {
validationResult.populateOperationOutcome(oo);
}
}
}
}
return true;
}
@Override
String provideDefaultResponseHeaderName() {
return DEFAULT_RESPONSE_HEADER_NAME;

View File

@ -0,0 +1,53 @@
package ca.uhn.fhir.rest.server.interceptor;
import ca.uhn.fhir.interceptor.api.Hook;
import ca.uhn.fhir.interceptor.api.Interceptor;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.validation.ValidationResult;
import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
import org.hl7.fhir.instance.model.api.IBaseResource;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
@Interceptor
public class ValidationResultEnrichingInterceptor {
/**
* A {@link RequestDetails#getUserData() user data} entry will be created with this
* key which contains the {@link ValidationResult} from validating the request.
*/
public static final String REQUEST_VALIDATION_RESULT = ValidationResultEnrichingInterceptor.class.getName() + "_REQUEST_VALIDATION_RESULT";
@SuppressWarnings("unchecked")
@Hook(Pointcut.SERVER_OUTGOING_RESPONSE)
public boolean addValidationResultsToOperationOutcome(RequestDetails theRequestDetails, IBaseResource theResponseObject) {
if (theResponseObject instanceof IBaseOperationOutcome) {
IBaseOperationOutcome oo = (IBaseOperationOutcome) theResponseObject;
if (theRequestDetails != null) {
List<ValidationResult> validationResult = (List<ValidationResult>) theRequestDetails.getUserData().remove(REQUEST_VALIDATION_RESULT);
if (validationResult != null) {
for (ValidationResult next : validationResult) {
next.populateOperationOutcome(oo);
}
}
}
}
return true;
}
@SuppressWarnings("unchecked")
public static void addValidationResultToRequestDetails(@Nullable RequestDetails theRequestDetails, @Nonnull ValidationResult theValidationResult) {
if (theRequestDetails != null) {
List<ValidationResult> results = (List<ValidationResult>) theRequestDetails.getUserData().computeIfAbsent(REQUEST_VALIDATION_RESULT, t -> new ArrayList<>(2));
results.add(theValidationResult);
}
}
}