Skip to content

Commit a29b3ee

Browse files
committed
add regex matching for headers and query params
1 parent 6dc85d7 commit a29b3ee

File tree

18 files changed

+577
-111
lines changed

18 files changed

+577
-111
lines changed

examples/advanced-routing/cafe-routes.yaml

+18
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,24 @@ spec:
3131
backendRefs:
3232
- name: coffee-v2-svc
3333
port: 80
34+
- matches:
35+
- path:
36+
type: PathPrefix
37+
value: /coffee
38+
headers:
39+
- name: headerRegex
40+
type: RegularExpression
41+
value: "header-[a-z]{1}"
42+
- path:
43+
type: PathPrefix
44+
value: /coffee
45+
queryParams:
46+
- name: queryRegex
47+
type: RegularExpression
48+
value: "query-[a-z]{1}"
49+
backendRefs:
50+
- name: coffee-v3-svc
51+
port: 80
3452
---
3553
apiVersion: gateway.networking.k8s.io/v1
3654
kind: HTTPRoute

examples/advanced-routing/coffee.yaml

+33
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,36 @@ spec:
6363
name: http
6464
selector:
6565
app: coffee-v2
66+
---
67+
apiVersion: apps/v1
68+
kind: Deployment
69+
metadata:
70+
name: coffee-v3
71+
spec:
72+
replicas: 1
73+
selector:
74+
matchLabels:
75+
app: coffee-v3
76+
template:
77+
metadata:
78+
labels:
79+
app: coffee-v3
80+
spec:
81+
containers:
82+
- name: coffee-v3
83+
image: nginxdemos/nginx-hello:plain-text
84+
ports:
85+
- containerPort: 8080
86+
---
87+
apiVersion: v1
88+
kind: Service
89+
metadata:
90+
name: coffee-v3-svc
91+
spec:
92+
ports:
93+
- port: 80
94+
targetPort: 8080
95+
protocol: TCP
96+
name: http
97+
selector:
98+
app: coffee-v3

examples/grpc-routing/README.md

+14
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,20 @@ There are 3 options to configure gRPC routing. To access the application and tes
192192
2024/04/29 09:32:46 Received: version two
193193
```
194194
195+
We'll send a request with the header `headerRegex: grpc-header-a`
196+
197+
```shell
198+
grpcurl -plaintext -proto grpc.proto -authority bar.com -d '{"name": "version two regex"}' -H 'headerRegex: grpc-header-a' ${GW_IP}:${GW_PORT} helloworld.Greeter/SayHello
199+
```
200+
201+
```text
202+
{
203+
"message": "Hello version two regex"
204+
}
205+
```
206+
207+
Verify logs of `${POD_V2}` to ensure response is from the correct service.
208+
195209
Finally, we'll send a request with the headers `version: two` and `color: orange`
196210
197211
```shell

examples/grpc-routing/headers.yaml

+9
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ spec:
3636
backendRefs:
3737
- name: grpc-infra-backend-v2
3838
port: 8080
39+
# Matches "headerRegex: grpc-header-[a-z]{1}"
40+
- matches:
41+
- headers:
42+
- name: headerRegex
43+
value: "grpc-header-[a-z]{1}"
44+
type: RegularExpression
45+
backendRefs:
46+
- name: grpc-infra-backend-v2
47+
port: 8080
3948
# Matches "version: two" AND "color: orange"
4049
- matches:
4150
- headers:

internal/mode/static/nginx/config/servers.go

+16-4
Original file line numberDiff line numberDiff line change
@@ -716,19 +716,31 @@ func createRouteMatch(match dataplane.Match, redirectPath string) routeMatch {
716716
return hm
717717
}
718718

719-
// The name and values are delimited by "=". A name and value can always be recovered using strings.SplitN(arg,"=", 2).
719+
// The name, match type and values are delimited by "=".
720+
// A name, match type and value can always be recovered using strings.SplitN(arg,"=", 3).
720721
// Query Parameters are case-sensitive so case is preserved.
722+
// The match type is optional and defaults to "Exact".
721723
func createQueryParamKeyValString(p dataplane.HTTPQueryParamMatch) string {
722-
return p.Name + "=" + p.Value
724+
// this condition check is added for unit tests
725+
if p.Type == "" {
726+
p.Type = dataplane.MatchTypeExact
727+
}
728+
return p.Name + "=" + string(p.Type) + "=" + p.Value
723729
}
724730

