diff --git a/build.gradle b/build.gradle index bb716ae3d..cc1c0d07a 100644 --- a/build.gradle +++ b/build.gradle @@ -92,7 +92,6 @@ ext.jacoco_excludes = [ '**/slf4j/LocalLogger*', '**/slf4j/DeployedLogger*', '**/slf4j/LoggerHelper*', - '**/hapi/HapiFhirImplementation*', '**/jjwt/JjwtEngine*', '**/apache/ApacheClient*', '**/azure/AzureSecrets*', diff --git a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/EtorDomainRegistration.java b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/EtorDomainRegistration.java index 4872a9c8b..d4409575a 100644 --- a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/EtorDomainRegistration.java +++ b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/EtorDomainRegistration.java @@ -32,6 +32,8 @@ import gov.hhs.cdc.trustedintermediary.etor.results.ResultResponse; import gov.hhs.cdc.trustedintermediary.etor.results.ResultSender; import gov.hhs.cdc.trustedintermediary.etor.results.SendResultUseCase; +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.RuleEngine; +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.RuleLoader; import gov.hhs.cdc.trustedintermediary.external.database.DatabasePartnerMetadataStorage; import gov.hhs.cdc.trustedintermediary.external.database.DbDao; import gov.hhs.cdc.trustedintermediary.external.database.PostgresDao; @@ -123,6 +125,9 @@ public Map> domainRegistra PartnerMetadataOrchestrator.class, PartnerMetadataOrchestrator.getInstance()); ApplicationContext.register( PartnerMetadataConverter.class, HapiPartnerMetadataConverter.getInstance()); + // Validation rules + ApplicationContext.register(RuleLoader.class, RuleLoader.getInstance()); + ApplicationContext.register(RuleEngine.class, RuleEngine.getInstance()); ApplicationContext.register(SendMessageHelper.class, SendMessageHelper.getInstance()); diff --git a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/orders/OrderController.java b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/orders/OrderController.java index a070d0c6a..203bb6ffe 100644 --- a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/orders/OrderController.java +++ b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/orders/OrderController.java @@ -2,6 +2,8 @@ import gov.hhs.cdc.trustedintermediary.domainconnector.DomainRequest; import gov.hhs.cdc.trustedintermediary.etor.metadata.EtorMetadataStep; +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.RuleEngine; +import gov.hhs.cdc.trustedintermediary.external.hapi.HapiFhirResource; import gov.hhs.cdc.trustedintermediary.external.hapi.HapiOrder; import gov.hhs.cdc.trustedintermediary.wrappers.FhirParseException; import gov.hhs.cdc.trustedintermediary.wrappers.HapiFhir; @@ -18,6 +20,7 @@ public class OrderController { @Inject HapiFhir fhir; @Inject Logger logger; @Inject MetricMetadata metadata; + @Inject RuleEngine ruleEngine; private OrderController() {} @@ -28,6 +31,7 @@ public static OrderController getInstance() { public Order parseOrders(DomainRequest request) throws FhirParseException { logger.logInfo("Parsing orders"); var fhirBundle = fhir.parseResource(request.getBody(), Bundle.class); + ruleEngine.validate(new HapiFhirResource(fhirBundle)); metadata.put(fhirBundle.getId(), EtorMetadataStep.RECEIVED_FROM_REPORT_STREAM); return new HapiOrder(fhirBundle); } diff --git a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/FhirResource.java b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/FhirResource.java new file mode 100644 index 000000000..e3fa5bda9 --- /dev/null +++ b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/FhirResource.java @@ -0,0 +1,11 @@ +package gov.hhs.cdc.trustedintermediary.etor.ruleengine; + +/** + * This interface represents a FHIR resource. It's used as a wrapper to decouple dependency on third + * party libraries. + * + * @param the type of the underlying resource + */ +public interface FhirResource { + T getUnderlyingResource(); +} diff --git a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/Rule.java b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/Rule.java new file mode 100644 index 000000000..55146028d --- /dev/null +++ b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/Rule.java @@ -0,0 +1,28 @@ +package gov.hhs.cdc.trustedintermediary.etor.ruleengine; + +import java.util.List; + +/** + * The Rule interface defines the structure for a rule in the rule engine. Each rule has a name, + * description, warning message, conditions, validations, and methods to check if a resource is + * valid and if the rule applies to a resource. + */ +public interface Rule { + String getName(); + + String getDescription(); + + /** + * Descriptive message when there's a rule violation Note: When implementing this method, make + * sure that no PII or PHI is included in the message! + */ + String getViolationMessage(); + + List getConditions(); + + List getValidations(); + + boolean isValid(FhirResource resource); + + boolean appliesTo(FhirResource resource); +} diff --git a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleEngine.java b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleEngine.java new file mode 100644 index 000000000..cdf46ce84 --- /dev/null +++ b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleEngine.java @@ -0,0 +1,59 @@ +package gov.hhs.cdc.trustedintermediary.etor.ruleengine; + +import gov.hhs.cdc.trustedintermediary.wrappers.Logger; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; + +/** Manages the application of rules loaded from a definitions file using the RuleLoader. */ +public class RuleEngine { + + private static final RuleEngine INSTANCE = new RuleEngine(); + + final List rules = new ArrayList<>(); + + @Inject Logger logger; + @Inject RuleLoader ruleLoader; + + private RuleEngine() {} + + public static RuleEngine getInstance() { + return INSTANCE; + } + + public void unloadRules() { + rules.clear(); + } + + public void ensureRulesLoaded() { + if (rules.isEmpty()) { + loadRules(); + } + } + + private synchronized void loadRules() { + String fileName = "rule_definitions.json"; + try (InputStream ruleDefinitionStream = + getClass().getClassLoader().getResourceAsStream(fileName)) { + assert ruleDefinitionStream != null; + var ruleStream = + new String(ruleDefinitionStream.readAllBytes(), StandardCharsets.UTF_8); + rules.addAll(ruleLoader.loadRules(ruleStream)); + } catch (IOException | RuleLoaderException e) { + logger.logError("Failed to load rules definitions from: " + fileName, e); + } + } + + public void validate(FhirResource resource) { + logger.logDebug("Validating FHIR resource"); + ensureRulesLoaded(); + for (Rule rule : rules) { + if (rule.appliesTo(resource) && !rule.isValid(resource)) { + logger.logWarning("Rule violation: " + rule.getViolationMessage()); + } + } + } +} diff --git a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleLoader.java b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleLoader.java new file mode 100644 index 000000000..bbfa66ce1 --- /dev/null +++ b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleLoader.java @@ -0,0 +1,32 @@ +package gov.hhs.cdc.trustedintermediary.etor.ruleengine; + +import gov.hhs.cdc.trustedintermediary.wrappers.formatter.Formatter; +import gov.hhs.cdc.trustedintermediary.wrappers.formatter.FormatterProcessingException; +import gov.hhs.cdc.trustedintermediary.wrappers.formatter.TypeReference; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import javax.inject.Inject; + +/** Manages the loading of rules from a definitions file. */ +public class RuleLoader { + private static final RuleLoader INSTANCE = new RuleLoader(); + @Inject Formatter formatter; + + private RuleLoader() {} + + public static RuleLoader getInstance() { + return INSTANCE; + } + + public List loadRules(String ruleStream) throws RuleLoaderException { + try { + Map> jsonObj = + formatter.convertJsonToObject(ruleStream, new TypeReference<>() {}); + return jsonObj.getOrDefault("rules", Collections.emptyList()); + } catch (FormatterProcessingException e) { + throw new RuleLoaderException( + "Failed to load rules definitions for provided stream", e); + } + } +} diff --git a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleLoaderException.java b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleLoaderException.java new file mode 100644 index 000000000..449f46e97 --- /dev/null +++ b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleLoaderException.java @@ -0,0 +1,9 @@ +package gov.hhs.cdc.trustedintermediary.etor.ruleengine; + +/** Custom exception class use to catch RuleLoader exceptions */ +public class RuleLoaderException extends Exception { + + public RuleLoaderException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/ValidationRule.java b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/ValidationRule.java new file mode 100644 index 000000000..ecf02cb6f --- /dev/null +++ b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/ValidationRule.java @@ -0,0 +1,108 @@ +package gov.hhs.cdc.trustedintermediary.etor.ruleengine; + +import gov.hhs.cdc.trustedintermediary.context.ApplicationContext; +import gov.hhs.cdc.trustedintermediary.wrappers.HapiFhir; +import gov.hhs.cdc.trustedintermediary.wrappers.Logger; +import java.util.List; + +/** + * Implements the Rule interface. It represents a rule with a name, description, warning message, + * conditions, and validations. It uses the HapiFhir engine to evaluate the conditions and + * validations. + */ +public class ValidationRule implements Rule { + + private final Logger logger = ApplicationContext.getImplementation(Logger.class); + private final HapiFhir fhirEngine = ApplicationContext.getImplementation(HapiFhir.class); + private String name; + private String description; + private String violationMessage; + private List conditions; + private List validations; + + /** + * Do not delete this constructor! It is used for JSON deserialization when loading rules from a + * file. + */ + public ValidationRule() {} + + public ValidationRule( + String ruleName, + String ruleDescription, + String ruleWarningMessage, + List ruleConditions, + List ruleValidations) { + name = ruleName; + description = ruleDescription; + violationMessage = ruleWarningMessage; + conditions = ruleConditions; + validations = ruleValidations; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getDescription() { + return description; + } + + @Override + public String getViolationMessage() { + return violationMessage; + } + + @Override + public List getConditions() { + return conditions; + } + + @Override + public List getValidations() { + return validations; + } + + @Override + public boolean isValid(FhirResource resource) { + return validations.stream() + .allMatch( + validation -> { + try { + return fhirEngine.evaluateCondition( + resource.getUnderlyingResource(), validation); + } catch (Exception e) { + logger.logError( + "Rule [" + + name + + "]: " + + "An error occurred while evaluating the validation: " + + validation, + e); + return false; + } + }); + } + + @Override + public boolean appliesTo(FhirResource resource) { + return conditions.stream() + .allMatch( + condition -> { + try { + return fhirEngine.evaluateCondition( + resource.getUnderlyingResource(), condition); + } catch (Exception e) { + logger.logError( + "Rule [" + + name + + "]: " + + "An error occurred while evaluating the condition: " + + condition, + e); + return false; + } + }); + } +} diff --git a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/external/hapi/HapiFhirResource.java b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/external/hapi/HapiFhirResource.java new file mode 100644 index 000000000..a0f41783e --- /dev/null +++ b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/external/hapi/HapiFhirResource.java @@ -0,0 +1,19 @@ +package gov.hhs.cdc.trustedintermediary.external.hapi; + +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.FhirResource; +import org.hl7.fhir.instance.model.api.IBaseResource; + +/** An implementation of {@link FhirResource} to use as a wrapper around HAPI FHIR IBaseResource */ +public class HapiFhirResource implements FhirResource { + + private final IBaseResource innerResource; + + public HapiFhirResource(IBaseResource innerResource) { + this.innerResource = innerResource; + } + + @Override + public IBaseResource getUnderlyingResource() { + return innerResource; + } +} diff --git a/etor/src/main/resources/rule_definitions.json b/etor/src/main/resources/rule_definitions.json new file mode 100644 index 000000000..a6a381e4b --- /dev/null +++ b/etor/src/main/resources/rule_definitions.json @@ -0,0 +1,23 @@ +{ + "rules": [ { + "name": "requiredReceiverId", + "description": "Message has required receiver id", + "violationMessage": "Message doesn't have required receiver id", + "conditions": [ ], + "validations": [ + "Bundle.entry.resource.ofType(MessageHeader).destination.receiver.resolve().identifier.value.exists()" + ] + }, + { + "name": "requiredReceiverId", + "description": "ORM has required Card Number", + "violationMessage": "ORM doesn't have required Card Number", + "conditions": [ + "Bundle.entry.resource.ofType(MessageHeader).event.code = 'O01'" + ], + "validations": [ + "Bundle.entry.resource.ofType(Observation).where(code.coding.code = '57723-9').value.coding.code.exists()" + ] + } + ] +} diff --git a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/FhirResourceMock.groovy b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/FhirResourceMock.groovy new file mode 100644 index 000000000..07a000688 --- /dev/null +++ b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/FhirResourceMock.groovy @@ -0,0 +1,17 @@ +package gov.hhs.cdc.trustedintermediary + +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.FhirResource + +class FhirResourceMock implements FhirResource { + + private T innerResource + + FhirResourceMock(T innerResource) { + this.innerResource = innerResource + } + + @Override + public T getUnderlyingResource() { + return innerResource + } +} diff --git a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/orders/OrderControllerTest.groovy b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/orders/OrderControllerTest.groovy index 4407c3c7f..2ad6e6a5a 100644 --- a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/orders/OrderControllerTest.groovy +++ b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/orders/OrderControllerTest.groovy @@ -3,6 +3,7 @@ package gov.hhs.cdc.trustedintermediary.etor.orders import gov.hhs.cdc.trustedintermediary.context.TestApplicationContext import gov.hhs.cdc.trustedintermediary.domainconnector.DomainRequest import gov.hhs.cdc.trustedintermediary.etor.metadata.EtorMetadataStep +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.RuleEngine import gov.hhs.cdc.trustedintermediary.wrappers.FhirParseException import gov.hhs.cdc.trustedintermediary.wrappers.HapiFhir import gov.hhs.cdc.trustedintermediary.wrappers.MetricMetadata @@ -10,12 +11,14 @@ import org.hl7.fhir.r4.model.Bundle import spock.lang.Specification class OrderControllerTest extends Specification { + def ruleEngine = Mock(RuleEngine) def setup() { TestApplicationContext.reset() TestApplicationContext.init() TestApplicationContext.register(OrderController, OrderController.getInstance()) TestApplicationContext.register(MetricMetadata, Mock(MetricMetadata)) + TestApplicationContext.register(RuleEngine, ruleEngine) } def "parseOrders happy path works"() { @@ -33,6 +36,7 @@ class OrderControllerTest extends Specification { then: actualBundle == expectedBundle + (1.._) * ruleEngine.validate(_) } def "parseOrders registers a metadata step"() { diff --git a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleEngineIntegrationTest.groovy b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleEngineIntegrationTest.groovy new file mode 100644 index 000000000..42f0731cd --- /dev/null +++ b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleEngineIntegrationTest.groovy @@ -0,0 +1,47 @@ +package gov.hhs.cdc.trustedintermediary.etor.ruleengine + +import gov.hhs.cdc.trustedintermediary.context.TestApplicationContext +import gov.hhs.cdc.trustedintermediary.external.hapi.HapiFhirImplementation +import gov.hhs.cdc.trustedintermediary.external.hapi.HapiFhirResource +import gov.hhs.cdc.trustedintermediary.external.jackson.Jackson +import gov.hhs.cdc.trustedintermediary.wrappers.HapiFhir +import gov.hhs.cdc.trustedintermediary.wrappers.Logger +import gov.hhs.cdc.trustedintermediary.wrappers.formatter.Formatter +import org.hl7.fhir.r4.model.Bundle +import spock.lang.Specification + +import java.nio.file.Files +import java.nio.file.Path + +class RuleEngineIntegrationTest extends Specification { + def fhir = HapiFhirImplementation.getInstance() + def engine = RuleEngine.getInstance() + def mockLogger = Mock(Logger) + + String fhirBody + + def setup() { + TestApplicationContext.reset() + TestApplicationContext.init() + + TestApplicationContext.register(Formatter, Jackson.getInstance()) + TestApplicationContext.register(HapiFhir, fhir) + TestApplicationContext.register(RuleEngine, engine) + TestApplicationContext.register(RuleLoader, RuleLoader.getInstance()) + TestApplicationContext.register(Logger, mockLogger) + + TestApplicationContext.injectRegisteredImplementations() + } + + def "validation logs a warning when a validation fails"() { + given: + fhirBody = Files.readString(Path.of("../examples/Test/Orders/001_OML_O21_short.fhir")) + def bundle = fhir.parseResource(fhirBody, Bundle) + + when: + engine.validate(new HapiFhirResource(bundle)) + + then: + 1 * mockLogger.logWarning(_ as String) + } +} diff --git a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleEngineTest.groovy b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleEngineTest.groovy new file mode 100644 index 000000000..be678b9d7 --- /dev/null +++ b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleEngineTest.groovy @@ -0,0 +1,87 @@ +package gov.hhs.cdc.trustedintermediary.etor.ruleengine + +import gov.hhs.cdc.trustedintermediary.context.TestApplicationContext +import gov.hhs.cdc.trustedintermediary.wrappers.Logger +import spock.lang.Specification + +class RuleEngineTest extends Specification { + def ruleEngine = RuleEngine.getInstance() + def mockRuleLoader = Mock(RuleLoader) + def mockLogger = Mock(Logger) + + def setup() { + ruleEngine.unloadRules() + + TestApplicationContext.reset() + TestApplicationContext.init() + TestApplicationContext.register(RuleLoader, mockRuleLoader) + TestApplicationContext.register(Logger, mockLogger) + TestApplicationContext.register(RuleEngine, ruleEngine) + + TestApplicationContext.injectRegisteredImplementations() + } + + def "ensureRulesLoaded happy path"() { + when: + ruleEngine.ensureRulesLoaded() + + then: + 1 * mockRuleLoader.loadRules(_ as String) >> [Mock(Rule)] + ruleEngine.rules.size() == 1 + } + + def "ensureRulesLoaded loads rules only once by default"() { + when: + ruleEngine.ensureRulesLoaded() + ruleEngine.ensureRulesLoaded() // Call twice to test if rules are loaded only once + + then: + 1 * mockRuleLoader.loadRules(_ as String) >> [Mock(Rule)] + } + + def "ensureRulesLoaded logs an error if there is an exception loading the rules"() { + given: + def exception = new RuleLoaderException("Error loading rules", new Exception()) + mockRuleLoader.loadRules(_ as String) >> { throw exception } + + when: + ruleEngine.validate(Mock(FhirResource)) + + then: + 1 * mockLogger.logError(_ as String, exception) + } + + def "validate handles logging warning correctly"() { + given: + def ruleViolationMessage = "Rule violation message" + def fullRuleViolationMessage = "Rule violation: " + ruleViolationMessage + def fhirBundle = Mock(FhirResource) + def invalidRule = Mock(Rule) + invalidRule.getViolationMessage() >> ruleViolationMessage + mockRuleLoader.loadRules(_ as String) >> [invalidRule] + + when: + invalidRule.appliesTo(fhirBundle) >> true + invalidRule.isValid(fhirBundle) >> false + ruleEngine.validate(fhirBundle) + + then: + 1 * mockLogger.logWarning(fullRuleViolationMessage) + + when: + invalidRule.appliesTo(fhirBundle) >> true + invalidRule.isValid(fhirBundle) >> true + ruleEngine.validate(fhirBundle) + + then: + 0 * mockLogger.logWarning(fullRuleViolationMessage) + + when: + invalidRule.appliesTo(fhirBundle) >> false + invalidRule.isValid(fhirBundle) >> false + ruleEngine.validate(fhirBundle) + + then: + 0 * mockLogger.logWarning(fullRuleViolationMessage) + } +} diff --git a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleLoaderExceptionTest.groovy b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleLoaderExceptionTest.groovy new file mode 100644 index 000000000..5923a818d --- /dev/null +++ b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleLoaderExceptionTest.groovy @@ -0,0 +1,22 @@ +package gov.hhs.cdc.trustedintermediary.etor.ruleengine + +import gov.hhs.cdc.trustedintermediary.etor.messages.UnableToSendMessageException +import gov.hhs.cdc.trustedintermediary.wrappers.HttpClientException +import spock.lang.Specification + +class RuleLoaderExceptionTest extends Specification { + def "constructor works"() { + given: + def message = "rules loaded wrong!" + def cause = new HttpClientException(message, new IOException()) + + when: + def exceptionWithCause = new RuleLoaderException(message, cause) + def exceptionWithout = new RuleLoaderException(message, new Exception()) + + then: + exceptionWithCause.getMessage() == message + exceptionWithCause.getCause() == cause + exceptionWithout.getMessage() == message + } +} diff --git a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleLoaderTest.groovy b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleLoaderTest.groovy new file mode 100644 index 000000000..6f3245876 --- /dev/null +++ b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleLoaderTest.groovy @@ -0,0 +1,62 @@ +package gov.hhs.cdc.trustedintermediary.etor.ruleengine + +import gov.hhs.cdc.trustedintermediary.context.TestApplicationContext +import gov.hhs.cdc.trustedintermediary.external.jackson.Jackson +import gov.hhs.cdc.trustedintermediary.wrappers.HapiFhir +import gov.hhs.cdc.trustedintermediary.wrappers.formatter.Formatter +import spock.lang.Specification + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +class RuleLoaderTest extends Specification { + + String fileContents + + def setup() { + TestApplicationContext.reset() + TestApplicationContext.init() + TestApplicationContext.register(RuleLoader, RuleLoader.getInstance()) + TestApplicationContext.injectRegisteredImplementations() + + fileContents = """ + { + "rules": [ + { + "name": "patientName", + "conditions": ["Patient.name.exists()"], + "validations": ["Patient.name.where(use='usual').given.exists()"] + } + ] + } + """ + } + + def "load rules from file"() { + given: + TestApplicationContext.register(HapiFhir, Mock(HapiFhir)) + TestApplicationContext.register(Formatter, Jackson.getInstance()) + TestApplicationContext.injectRegisteredImplementations() + + when: + List rules = RuleLoader.getInstance().loadRules(fileContents) + + then: + rules.size() == 1 + ValidationRule rule = rules.get(0) as ValidationRule + rule.getName() == "patientName" + rule.getConditions() == ["Patient.name.exists()"] + rule.getValidations() == [ + "Patient.name.where(use='usual').given.exists()" + ] + } + + def "handle FormatterProcessingException when loading rules from file"() { + when: + RuleLoader.getInstance().loadRules("!K@WJ#8uhy") + + then: + thrown(RuleLoaderException) + } +} diff --git a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/ValidationRuleTest.groovy b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/ValidationRuleTest.groovy new file mode 100644 index 000000000..95a0b2545 --- /dev/null +++ b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/ValidationRuleTest.groovy @@ -0,0 +1,112 @@ +package gov.hhs.cdc.trustedintermediary.etor.ruleengine + +import gov.hhs.cdc.trustedintermediary.FhirResourceMock +import gov.hhs.cdc.trustedintermediary.context.TestApplicationContext +import gov.hhs.cdc.trustedintermediary.external.hapi.HapiFhirImplementation +import gov.hhs.cdc.trustedintermediary.wrappers.HapiFhir +import gov.hhs.cdc.trustedintermediary.wrappers.Logger +import spock.lang.Specification + +class ValidationRuleTest extends Specification { + + def mockLogger = Mock(Logger) + + def setup() { + TestApplicationContext.reset() + TestApplicationContext.init() + TestApplicationContext.register(Logger, mockLogger) + TestApplicationContext.injectRegisteredImplementations() + } + + def "ValidationRule's properties are set and get correctly"() { + given: + def ruleName = "Rule name" + def ruleDescription = "Rule Description" + def ruleWarningMessage = "Rule Warning Message" + def conditions = ["condition1", "condition2"] + def validations = ["validation1", "validation2"] + TestApplicationContext.register(HapiFhir, Mock(HapiFhir)) + + when: + def rule = new ValidationRule(ruleName, ruleDescription, ruleWarningMessage, conditions, validations) + + then: + rule.getName() == ruleName + rule.getDescription() == ruleDescription + rule.getViolationMessage() == ruleWarningMessage + rule.getConditions() == conditions + rule.getValidations() == validations + } + + def "appliesTo returns expected boolean depending on conditions"() { + given: + def mockFhir = Mock(HapiFhir) + mockFhir.evaluateCondition(_ as Object, _ as String) >> true >> conditionResult + TestApplicationContext.register(HapiFhir, mockFhir) + + def rule = new ValidationRule(null, null, null, [ + "trueCondition", + "secondCondition" + ], null) + + expect: + rule.appliesTo(new FhirResourceMock("resource")) == applies + + where: + conditionResult | applies + true | true + false | false + } + + def "appliesTo logs an error and returns false if an exception happens when evaluating a condition"() { + given: + def mockFhir = Mock(HapiFhirImplementation) + mockFhir.evaluateCondition(_ as Object, "condition") >> { throw new Exception() } + TestApplicationContext.register(HapiFhir, mockFhir) + + def rule = new ValidationRule(null, null, null, ["condition"], null) + + when: + def applies = rule.appliesTo(Mock(FhirResource)) + + then: + 1 * mockLogger.logError(_ as String, _ as Exception) + !applies + } + + def "isValid returns expected boolean depending on validations"() { + given: + def mockFhir = Mock(HapiFhir) + mockFhir.evaluateCondition(_ as Object, _ as String) >> true >> validationResult + TestApplicationContext.register(HapiFhir, mockFhir) + + def rule = new ValidationRule(null, null, null, null, [ + "trueValidation", + "secondValidation" + ]) + + expect: + rule.isValid(new FhirResourceMock("resource")) == valid + + where: + validationResult | valid + true | true + false | false + } + + def "isValid logs an error and returns false if an exception happens when evaluating a validation"() { + given: + def mockFhir = Mock(HapiFhirImplementation) + mockFhir.evaluateCondition(_ as Object, "condition") >> { throw new Exception() } + TestApplicationContext.register(HapiFhir, mockFhir) + + def rule = new ValidationRule(null, null, null, null, ["validation"]) + + when: + def valid = rule.isValid(Mock(FhirResource)) + + then: + 1 * mockLogger.logError(_ as String, _ as Exception) + !valid + } +} diff --git a/shared/src/main/java/gov/hhs/cdc/trustedintermediary/external/hapi/HapiFhirImplementation.java b/shared/src/main/java/gov/hhs/cdc/trustedintermediary/external/hapi/HapiFhirImplementation.java index 03b9aa34e..09ed6b310 100644 --- a/shared/src/main/java/gov/hhs/cdc/trustedintermediary/external/hapi/HapiFhirImplementation.java +++ b/shared/src/main/java/gov/hhs/cdc/trustedintermediary/external/hapi/HapiFhirImplementation.java @@ -1,17 +1,19 @@ package gov.hhs.cdc.trustedintermediary.external.hapi; import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.fhirpath.IFhirPath; import ca.uhn.fhir.parser.IParser; import gov.hhs.cdc.trustedintermediary.wrappers.FhirParseException; import gov.hhs.cdc.trustedintermediary.wrappers.HapiFhir; import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.BooleanType; /** Concrete implementation that calls the Hapi FHIR library. */ public class HapiFhirImplementation implements HapiFhir { private static final HapiFhirImplementation INSTANCE = new HapiFhirImplementation(); - private static final FhirContext CONTEXT = FhirContext.forR4(); + private static final IFhirPath PATH_ENGINE = CONTEXT.newFhirPath(); private HapiFhirImplementation() {} @@ -33,9 +35,29 @@ public T parseResource( } } + /** + * Encode resource to JSON string. + * + * @param resource Object to encode into a string. + * @return String-encoded resource. + */ @Override public String encodeResourceToJson(Object resource) { IParser encodeResourceParser = CONTEXT.newJsonParser(); return encodeResourceParser.encodeResourceToString((IBaseResource) resource); } + + /** + * Evaluate a FHIR Path expression for a given Resource to find if the expression has matches + * + * @param resource FHIR resource the evaluation starts from. + * @param expression FHIR Path statement to run evaluations on. + * @return True if the expression has at least one match for the given root, else false. + */ + @Override + public Boolean evaluateCondition(Object resource, String expression) { + var result = + PATH_ENGINE.evaluateFirst((IBaseResource) resource, expression, BooleanType.class); + return result.map(BooleanType::booleanValue).orElse(false); + } } diff --git a/shared/src/main/java/gov/hhs/cdc/trustedintermediary/wrappers/HapiFhir.java b/shared/src/main/java/gov/hhs/cdc/trustedintermediary/wrappers/HapiFhir.java index 9a269bf81..f2537bb45 100644 --- a/shared/src/main/java/gov/hhs/cdc/trustedintermediary/wrappers/HapiFhir.java +++ b/shared/src/main/java/gov/hhs/cdc/trustedintermediary/wrappers/HapiFhir.java @@ -13,4 +13,6 @@ T parseResource(String fhirResource, Class clazz) throws FhirParseException; String encodeResourceToJson(Object resource); + + Boolean evaluateCondition(Object resource, String expression); } diff --git a/shared/src/test/groovy/gov/hhs/cdc/trustedintermediary/external/hapi/HapiFhirImplementationTest.groovy b/shared/src/test/groovy/gov/hhs/cdc/trustedintermediary/external/hapi/HapiFhirImplementationTest.groovy new file mode 100644 index 000000000..c22bc2518 --- /dev/null +++ b/shared/src/test/groovy/gov/hhs/cdc/trustedintermediary/external/hapi/HapiFhirImplementationTest.groovy @@ -0,0 +1,126 @@ +package gov.hhs.cdc.trustedintermediary.external.hapi + +import ca.uhn.fhir.fhirpath.FhirPathExecutionException +import gov.hhs.cdc.trustedintermediary.context.TestApplicationContext +import gov.hhs.cdc.trustedintermediary.wrappers.FhirParseException +import org.hl7.fhir.instance.model.api.IBaseResource +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.DiagnosticReport +import org.hl7.fhir.r4.model.ServiceRequest +import spock.lang.Specification + +import java.nio.file.Files +import java.nio.file.Path + +class HapiFhirImplementationTest extends Specification { + Bundle bundle + DiagnosticReport diaReport + ServiceRequest servRequest + HapiFhirImplementation fhir = HapiFhirImplementation.getInstance() + + def setup() { + TestApplicationContext.reset() + TestApplicationContext.init() + TestApplicationContext.register(HapiFhirImplementation, HapiFhirImplementation.getInstance()) + + TestApplicationContext.injectRegisteredImplementations() + + bundle = new Bundle() + bundle.id = "abc123" + + diaReport = new DiagnosticReport() + diaReport.id = "ghi789" + servRequest = new ServiceRequest() + servRequest.id = "def456" + + def entry1 = new Bundle.BundleEntryComponent() + entry1.resource = diaReport + bundle.addEntry(entry1) + + def entry2 = new Bundle.BundleEntryComponent() + entry2.resource = servRequest + bundle.addEntry(entry2) + } + + def "evaluateCondition returns true on finding existing value"() { + given: + def path = "Bundle.id.exists()" + + when: + def result = fhir.evaluateCondition(bundle as IBaseResource, path) + + then: + result == true + } + + def "evaluateCondition returns false on not finding non-existing value"() { + given: + def path = "Bundle.timestamp.exists()" + + when: + def result = fhir.evaluateCondition(bundle as IBaseResource, path) + + then: + result == false + } + + def "evaluateCondition returns false on not finding matching extension"() { + given: + def path = "Bundle.entry[0].resource.extension('blah')" + + when: + def result = fhir.evaluateCondition(bundle as IBaseResource, path) + + then: + result == false + } + + def "evaluateCondition throws FhirPathExecutionException on empty string"() { + given: + def path = "" + + when: + fhir.evaluateCondition(bundle as IBaseResource, path) + + then: + thrown(FhirPathExecutionException) + } + + def "evaluateCondition throws FhirPathExecutionException on fake method"() { + given: + def path = "Bundle.entry[0].resource.BadMethod('blah')" + + when: + fhir.evaluateCondition(bundle as IBaseResource, path) + + then: + thrown(FhirPathExecutionException) + } + + def "parseResource can convert a valid string to Bundle"() { + given: + def fhirBody = Files.readString(Path.of("../examples/Test/Orders/001_OML_O21_short.fhir")) + + when: + def parsedBundle = fhir.parseResource(fhirBody, Bundle.class) + + then: + parsedBundle.class == Bundle.class + } + + def "parseResource throws FhirParseException on an invalid string"() { + when: + fhir.parseResource("badString", Bundle.class) + + then: + thrown(FhirParseException) + } + + def "encodeResourceToJson successfully converts a Bundle to a string" () { + when: + def encodedBundle = fhir.encodeResourceToJson(bundle) + + then: + encodedBundle.class == String.class + } +}