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 cf16fb0ad..bad7fbabc 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 @@ -33,8 +33,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.etor.ruleengine.validation.ValidationRuleEngine; import gov.hhs.cdc.trustedintermediary.external.database.DatabaseMessageLinkStorage; import gov.hhs.cdc.trustedintermediary.external.database.DatabasePartnerMetadataStorage; import gov.hhs.cdc.trustedintermediary.external.database.DbDao; @@ -133,7 +133,9 @@ public Map> domainRegistra PartnerMetadataConverter.class, HapiPartnerMetadataConverter.getInstance()); // Validation rules ApplicationContext.register(RuleLoader.class, RuleLoader.getInstance()); - ApplicationContext.register(RuleEngine.class, RuleEngine.getInstance()); + ApplicationContext.register( + ValidationRuleEngine.class, + ValidationRuleEngine.getInstance("validation_definitions.json")); 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 203bb6ffe..a841c29fd 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,7 +2,7 @@ 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.etor.ruleengine.validation.ValidationRuleEngine; import gov.hhs.cdc.trustedintermediary.external.hapi.HapiFhirResource; import gov.hhs.cdc.trustedintermediary.external.hapi.HapiOrder; import gov.hhs.cdc.trustedintermediary.wrappers.FhirParseException; @@ -20,7 +20,7 @@ public class OrderController { @Inject HapiFhir fhir; @Inject Logger logger; @Inject MetricMetadata metadata; - @Inject RuleEngine ruleEngine; + @Inject ValidationRuleEngine validationEngine; private OrderController() {} @@ -31,7 +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)); + validationEngine.runRules(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/Rule.java b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/Rule.java index 55146028d..1b421cfed 100644 --- 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 @@ -1,28 +1,85 @@ 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; /** - * 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. + * Represents a rule that can be run on a FHIR resource. Each rule has a name, description, logging + * message, conditions to determine if the rule should run, and actions to run in case the condition + * is met. */ -public interface Rule { - String getName(); +public class Rule { - String getDescription(); + protected final Logger logger = ApplicationContext.getImplementation(Logger.class); + protected final HapiFhir fhirEngine = ApplicationContext.getImplementation(HapiFhir.class); + private String name; + private String description; + private String message; + private List conditions; + private List rules; /** - * 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! + * Do not delete this constructor! It is used for JSON deserialization when loading rules from a + * file. */ - String getViolationMessage(); + public Rule() {} - List getConditions(); + public Rule( + String ruleName, + String ruleDescription, + String ruleMessage, + List ruleConditions, + List ruleActions) { + name = ruleName; + description = ruleDescription; + message = ruleMessage; + conditions = ruleConditions; + rules = ruleActions; + } - List getValidations(); + public String getName() { + return name; + } - boolean isValid(FhirResource resource); + public String getDescription() { + return description; + } - boolean appliesTo(FhirResource resource); + public String getMessage() { + return message; + } + + public List getConditions() { + return conditions; + } + + public List getRules() { + return rules; + } + + public boolean shouldRun(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; + } + }); + } + + public void runRule(FhirResource resource) { + throw new UnsupportedOperationException("This method must be implemented by subclasses."); + } } 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 index 0b72ad3eb..6357598d8 100644 --- 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 @@ -1,63 +1,13 @@ 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; +/** + * The RuleEngine interface defines the structure for a rule engine. Each rule engine has methods to + * load rules, ensure rules are loaded, and run rules on a resource. + */ +public interface RuleEngine { + void unloadRules(); -/** Manages the application of rules loaded from a definitions file using the RuleLoader. */ -public class RuleEngine { + void ensureRulesLoaded() throws RuleLoaderException; - 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()) { - synchronized (this) { - 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()); - } - } - } + void runRules(FhirResource resource); } 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 index bbfa66ce1..7992a01d6 100644 --- 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 @@ -1,8 +1,14 @@ package gov.hhs.cdc.trustedintermediary.etor.ruleengine; +import gov.hhs.cdc.trustedintermediary.wrappers.Logger; 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.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Collections; import java.util.List; import java.util.Map; @@ -12,6 +18,7 @@ public class RuleLoader { private static final RuleLoader INSTANCE = new RuleLoader(); @Inject Formatter formatter; + @Inject Logger logger; private RuleLoader() {} @@ -19,14 +26,17 @@ 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) { + public List loadRules(Path path, TypeReference>> typeReference) + throws RuleLoaderException { + try (InputStream ruleDefinitionStream = Files.newInputStream(path)) { + var rulesString = + new String(ruleDefinitionStream.readAllBytes(), StandardCharsets.UTF_8); + Map> jsonObj = + formatter.convertJsonToObject(rulesString, typeReference); + return jsonObj.getOrDefault("definitions", Collections.emptyList()); + } catch (IOException | FormatterProcessingException e) { throw new RuleLoaderException( - "Failed to load rules definitions for provided stream", e); + "Failed to load rules definitions for provided path: " + path, e); } } } 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 deleted file mode 100644 index ecf02cb6f..000000000 --- a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/ValidationRule.java +++ /dev/null @@ -1,108 +0,0 @@ -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/etor/ruleengine/validation/ValidationRule.java b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/validation/ValidationRule.java new file mode 100644 index 000000000..4dbcdc57f --- /dev/null +++ b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/validation/ValidationRule.java @@ -0,0 +1,51 @@ +package gov.hhs.cdc.trustedintermediary.etor.ruleengine.validation; + +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.FhirResource; +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.Rule; +import java.util.List; + +/** + * The ValidationRule class extends the {@link gov.hhs.cdc.trustedintermediary.etor.ruleengine.Rule + * Rule} class and represents a validation rule. It implements the {@link + * gov.hhs.cdc.trustedintermediary.etor.ruleengine.Rule#runRule(FhirResource) runRule} method to + * evaluate the validation and log a warning if the validation fails. + */ +public class ValidationRule extends Rule { + + /** + * 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 ruleMessage, + List ruleConditions, + List ruleActions) { + super(ruleName, ruleDescription, ruleMessage, ruleConditions, ruleActions); + } + + @Override + public void runRule(FhirResource resource) { + for (String validation : this.getRules()) { + try { + boolean isValid = + this.fhirEngine.evaluateCondition( + resource.getUnderlyingResource(), validation); + if (!isValid) { + this.logger.logWarning("Validation failed: " + this.getMessage()); + } + } catch (Exception e) { + this.logger.logError( + "Rule [" + + this.getName() + + "]: " + + "An error occurred while evaluating the validation: " + + validation, + e); + } + } + } +} diff --git a/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/validation/ValidationRuleEngine.java b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/validation/ValidationRuleEngine.java new file mode 100644 index 000000000..8554a6394 --- /dev/null +++ b/etor/src/main/java/gov/hhs/cdc/trustedintermediary/etor/ruleengine/validation/ValidationRuleEngine.java @@ -0,0 +1,71 @@ +package gov.hhs.cdc.trustedintermediary.etor.ruleengine.validation; + +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.FhirResource; +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.RuleEngine; +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.RuleLoader; +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.RuleLoaderException; +import gov.hhs.cdc.trustedintermediary.wrappers.Logger; +import gov.hhs.cdc.trustedintermediary.wrappers.formatter.TypeReference; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; + +/** Implements the RuleEngine interface. It represents a rule engine for validations. */ +public class ValidationRuleEngine implements RuleEngine { + private String ruleDefinitionsFileName; + final List rules = new ArrayList<>(); + + private static final ValidationRuleEngine INSTANCE = new ValidationRuleEngine(); + + @Inject Logger logger; + @Inject RuleLoader ruleLoader; + + public static ValidationRuleEngine getInstance(String ruleDefinitionsFileName) { + INSTANCE.ruleDefinitionsFileName = ruleDefinitionsFileName; + return INSTANCE; + } + + private ValidationRuleEngine() {} + + @Override + public void unloadRules() { + rules.clear(); + } + + @Override + public void ensureRulesLoaded() throws RuleLoaderException { + if (rules.isEmpty()) { + synchronized (this) { + if (rules.isEmpty()) { + Path path = + Paths.get( + getClass() + .getClassLoader() + .getResource(ruleDefinitionsFileName) + .getPath()); + + List parsedRules = + ruleLoader.loadRules(path, new TypeReference<>() {}); + this.rules.addAll(parsedRules); + } + } + } + } + + @Override + public void runRules(FhirResource resource) { + try { + ensureRulesLoaded(); + } catch (RuleLoaderException e) { + logger.logError("Failed to load rules definitions", e); + return; + } + for (ValidationRule rule : rules) { + if (rule.shouldRun(resource)) { + rule.runRule(resource); + } + } + } +} diff --git a/etor/src/main/resources/rule_definitions.json b/etor/src/main/resources/validation_definitions.json similarity index 76% rename from etor/src/main/resources/rule_definitions.json rename to etor/src/main/resources/validation_definitions.json index aa828918f..f6d05cb1c 100644 --- a/etor/src/main/resources/rule_definitions.json +++ b/etor/src/main/resources/validation_definitions.json @@ -1,21 +1,21 @@ { - "rules": [ { + "definitions": [ { "name": "messageHasRequiredReceiverId", "description": "Message has required receiver id", - "violationMessage": "Message doesn't have required receiver id", + "message": "Message doesn't have required receiver id", "conditions": [ ], - "validations": [ + "rules": [ "Bundle.entry.resource.ofType(MessageHeader).destination.receiver.resolve().identifier.where(system = 'urn:ietf:rfc:3986').value.exists()" ] }, { "name": "ORMHasRequiredCardNumber", "description": "ORM has required Card Number", - "violationMessage": "ORM doesn't have required Card Number", + "message": "ORM doesn't have required Card Number", "conditions": [ "Bundle.entry.resource.ofType(MessageHeader).event.code = 'O01'" ], - "validations": [ + "rules": [ "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/etor/orders/OrderControllerTest.groovy b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/orders/OrderControllerTest.groovy index 75bdc1bc1..f1f8c794c 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,7 +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.etor.ruleengine.validation.ValidationRuleEngine import gov.hhs.cdc.trustedintermediary.external.hapi.HapiMessageHelper import gov.hhs.cdc.trustedintermediary.wrappers.FhirParseException import gov.hhs.cdc.trustedintermediary.wrappers.HapiFhir @@ -12,14 +12,14 @@ import org.hl7.fhir.r4.model.Bundle import spock.lang.Specification class OrderControllerTest extends Specification { - def ruleEngine = Mock(RuleEngine) + def ruleEngine = Mock(ValidationRuleEngine) def setup() { TestApplicationContext.reset() TestApplicationContext.init() TestApplicationContext.register(OrderController, OrderController.getInstance()) TestApplicationContext.register(MetricMetadata, Mock(MetricMetadata)) - TestApplicationContext.register(RuleEngine, ruleEngine) + TestApplicationContext.register(ValidationRuleEngine, ruleEngine) TestApplicationContext.register(HapiMessageHelper, HapiMessageHelper.getInstance()) } @@ -38,7 +38,7 @@ class OrderControllerTest extends Specification { then: actualBundle == expectedBundle - (1.._) * ruleEngine.validate(_) + (1.._) * ruleEngine.runRules(_) } def "parseOrders registers a metadata step"() { 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 deleted file mode 100644 index 1e1cf87c4..000000000 --- a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleEngineTest.groovy +++ /dev/null @@ -1,108 +0,0 @@ -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 loads rules only once on multiple threads"() { - given: - def threadsNum = 10 - def iterations = 4 - - when: - List threads = [] - (1..threadsNum).each { threadId -> - threads.add(new Thread({ - for (int i = 0; i < iterations; i++) { - ruleEngine.ensureRulesLoaded() - } - })) - } - threads*.start() - threads*.join() - - 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/RuleLoaderTest.groovy b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleLoaderTest.groovy index 6f3245876..85edab9fe 100644 --- 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 @@ -1,60 +1,74 @@ package gov.hhs.cdc.trustedintermediary.etor.ruleengine import gov.hhs.cdc.trustedintermediary.context.TestApplicationContext +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.validation.ValidationRule import gov.hhs.cdc.trustedintermediary.external.jackson.Jackson import gov.hhs.cdc.trustedintermediary.wrappers.HapiFhir import gov.hhs.cdc.trustedintermediary.wrappers.formatter.Formatter +import gov.hhs.cdc.trustedintermediary.wrappers.formatter.TypeReference 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 + Path tempFile def setup() { TestApplicationContext.reset() TestApplicationContext.init() TestApplicationContext.register(RuleLoader, RuleLoader.getInstance()) + TestApplicationContext.register(Formatter, Jackson.getInstance()) + TestApplicationContext.register(HapiFhir, Mock(HapiFhir)) TestApplicationContext.injectRegisteredImplementations() - fileContents = """ - { - "rules": [ - { - "name": "patientName", - "conditions": ["Patient.name.exists()"], - "validations": ["Patient.name.where(use='usual').given.exists()"] - } - ] - } - """ + tempFile = Files.createTempFile("test_validation_definition", ".json") + } + + def cleanup(){ + Files.deleteIfExists(tempFile) } def "load rules from file"() { given: - TestApplicationContext.register(HapiFhir, Mock(HapiFhir)) - TestApplicationContext.register(Formatter, Jackson.getInstance()) - TestApplicationContext.injectRegisteredImplementations() + fileContents = """ + { + "definitions": [ + { + "name": "patientName", + "description": "a test rule", + "message": "testing the message", + "conditions": ["Patient.name.exists()"], + "rules": ["Patient.name.where(use='usual').given.exists()"] + } + ] + } + """ + Files.writeString(tempFile, fileContents) when: - List rules = RuleLoader.getInstance().loadRules(fileContents) + List rules = RuleLoader.getInstance().loadRules(tempFile, new TypeReference>>() {}) then: rules.size() == 1 ValidationRule rule = rules.get(0) as ValidationRule rule.getName() == "patientName" + rule.getDescription() == "a test rule" + rule.getMessage() == "testing the message" rule.getConditions() == ["Patient.name.exists()"] - rule.getValidations() == [ + rule.getRules() == [ "Patient.name.where(use='usual').given.exists()" ] } - def "handle FormatterProcessingException when loading rules from file"() { + def "handle FormatterProcessingException when loading rules from a non existent file"() { + given: + Files.writeString(tempFile, "!K@WJ#8uhy") + when: - RuleLoader.getInstance().loadRules("!K@WJ#8uhy") + RuleLoader.getInstance().loadRules(tempFile, new TypeReference>>() {}) then: thrown(RuleLoaderException) diff --git a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleTest.groovy b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleTest.groovy new file mode 100644 index 000000000..3f335d233 --- /dev/null +++ b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleTest.groovy @@ -0,0 +1,29 @@ +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.wrappers.HapiFhir +import gov.hhs.cdc.trustedintermediary.wrappers.Logger +import spock.lang.Specification + +class RuleTest extends Specification { + + def setup() { + TestApplicationContext.reset() + TestApplicationContext.init() + TestApplicationContext.register(Logger, Mock(Logger)) + TestApplicationContext.register(HapiFhir, Mock(HapiFhir)) + TestApplicationContext.injectRegisteredImplementations() + } + + def "runRule throws an UnsupportedOperationException when ran from the Rule class"() { + given: + def rule = new Rule() + + when: + rule.runRule(new FhirResourceMock("resource")) + + then: + thrown(UnsupportedOperationException) + } +} 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/ValidationRuleEngineIntegrationTest.groovy similarity index 82% rename from etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleEngineIntegrationTest.groovy rename to etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/ValidationRuleEngineIntegrationTest.groovy index 0bc0d13dc..001ce7bff 100644 --- a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/RuleEngineIntegrationTest.groovy +++ b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/ValidationRuleEngineIntegrationTest.groovy @@ -1,6 +1,8 @@ package gov.hhs.cdc.trustedintermediary.etor.ruleengine import gov.hhs.cdc.trustedintermediary.context.TestApplicationContext +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.validation.ValidationRule +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.validation.ValidationRuleEngine import gov.hhs.cdc.trustedintermediary.external.hapi.HapiFhirImplementation import gov.hhs.cdc.trustedintermediary.external.hapi.HapiFhirResource import gov.hhs.cdc.trustedintermediary.external.jackson.Jackson @@ -17,10 +19,10 @@ import spock.lang.Specification import java.nio.file.Files import java.nio.file.Path -class RuleEngineIntegrationTest extends Specification { +class ValidationRuleEngineIntegrationTest extends Specification { def testExampleFilesPath = "../examples/Test" def fhir = HapiFhirImplementation.getInstance() - def engine = RuleEngine.getInstance() + def engine = ValidationRuleEngine.getInstance("validation_definitions.json") def mockLogger = Mock(Logger) def setup() { @@ -29,7 +31,7 @@ class RuleEngineIntegrationTest extends Specification { TestApplicationContext.register(Formatter, Jackson.getInstance()) TestApplicationContext.register(HapiFhir, fhir) - TestApplicationContext.register(RuleEngine, engine) + TestApplicationContext.register(ValidationRuleEngine, engine) TestApplicationContext.register(RuleLoader, RuleLoader.getInstance()) TestApplicationContext.register(Logger, mockLogger) @@ -41,7 +43,7 @@ class RuleEngineIntegrationTest extends Specification { def bundle = new Bundle() when: - engine.validate(new HapiFhirResource(bundle)) + engine.runRules(new HapiFhirResource(bundle)) then: (1.._) * mockLogger.logWarning(_ as String) @@ -53,7 +55,7 @@ class RuleEngineIntegrationTest extends Specification { when: exampleFhirResources.each { resource -> - engine.validate(resource) + engine.runRules(resource) } then: @@ -67,19 +69,22 @@ class RuleEngineIntegrationTest extends Specification { def rule = createValidationRule([], [validation]) when: - def applies = rule.isValid(fhirResource) + rule.runRule(fhirResource) then: - applies + 0 * mockLogger.logWarning(_ as String) + 0 * mockLogger.logError(_ as String, _ as Exception) } def "validation rules pass for test files"() { given: def fhirResource = getExampleFhirResource(testFile) def rule = createValidationRule([], [validation]) + 0 * mockLogger.logWarning(_ as String) + 0 * mockLogger.logError(_ as String, _ as Exception) expect: - rule.isValid(fhirResource) + rule.runRule(fhirResource) where: testFile | validation @@ -93,9 +98,11 @@ class RuleEngineIntegrationTest extends Specification { given: def fhirResource = getExampleFhirResource(testFile) def rule = createValidationRule([], [validation]) + 1 * mockLogger.logWarning(_ as String) + 0 * mockLogger.logError(_ as String, _ as Exception) expect: - !rule.isValid(fhirResource) + rule.runRule(fhirResource) where: testFile | validation @@ -118,9 +125,11 @@ class RuleEngineIntegrationTest extends Specification { def bundle = createMessageBundle(receiverOrganization: receiverOrganization) // for some reason, we need to encode and decode the bundle for resolve() to work def fhirResource = new HapiFhirResource(fhir.parseResource(fhir.encodeResourceToJson(bundle), Bundle)) + rule.runRule(fhirResource) then: - rule.isValid(fhirResource) + 0 * mockLogger.logWarning(_ as String) + 0 * mockLogger.logError(_ as String, _ as Exception) when: receiverOrganization = new Organization() @@ -131,9 +140,11 @@ class RuleEngineIntegrationTest extends Specification { .setValue("simulated-hospital-id") bundle = createMessageBundle(receiverOrganization: receiverOrganization) fhirResource = new HapiFhirResource(fhir.parseResource(fhir.encodeResourceToJson(bundle), Bundle)) + rule.runRule(fhirResource) then: - !rule.isValid(fhirResource) + 1 * mockLogger.logWarning(_ as String) + 0 * mockLogger.logError(_ as String, _ as Exception) when: receiverOrganization = new Organization() @@ -143,9 +154,11 @@ class RuleEngineIntegrationTest extends Specification { .setValue("simulated-hospital-id") bundle = createMessageBundle(receiverOrganization: receiverOrganization) fhirResource = new HapiFhirResource(fhir.parseResource(fhir.encodeResourceToJson(bundle), Bundle)) + rule.runRule(fhirResource) then: - !rule.isValid(fhirResource) + 1 * mockLogger.logWarning(_ as String) + 0 * mockLogger.logError(_ as String, _ as Exception) } Bundle createMessageBundle(Map params) { @@ -171,13 +184,13 @@ class RuleEngineIntegrationTest extends Specification { return bundle } - Rule createValidationRule(List ruleConditions, List ruleValidations) { + ValidationRule createValidationRule(List ruleConditions, List ruleValidations) { return new ValidationRule( - name: "Rule name", - description: "Rule description", - violationMessage: "Rule warning message", - conditions: ruleConditions, - validations: ruleValidations, + "Rule name", + "Rule description", + "Rule warning message", + ruleConditions, + ruleValidations, ) } diff --git a/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/ValidationRuleEngineTest.groovy b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/ValidationRuleEngineTest.groovy new file mode 100644 index 000000000..e52261f2d --- /dev/null +++ b/etor/src/test/groovy/gov/hhs/cdc/trustedintermediary/etor/ruleengine/ValidationRuleEngineTest.groovy @@ -0,0 +1,139 @@ +package gov.hhs.cdc.trustedintermediary.etor.ruleengine + +import gov.hhs.cdc.trustedintermediary.context.TestApplicationContext +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.validation.ValidationRule +import gov.hhs.cdc.trustedintermediary.etor.ruleengine.validation.ValidationRuleEngine +import gov.hhs.cdc.trustedintermediary.wrappers.Logger +import gov.hhs.cdc.trustedintermediary.wrappers.formatter.TypeReference +import spock.lang.Specification + +import java.nio.file.Path + +class ValidationRuleEngineTest extends Specification { + def ruleEngine = ValidationRuleEngine.getInstance("validation_definitions.json") + def mockRuleLoader = Mock(RuleLoader) + def mockLogger = Mock(Logger) + def mockRule = Mock(ValidationRule) + + 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"() { + given: + mockRuleLoader.loadRules(_ as Path, _ as TypeReference) >> [mockRule] + + when: + ruleEngine.ensureRulesLoaded() + + then: + 1 * mockRuleLoader.loadRules(_ as Path, _ as TypeReference) >> [mockRule] + ruleEngine.rules.size() == 1 + } + + def "ensureRulesLoaded loads rules only once by default"() { + given: + mockRuleLoader.loadRules(_ as Path, _ as TypeReference) >> [mockRule] + + when: + ruleEngine.ensureRulesLoaded() + ruleEngine.ensureRulesLoaded() // Call twice to test if rules are loaded only once + + then: + 1 * mockRuleLoader.loadRules(_ as Path, _ as TypeReference) >> [mockRule] + ruleEngine.rules.size() == 1 + } + + def "ensureRulesLoaded loads rules only once on multiple threads"() { + given: + def threadsNum = 10 + def iterations = 4 + + when: + mockRuleLoader.loadRules(_ as Path, _ as TypeReference) >> [mockRule] + List threads = [] + (1..threadsNum).each { threadId -> + threads.add(new Thread({ + for (int i = 0; i < iterations; i++) { + ruleEngine.ensureRulesLoaded() + } + })) + } + threads*.start() + threads*.join() + + then: + 1 * mockRuleLoader.loadRules(_ as Path, _ as TypeReference) + } + + 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 Path, _ as TypeReference) >> { + mockLogger.logError("Error loading rules", exception) + return [] + } + + when: + ruleEngine.runRules(Mock(FhirResource)) + + then: + 1 * mockLogger.logError(_ as String, exception) + } + + def "runRules handles logging warning correctly"() { + given: + def failedValidationMessage = "Failed validation message" + def fullFailedValidationMessage = "Validation failed: " + failedValidationMessage + def fhirBundle = Mock(FhirResource) + def invalidRule = Mock(ValidationRule) + invalidRule.getMessage() >> failedValidationMessage + invalidRule.shouldRun(fhirBundle) >> true + mockRuleLoader.loadRules(_ as Path, _ as TypeReference) >> [invalidRule] + + when: + invalidRule.runRule(fhirBundle) >> { + mockLogger.logWarning(fullFailedValidationMessage) + } + ruleEngine.runRules(fhirBundle) + + then: + 1 * mockLogger.logWarning(fullFailedValidationMessage) + + when: + invalidRule.runRule(fhirBundle) >> null + invalidRule.shouldRun(fhirBundle) >> true + ruleEngine.runRules(fhirBundle) + + then: + 0 * mockLogger.logWarning(fullFailedValidationMessage) + + when: + invalidRule.shouldRun(fhirBundle) >> false + ruleEngine.runRules(fhirBundle) + + then: + 0 * mockLogger.logWarning(fullFailedValidationMessage) + } + + + def "runRules logs an error and doesn't run any rules when there's a RuleLoaderException"() { + given: + def exception = new RuleLoaderException("Error loading rules", new Exception()) + mockRuleLoader.loadRules(_ as Path, _ as TypeReference) >> { throw exception } + + when: + ruleEngine.runRules(Mock(FhirResource)) + + then: + 1 * mockLogger.logError(_ as String, exception) + } +} 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 index 95a0b2545..b6fb44ad6 100644 --- 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 @@ -2,6 +2,7 @@ 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.etor.ruleengine.validation.ValidationRule import gov.hhs.cdc.trustedintermediary.external.hapi.HapiFhirImplementation import gov.hhs.cdc.trustedintermediary.wrappers.HapiFhir import gov.hhs.cdc.trustedintermediary.wrappers.Logger @@ -33,12 +34,12 @@ class ValidationRuleTest extends Specification { then: rule.getName() == ruleName rule.getDescription() == ruleDescription - rule.getViolationMessage() == ruleWarningMessage + rule.getMessage() == ruleWarningMessage rule.getConditions() == conditions - rule.getValidations() == validations + rule.getRules() == validations } - def "appliesTo returns expected boolean depending on conditions"() { + def "shouldRun returns expected boolean depending on conditions"() { given: def mockFhir = Mock(HapiFhir) mockFhir.evaluateCondition(_ as Object, _ as String) >> true >> conditionResult @@ -50,7 +51,7 @@ class ValidationRuleTest extends Specification { ], null) expect: - rule.appliesTo(new FhirResourceMock("resource")) == applies + rule.shouldRun(new FhirResourceMock("resource")) == applies where: conditionResult | applies @@ -58,7 +59,7 @@ class ValidationRuleTest extends Specification { false | false } - def "appliesTo logs an error and returns false if an exception happens when evaluating a condition"() { + def "shouldRun 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() } @@ -67,17 +68,16 @@ class ValidationRuleTest extends Specification { def rule = new ValidationRule(null, null, null, ["condition"], null) when: - def applies = rule.appliesTo(Mock(FhirResource)) + def applies = rule.shouldRun(Mock(FhirResource)) then: 1 * mockLogger.logError(_ as String, _ as Exception) !applies } - def "isValid returns expected boolean depending on validations"() { + def "runRule 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, [ @@ -85,16 +85,24 @@ class ValidationRuleTest extends Specification { "secondValidation" ]) - expect: - rule.isValid(new FhirResourceMock("resource")) == valid + when: + mockFhir.evaluateCondition(_ as Object, _ as String) >> true >> true + rule.runRule(new FhirResourceMock("resource")) - where: - validationResult | valid - true | true - false | false + then: + 0 * mockLogger.logWarning(_ as String) + 0 * mockLogger.logError(_ as String, _ as Exception) + + when: + mockFhir.evaluateCondition(_ as Object, _ as String) >> true >> false + rule.runRule(new FhirResourceMock("resource")) + + then: + 1 * mockLogger.logWarning(_ as String) + 0 * mockLogger.logError(_ as String, _ as Exception) } - def "isValid logs an error and returns false if an exception happens when evaluating a validation"() { + def "runRule 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() } @@ -103,10 +111,10 @@ class ValidationRuleTest extends Specification { def rule = new ValidationRule(null, null, null, null, ["validation"]) when: - def valid = rule.isValid(Mock(FhirResource)) + rule.runRule(Mock(FhirResource)) then: + 0 * mockLogger.logWarning(_ as String) 1 * mockLogger.logError(_ as String, _ as Exception) - !valid } }