From a29b3ee4e55b4beac5e63a4ab71514417acbb92d Mon Sep 17 00:00:00 2001 From: salonichf5 <146118978+salonichf5@users.noreply.github.com> Date: Tue, 4 Feb 2025 16:35:05 -0700 Subject: [PATCH] add regex matching for headers and query params --- examples/advanced-routing/cafe-routes.yaml | 18 ++ examples/advanced-routing/coffee.yaml | 33 ++++ examples/grpc-routing/README.md | 14 ++ examples/grpc-routing/headers.yaml | 9 + internal/mode/static/nginx/config/servers.go | 20 ++- .../mode/static/nginx/config/servers_test.go | 37 +++-- .../static/nginx/modules/src/httpmatches.js | 68 ++++++-- .../nginx/modules/test/httpmatches.test.js | 85 ++++++++-- .../mode/static/state/dataplane/convert.go | 13 ++ .../static/state/dataplane/convert_test.go | 95 +++++++++++ internal/mode/static/state/dataplane/types.go | 13 ++ internal/mode/static/state/graph/grpcroute.go | 27 ++- .../mode/static/state/graph/grpcroute_test.go | 157 +++++++++++++----- internal/mode/static/state/graph/httproute.go | 8 +- .../mode/static/state/graph/httproute_test.go | 30 ++-- .../mode/static/state/graph/route_common.go | 4 +- .../traffic-management/advanced-routing.md | 53 +++++- .../overview/gateway-api-compatibility.md | 4 +- 18 files changed, 577 insertions(+), 111 deletions(-) diff --git a/examples/advanced-routing/cafe-routes.yaml b/examples/advanced-routing/cafe-routes.yaml index 7430ca1a0e..1ada7f1fe0 100644 --- a/examples/advanced-routing/cafe-routes.yaml +++ b/examples/advanced-routing/cafe-routes.yaml @@ -31,6 +31,24 @@ spec: backendRefs: - name: coffee-v2-svc port: 80 + - matches: + - path: + type: PathPrefix + value: /coffee + headers: + - name: headerRegex + type: RegularExpression + value: "header-[a-z]{1}" + - path: + type: PathPrefix + value: /coffee + queryParams: + - name: queryRegex + type: RegularExpression + value: "query-[a-z]{1}" + backendRefs: + - name: coffee-v3-svc + port: 80 --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute diff --git a/examples/advanced-routing/coffee.yaml b/examples/advanced-routing/coffee.yaml index 2f94d7b801..0c68770afc 100644 --- a/examples/advanced-routing/coffee.yaml +++ b/examples/advanced-routing/coffee.yaml @@ -63,3 +63,36 @@ spec: name: http selector: app: coffee-v2 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: coffee-v3 +spec: + replicas: 1 + selector: + matchLabels: + app: coffee-v3 + template: + metadata: + labels: + app: coffee-v3 + spec: + containers: + - name: coffee-v3 + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: coffee-v3-svc +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: coffee-v3 diff --git a/examples/grpc-routing/README.md b/examples/grpc-routing/README.md index 97f953ab9a..d58ac43fd1 100644 --- a/examples/grpc-routing/README.md +++ b/examples/grpc-routing/README.md @@ -192,6 +192,20 @@ There are 3 options to configure gRPC routing. To access the application and tes 2024/04/29 09:32:46 Received: version two ``` + We'll send a request with the header `headerRegex: grpc-header-a` + + ```shell + 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 + ``` + + ```text + { + "message": "Hello version two regex" + } + ``` + + Verify logs of `${POD_V2}` to ensure response is from the correct service. + Finally, we'll send a request with the headers `version: two` and `color: orange` ```shell diff --git a/examples/grpc-routing/headers.yaml b/examples/grpc-routing/headers.yaml index 715f02cd90..43834d6b41 100644 --- a/examples/grpc-routing/headers.yaml +++ b/examples/grpc-routing/headers.yaml @@ -36,6 +36,15 @@ spec: backendRefs: - name: grpc-infra-backend-v2 port: 8080 + # Matches "headerRegex: grpc-header-[a-z]{1}" + - matches: + - headers: + - name: headerRegex + value: "grpc-header-[a-z]{1}" + type: RegularExpression + backendRefs: + - name: grpc-infra-backend-v2 + port: 8080 # Matches "version: two" AND "color: orange" - matches: - headers: diff --git a/internal/mode/static/nginx/config/servers.go b/internal/mode/static/nginx/config/servers.go index 101c2cbd0a..8e1dd144f7 100644 --- a/internal/mode/static/nginx/config/servers.go +++ b/internal/mode/static/nginx/config/servers.go @@ -716,19 +716,31 @@ func createRouteMatch(match dataplane.Match, redirectPath string) routeMatch { return hm } -// The name and values are delimited by "=". A name and value can always be recovered using strings.SplitN(arg,"=", 2). +// The name, match type and values are delimited by "=". +// A name, match type and value can always be recovered using strings.SplitN(arg,"=", 3). // Query Parameters are case-sensitive so case is preserved. +// The match type is optional and defaults to "Exact". func createQueryParamKeyValString(p dataplane.HTTPQueryParamMatch) string { - return p.Name + "=" + p.Value + // this condition check is added for unit tests + if p.Type == "" { + p.Type = dataplane.MatchTypeExact + } + return p.Name + "=" + string(p.Type) + "=" + p.Value } -// The name and values are delimited by ":". A name and value can always be recovered using strings.Split(arg, ":"). +// The name, match type and values are delimited by ":". +// A name, match type and value can always be recovered using strings.Split(arg, ":"). // Header names are case-insensitive and header values are case-sensitive. +// The match type is optional and defaults to "Exact". // Ex. foo:bar == FOO:bar, but foo:bar != foo:BAR, // We preserve the case of the name here because NGINX allows us to look up the header names in a case-insensitive // manner. func createHeaderKeyValString(h dataplane.HTTPHeaderMatch) string { - return h.Name + HeaderMatchSeparator + h.Value + // this condition check is added for unit tests + if h.Type == "" { + h.Type = dataplane.MatchTypeExact + } + return h.Name + HeaderMatchSeparator + string(h.Type) + HeaderMatchSeparator + h.Value } func isPathOnlyMatch(match dataplane.Match) bool { diff --git a/internal/mode/static/nginx/config/servers_test.go b/internal/mode/static/nginx/config/servers_test.go index 86919091a6..dae6380252 100644 --- a/internal/mode/static/nginx/config/servers_test.go +++ b/internal/mode/static/nginx/config/servers_test.go @@ -1071,23 +1071,23 @@ func TestCreateServers(t *testing.T) { "1_1": { { Method: "GET", - Headers: []string{"Version:V1", "test:foo", "my-header:my-value"}, - QueryParams: []string{"GrEat=EXAMPLE", "test=foo=bar"}, + Headers: []string{"Version:Exact:V1", "test:Exact:foo", "my-header:Exact:my-value"}, + QueryParams: []string{"GrEat=Exact=EXAMPLE", "test=Exact=foo=bar"}, RedirectPath: "/_ngf-internal-rule1-route0", }, }, "1_6": { - {RedirectPath: "/_ngf-internal-rule6-route0", Headers: []string{"redirect:this"}}, + {RedirectPath: "/_ngf-internal-rule6-route0", Headers: []string{"redirect:Exact:this"}}, }, "1_8": { { - Headers: []string{"rewrite:this"}, + Headers: []string{"rewrite:Exact:this"}, RedirectPath: "/_ngf-internal-rule8-route0", }, }, "1_10": { { - Headers: []string{"filter:this"}, + Headers: []string{"filter:Exact:this"}, RedirectPath: "/_ngf-internal-rule10-route0", }, }, @@ -2736,8 +2736,8 @@ func TestCreateRouteMatch(t *testing.T) { }, } - expectedHeaders := []string{"header-1:val-1", "header-2:val-2", "header-3:val-3"} - expectedArgs := []string{"arg1=val1", "arg2=val2=another-val", "arg3===val3"} + expectedHeaders := []string{"header-1:Exact:val-1", "header-2:Exact:val-2", "header-3:Exact:val-3"} + expectedArgs := []string{"arg1=Exact=val1", "arg2=Exact=val2=another-val", "arg3=Exact===val3"} tests := []struct { match dataplane.Match @@ -2858,23 +2858,25 @@ func TestCreateQueryParamKeyValString(t *testing.T) { t.Parallel() g := NewWithT(t) - expected := "key=value" + expected := "key=Exact=value" result := createQueryParamKeyValString( dataplane.HTTPQueryParamMatch{ Name: "key", Value: "value", + Type: dataplane.MatchTypeExact, }, ) g.Expect(result).To(Equal(expected)) - expected = "KeY=vaLUe==" + expected = "KeY=RegularExpression=vaLUe-[a-z]==" result = createQueryParamKeyValString( dataplane.HTTPQueryParamMatch{ Name: "KeY", - Value: "vaLUe==", + Value: "vaLUe-[a-z]==", + Type: dataplane.MatchTypeRegularExpression, }, ) @@ -2885,12 +2887,25 @@ func TestCreateHeaderKeyValString(t *testing.T) { t.Parallel() g := NewWithT(t) - expected := "kEy:vALUe" + expected := "kEy:Exact:vALUe" result := createHeaderKeyValString( dataplane.HTTPHeaderMatch{ Name: "kEy", Value: "vALUe", + Type: dataplane.MatchTypeExact, + }, + ) + + g.Expect(result).To(Equal(expected)) + + expected = "kEy:RegularExpression:vALUe-[0-9]" + + result = createHeaderKeyValString( + dataplane.HTTPHeaderMatch{ + Name: "kEy", + Value: "vALUe-[0-9]", + Type: dataplane.MatchTypeRegularExpression, }, ) diff --git a/internal/mode/static/nginx/modules/src/httpmatches.js b/internal/mode/static/nginx/modules/src/httpmatches.js index bcee4d6569..9d2316a320 100644 --- a/internal/mode/static/nginx/modules/src/httpmatches.js +++ b/internal/mode/static/nginx/modules/src/httpmatches.js @@ -154,7 +154,8 @@ function headersMatch(requestHeaders, headers) { const h = headers[i]; const kv = h.split(':'); - if (kv.length !== 2) { + // header should be of the format "key:MatchType:value" + if (kv.length !== 3) { throw Error(`invalid header match: ${h}`); } // 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) { // split on comma because nginx uses commas to delimit multiple header values const values = val.split(','); - if (!values.includes(kv[1])) { - return false; + + let type = kv[1]; + // verify the type of header match + if (!(type == 'Exact' || type == 'RegularExpression')) { + throw Error(`invalid header match type: ${type}`); + } + + // match the value based on the type + if (type === 'Exact') { + if (!values.includes(kv[2])) { + return false; + } + } else if (type === 'RegularExpression') { + if (!values.some((v) => new RegExp(kv[2]).test(v))) { + return false; + } } } @@ -179,20 +194,38 @@ function headersMatch(requestHeaders, headers) { function paramsMatch(requestParams, params) { for (let i = 0; i < params.length; i++) { let p = params[i]; - // We store query parameter matches as strings with the format "key=value"; however, there may be more than one + // We store query parameter matches as strings with the format "key=MatchType=value"; however, there may be more than one // instance of "=" in the string. - // To recover the key and value, we need to find the first occurrence of "=" in the string. - const idx = params[i].indexOf('='); - // Check for an improperly constructed query parameter match. There are three possible error cases: - // (1) if the index is -1, then there are no "=" in the string (e.g. "keyvalue") - // (2) if the index is 0, then there is no value in the string (e.g. "key="). - // (3) if the index is equal to length -1, then there is no key in the string (e.g. "=value"). - if (idx === -1 || (idx === 0) | (idx === p.length - 1)) { + // To recover the key, type and and value, we need to find the first occurrence of "=" in the string. + const firstIdx = p.indexOf('='); + + // Check for an improperly constructed query parameter match. There are two possible error cases: + // (1) if the index is -1, then there are no "=" in the string (e.g. "keyExactvalue") + // (2) if the index is 0, then there is no key in the string (e.g. "=Exact=value"). + if (firstIdx === -1 || firstIdx === 0) { throw Error(`invalid query parameter: ${p}`); } + // find the next occurence of "=" in the string + const idx = p.indexOf('=', firstIdx + 1); + + // Three possible error cases for improperly constructed query parameter match: + // (1) if the index is -1, then there are no second occurence of "=" in the string (e.g. "Exactvalue") + // (2) if the index is 0, then there is no value in the string and has only one "=" (e.g. "key=Exact"). + // (3) if the index is equal to length -1, then there is no key and type in the string (e.g. "=Exact=value"). + if (idx === -1 || idx === 0 || idx === p.length - 1) { + throw Error(`invalid query parameter: ${p}`); + } + + // extract the type match from the string + const type = p.slice(firstIdx + 1, idx); + + if (!(type == 'Exact' || type == 'RegularExpression')) { + throw Error(`invalid header match type: ${type}`); + } + // Divide string into key value using the index. - let kv = [p.slice(0, idx), p.slice(idx + 1)]; + let kv = [p.slice(0, firstIdx), p.slice(idx + 1)]; // val can either be a string or an array of strings. // Also, the NGINX request's args object lookup is case-sensitive. @@ -207,8 +240,15 @@ function paramsMatch(requestParams, params) { val = val[0]; } - if (val !== kv[1]) { - return false; + // match the value based on the type + if (type === 'Exact') { + if (val !== kv[1]) { + return false; + } + } else if (type === 'RegularExpression') { + if (!new RegExp(kv[1]).test(val)) { + return false; + } } } diff --git a/internal/mode/static/nginx/modules/test/httpmatches.test.js b/internal/mode/static/nginx/modules/test/httpmatches.test.js index 87dd107055..c7fef074f7 100644 --- a/internal/mode/static/nginx/modules/test/httpmatches.test.js +++ b/internal/mode/static/nginx/modules/test/httpmatches.test.js @@ -131,19 +131,19 @@ describe('testMatch', () => { }, { name: 'returns true if headers match and no other conditions are set', - match: { headers: ['header:value'] }, + match: { headers: ['header:Exact:value'] }, request: createRequest({ headers: { header: 'value' } }), expected: true, }, { name: 'returns true if query parameters match and no other conditions are set', - match: { params: ['key=value'] }, + match: { params: ['key=Exact=value'] }, request: createRequest({ params: { key: 'value' } }), expected: true, }, { name: 'returns true if multiple conditions match', - match: { method: 'GET', headers: ['header:value'], params: ['key=value'] }, + match: { method: 'GET', headers: ['header:Exact:value'], params: ['key=Exact=value'] }, request: createRequest({ method: 'GET', headers: { header: 'value' }, @@ -159,13 +159,13 @@ describe('testMatch', () => { }, { name: 'returns false if headers do not match', - match: { method: 'GET', headers: ['header:value'] }, + match: { method: 'GET', headers: ['header:Exact:value'] }, request: createRequest({ method: 'GET' }), // no headers are set on request expected: false, }, { name: 'returns false if query parameters do not match', - match: { method: 'GET', headers: ['header:value'], params: ['key=value'] }, + match: { method: 'GET', headers: ['header:Exact:value'], params: ['key=Exact=value'] }, request: createRequest({ method: 'GET', headers: { header: 'value' } }), // no params set on request expected: false, }, @@ -198,8 +198,8 @@ describe('testMatch', () => { }); describe('findWinningMatch', () => { - const headerMatch = { headers: ['header:value'] }; - const queryParamMatch = { params: ['key=value'] }; + const headerMatch = { headers: ['header:Exact:value'] }; + const queryParamMatch = { params: ['key=Exact=value'] }; const methodMatch = { method: 'POST' }; const anyMatch = { any: true }; const malformedMatch = { headers: ['malformed'] }; @@ -241,12 +241,16 @@ describe('findWinningMatch', () => { }); describe('headersMatch', () => { - const multipleHeaders = ['header1:VALUE1', 'header2:value2', 'header3:value3']; // case matters for header values + const multipleHeaders = [ + 'header1:Exact:VALUE1', + 'header2:RegularExpression:Header-[a-z]{1}', + 'header3:Exact:value3', + ]; // case matters for header values const tests = [ { name: 'throws an error if a header has multiple colons', - headers: ['too:many:colons'], + headers: ['too:Exact:many:colons'], expectThrow: true, }, { @@ -255,6 +259,14 @@ describe('headersMatch', () => { requestHeaders: {}, expectThrow: true, }, + { + name: 'throws an error if a header has invalid match type', + headers: ['key:Incorrect:val'], + requestHeaders: { + key: 'val', + }, + expectTypeError: true, + }, { name: 'returns false if one of the header values does not match', headers: multipleHeaders, @@ -280,14 +292,14 @@ describe('headersMatch', () => { headers: multipleHeaders, requestHeaders: { header1: 'VALUE1', // this value is not the correct case - header2: 'value2', + header2: 'Header-a', header3: 'value3', }, expected: true, }, { name: 'returns true if request has multiple values for a header name and one value matches ', - headers: ['multiValueHeader:val3'], + headers: ['multiValueHeader:Exact:val3'], requestHeaders: { multiValueHeader: 'val1,val2,val3,val4,val5', }, @@ -301,6 +313,10 @@ describe('headersMatch', () => { expect(() => hm.headersMatch(test.requestHeaders, test.headers)).to.throw( 'invalid header match', ); + } else if (test.expectTypeError) { + expect(() => hm.headersMatch(test.requestHeaders, test.headers)).to.throw( + 'invalid header match type', + ); } else { expect(hm.headersMatch(test.requestHeaders, test.headers)).to.equal(test.expected); } @@ -309,17 +325,21 @@ describe('headersMatch', () => { }); describe('paramsMatch', () => { - const params = ['Arg1=value1', 'arg2=value2=SOME=other=value', 'arg3===value3&*1(*+']; // case matters for header values + const params = [ + 'Arg1=Exact=value1', + 'arg2=Exact=value2=SOME=other=value', + 'arg3=Exact===value3&*1(*+', + ]; // case matters for header values const tests = [ { name: 'throws an error a param has no key', - params: ['=nokey'], + params: ['=Exact=nokey'], expectThrow: true, }, { name: 'throws an error if a param has no value', - params: ['novalue='], + params: ['novalue=Exact='], expectThrow: true, }, { @@ -327,6 +347,11 @@ describe('paramsMatch', () => { params: ['keyval'], expectThrow: true, }, + { + name: 'throws an error if a param has invalid match type', + params: ['key=Incorrect=val'], + expectTypeError: true, + }, { name: 'returns false if one of the params is missing from request', params: params, @@ -397,6 +422,22 @@ describe('paramsMatch', () => { }, expected: true, }, + { + name: 'returns true if param matches the regular expression', + params: ['key=RegularExpression=Query-[a-z]{1}'], + requestParams: { + key: 'Query-a', + }, + expected: true, + }, + { + name: 'returns false if param does not match the regular expression', + params: ['key=RegularExpression=Query-[a-z]{1}'], + requestParams: { + key: 'value', + }, + expected: false, + }, { name: 'returns false if one param does not match because of multiple values', params: params, @@ -415,6 +456,10 @@ describe('paramsMatch', () => { expect(() => hm.paramsMatch(test.requestParams, test.params)).to.throw( 'invalid query parameter', ); + } else if (test.expectTypeError) { + expect(() => hm.paramsMatch(test.requestParams, test.params)).to.throw( + 'invalid header match type', + ); } else { expect(hm.paramsMatch(test.requestParams, test.params)).to.equal(test.expected); } @@ -425,17 +470,21 @@ describe('paramsMatch', () => { describe('redirectForMatchList', () => { const testAnyMatch = { any: true, redirectPath: '/any' }; const testHeaderMatches = { - headers: ['header1:VALUE1', 'header2:value2', 'header3:value3'], + headers: ['header1:Exact:VALUE1', 'header2:Exact:value2', 'header3:Exact:value3'], redirectPath: '/headers', }; const testQueryParamMatches = { - params: ['Arg1=value1', 'arg2=value2=SOME=other=value', 'arg3===value3&*1(*+'], + params: [ + 'Arg1=Exact=value1', + 'arg2=Exact=value2=SOME=other=value', + 'arg3=Exact===value3&*1(*+', + ], redirectPath: '/params', }; const testAllMatchTypes = { method: 'GET', - headers: ['header1:value1', 'header2:value2'], - params: ['Arg1=value1', 'arg2=value2=SOME=other=value'], + headers: ['header1:Exact:value1', 'header2:Exact:value2'], + params: ['Arg1=Exact=value1', 'arg2=Exact=value2=SOME=other=value'], redirectPath: '/a-match', }; diff --git a/internal/mode/static/state/dataplane/convert.go b/internal/mode/static/state/dataplane/convert.go index 4bc03635de..d44a47ed7a 100644 --- a/internal/mode/static/state/dataplane/convert.go +++ b/internal/mode/static/state/dataplane/convert.go @@ -24,6 +24,7 @@ func convertMatch(m v1.HTTPRouteMatch) Match { match.Headers = append(match.Headers, HTTPHeaderMatch{ Name: string(h.Name), Value: h.Value, + Type: convertMatchType(h.Type), }) } } @@ -34,6 +35,7 @@ func convertMatch(m v1.HTTPRouteMatch) Match { match.QueryParams = append(match.QueryParams, HTTPQueryParamMatch{ Name: string(q.Name), Value: q.Value, + Type: convertMatchType(q.Type), }) } } @@ -91,6 +93,17 @@ func convertPathType(pathType v1.PathMatchType) PathType { } } +func convertMatchType[T ~string](matchType *T) MatchType { + switch *matchType { + case T(v1.HeaderMatchExact), T(v1.QueryParamMatchExact): + return MatchTypeExact + case T(v1.HeaderMatchRegularExpression), T(v1.QueryParamMatchRegularExpression): + return MatchTypeRegularExpression + default: + panic(fmt.Sprintf("unsupported match type: %v", *matchType)) + } +} + func convertPathModifier(path *v1.HTTPPathModifier) *HTTPPathModifier { if path != nil { switch path.Type { diff --git a/internal/mode/static/state/dataplane/convert_test.go b/internal/mode/static/state/dataplane/convert_test.go index cc1a9e1293..8a133e4088 100644 --- a/internal/mode/static/state/dataplane/convert_test.go +++ b/internal/mode/static/state/dataplane/convert_test.go @@ -45,6 +45,7 @@ func TestConvertMatch(t *testing.T) { { Name: "Test-Header", Value: "test-header-value", + Type: helpers.GetPointer(v1.HeaderMatchExact), }, }, }, @@ -53,6 +54,7 @@ func TestConvertMatch(t *testing.T) { { Name: "Test-Header", Value: "test-header-value", + Type: MatchTypeExact, }, }, }, @@ -65,6 +67,7 @@ func TestConvertMatch(t *testing.T) { { Name: "Test-Param", Value: "test-param-value", + Type: helpers.GetPointer(v1.QueryParamMatchExact), }, }, }, @@ -73,11 +76,50 @@ func TestConvertMatch(t *testing.T) { { Name: "Test-Param", Value: "test-param-value", + Type: MatchTypeExact, }, }, }, name: "path and query param", }, + { + match: v1.HTTPRouteMatch{ + Path: &path, + Method: helpers.GetPointer(v1.HTTPMethodGet), + Headers: []v1.HTTPHeaderMatch{ + { + Name: "Test-Header", + Value: "header-[0-9]+", + Type: helpers.GetPointer(v1.HeaderMatchRegularExpression), + }, + }, + QueryParams: []v1.HTTPQueryParamMatch{ + { + Name: "Test-Param", + Value: "query-[0-9]+", + Type: helpers.GetPointer(v1.QueryParamMatchRegularExpression), + }, + }, + }, + expected: Match{ + Method: helpers.GetPointer("GET"), + Headers: []HTTPHeaderMatch{ + { + Name: "Test-Header", + Value: "header-[0-9]+", + Type: MatchTypeRegularExpression, + }, + }, + QueryParams: []HTTPQueryParamMatch{ + { + Name: "Test-Param", + Value: "query-[0-9]+", + Type: MatchTypeRegularExpression, + }, + }, + }, + name: "path, method, header, and query param with regex", + }, { match: v1.HTTPRouteMatch{ Path: &path, @@ -86,12 +128,14 @@ func TestConvertMatch(t *testing.T) { { Name: "Test-Header", Value: "test-header-value", + Type: helpers.GetPointer(v1.HeaderMatchExact), }, }, QueryParams: []v1.HTTPQueryParamMatch{ { Name: "Test-Param", Value: "test-param-value", + Type: helpers.GetPointer(v1.QueryParamMatchExact), }, }, }, @@ -101,12 +145,14 @@ func TestConvertMatch(t *testing.T) { { Name: "Test-Header", Value: "test-header-value", + Type: MatchTypeExact, }, }, QueryParams: []HTTPQueryParamMatch{ { Name: "Test-Param", Value: "test-param-value", + Type: MatchTypeExact, }, }, }, @@ -355,3 +401,52 @@ func TestConvertPathType(t *testing.T) { }) } } + +func TestConvertMatchType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + headerMatchType *v1.HeaderMatchType + queryMatchType *v1.QueryParamMatchType + expectedType MatchType + shouldPanic bool + }{ + { + name: "exact match for header and query param", + headerMatchType: helpers.GetPointer(v1.HeaderMatchExact), + queryMatchType: helpers.GetPointer(v1.QueryParamMatchExact), + expectedType: MatchTypeExact, + shouldPanic: false, + }, + { + name: "regex match for header and query param", + headerMatchType: helpers.GetPointer(v1.HeaderMatchRegularExpression), + queryMatchType: helpers.GetPointer(v1.QueryParamMatchRegularExpression), + expectedType: MatchTypeRegularExpression, + shouldPanic: false, + }, + { + name: "unsupported match type for header and query param", + headerMatchType: helpers.GetPointer(v1.HeaderMatchType(v1.PathMatchPathPrefix)), + queryMatchType: helpers.GetPointer(v1.QueryParamMatchType(v1.PathMatchPathPrefix)), + expectedType: MatchTypeExact, + shouldPanic: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + if tc.shouldPanic { + g.Expect(func() { convertMatchType(tc.headerMatchType) }).To(Panic()) + g.Expect(func() { convertMatchType(tc.queryMatchType) }).To(Panic()) + } else { + g.Expect(convertMatchType(tc.headerMatchType)).To(Equal(tc.expectedType)) + g.Expect(convertMatchType(tc.queryMatchType)).To(Equal(tc.expectedType)) + } + }) + } +} diff --git a/internal/mode/static/state/dataplane/types.go b/internal/mode/static/state/dataplane/types.go index 8a46dbf126..2aa9b6974c 100644 --- a/internal/mode/static/state/dataplane/types.go +++ b/internal/mode/static/state/dataplane/types.go @@ -208,11 +208,20 @@ type HTTPURLRewriteFilter struct { // PathModifierType is the type of the PathModifier in a redirect or rewrite rule. type PathModifierType string +// MatchType is the type of match in a MatchRule for headers and query parameters. +type MatchType string + const ( // ReplaceFullPath indicates that we replace the full path. ReplaceFullPath PathModifierType = "ReplaceFullPath" // ReplacePrefixMatch indicates that we replace a prefix match. ReplacePrefixMatch PathModifierType = "ReplacePrefixMatch" + + // MatchTypeExact indicates that the match type is exact. + MatchTypeExact MatchType = "Exact" + + // MatchTypeRegularExpression indicates that the match type is a regular expression. + MatchTypeRegularExpression MatchType = "RegularExpression" ) // HTTPPathModifier defines configuration for path modifiers. @@ -230,6 +239,8 @@ type HTTPHeaderMatch struct { Name string // Value is the value of the header to match. Value string + // Type specifies the type of match. + Type MatchType } // HTTPQueryParamMatch matches an HTTP query parameter. @@ -238,6 +249,8 @@ type HTTPQueryParamMatch struct { Name string // Value is the value of the query parameter to match. Value string + // Type specifies the type of match. + Type MatchType } // MatchRule represents a routing rule. It corresponds directly to a Match in the HTTPRoute resource. diff --git a/internal/mode/static/state/graph/grpcroute.go b/internal/mode/static/state/graph/grpcroute.go index 713a0eb9e2..8a07a8395f 100644 --- a/internal/mode/static/state/graph/grpcroute.go +++ b/internal/mode/static/state/graph/grpcroute.go @@ -1,6 +1,8 @@ package graph import ( + "fmt" + "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" v1 "sigs.k8s.io/gateway-api/apis/v1" @@ -199,6 +201,7 @@ func ConvertGRPCMatches(grpcMatches []v1.GRPCRouteMatch) []v1.HTTPRouteMatch { hmHeaders = append(hmHeaders, v1.HTTPHeaderMatch{ Name: v1.HTTPHeaderName(head.Name), Value: head.Value, + Type: convertGRPCHeaderMatchType(head.Type), }) } hm.Headers = hmHeaders @@ -221,6 +224,26 @@ func ConvertGRPCMatches(grpcMatches []v1.GRPCRouteMatch) []v1.HTTPRouteMatch { return hms } +func convertGRPCHeaderMatchType(matchType *v1.GRPCHeaderMatchType) *v1.HeaderMatchType { + // these condition checks are added for the unit tests where matchType is not empty + // but length is zero. In general case, this error would be caught by validateGRPCMatch. + if matchType == nil { + return helpers.GetPointer(v1.HeaderMatchExact) + } + if len(*matchType) == 0 { + return helpers.GetPointer(v1.HeaderMatchExact) + } + + switch *matchType { + case v1.GRPCHeaderMatchExact: + return helpers.GetPointer(v1.HeaderMatchExact) + case v1.GRPCHeaderMatchRegularExpression: + return helpers.GetPointer(v1.HeaderMatchRegularExpression) + default: + panic(fmt.Sprintf("unsupported header match type: %v", matchType)) + } +} + func validateGRPCMatch( validator validation.HTTPFieldsValidator, match v1.GRPCRouteMatch, @@ -289,11 +312,11 @@ func validateGRPCHeaderMatch( if headerType == nil { allErrs = append(allErrs, field.Required(headerPath.Child("type"), "cannot be empty")) - } else if *headerType != v1.GRPCHeaderMatchExact { + } else if *headerType != v1.GRPCHeaderMatchExact && *headerType != v1.GRPCHeaderMatchRegularExpression { valErr := field.NotSupported( headerPath.Child("type"), *headerType, - []string{string(v1.GRPCHeaderMatchExact)}, + []string{string(v1.GRPCHeaderMatchExact), string(v1.GRPCHeaderMatchRegularExpression)}, ) allErrs = append(allErrs, valErr) } diff --git a/internal/mode/static/state/graph/grpcroute_test.go b/internal/mode/static/state/graph/grpcroute_test.go index 0e3edde7be..2c949900b2 100644 --- a/internal/mode/static/state/graph/grpcroute_test.go +++ b/internal/mode/static/state/graph/grpcroute_test.go @@ -350,13 +350,14 @@ func TestBuildGRPCRoute(t *testing.T) { ) grValidFilterRule := createGRPCMethodMatch("myService", "myMethod", "Exact") + grValidHeaderMatch := createGRPCHeadersMatch("RegularExpression", "MyHeader", "headers-[a-z]+") validSnippetsFilterRef := &v1.LocalObjectReference{ Group: ngfAPI.GroupName, Kind: kinds.SnippetsFilter, Name: "sf", } - grValidFilterRule.Filters = []v1.GRPCRouteFilter{ + grpcRouteFilters := []v1.GRPCRouteFilter{ { Type: "RequestHeaderModifier", RequestHeaderModifier: &v1.HTTPHeaderFilter{ @@ -377,11 +378,14 @@ func TestBuildGRPCRoute(t *testing.T) { }, } + grValidFilterRule.Filters = grpcRouteFilters + grValidHeaderMatch.Filters = grpcRouteFilters + grValidFilter := createGRPCRoute( "gr", gatewayNsName.Name, "example.com", - []v1.GRPCRouteRule{grValidFilterRule}, + []v1.GRPCRouteRule{grValidFilterRule, grValidHeaderMatch}, ) // route with invalid snippets filter extension ref @@ -455,6 +459,37 @@ func TestBuildGRPCRoute(t *testing.T) { return v } + routeFilters := []Filter{ + { + RouteType: RouteTypeGRPC, + FilterType: FilterRequestHeaderModifier, + RequestHeaderModifier: &v1.HTTPHeaderFilter{ + Remove: []string{"header"}, + }, + }, + { + RouteType: RouteTypeGRPC, + FilterType: FilterResponseHeaderModifier, + ResponseHeaderModifier: &v1.HTTPHeaderFilter{ + Add: []v1.HTTPHeader{ + {Name: "Accept-Encoding", Value: "gzip"}, + }, + }, + }, + { + RouteType: RouteTypeGRPC, + FilterType: FilterExtensionRef, + ExtensionRef: validSnippetsFilterRef, + ResolvedExtensionRef: &ExtensionRefFilter{ + SnippetsFilter: &SnippetsFilter{ + Valid: true, + Referenced: true, + }, + Valid: true, + }, + }, + } + tests := []struct { validator *validationfakes.FakeHTTPFieldsValidator gr *v1.GRPCRoute @@ -558,43 +593,23 @@ func TestBuildGRPCRoute(t *testing.T) { Matches: ConvertGRPCMatches(grValidFilter.Spec.Rules[0].Matches), RouteBackendRefs: []RouteBackendRef{}, Filters: RouteRuleFilters{ - Filters: []Filter{ - { - RouteType: RouteTypeGRPC, - FilterType: FilterRequestHeaderModifier, - RequestHeaderModifier: &v1.HTTPHeaderFilter{ - Remove: []string{"header"}, - }, - }, - { - RouteType: RouteTypeGRPC, - FilterType: FilterResponseHeaderModifier, - ResponseHeaderModifier: &v1.HTTPHeaderFilter{ - Add: []v1.HTTPHeader{ - {Name: "Accept-Encoding", Value: "gzip"}, - }, - }, - }, - { - RouteType: RouteTypeGRPC, - FilterType: FilterExtensionRef, - ExtensionRef: validSnippetsFilterRef, - ResolvedExtensionRef: &ExtensionRefFilter{ - SnippetsFilter: &SnippetsFilter{ - Valid: true, - Referenced: true, - }, - Valid: true, - }, - }, - }, - Valid: true, + Valid: true, + Filters: routeFilters, }, }, + { + ValidMatches: true, + Matches: ConvertGRPCMatches(grValidFilter.Spec.Rules[1].Matches), + Filters: RouteRuleFilters{ + Valid: true, + Filters: routeFilters, + }, + RouteBackendRefs: []RouteBackendRef{}, + }, }, }, }, - name: "valid rule with filter", + name: "valid path rule, headers with filters", }, { validator: createAllValidValidator(), @@ -727,7 +742,7 @@ func TestBuildGRPCRoute(t *testing.T) { }, Conditions: []conditions.Condition{ staticConds.NewRoutePartiallyInvalid( - `spec.rules[1].matches[0].headers[0].type: Unsupported value: "": supported values: "Exact"`, + `spec.rules[1].matches[0].headers[0].type: Unsupported value: "": supported values: "Exact", "RegularExpression"`, ), }, Spec: L7RouteSpec{ @@ -774,7 +789,7 @@ func TestBuildGRPCRoute(t *testing.T) { Conditions: []conditions.Condition{ staticConds.NewRouteUnsupportedValue( `All rules are invalid: spec.rules[0].matches[0].headers[0].type: ` + - `Unsupported value: "": supported values: "Exact"`, + `Unsupported value: "": supported values: "Exact", "RegularExpression"`, ), }, Spec: L7RouteSpec{ @@ -1085,6 +1100,8 @@ func TestConvertGRPCMatches(t *testing.T) { headersMatch := createGRPCHeadersMatch("Exact", "MyHeader", "SomeValue").Matches + headerMatchRegularExp := createGRPCHeadersMatch("RegularExpression", "HeaderRegex", "headers-[a-z]+").Matches + expectedHTTPMatches := []v1.HTTPRouteMatch{ { Path: &v1.HTTPPathMatch{ @@ -1105,6 +1122,23 @@ func TestConvertGRPCMatches(t *testing.T) { { Value: "SomeValue", Name: v1.HTTPHeaderName("MyHeader"), + Type: helpers.GetPointer(v1.HeaderMatchExact), + }, + }, + }, + } + + expectedHeaderMatchesRegularExp := []v1.HTTPRouteMatch{ + { + Path: &v1.HTTPPathMatch{ + Type: helpers.GetPointer(v1.PathMatchPathPrefix), + Value: helpers.GetPointer("/"), + }, + Headers: []v1.HTTPHeaderMatch{ + { + Value: "headers-[a-z]+", + Name: v1.HTTPHeaderName("HeaderRegex"), + Type: helpers.GetPointer(v1.HeaderMatchRegularExpression), }, }, }, @@ -1130,10 +1164,15 @@ func TestConvertGRPCMatches(t *testing.T) { expected: expectedHTTPMatches, }, { - name: "headers matches", + name: "headers matches exact", methodMatches: headersMatch, expected: expectedHeadersMatches, }, + { + name: "headers matches regular expression", + methodMatches: headerMatchRegularExp, + expected: expectedHeaderMatchesRegularExp, + }, { name: "empty matches", methodMatches: []v1.GRPCRouteMatch{}, @@ -1151,3 +1190,47 @@ func TestConvertGRPCMatches(t *testing.T) { }) } } + +func TestConvertGRPCHeaderMatchType(t *testing.T) { + t.Parallel() + tests := []struct { + input *v1.GRPCHeaderMatchType + expected *v1.HeaderMatchType + name string + shouldPanic bool + }{ + { + name: "exact match type", + input: helpers.GetPointer(v1.GRPCHeaderMatchExact), + expected: helpers.GetPointer(v1.HeaderMatchExact), + }, + { + name: "regular expression match type", + input: helpers.GetPointer(v1.GRPCHeaderMatchRegularExpression), + expected: helpers.GetPointer(v1.HeaderMatchRegularExpression), + }, + { + name: "unsupported match type", + input: helpers.GetPointer(v1.GRPCHeaderMatchType("unsupported")), + expected: helpers.GetPointer(v1.HeaderMatchType("unsupported")), + shouldPanic: true, + }, + { + name: "nil match type", + expected: helpers.GetPointer(v1.HeaderMatchExact), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + if test.shouldPanic { + g.Expect(func() { convertGRPCHeaderMatchType(test.input) }).To(Panic()) + } else { + g.Expect(convertGRPCHeaderMatchType(test.input)).To(Equal(test.expected)) + } + }) + } +} diff --git a/internal/mode/static/state/graph/httproute.go b/internal/mode/static/state/graph/httproute.go index c8278280e0..dd3258a4cd 100644 --- a/internal/mode/static/state/graph/httproute.go +++ b/internal/mode/static/state/graph/httproute.go @@ -232,8 +232,12 @@ func validateQueryParamMatch( if q.Type == nil { allErrs = append(allErrs, field.Required(queryParamPath.Child("type"), "cannot be empty")) - } else if *q.Type != v1.QueryParamMatchExact { - valErr := field.NotSupported(queryParamPath.Child("type"), *q.Type, []string{string(v1.QueryParamMatchExact)}) + } else if *q.Type != v1.QueryParamMatchExact && *q.Type != v1.QueryParamMatchRegularExpression { + valErr := field.NotSupported( + queryParamPath.Child("type"), + *q.Type, + []string{string(v1.QueryParamMatchExact), string(v1.QueryParamMatchRegularExpression)}, + ) allErrs = append(allErrs, valErr) } diff --git a/internal/mode/static/state/graph/httproute_test.go b/internal/mode/static/state/graph/httproute_test.go index 0f44f358b9..d08f00b9ed 100644 --- a/internal/mode/static/state/graph/httproute_test.go +++ b/internal/mode/static/state/graph/httproute_test.go @@ -1015,36 +1015,36 @@ func TestValidateMatch(t *testing.T) { name: "header match type is nil", }, { - validator: createAllValidValidator(), + validator: func() *validationfakes.FakeHTTPFieldsValidator { + validator := createAllValidValidator() + validator.ValidateHeaderNameInMatchReturns(errors.New("invalid header name")) + return validator + }(), match: gatewayv1.HTTPRouteMatch{ Headers: []gatewayv1.HTTPHeaderMatch{ { - Type: helpers.GetPointer(gatewayv1.HeaderMatchRegularExpression), - Name: "header", + Type: helpers.GetPointer(gatewayv1.HeaderMatchExact), + Name: "header", // any value is invalid by the validator Value: "x", }, }, }, expectErrCount: 1, - name: "header match type is invalid", + name: "header name is invalid", }, { - validator: func() *validationfakes.FakeHTTPFieldsValidator { - validator := createAllValidValidator() - validator.ValidateHeaderNameInMatchReturns(errors.New("invalid header name")) - return validator - }(), + validator: createAllValidValidator(), match: gatewayv1.HTTPRouteMatch{ Headers: []gatewayv1.HTTPHeaderMatch{ { - Type: helpers.GetPointer(gatewayv1.HeaderMatchExact), - Name: "header", // any value is invalid by the validator + Type: helpers.GetPointer(gatewayv1.HeaderMatchType("invalid")), + Name: "header", Value: "x", }, }, }, expectErrCount: 1, - name: "header name is invalid", + name: "header match type is invalid", }, { validator: func() *validationfakes.FakeHTTPFieldsValidator { @@ -1083,7 +1083,7 @@ func TestValidateMatch(t *testing.T) { match: gatewayv1.HTTPRouteMatch{ QueryParams: []gatewayv1.HTTPQueryParamMatch{ { - Type: helpers.GetPointer(gatewayv1.QueryParamMatchRegularExpression), + Type: helpers.GetPointer(gatewayv1.QueryParamMatchType("invalid")), Name: "param", Value: "y", }, @@ -1149,14 +1149,14 @@ func TestValidateMatch(t *testing.T) { }, Headers: []gatewayv1.HTTPHeaderMatch{ { - Type: helpers.GetPointer(gatewayv1.HeaderMatchRegularExpression), // invalid + Type: helpers.GetPointer(gatewayv1.HeaderMatchType("invalid")), // invalid Name: "header", Value: "x", }, }, QueryParams: []gatewayv1.HTTPQueryParamMatch{ { - Type: helpers.GetPointer(gatewayv1.QueryParamMatchRegularExpression), // invalid + Type: helpers.GetPointer(gatewayv1.QueryParamMatchType("invalid")), // invalid Name: "param", Value: "y", }, diff --git a/internal/mode/static/state/graph/route_common.go b/internal/mode/static/state/graph/route_common.go index 2db3e9e0b4..a1588c286f 100644 --- a/internal/mode/static/state/graph/route_common.go +++ b/internal/mode/static/state/graph/route_common.go @@ -859,11 +859,11 @@ func validateHeaderMatch( if headerType == nil { allErrs = append(allErrs, field.Required(headerPath.Child("type"), "cannot be empty")) - } else if *headerType != v1.HeaderMatchExact { + } else if *headerType != v1.HeaderMatchExact && *headerType != v1.HeaderMatchRegularExpression { valErr := field.NotSupported( headerPath.Child("type"), *headerType, - []string{string(v1.HeaderMatchExact)}, + []string{string(v1.HeaderMatchExact), string(v1.HeaderMatchRegularExpression)}, ) allErrs = append(allErrs, valErr) } diff --git a/site/content/how-to/traffic-management/advanced-routing.md b/site/content/how-to/traffic-management/advanced-routing.md index cdac87aa6b..76fb51e31a 100644 --- a/site/content/how-to/traffic-management/advanced-routing.md +++ b/site/content/how-to/traffic-management/advanced-routing.md @@ -42,7 +42,7 @@ The goal is to create a set of rules that will result in client requests being s ### Deploy the Coffee applications -Begin by deploying the `coffee-v1` and `coffee-v2` applications: +Begin by deploying the `coffee-v1`, `coffee-v2` and `coffee-v3` applications: ```shell kubectl apply -f https://raw.githubusercontent.com/nginx/nginx-gateway-fabric/v1.6.0/examples/advanced-routing/coffee.yaml @@ -82,7 +82,6 @@ metadata: spec: parentRefs: - name: cafe - sectionName: http hostnames: - cafe.example.com rules: @@ -109,6 +108,24 @@ spec: backendRefs: - name: coffee-v2-svc port: 80 + - matches: + - path: + type: PathPrefix + value: /coffee + headers: + - name: headerRegex + type: RegularExpression + value: "header-[a-z]{1}" + - path: + type: PathPrefix + value: /coffee + queryParams: + - name: queryRegex + type: RegularExpression + value: "query-[a-z]{1}" + backendRefs: + - name: coffee-v3-svc + port: 80 EOF ``` @@ -119,8 +136,17 @@ This HTTPRoute has a few important properties: - The first rule defines that all requests with the path prefix `/coffee` and no other matching conditions are sent to the `coffee-v1` Service. - The second rule defines two matching conditions. If *either* of these conditions match, requests are forwarded to the `coffee-v2` Service: - - Request with the path prefix `/coffee` and header `version=v2` - - Request with the path prefix `/coffee` and the query parameter `TEST=v2` + - Request with the path prefix `/coffee` and header `version=v2`. + - Request with the path prefix `/coffee` and the query parameter `TEST=v2`. + + {{< note >}} The match type is `Exact` for both header and query param, by default. {{< /note >}} + +- The third rule defines two matching conditions. If *either* of these conditions match, requests are forwarded to the `coffee-v3` Service: + + - Request with the path prefix `/coffee` and header `HeaderRegex=Header-[a-z]{1}`. + - Request with the path prefix `/coffee` and the query parameter `QueryRegex=Query-[a-z]{1}`. + + {{< note >}} The match type used here is `RegularExpression`. A request will succeed if the header or query parameter value matches the specified regular expression. {{< /note >}} If you want both conditions to be required, you can define headers and queryParams in the same match object. @@ -162,6 +188,25 @@ Server address: 10.244.0.9:8080 Server name: coffee-v2-68bd55f798-s9z5q ``` +If we want our request to be routed to `coffee-v3`, then we need to meet the defined conditions. We can include a header matching the regular expression: + +```shell +curl --resolve cafe.example.com:$GW_PORT:$GW_IP http://cafe.example.com:$GW_PORT/coffee -H "headerRegex:header-a" +``` + +or include a query parameter matching the regular expression: + +```shell +curl --resolve cafe.example.com:$GW_PORT:$GW_IP http://cafe.example.com:$GW_PORT/coffee?queryRegex=query-a +``` + +Either request should result in a response from the `coffee-v3` Pod. + +```text +Server address: 10.244.0.104:8080 +Server name: coffee-v3-66d58645f4-6zsl2 +``` + --- ## Tea applications diff --git a/site/content/overview/gateway-api-compatibility.md b/site/content/overview/gateway-api-compatibility.md index 7573597983..305ade6908 100644 --- a/site/content/overview/gateway-api-compatibility.md +++ b/site/content/overview/gateway-api-compatibility.md @@ -161,8 +161,8 @@ See the [static-mode]({{< relref "/reference/cli-help.md#static-mode">}}) comman - `rules` - `matches` - `path`: Partially supported. Only `PathPrefix` and `Exact` types. - - `headers`: Partially supported. Only `Exact` type. - - `queryParams`: Partially supported. Only `Exact` type. + - `headers`: Supported. + - `queryParams`: Supported. - `method`: Supported. - `filters` - `type`: Supported.