Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,11 @@ public ReportClient reportClient(JwtService jwtService, RestClient restClient) {

@Getter @Setter
private List<String> whiteListValueSetRegex = new ArrayList<>();

/**
* Configured validation-result rules whose matches should be dropped before categorization,
* persistence, and downstream validity calculations.
*/
@Getter @Setter
private List<ValidationResultIgnoreRuleConfig> validationResultIgnoreRules = new ArrayList<>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.lantanagroup.link.validation.configs;

import com.lantanagroup.link.validation.entities.ResultField;
import lombok.Getter;
import lombok.Setter;

import java.util.List;

@Getter
@Setter
public class ValidationResultIgnoreRuleConfig {
private String id;
private String description;
private MatcherConfig matcher;

@Getter
@Setter
public static class MatcherConfig {
private ResultField field;
private String regex;
private boolean inverted;
private boolean requiresAllChildren;
private List<MatcherConfig> children;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.lantanagroup.link.validation.services;

import com.lantanagroup.link.validation.configs.LinkConfig;
import com.lantanagroup.link.validation.configs.ValidationResultIgnoreRuleConfig;
import com.lantanagroup.link.validation.entities.Result;
import com.lantanagroup.link.validation.matchers.CompositeMatcher;
import com.lantanagroup.link.validation.matchers.Matcher;
import com.lantanagroup.link.validation.matchers.RegexMatcher;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
public class ValidationResultIgnoreService {
private static final Logger logger = LoggerFactory.getLogger(ValidationResultIgnoreService.class);

private final List<CompiledValidationResultIgnoreRule> rules;

public ValidationResultIgnoreService(LinkConfig linkConfig) {
List<ValidationResultIgnoreRuleConfig> configuredRules = linkConfig.getValidationResultIgnoreRules();
this.rules = configuredRules == null
? List.of()
: configuredRules.stream()
.map(this::compileRule)
.toList();
}

public List<Result> filterIgnored(List<Result> results) {
if (CollectionUtils.isEmpty(results) || CollectionUtils.isEmpty(rules)) {
return results;
}

List<Result> filtered = new ArrayList<>(results.size());
int ignoredCount = 0;
for (Result result : results) {
CompiledValidationResultIgnoreRule matchingRule = getFirstMatchingRule(result);
if (matchingRule != null) {
ignoredCount++;
logger.debug(
"Ignoring validation result via rule {}: expression='{}', message='{}'",
StringUtils.defaultIfBlank(matchingRule.id(), "<unnamed>"),
StringUtils.defaultString(result.getExpression()),
StringUtils.defaultString(result.getMessage()));
Comment on lines +44 to +48

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Sanitize every logging argument before the logger call.

StringUtils.defaultIfBlank and StringUtils.defaultString do not sanitize values. Pass matchingRule.id(), result.getExpression(), and result.getMessage() through the repository sanitizer before logger.debug.

As per path instructions, all logging message arguments must be sanitized before they are passed to logger methods.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Java/validation/src/main/java/com/lantanagroup/link/validation/services/ValidationResultIgnoreService.java`
around lines 44 - 48, Update the logger.debug call in
ValidationResultIgnoreService to sanitize matchingRule.id(),
result.getExpression(), and result.getMessage() with the repository’s
established sanitizer before passing them as logging arguments. Preserve the
existing blank/default fallback behavior while ensuring every argument is
sanitized before the logger call.

Source: Path instructions

continue;
}

filtered.add(result);
}

if (ignoredCount > 0) {
logger.debug("Ignored {} validation result(s) using configured validation-result-ignore rules", ignoredCount);
}

return filtered;
}

String getFirstMatchingRuleId(Result result) {
CompiledValidationResultIgnoreRule rule = getFirstMatchingRule(result);
return rule == null ? null : rule.id();
}

private CompiledValidationResultIgnoreRule getFirstMatchingRule(Result result) {
for (CompiledValidationResultIgnoreRule rule : rules) {
if (rule.matcher().isMatch(result)) {
return rule;
}
}

return null;
}

private CompiledValidationResultIgnoreRule compileRule(ValidationResultIgnoreRuleConfig config) {
ValidationResultIgnoreRuleConfig.MatcherConfig matcherConfig = config.getMatcher();
if (matcherConfig == null) {
throw new IllegalStateException("Validation-result-ignore rule '" + StringUtils.defaultIfBlank(config.getId(), "<unnamed>") + "' is missing matcher");
}

return new CompiledValidationResultIgnoreRule(config.getId(), buildMatcher(matcherConfig));
}

private Matcher buildMatcher(ValidationResultIgnoreRuleConfig.MatcherConfig config) {
if (CollectionUtils.isNotEmpty(config.getChildren())) {
CompositeMatcher matcher = new CompositeMatcher();
matcher.setInverted(config.isInverted());
matcher.setRequiresAllChildren(config.isRequiresAllChildren());
matcher.setChildren(config.getChildren().stream().map(this::buildMatcher).toList());
return matcher;
}

RegexMatcher matcher = new RegexMatcher();
matcher.setInverted(config.isInverted());
matcher.setField(config.getField());
matcher.setRegex(config.getRegex());
return matcher;
}

private record CompiledValidationResultIgnoreRule(String id, Matcher matcher) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,22 @@
public class ValidationService {
private static final Logger logger = LoggerFactory.getLogger(ValidationService.class);
private final FhirValidator fhirValidator;
private final ValidationResultIgnoreService validationResultIgnoreService;


public ValidationService(FhirContext fhirContext, ArtifactService artifactService, LinkConfig linkConfig, ValidationCacheService validationCacheService) throws IOException {
public ValidationService(
FhirContext fhirContext,
ArtifactService artifactService,
LinkConfig linkConfig,
ValidationCacheService validationCacheService,
ValidationResultIgnoreService validationResultIgnoreService) throws IOException {
ValidationSupportChain validationSupportChain = new ValidationSupportChain(
new DefaultProfileValidationSupport(fhirContext),
artifactService.getValidationSupport(),
new SnapshotGeneratingValidationSupport(fhirContext));

this.validationResultIgnoreService = validationResultIgnoreService;

loadTerminologyValidationSupport(fhirContext, linkConfig, validationSupportChain, validationCacheService);

CachingValidationSupport cachingValidationSupport = new CachingValidationSupport(validationSupportChain);
Expand Down Expand Up @@ -81,7 +89,7 @@ public List<Result> validate(IBaseResource resource) {
List<Result> results = validationResult.getMessages().stream()
.map(Result::fromMessage)
.toList();
return deduplicateInactiveResults(results);
return validationResultIgnoreService.filterIgnored(deduplicateInactiveResults(results));
} catch (Exception ex) {
logger.error("Validation failed", ex);
throw ex;
Expand Down
21 changes: 21 additions & 0 deletions Java/validation/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,27 @@ authentication:
link:
info-route: /api/validation/info
terminology-service-url: ''
validation-result-ignore-rules:
- id: ignore_measureeval_measure_report_population_description_invalid_version
description: Ignore known MeasureEval-generated MeasureReport population description extension version warnings
matcher:
field: EXPRESSION
regex: "\\.extension\\[[0-9]+\\]\\[url='http://hl7\\.org/fhir/5\\.0/StructureDefinition/extension-MeasureReport\\.population\\.description'\\]$"
- id: ignore_sde_reference_extension
description: Ignore known MeasureReport.supplementalDataElement.reference extension
matcher:
field: EXPRESSION
regex: "\\.extension\\[[0-9]+\\]\\[url='http://hl7\\.org/fhir/5\\.0/StructureDefinition/extension-MeasureReport\\.supplementalDataElement\\.reference'\\]$"
Comment on lines +114 to +124

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Constrain these rules to the known false-positive warning.

Each rule matches only EXPRESSION. It can therefore discard any validation error at the same extension path. ValidationResultIgnoreService removes matched results before categorization, persistence, and validity calculation.

Use a composite matcher that requires the target expression and the specific expected message or severity. Add a nearby non-matching error case to the rule tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Java/validation/src/main/resources/application.yml` around lines 114 - 124,
Update the validation-result-ignore-rules entries
ignore_measureeval_measure_report_population_description_invalid_version and
ignore_sde_reference_extension to use composite matchers requiring both the
existing EXPRESSION path and the specific known false-positive message or
severity. Update the associated rule tests with nearby errors that match the
path but differ in message or severity, and verify those results are not
ignored.

- id: ignore_measureeval_measure_report_population_description_unknown_extension
description: Ignore known MeasureEval-generated MeasureReport population description unknown extension warnings
matcher:
field: MESSAGE
regex: "^Unknown extension http://hl7\\.org/fhir/5\\.0/StructureDefinition/extension-MeasureReport\\.population\\.description$"
- id: ignore_deprecated_criteria_reference_extension
description: Ignore deprecated criteriaReference extension warnings from measure-generated content
matcher:
field: MESSAGE
regex: "^The extension http://hl7\\.org/fhir/us/davinci-deqm/StructureDefinition/extension-criteriaReference\\|5\\.0\\.0 is deprecated$"
# Retry for all HAPI FHIR REST clients (terminology validate-code/$lookup/etc.).
# Transient failures only: connection IOExceptions, HTTP 429, and 5xx (e.g. Envoy
# "503 no healthy upstream" during a terminology-service rollout). 4xx never retries.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,17 @@ void process_savesAllResultsToRepository() throws Exception {
verify(resultRepository).saveAll(List.of(result));
}

@Test
void process_withNoResults_stillCategorizesAndPersistsEmptyList() throws Exception {
stubRestRetrieval();
when(validationService.validate(bundle)).thenReturn(Collections.emptyList());

consumer.process(buildRecord(null));

verify(categorizationService).categorize(Collections.emptyList());
verify(resultRepository).saveAll(Collections.emptyList());
}

@Test
void process_inactiveCodeResult_isCategorizedAsInactiveCodeAndPersisted() throws Exception {
// Wire a real CategorizationService backed by the shipped categories.json so this exercises the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.lantanagroup.link.validation.services;

import com.lantanagroup.link.validation.configs.LinkConfig;
import com.lantanagroup.link.validation.configs.ValidationResultIgnoreRuleConfig;
import com.lantanagroup.link.validation.entities.Result;
import com.lantanagroup.link.validation.entities.ResultField;
import org.hl7.fhir.r4.model.OperationOutcome;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;

class ValidationResultIgnoreServiceTest {

@Test
void filterIgnored_removesResultWhenAnyRuleMatches() {
LinkConfig config = new LinkConfig();
config.setValidationResultIgnoreRules(List.of(messageRule("ignore_deprecated", "deprecated")));
ValidationResultIgnoreService service = new ValidationResultIgnoreService(config);

Result ignored = new Result();
ignored.setMessage("The extension http://example is deprecated");

Result kept = new Result();
kept.setMessage("A different validation message");

List<Result> filtered = service.filterIgnored(List.of(ignored, kept));

assertEquals(1, filtered.size());
assertEquals(kept, filtered.get(0));
}

@Test
void getFirstMatchingRule_requiresAllConfiguredFieldsToMatch() {
LinkConfig config = new LinkConfig();
config.setValidationResultIgnoreRules(List.of(expressionAndMessageRule()));
ValidationResultIgnoreService service = new ValidationResultIgnoreService(config);

Result result = new Result();
result.setExpression("Bundle.entry[0].resource.ofType(MeasureReport).extension[0][url='http://hl7.org/fhir/5.0/StructureDefinition/extension-MeasureReport.population.description']");
result.setMessage("Unknown extension http://hl7.org/fhir/5.0/StructureDefinition/extension-MeasureReport.population.description");

String ruleId = service.getFirstMatchingRuleId(result);

assertNotNull(ruleId);
assertEquals("ignore_measure_report_population_description", ruleId);
}

@Test
void getFirstMatchingRule_returnsNullWhenOneRequiredFieldDoesNotMatch() {
LinkConfig config = new LinkConfig();
config.setValidationResultIgnoreRules(List.of(expressionAndMessageRule()));
ValidationResultIgnoreService service = new ValidationResultIgnoreService(config);

Result result = new Result();
result.setExpression("Bundle.entry[0].resource.ofType(Patient).extension[0]");
result.setMessage("Unknown extension http://hl7.org/fhir/5.0/StructureDefinition/extension-MeasureReport.population.description");
result.setSeverity(OperationOutcome.IssueSeverity.INFORMATION);

assertNull(service.getFirstMatchingRuleId(result));
}

private static ValidationResultIgnoreRuleConfig messageRule(String id, String regex) {
ValidationResultIgnoreRuleConfig rule = new ValidationResultIgnoreRuleConfig();
rule.setId(id);
ValidationResultIgnoreRuleConfig.MatcherConfig matcher = new ValidationResultIgnoreRuleConfig.MatcherConfig();
matcher.setField(ResultField.MESSAGE);
matcher.setRegex(regex);
rule.setMatcher(matcher);
return rule;
}

private static ValidationResultIgnoreRuleConfig expressionAndMessageRule() {
ValidationResultIgnoreRuleConfig rule = new ValidationResultIgnoreRuleConfig();
rule.setId("ignore_measure_report_population_description");

ValidationResultIgnoreRuleConfig.MatcherConfig expressionMatcher = new ValidationResultIgnoreRuleConfig.MatcherConfig();
expressionMatcher.setField(ResultField.EXPRESSION);
expressionMatcher.setRegex("Bundle\\.entry\\[[0-9]+\\]\\.resource\\.ofType\\(MeasureReport\\)\\.extension\\[[0-9]+\\]\\[url='http://hl7\\.org/fhir/5\\.0/StructureDefinition/extension-MeasureReport\\.population\\.description'\\]");

ValidationResultIgnoreRuleConfig.MatcherConfig messageMatcher = new ValidationResultIgnoreRuleConfig.MatcherConfig();
messageMatcher.setField(ResultField.MESSAGE);
messageMatcher.setRegex("^Unknown extension http://hl7\\.org/fhir/5\\.0/StructureDefinition/extension-MeasureReport\\.population\\.description$");

ValidationResultIgnoreRuleConfig.MatcherConfig compositeMatcher = new ValidationResultIgnoreRuleConfig.MatcherConfig();
compositeMatcher.setChildren(List.of(expressionMatcher, messageMatcher));
compositeMatcher.setRequiresAllChildren(true);
rule.setMatcher(compositeMatcher);
return rule;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import ca.uhn.fhir.context.support.DefaultProfileValidationSupport;
import ca.uhn.fhir.context.support.IValidationSupport;
import com.lantanagroup.link.validation.configs.LinkConfig;
import com.lantanagroup.link.validation.configs.ValidationResultIgnoreRuleConfig;
import com.lantanagroup.link.validation.entities.Result;
import com.lantanagroup.link.validation.entities.ResultField;
import com.lantanagroup.link.validation.providers.RemoteTermServiceValidation;
import com.lantanagroup.link.validation.providers.ValidationCacheService;
import org.hl7.fhir.common.hapi.validation.support.CommonCodeSystemsTerminologyService;
Expand All @@ -18,6 +20,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

Expand Down Expand Up @@ -125,6 +128,34 @@ void deduplicateInactiveResults_collapsesDuplicateInactiveWarningsPerElement() {
assertTrue(deduplicated.contains(inactiveDifferentElement), "inactive on a different element preserved");
}

@Test
void validationResultIgnoreService_doesNotMatchWhenNoRulesConfigured() {
LinkConfig config = mock(LinkConfig.class);
when(config.getValidationResultIgnoreRules()).thenReturn(null);

ValidationResultIgnoreService service = new ValidationResultIgnoreService(config);

assertNull(service.getFirstMatchingRuleId(result("expr", "1:1", "message")));
}

@Test
void validationResultIgnoreService_matchesConfiguredMessageRule() {
ValidationResultIgnoreRuleConfig rule = new ValidationResultIgnoreRuleConfig();
rule.setId("ignore_deprecated");
ValidationResultIgnoreRuleConfig.MatcherConfig matcher = new ValidationResultIgnoreRuleConfig.MatcherConfig();
matcher.setField(ResultField.MESSAGE);
matcher.setRegex("deprecated");
rule.setMatcher(matcher);

LinkConfig config = mock(LinkConfig.class);
when(config.getValidationResultIgnoreRules()).thenReturn(List.of(rule));

ValidationResultIgnoreService service = new ValidationResultIgnoreService(config);

Result result = result("expr", "1:1", "This extension is deprecated");
assertEquals("ignore_deprecated", service.getFirstMatchingRuleId(result));
}

private static Result result(String expression, String location, String message) {
Result result = new Result();
result.setExpression(expression);
Expand Down
Loading