xdsrouting: balancer config parsing (#3734)

diff --git a/xds/internal/balancer/xdsrouting/routing_config.go b/xds/internal/balancer/xdsrouting/routing_config.go
new file mode 100644
index 0000000..6daea22
--- /dev/null
+++ b/xds/internal/balancer/xdsrouting/routing_config.go
@@ -0,0 +1,205 @@
+/*
+ *
+ * Copyright 2020 gRPC authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package xdsrouting
+
+import (
+	"encoding/json"
+	"fmt"
+
+	wrapperspb "github.com/golang/protobuf/ptypes/wrappers"
+	internalserviceconfig "google.golang.org/grpc/internal/serviceconfig"
+	"google.golang.org/grpc/serviceconfig"
+	xdsclient "google.golang.org/grpc/xds/internal/client"
+)
+
+type action struct {
+	// ChildPolicy is the child policy and it's config.
+	ChildPolicy *internalserviceconfig.BalancerConfig
+}
+
+type int64Range struct {
+	start, end int64
+}
+
+type headerMatcher struct {
+	name string
+	// matchSpecifiers
+	invertMatch bool
+
+	// At most one of the following has non-default value.
+	exactMatch, regexMatch, prefixMatch, suffixMatch string
+	rangeMatch                                       *int64Range
+	presentMatch                                     bool
+}
+
+type route struct {
+	// Path, Prefix and Regex can have at most one set. This is guaranteed by
+	// config parsing.
+	path, prefix, regex string
+
+	headers  []headerMatcher
+	fraction *uint32
+
+	// Action is the name from the action list.
+	action string
+}
+
+// lbConfig is the balancer config for xds routing policy.
+type lbConfig struct {
+	serviceconfig.LoadBalancingConfig
+	routes  []route
+	actions map[string]action
+}
+
+// The following structs with `JSON` in name are temporary structs to unmarshal
+// json into. The fields will be read into lbConfig, to be used by the balancer.
+
+// routeJSON is temporary struct for json unmarshal.
+type routeJSON struct {
+	// Path, Prefix and Regex can have at most one non-nil.
+	Path, Prefix, Regex *string
+	// Zero or more header matchers.
+	Headers       []*xdsclient.HeaderMatcher
+	MatchFraction *wrapperspb.UInt32Value
+	// Action is the name from the action list.
+	Action string
+}
+
+// lbConfigJSON is temporary struct for json unmarshal.
+type lbConfigJSON struct {
+	Route  []routeJSON
+	Action map[string]action
+}
+
+func (jc lbConfigJSON) toLBConfig() *lbConfig {
+	var ret lbConfig
+	for _, r := range jc.Route {
+		var tempR route
+		switch {
+		case r.Path != nil:
+			tempR.path = *r.Path
+		case r.Prefix != nil:
+			tempR.prefix = *r.Prefix
+		case r.Regex != nil:
+			tempR.regex = *r.Regex
+		}
+		for _, h := range r.Headers {
+			if h.RangeMatch != nil {
+				fmt.Println("range not nil", *h.RangeMatch)
+			}
+			var tempHeader headerMatcher
+			switch {
+			case h.ExactMatch != nil:
+				tempHeader.exactMatch = *h.ExactMatch
+			case h.RegexMatch != nil:
+				tempHeader.regexMatch = *h.RegexMatch
+			case h.PrefixMatch != nil:
+				tempHeader.prefixMatch = *h.PrefixMatch
+			case h.SuffixMatch != nil:
+				tempHeader.suffixMatch = *h.SuffixMatch
+			case h.RangeMatch != nil:
+				tempHeader.rangeMatch = &int64Range{
+					start: h.RangeMatch.Start,
+					end:   h.RangeMatch.End,
+				}
+			case h.PresentMatch != nil:
+				tempHeader.presentMatch = *h.PresentMatch
+			}
+			tempHeader.name = h.Name
+			if h.InvertMatch != nil {
+				tempHeader.invertMatch = *h.InvertMatch
+			}
+			tempR.headers = append(tempR.headers, tempHeader)
+		}
+		if r.MatchFraction != nil {
+			tempR.fraction = &r.MatchFraction.Value
+		}
+		tempR.action = r.Action
+		ret.routes = append(ret.routes, tempR)
+	}
+	ret.actions = jc.Action
+	return &ret
+}
+
+func parseConfig(c json.RawMessage) (*lbConfig, error) {
+	var tempConfig lbConfigJSON
+	if err := json.Unmarshal(c, &tempConfig); err != nil {
+		return nil, err
+	}
+
+	// For each route:
+	// - at most one of path/prefix/regex.
+	// - action is in action list.
+
+	allRouteActions := make(map[string]bool)
+	for _, r := range tempConfig.Route {
+		var oneOfCount int
+		if r.Path != nil {
+			oneOfCount++
+		}
+		if r.Prefix != nil {
+			oneOfCount++
+		}
+		if r.Regex != nil {
+			oneOfCount++
+		}
+		if oneOfCount != 1 {
+			return nil, fmt.Errorf("%d (not exactly one) of path/prefix/regex is set in route %+v", oneOfCount, r)
+		}
+
+		for _, h := range r.Headers {
+			var oneOfCountH int
+			if h.ExactMatch != nil {
+				oneOfCountH++
+			}
+			if h.RegexMatch != nil {
+				oneOfCountH++
+			}
+			if h.PrefixMatch != nil {
+				oneOfCountH++
+			}
+			if h.SuffixMatch != nil {
+				oneOfCountH++
+			}
+			if h.RangeMatch != nil {
+				oneOfCountH++
+			}
+			if h.PresentMatch != nil {
+				oneOfCountH++
+			}
+			if oneOfCountH != 1 {
+				return nil, fmt.Errorf("%d (not exactly one) of header matcher specifier is set in route %+v", oneOfCountH, h)
+			}
+		}
+
+		if _, ok := tempConfig.Action[r.Action]; !ok {
+			return nil, fmt.Errorf("action %q from route %+v is not found in action list", r.Action, r)
+		}
+		allRouteActions[r.Action] = true
+	}
+
+	// Verify that actions are used by at least one route.
+	for n := range tempConfig.Action {
+		if _, ok := allRouteActions[n]; !ok {
+			return nil, fmt.Errorf("action %q is not used by any route", n)
+		}
+	}
+
+	return tempConfig.toLBConfig(), nil
+}
diff --git a/xds/internal/balancer/xdsrouting/routing_config_test.go b/xds/internal/balancer/xdsrouting/routing_config_test.go
new file mode 100644
index 0000000..c1ff461
--- /dev/null
+++ b/xds/internal/balancer/xdsrouting/routing_config_test.go
@@ -0,0 +1,369 @@
+/*
+ *
+ * Copyright 2020 gRPC authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package xdsrouting
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"google.golang.org/grpc/balancer"
+	internalserviceconfig "google.golang.org/grpc/internal/serviceconfig"
+	_ "google.golang.org/grpc/xds/internal/balancer/cdsbalancer"
+	_ "google.golang.org/grpc/xds/internal/balancer/weightedtarget"
+)
+
+const (
+	testJSONConfig = `{
+      "action":{
+        "cds:cluster_1":{
+          "childPolicy":[{
+            "cds_experimental":{"cluster":"cluster_1"}
+          }]
+        },
+        "weighted:cluster_1_cluster_2_1":{
+          "childPolicy":[{
+            "weighted_target_experimental":{
+              "targets": {
+                "cluster_1" : {
+                  "weight":75,
+                  "childPolicy":[{"cds_experimental":{"cluster":"cluster_1"}}]
+                },
+                "cluster_2" : {
+                  "weight":25,
+                  "childPolicy":[{"cds_experimental":{"cluster":"cluster_2"}}]
+                }
+              }
+            }
+          }]
+        },
+        "weighted:cluster_1_cluster_3_1":{
+          "childPolicy":[{
+            "weighted_target_experimental":{
+              "targets": {
+                "cluster_1": {
+                  "weight":99,
+                  "childPolicy":[{"cds_experimental":{"cluster":"cluster_1"}}]
+                },
+                "cluster_3": {
+                  "weight":1,
+                  "childPolicy":[{"cds_experimental":{"cluster":"cluster_3"}}]
+                }
+              }
+            }
+          }]
+        }
+      },
+
+      "route":[{
+        "path":"/service_1/method_1",
+        "action":"cds:cluster_1"
+      },
+      {
+        "path":"/service_1/method_2",
+        "action":"cds:cluster_1"
+      },
+      {
+        "prefix":"/service_2/method_1",
+        "action":"weighted:cluster_1_cluster_2_1"
+      },
+      {
+        "prefix":"/service_2",
+        "action":"weighted:cluster_1_cluster_2_1"
+      },
+      {
+        "regex":"^/service_2/method_3$",
+        "action":"weighted:cluster_1_cluster_3_1"
+      }]
+    }
+`
+
+	testJSONConfigWithAllMatchers = `{
+      "action":{
+        "cds:cluster_1":{
+          "childPolicy":[{
+            "cds_experimental":{"cluster":"cluster_1"}
+          }]
+        },
+        "cds:cluster_2":{
+          "childPolicy":[{
+            "cds_experimental":{"cluster":"cluster_2"}
+          }]
+        },
+        "cds:cluster_3":{
+          "childPolicy":[{
+            "cds_experimental":{"cluster":"cluster_3"}
+          }]
+        }
+      },
+
+      "route":[{
+        "path":"/service_1/method_1",
+        "action":"cds:cluster_1"
+      },
+      {
+        "prefix":"/service_2/method_1",
+        "action":"cds:cluster_1"
+      },
+      {
+        "regex":"^/service_2/method_3$",
+        "action":"cds:cluster_1"
+      },
+      {
+        "prefix":"",
+        "headers":[{"name":"header-1", "exactMatch":"value-1", "invertMatch":true}],
+        "action":"cds:cluster_2"
+      },
+      {
+        "prefix":"",
+        "headers":[{"name":"header-1", "regexMatch":"^value-1$"}],
+        "action":"cds:cluster_2"
+      },
+      {
+        "prefix":"",
+        "headers":[{"name":"header-1", "rangeMatch":{"start":-1, "end":7}}],
+        "action":"cds:cluster_3"
+      },
+      {
+        "prefix":"",
+        "headers":[{"name":"header-1", "presentMatch":true}],
+        "action":"cds:cluster_3"
+      },
+      {
+        "prefix":"",
+        "headers":[{"name":"header-1", "prefixMatch":"value-1"}],
+        "action":"cds:cluster_2"
+      },
+      {
+        "prefix":"",
+        "headers":[{"name":"header-1", "suffixMatch":"value-1"}],
+        "action":"cds:cluster_2"
+      },
+      {
+        "prefix":"",
+        "matchFraction":{"value": 31415},
+        "action":"cds:cluster_3"
+      }]
+    }
+`
+
+	cdsName = "cds_experimental"
+	wtName  = "weighted_target_experimental"
+)
+
+var (
+	cdsConfigParser = balancer.Get(cdsName).(balancer.ConfigParser)
+	cdsConfigJSON1  = `{"cluster":"cluster_1"}`
+	cdsConfig1, _   = cdsConfigParser.ParseConfig([]byte(cdsConfigJSON1))
+	cdsConfigJSON2  = `{"cluster":"cluster_2"}`
+	cdsConfig2, _   = cdsConfigParser.ParseConfig([]byte(cdsConfigJSON2))
+	cdsConfigJSON3  = `{"cluster":"cluster_3"}`
+	cdsConfig3, _   = cdsConfigParser.ParseConfig([]byte(cdsConfigJSON3))
+
+	wtConfigParser = balancer.Get(wtName).(balancer.ConfigParser)
+	wtConfigJSON1  = `{
+	"targets": {
+	  "cluster_1" : { "weight":75, "childPolicy":[{"cds_experimental":{"cluster":"cluster_1"}}] },
+	  "cluster_2" : { "weight":25, "childPolicy":[{"cds_experimental":{"cluster":"cluster_2"}}] }
+	} }`
+	wtConfig1, _  = wtConfigParser.ParseConfig([]byte(wtConfigJSON1))
+	wtConfigJSON2 = `{
+    "targets": {
+      "cluster_1": { "weight":99, "childPolicy":[{"cds_experimental":{"cluster":"cluster_1"}}] },
+      "cluster_3": { "weight":1, "childPolicy":[{"cds_experimental":{"cluster":"cluster_3"}}] }
+    } }`
+	wtConfig2, _ = wtConfigParser.ParseConfig([]byte(wtConfigJSON2))
+)
+
+func Test_parseConfig(t *testing.T) {
+	tests := []struct {
+		name    string
+		js      string
+		want    *lbConfig
+		wantErr bool
+	}{
+		{
+			name:    "empty json",
+			js:      "",
+			want:    nil,
+			wantErr: true,
+		},
+		{
+			name: "more than one path matcher", // Path matcher is oneof, so this is an error.
+			js: `{
+              "Action":{
+                "cds:cluster_1":{ "childPolicy":[{ "cds_experimental":{"cluster":"cluster_1"} }]}
+              },
+              "Route": [{
+                "path":"/service_1/method_1",
+                "prefix":"/service_1/",
+                "action":"cds:cluster_1"
+              }]
+            }`,
+			want:    nil,
+			wantErr: true,
+		},
+		{
+			name: "no path matcher",
+			js: `{
+              "Action":{
+                "cds:cluster_1":{ "childPolicy":[{ "cds_experimental":{"cluster":"cluster_1"} }]}
+              },
+              "Route": [{
+                "action":"cds:cluster_1"
+              }]
+            }`,
+			want:    nil,
+			wantErr: true,
+		},
+		{
+			name: "route action not found in action list",
+			js: `{
+              "Action":{},
+              "Route": [{
+                "path":"/service_1/method_1",
+                "action":"cds:cluster_1"
+              }]
+            }`,
+			want:    nil,
+			wantErr: true,
+		},
+		{
+			name: "action list contains action not used",
+			js: `{
+              "Action":{
+                "cds:cluster_1":{ "childPolicy":[{ "cds_experimental":{"cluster":"cluster_1"} }]},
+                "cds:cluster_not_used":{ "childPolicy":[{ "cds_experimental":{"cluster":"cluster_1"} }]}
+              },
+              "Route": [{
+                "path":"/service_1/method_1",
+                "action":"cds:cluster_1"
+              }]
+            }`,
+			want:    nil,
+			wantErr: true,
+		},
+
+		{
+			name: "no header specifier in header matcher",
+			js: `{
+              "Action":{
+                "cds:cluster_1":{ "childPolicy":[{ "cds_experimental":{"cluster":"cluster_1"} }]}
+              },
+              "Route": [{
+                "path":"/service_1/method_1",
+                "headers":[{"name":"header-1"}],
+                "action":"cds:cluster_1"
+              }]
+            }`,
+			want:    nil,
+			wantErr: true,
+		},
+		{
+			name: "more than one header specifier in header matcher",
+			js: `{
+              "Action":{
+                "cds:cluster_1":{ "childPolicy":[{ "cds_experimental":{"cluster":"cluster_1"} }]}
+              },
+              "Route": [{
+                "path":"/service_1/method_1",
+                "headers":[{"name":"header-1", "prefixMatch":"a", "suffixMatch":"b"}],
+                "action":"cds:cluster_1"
+              }]
+            }`,
+			want:    nil,
+			wantErr: true,
+		},
+
+		{
+			name: "OK with path matchers only",
+			js:   testJSONConfig,
+			want: &lbConfig{
+				routes: []route{
+					{path: "/service_1/method_1", action: "cds:cluster_1"},
+					{path: "/service_1/method_2", action: "cds:cluster_1"},
+					{prefix: "/service_2/method_1", action: "weighted:cluster_1_cluster_2_1"},
+					{prefix: "/service_2", action: "weighted:cluster_1_cluster_2_1"},
+					{regex: "^/service_2/method_3$", action: "weighted:cluster_1_cluster_3_1"},
+				},
+				actions: map[string]action{
+					"cds:cluster_1": {ChildPolicy: &internalserviceconfig.BalancerConfig{
+						Name: cdsName, Config: cdsConfig1},
+					},
+					"weighted:cluster_1_cluster_2_1": {ChildPolicy: &internalserviceconfig.BalancerConfig{
+						Name: wtName, Config: wtConfig1},
+					},
+					"weighted:cluster_1_cluster_3_1": {ChildPolicy: &internalserviceconfig.BalancerConfig{
+						Name: wtName, Config: wtConfig2},
+					},
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "OK with all matchers",
+			js:   testJSONConfigWithAllMatchers,
+			want: &lbConfig{
+				routes: []route{
+					{path: "/service_1/method_1", action: "cds:cluster_1"},
+					{prefix: "/service_2/method_1", action: "cds:cluster_1"},
+					{regex: "^/service_2/method_3$", action: "cds:cluster_1"},
+
+					{prefix: "", headers: []headerMatcher{{name: "header-1", exactMatch: "value-1", invertMatch: true}}, action: "cds:cluster_2"},
+					{prefix: "", headers: []headerMatcher{{name: "header-1", regexMatch: "^value-1$"}}, action: "cds:cluster_2"},
+					{prefix: "", headers: []headerMatcher{{name: "header-1", rangeMatch: &int64Range{start: -1, end: 7}}}, action: "cds:cluster_3"},
+					{prefix: "", headers: []headerMatcher{{name: "header-1", presentMatch: true}}, action: "cds:cluster_3"},
+					{prefix: "", headers: []headerMatcher{{name: "header-1", prefixMatch: "value-1"}}, action: "cds:cluster_2"},
+					{prefix: "", headers: []headerMatcher{{name: "header-1", suffixMatch: "value-1"}}, action: "cds:cluster_2"},
+					{prefix: "", fraction: newUInt32P(31415), action: "cds:cluster_3"},
+				},
+				actions: map[string]action{
+					"cds:cluster_1": {ChildPolicy: &internalserviceconfig.BalancerConfig{
+						Name: cdsName, Config: cdsConfig1},
+					},
+					"cds:cluster_2": {ChildPolicy: &internalserviceconfig.BalancerConfig{
+						Name: cdsName, Config: cdsConfig2},
+					},
+					"cds:cluster_3": {ChildPolicy: &internalserviceconfig.BalancerConfig{
+						Name: cdsName, Config: cdsConfig3},
+					},
+				},
+			},
+			wantErr: false,
+		},
+	}
+
+	cmpOptions := []cmp.Option{cmp.AllowUnexported(lbConfig{}, route{}, headerMatcher{}, int64Range{})}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := parseConfig([]byte(tt.js))
+			if (err != nil) != tt.wantErr {
+				t.Errorf("parseConfig() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !cmp.Equal(got, tt.want, cmpOptions...) {
+				t.Errorf("parseConfig() got unexpected result, diff: %v", cmp.Diff(got, tt.want, cmpOptions...))
+			}
+		})
+	}
+}
+
+func newUInt32P(i uint32) *uint32 {
+	return &i
+}
diff --git a/xds/internal/client/client_watchers_rds.go b/xds/internal/client/client_watchers_rds.go
index 06f5279..4c8f5d4 100644
--- a/xds/internal/client/client_watchers_rds.go
+++ b/xds/internal/client/client_watchers_rds.go
@@ -22,6 +22,24 @@
 	"time"
 )
 
+// Int64Range is a range for header range match.
+type Int64Range struct {
+	Start int64 `json:"start"`
+	End   int64 `json:"end"`
+}
+
+// HeaderMatcher represents header matchers.
+type HeaderMatcher struct {
+	Name         string      `json:"name"`
+	InvertMatch  *bool       `json:"invertMatch,omitempty"`
+	ExactMatch   *string     `json:"exactMatch,omitempty"`
+	RegexMatch   *string     `json:"regexMatch,omitempty"`
+	PrefixMatch  *string     `json:"prefixMatch,omitempty"`
+	SuffixMatch  *string     `json:"suffixMatch,omitempty"`
+	RangeMatch   *Int64Range `json:"rangeMatch,omitempty"`
+	PresentMatch *bool       `json:"presentMatch,omitempty"`
+}
+
 type rdsUpdate struct {
 	weightedCluster map[string]uint32
 }