Skip to content

Commit 0cf5239

Browse files
authored
xds: Implementation of Unified Matcher (#12640)
This commit contains only Unified Matcher part of gRFC A106: grpc/proposal#520
1 parent 036c7f0 commit 0cf5239

26 files changed

Lines changed: 3718 additions & 26 deletions

xds/src/main/java/io/grpc/xds/internal/MatcherParser.java

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,47 @@ public static Matchers.StringMatcher parseStringMatcher(
101101
return Matchers.StringMatcher.forSafeRegEx(
102102
Pattern.compile(proto.getSafeRegex().getRegex()));
103103
case CONTAINS:
104-
return Matchers.StringMatcher.forContains(proto.getContains());
104+
return Matchers.StringMatcher.forContains(proto.getContains(), proto.getIgnoreCase());
105105
case MATCHPATTERN_NOT_SET:
106106
default:
107107
throw new IllegalArgumentException(
108108
"Unknown StringMatcher match pattern: " + proto.getMatchPatternCase());
109109
}
110110
}
111111

112+
/** Translate StringMatcher xDS proto to internal StringMatcher. */
113+
public static Matchers.StringMatcher parseStringMatcher(
114+
com.github.xds.type.matcher.v3.StringMatcher proto) {
115+
switch (proto.getMatchPatternCase()) {
116+
case EXACT:
117+
return Matchers.StringMatcher.forExact(proto.getExact(), proto.getIgnoreCase());
118+
case PREFIX:
119+
return Matchers.StringMatcher.forPrefix(
120+
checkNonEmpty(proto.getPrefix(), "prefix"), proto.getIgnoreCase());
121+
case SUFFIX:
122+
return Matchers.StringMatcher.forSuffix(
123+
checkNonEmpty(proto.getSuffix(), "suffix"), proto.getIgnoreCase());
124+
case SAFE_REGEX:
125+
String regex = checkNonEmpty(proto.getSafeRegex().getRegex(), "regex");
126+
return Matchers.StringMatcher.forSafeRegEx(Pattern.compile(regex));
127+
case CONTAINS:
128+
return Matchers.StringMatcher.forContains(
129+
checkNonEmpty(proto.getContains(), "contains"), proto.getIgnoreCase());
130+
case MATCHPATTERN_NOT_SET:
131+
default:
132+
throw new IllegalArgumentException(
133+
"Unknown StringMatcher match pattern: " + proto.getMatchPatternCase());
134+
}
135+
}
136+
137+
private static String checkNonEmpty(String value, String name) {
138+
if (value.isEmpty()) {
139+
throw new IllegalArgumentException("StringMatcher " + name
140+
+ " (match_pattern) must be non-empty");
141+
}
142+
return value;
143+
}
144+
112145
/** Translates envoy proto FractionalPercent to internal FractionMatcher. */
113146
public static Matchers.FractionMatcher parseFractionMatcher(
114147
io.envoyproxy.envoy.type.v3.FractionalPercent proto) {

xds/src/main/java/io/grpc/xds/internal/Matchers.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,10 +257,15 @@ public static StringMatcher forSafeRegEx(Pattern regEx) {
257257
}
258258

259259
/** The input string should contain this substring. */
260-
public static StringMatcher forContains(String contains) {
260+
public static StringMatcher forContains(String contains, boolean ignoreCase) {
261261
checkNotNull(contains, "contains");
262262
return StringMatcher.create(null, null, null, null, contains,
263-
false/* doesn't matter */);
263+
ignoreCase);
264+
}
265+
266+
/** The input string should contain this substring. */
267+
public static StringMatcher forContains(String contains) {
268+
return forContains(contains, false);
264269
}
265270

266271
/** Returns the matching result for this string. */
@@ -281,7 +286,9 @@ public boolean matches(String args) {
281286
? args.toLowerCase(Locale.ROOT).endsWith(suffix().toLowerCase(Locale.ROOT))
282287
: args.endsWith(suffix());
283288
} else if (contains() != null) {
284-
return args.contains(contains());
289+
return ignoreCase()
290+
? args.toLowerCase(Locale.ROOT).contains(contains().toLowerCase(Locale.ROOT))
291+
: args.contains(contains());
285292
}
286293
return regEx().matches(args);
287294
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright 2026 The gRPC Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.grpc.xds.internal.matcher;
18+
19+
import dev.cel.common.CelAbstractSyntaxTree;
20+
import dev.cel.common.types.SimpleType;
21+
import dev.cel.runtime.CelEvaluationException;
22+
import dev.cel.runtime.CelRuntime;
23+
import dev.cel.runtime.CelVariableResolver;
24+
25+
/**
26+
* Executes compiled CEL expressions.
27+
*/
28+
final class CelMatcher {
29+
private final CelRuntime.Program program;
30+
31+
private CelMatcher(CelRuntime.Program program) {
32+
this.program = program;
33+
}
34+
35+
/**
36+
* Compiles the AST into a CelMatcher.
37+
* Throws an Exception if evaluation fails during compilation setup.
38+
*/
39+
static CelMatcher compile(CelAbstractSyntaxTree ast)
40+
throws CelEvaluationException {
41+
// CelEvaluationException -> inside cel-runtime -> Allowed in production signatures
42+
// CelValidationException -> inside cel-compiler -> Forbidden in production signatures
43+
if (ast.getResultType() != SimpleType.BOOL) {
44+
throw new IllegalArgumentException(
45+
"CEL expression must evaluate to boolean, got: " + ast.getResultType());
46+
}
47+
CelCommon.checkAllowedReferences(ast);
48+
CelRuntime.Program program = CelCommon.RUNTIME.createProgram(ast);
49+
return new CelMatcher(program);
50+
}
51+
52+
/**
53+
* Evaluates the CEL expression against the input activation.
54+
*/
55+
boolean match(Object input) throws CelEvaluationException {
56+
Object result;
57+
if (input instanceof CelVariableResolver) {
58+
result = program.eval((CelVariableResolver) input);
59+
} else {
60+
throw new CelEvaluationException(
61+
"Unsupported input type for CEL evaluation: "
62+
+ (input == null ? "null" : input.getClass().getName()));
63+
}
64+
65+
if (result instanceof Boolean) {
66+
return (Boolean) result;
67+
}
68+
throw new CelEvaluationException(
69+
"CEL expression must evaluate to boolean, got: " + result.getClass().getName());
70+
}
71+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright 2026 The gRPC Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.grpc.xds.internal.matcher;
18+
19+
import com.github.xds.core.v3.TypedExtensionConfig;
20+
import com.github.xds.type.v3.CelExpression;
21+
import dev.cel.common.CelAbstractSyntaxTree;
22+
import dev.cel.common.CelProtoAbstractSyntaxTree;
23+
import dev.cel.runtime.CelEvaluationException;
24+
25+
/**
26+
* Matcher for CEL expressions handling xDS CEL Matcher extension.
27+
*/
28+
final class CelStateMatcher implements Matcher {
29+
private final CelMatcher compiledEndpoint;
30+
static final String TYPE_URL = "type.googleapis.com/xds.type.matcher.v3.CelMatcher";
31+
32+
CelStateMatcher(CelMatcher compiledEndpoint) {
33+
this.compiledEndpoint = compiledEndpoint;
34+
}
35+
36+
@Override
37+
public boolean match(Object value) {
38+
try {
39+
return compiledEndpoint.match(value);
40+
} catch (CelEvaluationException e) {
41+
return false;
42+
}
43+
}
44+
45+
@Override
46+
public Class<?> inputType() {
47+
return GrpcCelEnvironment.class;
48+
}
49+
50+
static final class Provider implements MatcherProvider {
51+
@Override
52+
public CelStateMatcher getMatcher(TypedExtensionConfig config) {
53+
try {
54+
com.github.xds.type.matcher.v3.CelMatcher celProto = config.getTypedConfig()
55+
.unpack(com.github.xds.type.matcher.v3.CelMatcher.class);
56+
if (!celProto.hasExprMatch()) {
57+
throw new IllegalArgumentException("CelMatcher must have expr_match");
58+
}
59+
CelExpression expr = celProto.getExprMatch();
60+
if (!expr.hasCelExprChecked()) {
61+
throw new IllegalArgumentException("CelMatcher must have cel_expr_checked");
62+
}
63+
CelAbstractSyntaxTree ast =
64+
CelProtoAbstractSyntaxTree.fromCheckedExpr(
65+
expr.getCelExprChecked()).getAst();
66+
CelMatcher compiled = CelMatcher.compile(ast);
67+
68+
return new CelStateMatcher(compiled);
69+
} catch (Exception e) {
70+
throw new IllegalArgumentException("Invalid CelMatcher config", e);
71+
}
72+
}
73+
74+
@Override
75+
public String typeUrl() {
76+
return TYPE_URL;
77+
}
78+
}
79+
}

xds/src/main/java/io/grpc/xds/internal/matcher/CelStringExtractor.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
/**
2727
* Executes compiled CEL expressions that extract a string.
2828
*/
29-
public final class CelStringExtractor {
29+
final class CelStringExtractor {
3030
private final CelRuntime.Program program;
3131
@Nullable
3232
private final String defaultValue;
@@ -40,7 +40,7 @@ private CelStringExtractor(CelRuntime.Program program, @Nullable String defaultV
4040
* Compiles the AST into a CelStringExtractor with an optional default value.
4141
* Throws an Exception if evaluation fails during compilation setup.
4242
*/
43-
public static CelStringExtractor compile(CelAbstractSyntaxTree ast, @Nullable String defaultValue)
43+
static CelStringExtractor compile(CelAbstractSyntaxTree ast, @Nullable String defaultValue)
4444
throws CelEvaluationException {
4545
if (ast.getResultType() != SimpleType.STRING && ast.getResultType() != SimpleType.DYN) {
4646
throw new IllegalArgumentException(
@@ -55,7 +55,7 @@ public static CelStringExtractor compile(CelAbstractSyntaxTree ast, @Nullable St
5555
* Compiles the AST into a CelStringExtractor with no default value.
5656
* Throws an Exception if evaluation fails during compilation setup.
5757
*/
58-
public static CelStringExtractor compile(CelAbstractSyntaxTree ast)
58+
static CelStringExtractor compile(CelAbstractSyntaxTree ast)
5959
throws CelEvaluationException {
6060
return compile(ast, null);
6161
}
@@ -65,7 +65,7 @@ public static CelStringExtractor compile(CelAbstractSyntaxTree ast)
6565
* Returns the default value if the result is not a string or if evaluation
6666
* fails.
6767
*/
68-
public String extract(Object input) throws CelEvaluationException {
68+
String extract(Object input) throws CelEvaluationException {
6969
if (input instanceof CelVariableResolver) {
7070
try {
7171
Object result = program.eval((CelVariableResolver) input);
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
* Copyright 2026 The gRPC Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.grpc.xds.internal.matcher;
18+
19+
import static com.google.common.base.Preconditions.checkNotNull;
20+
21+
import com.github.xds.core.v3.TypedExtensionConfig;
22+
import com.google.common.io.BaseEncoding;
23+
import com.google.protobuf.InvalidProtocolBufferException;
24+
import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput;
25+
import io.grpc.Metadata;
26+
import java.util.Locale;
27+
28+
/**
29+
* MatchInput for extracting HTTP headers.
30+
*/
31+
final class HeaderMatchInput implements MatchInput {
32+
private static final BaseEncoding BASE64 = BaseEncoding.base64();
33+
private final String headerName;
34+
private final Metadata.Key<byte[]> binaryKey;
35+
private final Metadata.Key<String> stringKey;
36+
37+
static final String TYPE_URL =
38+
"type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput";
39+
40+
HeaderMatchInput(String headerName) {
41+
this.headerName = checkNotNull(headerName, "headerName");
42+
if (headerName.isEmpty() || headerName.length() >= 16384) {
43+
throw new IllegalArgumentException(
44+
"Header name length must be in range [1, 16384): " + headerName.length());
45+
}
46+
if (!headerName.equals(headerName.toLowerCase(Locale.ROOT))) {
47+
throw new IllegalArgumentException("Header name must be lowercase: " + headerName);
48+
}
49+
try {
50+
if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) {
51+
this.binaryKey = Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER);
52+
this.stringKey = null;
53+
} else {
54+
this.binaryKey = null;
55+
this.stringKey = Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER);
56+
}
57+
} catch (IllegalArgumentException e) {
58+
throw new IllegalArgumentException("Invalid header name: " + headerName, e);
59+
}
60+
}
61+
62+
@Override
63+
public String apply(MatchContext context) {
64+
if ("te".equals(headerName)) {
65+
return null;
66+
}
67+
if (binaryKey != null) {
68+
Iterable<byte[]> values = context.getMetadata().getAll(binaryKey);
69+
if (values == null) {
70+
return null;
71+
}
72+
StringBuilder sb = new StringBuilder();
73+
boolean first = true;
74+
for (byte[] value : values) {
75+
if (!first) {
76+
sb.append(",");
77+
}
78+
first = false;
79+
sb.append(BASE64.encode(value));
80+
}
81+
return sb.toString();
82+
}
83+
Metadata metadata = context.getMetadata();
84+
Iterable<String> values = metadata.getAll(stringKey);
85+
if (values == null) {
86+
return null;
87+
}
88+
return String.join(",", values);
89+
}
90+
91+
@Override
92+
public Class<?> outputType() {
93+
return String.class;
94+
}
95+
96+
static final class Provider implements MatchInputProvider {
97+
@Override
98+
public HeaderMatchInput getInput(TypedExtensionConfig config) {
99+
try {
100+
HttpRequestHeaderMatchInput proto = config.getTypedConfig()
101+
.unpack(HttpRequestHeaderMatchInput.class);
102+
return new HeaderMatchInput(proto.getHeaderName());
103+
} catch (InvalidProtocolBufferException e) {
104+
throw new IllegalArgumentException(
105+
"Invalid input config: " + config.getTypedConfig().getTypeUrl(), e);
106+
}
107+
}
108+
109+
@Override
110+
public String typeUrl() {
111+
return TYPE_URL;
112+
}
113+
}
114+
}

0 commit comments

Comments
 (0)