Skip to content

Commit 63a0ceb

Browse files
authored
Allow excluding traffic by matching headers (#5)
1 parent d70f4b9 commit 63a0ceb

File tree

8 files changed

+132
-4
lines changed

8 files changed

+132
-4
lines changed

README.md

+3
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ openapi.validation.specification-file-path=folder/my-spec.json
8888

8989
# Comma separated list of paths to be excluded from validation. Default is no excluded paths
9090
openapi.validation.excluded-paths=/_readiness,/_liveness,/_metrics
91+
# Allows to exclude requests based on headers. Default is no excluded headers.
92+
# Each entry is the header plus a matching regex. The regex is case insensitive.
93+
openapi.validation.excluded-headers[0]=User-Agent: .*(bingbot|googlebot).*
9194

9295
# Throttle the validation reporting (logs & metrics) to a maximum of 1 log/metric per 10 seconds.
9396
# Default is null which results in no throttling.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.getyourguide.openapi.validation.api.exclusions;
2+
3+
import java.util.regex.Pattern;
4+
5+
public record ExcludedHeader(String headerName, Pattern headerValuePattern) { }

openapi-validation-api/src/main/java/com/getyourguide/openapi/validation/api/selector/DefaultTrafficSelector.java

+19-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package com.getyourguide.openapi.validation.api.selector;
22

3+
import com.getyourguide.openapi.validation.api.exclusions.ExcludedHeader;
34
import com.getyourguide.openapi.validation.api.model.RequestMetaData;
45
import com.getyourguide.openapi.validation.api.model.ResponseMetaData;
6+
import java.util.Collections;
7+
import java.util.List;
58
import java.util.Set;
69
import java.util.concurrent.ThreadLocalRandom;
710

@@ -11,21 +14,24 @@ public class DefaultTrafficSelector implements TrafficSelector {
1114

1215
private final double sampleRate;
1316
private final Set<String> excludedPaths;
17+
private final List<ExcludedHeader> excludedHeaders;
1418
private final Boolean shouldFailOnRequestViolation;
1519
private final Boolean shouldFailOnResponseViolation;
1620

17-
public DefaultTrafficSelector(Double sampleRate, Set<String> excludedPaths) {
18-
this(sampleRate, excludedPaths, null, null);
21+
public DefaultTrafficSelector(Double sampleRate, Set<String> excludedPaths, List<ExcludedHeader> excludedHeaders) {
22+
this(sampleRate, excludedPaths, excludedHeaders, null, null);
1923
}
2024

2125
public DefaultTrafficSelector(
2226
Double sampleRate,
2327
Set<String> excludedPaths,
28+
List<ExcludedHeader> excludedHeaders,
2429
Boolean shouldFailOnRequestViolation,
2530
Boolean shouldFailOnResponseViolation
2631
) {
2732
this.sampleRate = sampleRate != null ? sampleRate : SAMPLE_RATE_DEFAULT;
2833
this.excludedPaths = excludedPaths != null ? excludedPaths : Set.of();
34+
this.excludedHeaders = excludedHeaders != null ? excludedHeaders : Collections.emptyList();
2935
this.shouldFailOnRequestViolation = shouldFailOnRequestViolation != null ? shouldFailOnRequestViolation : false;
3036
this.shouldFailOnResponseViolation =
3137
shouldFailOnResponseViolation != null ? shouldFailOnResponseViolation : false;
@@ -65,6 +71,17 @@ public boolean shouldFailOnResponseViolation(RequestMetaData request) {
6571
}
6672

6773
private boolean isExcludedRequest(RequestMetaData request) {
74+
return isRequestExcludedByHeader(request) || isRequestExcludedByPath(request);
75+
}
76+
77+
private boolean isRequestExcludedByHeader(RequestMetaData request) {
78+
return excludedHeaders.stream().anyMatch(excludedHeader -> {
79+
var headerValue = request.getHeaders().get(excludedHeader.headerName());
80+
return headerValue != null && excludedHeader.headerValuePattern().matcher(headerValue).matches();
81+
});
82+
}
83+
84+
private boolean isRequestExcludedByPath(RequestMetaData request) {
6885
return excludedPaths.contains(request.getUri().getPath());
6986
}
7087

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.getyourguide.openapi.validation.api.selector;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
5+
import com.getyourguide.openapi.validation.api.exclusions.ExcludedHeader;
6+
import com.getyourguide.openapi.validation.api.model.RequestMetaData;
7+
import java.net.URI;
8+
import java.util.List;
9+
import java.util.Map;
10+
import java.util.TreeMap;
11+
import java.util.regex.Pattern;
12+
import org.junit.jupiter.api.Test;
13+
14+
class DefaultTrafficSelectorTest {
15+
private final TrafficSelector trafficSelector = new DefaultTrafficSelector(
16+
1.0,
17+
null,
18+
List.of(
19+
new ExcludedHeader("User-Agent", Pattern.compile(".*(bingbot|googlebot).*", Pattern.CASE_INSENSITIVE)),
20+
new ExcludedHeader("x-is-bot", Pattern.compile("true", Pattern.CASE_INSENSITIVE))
21+
)
22+
);
23+
24+
@Test
25+
public void testIsExcludedByHeaderPattern() {
26+
assertHeaderIsExcluded(true,
27+
"user-Agent", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)");
28+
assertHeaderIsExcluded(false,
29+
"User-Agent",
30+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36");
31+
32+
assertHeaderIsExcluded(true, "x-is-bot", "true");
33+
assertHeaderIsExcluded(false, "x-is-bot", "truebot");
34+
35+
}
36+
37+
private void assertHeaderIsExcluded(boolean expectedExclusion, String headerName, String headerValue) {
38+
39+
var request = new RequestMetaData(
40+
"GET",
41+
URI.create("https://api.example.com/v1/path"),
42+
toCaseInsensitiveMap(Map.of(
43+
"Content-Type", "application/json",
44+
"Content-Length", "10",
45+
headerName, headerValue
46+
))
47+
);
48+
assertEquals(!expectedExclusion, trafficSelector.shouldRequestBeValidated(request));
49+
}
50+
51+
private Map<String, String> toCaseInsensitiveMap(Map<String, String> map) {
52+
var newMap = new TreeMap<String, String>(String.CASE_INSENSITIVE_ORDER);
53+
newMap.putAll(map);
54+
return newMap;
55+
}
56+
}

spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationProperties.java

+22
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22

33
import static com.getyourguide.openapi.validation.OpenApiValidationApplicationProperties.PROPERTY_PREFIX;
44

5+
import com.getyourguide.openapi.validation.api.exclusions.ExcludedHeader;
56
import com.getyourguide.openapi.validation.api.metrics.MetricTag;
67
import com.getyourguide.openapi.validation.util.CommaSeparatedStringsUtil;
78
import java.util.Arrays;
9+
import java.util.Collections;
810
import java.util.List;
11+
import java.util.Objects;
912
import java.util.Set;
13+
import java.util.regex.Pattern;
1014
import lombok.AllArgsConstructor;
1115
import lombok.Getter;
1216
import lombok.NoArgsConstructor;
@@ -27,6 +31,7 @@ public class OpenApiValidationApplicationProperties {
2731
private String validationReportMetricName;
2832
private String validationReportMetricAdditionalTags;
2933
private String excludedPaths;
34+
private List<String> excludedHeaders;
3035
private Boolean shouldFailOnRequestViolation;
3136
private Boolean shouldFailOnResponseViolation;
3237

@@ -49,4 +54,21 @@ public List<MetricTag> getValidationReportMetricAdditionalTags() {
4954
public Set<String> getExcludedPathsAsSet() {
5055
return CommaSeparatedStringsUtil.convertCommaSeparatedStringToSet(excludedPaths);
5156
}
57+
58+
public List<ExcludedHeader> getExcludedHeaders() {
59+
if (excludedHeaders == null) {
60+
return Collections.emptyList();
61+
}
62+
63+
return excludedHeaders.stream()
64+
.map(header -> {
65+
var parts = header.split(":", 2);
66+
if (parts.length != 2) {
67+
return null;
68+
}
69+
return new ExcludedHeader(parts[0].trim(), Pattern.compile(parts[1].trim(), Pattern.CASE_INSENSITIVE));
70+
})
71+
.filter(Objects::nonNull)
72+
.toList();
73+
}
5274
}

spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/FallbackLibraryAutoConfiguration.java

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public TrafficSelector defaultTrafficSelector() {
2424
return new DefaultTrafficSelector(
2525
properties.getSampleRate(),
2626
properties.getExcludedPathsAsSet(),
27+
properties.getExcludedHeaders(),
2728
properties.getShouldFailOnRequestViolation(),
2829
properties.getShouldFailOnResponseViolation()
2930
);

spring-boot-starter/spring-boot-starter-core/src/main/resources/META-INF/spring-configuration-metadata.json

+5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
"type": "java.lang.String",
1616
"description": "Comma separated list of paths to be excluded from validation. Default is no excluded paths."
1717
},
18+
{
19+
"name": "openapi.validation.excluded-headers",
20+
"type": "java.util.List<java.lang.String>",
21+
"description": "Headers with patterns to be excluded. e.g. `User-Agent: .*(bingbot|googlebot).*`. Default is no excluded paths."
22+
},
1823
{
1924
"name": "openapi.validation.validation-report-throttle-wait-seconds",
2025
"type": "java.lang.Integer",

spring-boot-starter/spring-boot-starter-core/src/test/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationPropertiesTest.java

+21-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import static org.junit.jupiter.api.Assertions.assertFalse;
55
import static org.junit.jupiter.api.Assertions.assertTrue;
66

7+
import com.getyourguide.openapi.validation.api.exclusions.ExcludedHeader;
78
import com.getyourguide.openapi.validation.api.metrics.MetricTag;
89
import java.util.List;
910
import java.util.Set;
@@ -17,6 +18,7 @@ class OpenApiValidationApplicationPropertiesTest {
1718
private static final String VALIDATION_REPORT_METRIC_NAME = "openapi_validation_error";
1819
private static final String VALIDATION_REPORT_METRIC_ADDITONAL_TAGS_STRING = "service=payment,team=chk";
1920
private static final String EXCLUDED_PATHS = "/_readiness,/_liveness,/_metrics";
21+
private static final List<String> EXCLUDED_HEADERS = List.of("User-Agent: .*(bingbot|googlebot).*", "x-is-bot: true");
2022

2123
@Test
2224
void getters() {
@@ -27,21 +29,38 @@ void getters() {
2729
VALIDATION_REPORT_METRIC_NAME,
2830
VALIDATION_REPORT_METRIC_ADDITONAL_TAGS_STRING,
2931
EXCLUDED_PATHS,
32+
EXCLUDED_HEADERS,
3033
true,
3134
false
3235
);
3336

3437
assertEquals(SAMPLE_RATE, loggingConfiguration.getSampleRate());
3538
assertEquals(SPECIFICATION_FILE_PATH, loggingConfiguration.getSpecificationFilePath());
36-
assertEquals(VALIDATION_REPORT_THROTTLE_WAIT_SECONDS, loggingConfiguration.getValidationReportThrottleWaitSeconds());
39+
assertEquals(VALIDATION_REPORT_THROTTLE_WAIT_SECONDS,
40+
loggingConfiguration.getValidationReportThrottleWaitSeconds());
3741
assertEquals(VALIDATION_REPORT_METRIC_NAME, loggingConfiguration.getValidationReportMetricName());
3842
assertEquals(
3943
List.of(new MetricTag("service", "payment"), new MetricTag("team", "chk")),
4044
loggingConfiguration.getValidationReportMetricAdditionalTags()
4145
);
4246
assertEquals(EXCLUDED_PATHS, loggingConfiguration.getExcludedPaths());
43-
assertEquals(Set.of("/_readiness","/_liveness","/_metrics"), loggingConfiguration.getExcludedPathsAsSet());
47+
assertExcludedHeaders(loggingConfiguration.getExcludedHeaders());
48+
assertEquals(Set.of("/_readiness", "/_liveness", "/_metrics"), loggingConfiguration.getExcludedPathsAsSet());
4449
assertTrue(loggingConfiguration.getShouldFailOnRequestViolation());
4550
assertFalse(loggingConfiguration.getShouldFailOnResponseViolation());
4651
}
52+
53+
private void assertExcludedHeaders(List<ExcludedHeader> excludedHeaders) {
54+
assertEquals(EXCLUDED_HEADERS.size(), excludedHeaders.size());
55+
for (int i = 0; i < EXCLUDED_HEADERS.size(); i++) {
56+
assertExcludedHeader(excludedHeaders, i);
57+
}
58+
}
59+
60+
private static void assertExcludedHeader(List<ExcludedHeader> excludedHeaders, int index) {
61+
var excludedHeader = EXCLUDED_HEADERS.get(index);
62+
var parts = excludedHeader.split(":");
63+
assertEquals(parts[0].trim(), excludedHeaders.get(index).headerName());
64+
assertEquals(parts[1].trim(), excludedHeaders.get(index).headerValuePattern().pattern());
65+
}
4766
}

0 commit comments

Comments
 (0)