725-
// The name and values are delimited by ":". A name and value can always be recovered using strings.Split(arg, ":").
731+
// The name, match type and values are delimited by ":".
732+
// A name, match type and value can always be recovered using strings.Split(arg, ":").
726733
// Header names are case-insensitive and header values are case-sensitive.
734+
// The match type is optional and defaults to "Exact".
727735
// Ex. foo:bar == FOO:bar, but foo:bar != foo:BAR,
728736
// We preserve the case of the name here because NGINX allows us to look up the header names in a case-insensitive
729737
// manner.
730738
func createHeaderKeyValString(h dataplane.HTTPHeaderMatch) string {
731-
return h.Name + HeaderMatchSeparator + h.Value
739+
// this condition check is added for unit tests
740+
if h.Type == "" {
741+
h.Type = dataplane.MatchTypeExact
742+
}
743+
return h.Name + HeaderMatchSeparator + string(h.Type) + HeaderMatchSeparator + h.Value
732744
}
733745

734746
func isPathOnlyMatch(match dataplane.Match) bool {

internal/mode/static/nginx/config/servers_test.go

+26-11
Original file line numberDiff line numberDiff line change
@@ -1071,23 +1071,23 @@ func TestCreateServers(t *testing.T) {
10711071
"1_1": {
10721072
{
10731073
Method: "GET",
1074-
Headers: []string{"Version:V1", "test:foo", "my-header:my-value"},
1075-
QueryParams: []string{"GrEat=EXAMPLE", "test=foo=bar"},
1074+
Headers: []string{"Version:Exact:V1", "test:Exact:foo", "my-header:Exact:my-value"},
1075+
QueryParams: []string{"GrEat=Exact=EXAMPLE", "test=Exact=foo=bar"},
10761076
RedirectPath: "/_ngf-internal-rule1-route0",
10771077
},
10781078
},
10791079
"1_6": {
1080-
{RedirectPath: "/_ngf-internal-rule6-route0", Headers: []string{"redirect:this"}},
1080+
{RedirectPath: "/_ngf-internal-rule6-route0", Headers: []string{"redirect:Exact:this"}},
10811081
},
10821082
"1_8": {
10831083
{
1084-
Headers: []string{"rewrite:this"},
1084+
Headers: []string{"rewrite:Exact:this"},
10851085
RedirectPath: "/_ngf-internal-rule8-route0",
10861086
},
10871087
},
10881088
"1_10": {
10891089
{
1090-
Headers: []string{"filter:this"},
1090+
Headers: []string{"filter:Exact:this"},
10911091
RedirectPath: "/_ngf-internal-rule10-route0",
10921092
},
10931093
},
@@ -2736,8 +2736,8 @@ func TestCreateRouteMatch(t *testing.T) {
27362736
},
27372737
}
27382738

2739-
expectedHeaders := []string{"header-1:val-1", "header-2:val-2", "header-3:val-3"}
2740-
expectedArgs := []string{"arg1=val1", "arg2=val2=another-val", "arg3===val3"}
2739+
expectedHeaders := []string{"header-1:Exact:val-1", "header-2:Exact:val-2", "header-3:Exact:val-3"}
2740+
expectedArgs := []string{"arg1=Exact=val1", "arg2=Exact=val2=another-val", "arg3=Exact===val3"}
27412741

27422742
tests := []struct {
27432743
match dataplane.Match
@@ -2858,23 +2858,25 @@ func TestCreateQueryParamKeyValString(t *testing.T) {
28582858
t.Parallel()
28592859
g := NewWithT(t)
28602860

2861-
expected := "key=value"
2861+
expected := "key=Exact=value"
28622862

28632863
result := createQueryParamKeyValString(
28642864
dataplane.HTTPQueryParamMatch{
28652865
Name: "key",
28662866
Value: "value",
2867+
Type: dataplane.MatchTypeExact,
28672868
},
28682869
)
28692870

28702871
g.Expect(result).To(Equal(expected))
28712872

2872-
expected = "KeY=vaLUe=="
2873+
expected = "KeY=RegularExpression=vaLUe-[a-z]=="
28732874

28742875
result = createQueryParamKeyValString(
28752876
dataplane.HTTPQueryParamMatch{
28762877
Name: "KeY",
2877-
Value: "vaLUe==",
2878+
Value: "vaLUe-[a-z]==",
2879+
Type: dataplane.MatchTypeRegularExpression,
28782880
},
28792881
)
28802882

@@ -2885,12 +2887,25 @@ func TestCreateHeaderKeyValString(t *testing.T) {
28852887
t.Parallel()
28862888
g := NewWithT(t)
28872889

2888-
expected := "kEy:vALUe"
2890+
expected := "kEy:Exact:vALUe"
28892891

28902892
result := createHeaderKeyValString(
28912893
dataplane.HTTPHeaderMatch{
28922894
Name: "kEy",
28932895
Value: "vALUe",
2896+
Type: dataplane.MatchTypeExact,
2897+
},
2898+
)
2899+
2900+
g.Expect(result).To(Equal(expected))
2901+
2902+
expected = "kEy:RegularExpression:vALUe-[0-9]"
2903+
2904+
result = createHeaderKeyValString(
2905+
dataplane.HTTPHeaderMatch{
2906+
Name: "kEy",
2907+
Value: "vALUe-[0-9]",
2908+
Type: dataplane.MatchTypeRegularExpression,
28942909
},
28952910
)
28962911

internal/mode/static/nginx/modules/src/httpmatches.js

+54-14
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,8 @@ function headersMatch(requestHeaders, headers) {
154154
const h = headers[i];
155155
const kv = h.split(':');
156156

157-
if (kv.length !== 2) {
157+
// header should be of the format "key:MatchType:value"
158+
if (kv.length !== 3) {
158159
throw Error(`invalid header match: ${h}`);
159160
}
160161
// Header names are compared in a case-insensitive manner, meaning header name "FOO" is equivalent to "foo".
@@ -168,8 +169,22 @@ function headersMatch(requestHeaders, headers) {
168169

169170
// split on comma because nginx uses commas to delimit multiple header values
170171
const values = val.split(',');
171-
if (!values.includes(kv[1])) {
172-
return false;
172+
173+
let type = kv[1];
174+
// verify the type of header match
175+
if (!(type == 'Exact' || type == 'RegularExpression')) {
176+
throw Error(`invalid header match type: ${type}`);
177+
}
178+
179+
// match the value based on the type
180+
if (type === 'Exact') {
181+
if (!values.includes(kv[2])) {
182+
return false;
183+
}
184+
} else if (type === 'RegularExpression') {
185+
if (!values.some((v) => new RegExp(kv[2]).test(v))) {
186+
return false;
187+
}
173188
}
174189
}
175190

@@ -179,20 +194,38 @@ function headersMatch(requestHeaders, headers) {
179194
function paramsMatch(requestParams, params) {
180195
for (let i = 0; i < params.length; i++) {
181196
let p = params[i];
182-
// We store query parameter matches as strings with the format "key=value"; however, there may be more than one
197+
// We store query parameter matches as strings with the format "key=MatchType=value"; however, there may be more than one
183198
// instance of "=" in the string.
184-
// To recover the key and value, we need to find the first occurrence of "=" in the string.
185-
const idx = params[i].indexOf('=');
186-
// Check for an improperly constructed query parameter match. There are three possible error cases:
187-
// (1) if the index is -1, then there are no "=" in the string (e.g. "keyvalue")
188-
// (2) if the index is 0, then there is no value in the string (e.g. "key=").
189-
// (3) if the index is equal to length -1, then there is no key in the string (e.g. "=value").
190-
if (idx === -1 || (idx === 0) | (idx === p.length - 1)) {
199+
// To recover the key, type and and value, we need to find the first occurrence of "=" in the string.
200+
const firstIdx = p.indexOf('=');
201+
202+
// Check for an improperly constructed query parameter match. There are two possible error cases:
203+
// (1) if the index is -1, then there are no "=" in the string (e.g. "keyExactvalue")
204+
// (2) if the index is 0, then there is no key in the string (e.g. "=Exact=value").
205+
if (firstIdx === -1 || firstIdx === 0) {
191206
throw Error(`invalid query parameter: ${p}`);
192207
}
193208

209+
// find the next occurence of "=" in the string
210+
const idx = p.indexOf('=', firstIdx + 1);
211+
212+
// Three possible error cases for improperly constructed query parameter match:
213+
// (1) if the index is -1, then there are no second occurence of "=" in the string (e.g. "Exactvalue")
214+
// (2) if the index is 0, then there is no value in the string and has only one "=" (e.g. "key=Exact").
215+
// (3) if the index is equal to length -1, then there is no key and type in the string (e.g. "=Exact=value").
216+
if (idx === -1 || idx === 0 || idx === p.length - 1) {
217+
throw Error(`invalid query parameter: ${p}`);
218+
}
219+
220+
// extract the type match from the string
221+
const type = p.slice(firstIdx + 1, idx);
222+
223+
if (!(type == 'Exact' || type == 'RegularExpression')) {
224+
throw Error(`invalid header match type: ${type}`);
225+
}
226+
194227
// Divide string into key value using the index.
195-
let kv = [p.slice(0, idx), p.slice(idx + 1)];
228+
let kv = [p.slice(0, firstIdx), p.slice(idx + 1)];
196229

197230
// val can either be a string or an array of strings.
198231
// Also, the NGINX request's args object lookup is case-sensitive.
@@ -207,8 +240,15 @@ function paramsMatch(requestParams, params) {
207240
val = val[0];
208241
}
209242

210-
if (val !== kv[1]) {
211-
return false;
243+
// match the value based on the type
244+
if (type === 'Exact') {
245+
if (val !== kv[1]) {
246+
return false;
247+
}
248+
} else if (type === 'RegularExpression') {
249+
if (!new RegExp(kv[1]).test(val)) {
250+
return false;
251+
}
212252
}
213253
}
214254

0 commit comments

Comments
 (0)