Repository: googleapis/gapic-generator-go
Pull request #887 (2022-02-03 17:28:13)

feat: add dynamic routing header generation
Add support for the [google.api.routing](https://github.com/googleapis/googleapis/blob/master/google/api/routing.proto) annotation. In short, this annotation allows API producers to explicitly define those request fields should be include in request headers/metadata. Furthermore, it enables API producers to define simple segment matchers (primarily for resource name values) that are generated into regular expressions in the client that extract segments of a field's value to be sent in the header. Finally, it enables API producers to specify a key other than the field name to use in the header key-value-pair. In relation to the implicit headers (extraced from google.api.http annotations), the google.api.routing annotation takes precedence. Please read the annotation documentation for more information on the annotation resolution logic.

- This includes a `gapic-showcase` integration test

noahdietz: Great great start to this Alice! This is a tough feature and you are integrating it marvelously.

Patch:
diff --git a/internal/gengapic/gengapic.go b/internal/gengapic/gengapic.go
index 532e399..c705bce 100644
--- a/internal/gengapic/gengapic.go
+++ b/internal/gengapic/gengapic.go
@@ -243,12 +243,49 @@ func (g *generator) deadline(s, m string) {
 	g.imports[pbinfo.ImportSpec{Path: "time"}] = true
 }
 
-func (g *generator) insertMetadata(m *descriptor.MethodDescriptorProto) error {
-	headers, err := parseRequestHeaders(m)
+func (g *generator) getFormattedValue(m *descriptor.MethodDescriptorProto, field string, accessor string) (string, error) {
+	f := g.lookupField(m.GetInputType(), field)
+	value := ""
+	// TODO(noahdietz): need to handle []byte for TYPE_BYTES.
+	switch f.GetType() {
+	case descriptor.FieldDescriptorProto_TYPE_STRING:
+		value = fmt.Sprintf("url.QueryEscape(%s)", accessor)
+	case descriptor.FieldDescriptorProto_TYPE_DOUBLE:
+		// Double and float are handled the same way.
+		fallthrough
+	case descriptor.FieldDescriptorProto_TYPE_FLOAT:
+		// Format the floating point value with mode 'g' to allow for
+		// exponent formatting when necessary, and decimal when adequate.
+		// QueryEscape the resulting string in case there is a '+' in the
+		// exponent.
+		// See golang.org/pkg/fmt for more information on formatting.
+		value = fmt.Sprintf(`url.QueryEscape(fmt.Sprintf("%%g", %s))`, accessor)
+	case descriptor.FieldDescriptorProto_TYPE_ENUM:
+		en := g.descInfo.Type[f.GetTypeName()]
+
+		n, imp, err := g.descInfo.NameSpec(en)
+		if err != nil {
+			return "", err
+		}
+		g.imports[imp] = true
+
+		// protobuf Go generates a mapping from number to string
+		// representation of an enum, in UPPER_SNAKE_CASE form. The map
+		// is named with the enum name and the _name suffix. If it is a
+		// nested enum, the name is prefixed with the parent message name.
+		// For example, Severity_name or Error_Severity_name.
+		value = fmt.Sprintf("%s.%s_name[int32(%s)]", imp.Name, n, accessor)
+	default:
+		value = accessor
+	}
+	return value, nil
+}
+
+func (g *generator) insertImplicitRequestHeaders(m *descriptor.MethodDescriptorProto) error {
+	headers, err := parseImplicitRequestHeaders(m)
 	if err != nil {
 		return err
 	}
-
 	if len(headers) > 0 {
 		seen := map[string]bool{}
 		var formats, values strings.Builder
@@ -259,62 +296,82 @@ func (g *generator) insertMetadata(m *descriptor.MethodDescriptorProto) error {
 				continue
 			}
 			seen[field] = true
-
 			accessor := fmt.Sprintf("req%s", fieldGetter(field))
-			f := g.lookupField(m.GetInputType(), field)
-
-			// TODO(noahdietz): need to handle []byte for TYPE_BYTES.
-			switch f.GetType() {
-			case descriptor.FieldDescriptorProto_TYPE_STRING:
-				accessor = fmt.Sprintf("url.QueryEscape(%s)", accessor)
-			case descriptor.FieldDescriptorProto_TYPE_DOUBLE:
-				// Double and float are handled the same way.
-				fallthrough
-			case descriptor.FieldDescriptorProto_TYPE_FLOAT:
-				// Format the floating point value with mode 'g' to allow for
-				// exponent formatting when necessary, and decimal when adequate.
-				// QueryEscape the resulting string in case there is a '+' in the
-				// exponent.
-				// See golang.org/pkg/fmt for more information on formatting.
-				accessor = fmt.Sprintf(`url.QueryEscape(fmt.Sprintf("%%g", %s))`, accessor)
-			case descriptor.FieldDescriptorProto_TYPE_ENUM:
-				en := g.descInfo.Type[f.GetTypeName()]
-
-				n, imp, err := g.descInfo.NameSpec(en)
-				if err != nil {
-					return err
-				}
-				g.imports[imp] = true
-
-				// protobuf Go generates a mapping from number to string
-				// representation of an enum, in UPPER_SNAKE_CASE form. The map
-				// is named with the enum name and the _name suffix. If it is a
-				// nested enum, the name is prefixed with the parent message name.
-				// For example, Severity_name or Error_Severity_name.
-				accessor = fmt.Sprintf("%s.%s_name[int32(%s)]", imp.Name, n, accessor)
+			accessor, err = g.getFormattedValue(m, field, accessor)
+			if err != nil {
+				return err
 			}
-
-			// URL encode key & values separately per aip.dev/4222.
-			// Encode the key ahead of time to reduce clutter
-			// and because it will likely never be necessary
 			fmt.Fprintf(&values, " %q, %s,", url.QueryEscape(field), accessor)
 			formats.WriteString("%s=%v&")
 		}
 		// Trim the trailing comma and ampersand symbols.
 		f := formats.String()[:formats.Len()-1]
 		v := values.String()[:values.Len()-1]
-
 		g.printf("md := metadata.Pairs(\"x-goog-request-params\", fmt.Sprintf(%q,%s))", f, v)
-		g.printf("ctx = insertMetadata(ctx, c.xGoogMetadata, md)")
+	}
+	return nil
+}
+
+func (g *generator) insertDynamicRequestHeaders(m *descriptor.MethodDescriptorProto) error {
+	headers, err := parseDynamicRequestHeaders(m)
+	if err != nil {
+		return err
+	}
+	if len(headers) > 0 {
+		g.printf(`routingHeaders := ""`)
+		g.printf("routingHeadersMap := make(map[string]string)")
+		for i := range headers {
+			namedCaptureRegex := headers[i][0]
+			field := headers[i][1]
+			headerName := headers[i][2]
+			accessor := fmt.Sprintf("req%s", fieldGetter(field))
+			regexHelper := fmt.Sprintf("reg.FindStringSubmatch(%s)[1]", accessor)
+			regexHelper, err = g.getFormattedValue(m, field, regexHelper)
+			if err != nil {
+				return err
+			}
+			// There could be an edge case where the request field is empty and the path template is a wildcard. In that case, we still don't want to send an empty header name.
+			g.printf("if reg := regexp.MustCompile(%q); reg.MatchString(%s) && len(%s) > 0 {", namedCaptureRegex, accessor, regexHelper)
+			g.printf("  routingHeadersMap[%q] = %s", headerName, regexHelper)
+			g.printf("}")
+		}
+		g.printf("for headerName, headerValue := range routingHeadersMap {")
+		g.printf(`  routingHeaders = fmt.Sprintf("%%s%%s=%%s&", routingHeaders, headerName, headerValue)`)
+		g.printf("}")
+		g.printf(`routingHeaders = strings.TrimSuffix(routingHeaders, "&")`)
+		g.imports[pbinfo.ImportSpec{Path: "strings"}] = true
+		g.printf("md := metadata.Pairs(\"x-goog-request-params\", routingHeaders)")
+		g.imports[pbinfo.ImportSpec{Path: "regexp"}] = true
+	}
+	return nil
+}
 
+func (g *generator) insertMetadata(m *descriptor.MethodDescriptorProto) error {
+	headers, err := parseImplicitRequestHeaders(m)
+	if err != nil {
+		return err
+	}
+	// If dynamic routing annotation exists, it supercedes and replaces other request headers.
+	if dynamicRequestHeadersExist(m) {
+		headers, err = parseDynamicRequestHeaders(m)
+		if err != nil {
+			return err
+		}
+	}
+
+	if len(headers) > 0 {
+		insertHeaders := g.insertImplicitRequestHeaders
+		if dynamicRequestHeadersExist(m) {
+			insertHeaders = g.insertDynamicRequestHeaders
+		}
+		insertHeaders(m)
+		g.printf("")
+		g.printf("ctx = insertMetadata(ctx, c.xGoogMetadata, md)")
 		g.imports[pbinfo.ImportSpec{Path: "fmt"}] = true
 		g.imports[pbinfo.ImportSpec{Path: "net/url"}] = true
-
 		return nil
 	}
-
 	g.printf("ctx = insertMetadata(ctx, c.xGoogMetadata)")
-
 	return nil
 }
 
@@ -498,7 +555,7 @@ func (g *generator) returnType(m *descriptor.MethodDescriptorProto) (string, err
 	return retTyp, nil
 }
 
-func parseRequestHeaders(m *descriptor.MethodDescriptorProto) ([][]string, error) {
+func parseImplicitRequestHeaders(m *descriptor.MethodDescriptorProto) ([][]string, error) {
 	var matches [][]string
 
 	eHTTP := proto.GetExtension(m.GetOptions(), annotations.E_Http)
@@ -531,3 +588,30 @@ func parseRequestHeaders(m *descriptor.MethodDescriptorProto) ([][]string, error
 
 	return matches, nil
 }
+
+// Determine whether routing annotation exists
+func dynamicRequestHeadersExist(m *descriptor.MethodDescriptorProto) bool {
+	return proto.HasExtension(m.GetOptions(), annotations.E_Routing)
+}
+
+// Parse routing annotations to be used as request headers
+func parseDynamicRequestHeaders(m *descriptor.MethodDescriptorProto) ([][]string, error) {
+	var matches [][]string
+
+	reqHeaders := proto.GetExtension(m.GetOptions(), annotations.E_Routing)
+	routingRule := reqHeaders.(*annotations.RoutingRule)
+	routingParameter := routingRule.GetRoutingParameters()
+
+	for _, param := range routingParameter {
+		pathTemplateRegex := convertPathTemplateToRegex(param.GetPathTemplate())
+		fieldReq := param.Field
+		headerName := getHeaderName(param.GetPathTemplate())
+		if len(headerName) < 1 {
+			headerName = fieldReq
+		}
+		paramSlice := []string{pathTemplateRegex, fieldReq, headerName}
+		matches = append(matches, paramSlice)
+	}
+
+	return matches, nil
+}
diff --git a/internal/gengapic/helpers.go b/internal/gengapic/helpers.go
index 4e55f91..926a0e2 100644
--- a/internal/gengapic/helpers.go
+++ b/internal/gengapic/helpers.go
@@ -15,6 +15,7 @@
 package gengapic
 
 import (
+	"regexp"
 	"strings"
 	"unicode"
 	"unicode/utf8"
@@ -162,3 +163,50 @@ func isRequired(field *descriptor.FieldDescriptorProto) bool {
 
 	return false
 }
+
+// This takes in a path template from a routing annotation and converts it into a regex string.
+// The named capture is the named segment portion for the header itself.
+func convertPathTemplateToRegex(pattern string) string {
+	// If path template doesn't exist, then use a wildcard.
+	if pattern == "" {
+		return "(.*)"
+	}
+	// Replace name of header to named capture.
+	regexPattern := strings.ReplaceAll(pattern, "{", "(?P<")
+	regexPattern = strings.ReplaceAll(regexPattern, "}", ")")
+	// If not named, then entire segment is a wildcard.
+	if !strings.Contains(pattern, "=") || !strings.Contains(pattern, "/") {
+		regexPattern = strings.ReplaceAll(regexPattern, "*", "")
+		regexPattern = strings.ReplaceAll(regexPattern, "=", "")
+		regexPattern = strings.ReplaceAll(regexPattern, ")", ">.*)")
+		return regexPattern
+	}
+	// Replace segment wildcards with regex equivalent
+	regexPattern = strings.ReplaceAll(regexPattern, "/**", "(?:/.*)?")
+	regexPattern = strings.ReplaceAll(regexPattern, "/*", "/[^/]+")
+	regexPattern = strings.ReplaceAll(regexPattern, "=**", ">.*")
+	regexPattern = strings.ReplaceAll(regexPattern, "=*", ">[^/]+")
+	regexPattern = strings.ReplaceAll(regexPattern, "=", ">")
+	regexPattern = strings.ReplaceAll(regexPattern, "**", ".*")
+	return regexPattern
+}
+
+// This intakes a path template and returns the name of the header to be returned.
+func getHeaderName(pattern string) string {
+	curlyBraceRegex := regexp.MustCompile("\\{([^}]+)\\}")
+	// Path template should only contain one name (e.g. at most one `=`) or
+	// a collectionId, and should be contained within curly braces.
+	if strings.Count(pattern, "=") > 1 || !curlyBraceRegex.MatchString(pattern) {
+		return ""
+	}
+	// curlyBraceSegment returns the named capture within the path template that is within the curly braces.
+	curlyBraceSegment := curlyBraceRegex.FindStringSubmatch(pattern)[1]
+	// If there is no equal sign, then the path template is a collectionId which is its own name.
+	// and both the named segment and path template are wildcards.
+	if strings.Count(pattern, "=") < 1 {
+		return curlyBraceSegment
+	}
+	getBeforeEqualsSign := regexp.MustCompile("(?P<before>[^=]*)=.*")
+	matches := getBeforeEqualsSign.FindStringSubmatch(curlyBraceSegment)
+	return matches[1]
+}


Test patch:
diff --git a/cmd/protoc-gen-go_cli/test.sh b/cmd/protoc-gen-go_cli/test.sh
index 50c1ee8..6a711a3 100755
--- a/cmd/protoc-gen-go_cli/test.sh
+++ b/cmd/protoc-gen-go_cli/test.sh
@@ -30,7 +30,7 @@ mkdir -p "$OUT/showcase"
 # make test proto directories
 mkdir -p $SHOW_PROTOS
 
-SHOWCASE_VERSION=0.9.0
+SHOWCASE_VERSION=0.19.0
 
 # download gapic-showcase proto descriptor set
 curl -L -O https://github.com/googleapis/gapic-showcase/releases/download/v$SHOWCASE_VERSION/gapic-showcase-$SHOWCASE_VERSION.desc
diff --git a/internal/gengapic/gengapic_test.go b/internal/gengapic/gengapic_test.go
index 4087b2f..8bd21af 100644
--- a/internal/gengapic/gengapic_test.go
+++ b/internal/gengapic/gengapic_test.go
@@ -254,6 +254,44 @@ func TestGenMethod(t *testing.T) {
 	}
 	proto.SetExtension(opts, annotations.E_Http, ext)
 
+	optsGetAnotherThing := &descriptor.MethodOptions{}
+	extGetAnotherThing := &annotations.RoutingRule{
+		RoutingParameters: []*annotations.RoutingParameter{
+			{
+				Field: "other",
+			},
+			{
+				Field:        "other",
+				PathTemplate: "{name=projects/*}/foos",
+			},
+			{
+				Field:        "another",
+				PathTemplate: "{foo_name=projects/*}/bars/*/**",
+			},
+			{
+				Field:        "another",
+				PathTemplate: "{foo_name=projects/*/foos/*}/bars/*/**",
+			},
+			{
+				Field:        "another",
+				PathTemplate: "{foo_name=**}",
+			},
+			{
+				Field:        "field_name.nested",
+				PathTemplate: "{nested_name=**}",
+			},
+			{
+				Field:        "field_name.nested",
+				PathTemplate: "{part_of_nested=projects/*}/bars",
+			},
+		},
+	}
+	proto.SetExtension(optsGetAnotherThing, annotations.E_Routing, extGetAnotherThing)
+
+	optsGetManyOtherThings := &descriptor.MethodOptions{}
+	extGetManyOtherThings := &annotations.RoutingRule{}
+	proto.SetExtension(optsGetManyOtherThings, annotations.E_Routing, extGetManyOtherThings)
+
 	file := &descriptor.FileDescriptorProto{
 		Package: proto.String("my.pkg"),
 		Options: &descriptor.FileOptions{
@@ -413,6 +451,37 @@ methods:
 				{Name: "mypackagepb", Path: "mypackage"}: true,
 			},
 		},
+		// Test for dynamic routing header annotations.
+		{
+			m: &descriptor.MethodDescriptorProto{
+				Name:       proto.String("GetAnotherThing"),
+				InputType:  proto.String(".my.pkg.InputType"),
+				OutputType: proto.String(".my.pkg.OutputType"),
+				Options:    optsGetAnotherThing,
+			},
+			imports: map[pbinfo.ImportSpec]bool{
+				{Path: "fmt"}:                            true,
+				{Path: "net/url"}:                        true,
+				{Path: "regexp"}:                         true,
+				{Path: "strings"}:                        true,
+				{Path: "time"}:                           true,
+				{Name: "mypackagepb", Path: "mypackage"}: true,
+			},
+		},
+		// Test for empty dynamic routing annotation, so no headers should be sent.
+		{
+			m: &descriptor.MethodDescriptorProto{
+				Name:       proto.String("GetManyOtherThings"),
+				InputType:  proto.String(".my.pkg.PageInputType"),
+				OutputType: proto.String(".my.pkg.PageOutputType"),
+				Options:    optsGetManyOtherThings,
+			},
+			imports: map[pbinfo.ImportSpec]bool{
+				{Path: "google.golang.org/api/iterator"}:   true,
+				{Path: "google.golang.org/protobuf/proto"}: true,
+				{Name: "mypackagepb", Path: "mypackage"}:   true,
+			},
+		},
 	} {
 		g.reset()
 		g.descInfo.ParentElement[tst.m] = serv
@@ -715,7 +784,7 @@ func TestIsLRO(t *testing.T) {
 	}
 }
 
-func Test_parseRequestHeaders(t *testing.T) {
+func Test_parseImplicitRequestHeaders(t *testing.T) {
 	for _, tst := range []struct {
 		name, pattern string
 		want          [][]string
@@ -754,14 +823,102 @@ func Test_parseRequestHeaders(t *testing.T) {
 			setHTTPOption(m.Options, tst.pattern)
 		}
 
-		got, err := parseRequestHeaders(m)
+		got, err := parseImplicitRequestHeaders(m)
 		if err != nil {
 			t.Error(err)
 			continue
 		}
 
 		if diff := cmp.Diff(got, tst.want); diff != "" {
-			t.Errorf("parseRequestHeaders(%s) = got(-), want(+):\n%s", tst.name, diff)
+			t.Errorf("parseImplicitRequestHeaders(%s) = got(-), want(+):\n%s", tst.name, diff)
+		}
+	}
+}
+
+func Test_ContainsDynamicRequestHeaders(t *testing.T) {
+	methodNoRule := &descriptor.MethodDescriptorProto{
+		Name:       proto.String("MethodNoRule"),
+		InputType:  proto.String(".my.pkg.InputType"),
+		OutputType: proto.String(".google.longrunning.Operation"),
+		Options:    &descriptor.MethodOptions{},
+	}
+	proto.SetExtension(methodNoRule.GetOptions(), annotations.E_Http, &annotations.HttpRule{
+		Pattern: &annotations.HttpRule_Post{
+			Post: "/v1/operations",
+		},
+	})
+	methodOneRule := &descriptor.MethodDescriptorProto{
+		Name:       proto.String("MethodOneRule"),
+		InputType:  proto.String(".my.pkg.InputType"),
+		OutputType: proto.String(".google.longrunning.Operation"),
+		Options:    &descriptor.MethodOptions{},
+	}
+	extRoutingOneRule := &annotations.RoutingRule{
+		RoutingParameters: []*annotations.RoutingParameter{
+			{
+				Field:        "other",
+				PathTemplate: "{routing_id=**}",
+			},
+		},
+	}
+	proto.SetExtension(methodOneRule.GetOptions(), annotations.E_Routing, extRoutingOneRule)
+	proto.SetExtension(methodOneRule.GetOptions(), annotations.E_Http, &annotations.HttpRule{
+		Pattern: &annotations.HttpRule_Post{
+			Post: "/v1/operations",
+		},
+	})
+	methodEmptyRule := &descriptor.MethodDescriptorProto{
+		Options: &descriptor.MethodOptions{},
+	}
+	extRoutingEmptyRule := &annotations.RoutingRule{
+		RoutingParameters: []*annotations.RoutingParameter{
+			{},
+		},
+	}
+	proto.SetExtension(methodEmptyRule.GetOptions(), annotations.E_Routing, extRoutingEmptyRule)
+	methodMultipleRules := &descriptor.MethodDescriptorProto{
+		Options: &descriptor.MethodOptions{},
+	}
+	extRoutingMultipleRules := &annotations.RoutingRule{
+		RoutingParameters: []*annotations.RoutingParameter{
+			{
+				Field: "other",
+			},
+			{
+				Field:        "other",
+				PathTemplate: "{name=projects/*}/foos",
+			},
+			{
+				Field:        "another",
+				PathTemplate: "{foo_name=projects/*}/bars/*/**",
+			},
+		},
+	}
+	proto.SetExtension(methodMultipleRules.GetOptions(), annotations.E_Routing, extRoutingMultipleRules)
+	for _, tst := range []struct {
+		want   bool
+		method *descriptor.MethodDescriptorProto
+	}{
+		{
+			method: methodOneRule,
+			want:   true,
+		},
+		{
+			method: methodEmptyRule,
+			want:   true,
+		},
+		{
+			method: methodMultipleRules,
+			want:   true,
+		},
+		{
+			method: methodNoRule,
+			want:   false,
+		},
+	} {
+		got := dynamicRequestHeadersExist(tst.method)
+		if diff := cmp.Diff(got, tst.want); diff != "" {
+			t.Errorf("%s: got(-),want(+):\n%s", tst.method, diff)
 		}
 	}
 }
diff --git a/internal/gengapic/helpers_test.go b/internal/gengapic/helpers_test.go
index e884d4a..467fea6 100644
--- a/internal/gengapic/helpers_test.go
+++ b/internal/gengapic/helpers_test.go
@@ -192,3 +192,83 @@ func TestIsRequired(t *testing.T) {
 		}
 	}
 }
+
+func TestConvertPathTemplateToRegex(t *testing.T) {
+	for _, tst := range []struct {
+		in   string
+		want string
+	}{
+		{
+			in:   "",
+			want: "(.*)",
+		},
+		{
+			in:   "{foo}",
+			want: "(?P<foo>.*)",
+		},
+		{
+			in:   "{foo=*}",
+			want: "(?P<foo>.*)",
+		},
+		{
+			in:   "{foo=**}",
+			want: "(?P<foo>.*)",
+		},
+		{
+			in:   "{foo=projects/*}/bars",
+			want: "(?P<foo>projects/[^/]+)/bars",
+		},
+		{
+			in:   "{database=projects/*/databases/*}/documents/*/**",
+			want: "(?P<database>projects/[^/]+/databases/[^/]+)/documents/[^/]+(?:/.*)?",
+		},
+		{
+			in:   "projects/*/foos/*/{bar_name=bars/*}/**",
+			want: "projects/[^/]+/foos/[^/]+/(?P<bar_name>bars/[^/]+)(?:/.*)?",
+		},
+	} {
+		if got := convertPathTemplateToRegex(tst.in); got != tst.want {
+			t.Errorf("convertPathTemplateToRegex(%v): got %v, want %v", tst.in, got, tst.want)
+		}
+	}
+}
+
+func TestGetHeaderName(t *testing.T) {
+	for _, tst := range []struct {
+		in   string
+		want string
+	}{
+		{
+			in:   "{foo}",
+			want: "foo",
+		},
+		{
+			in:   "foo",
+			want: "",
+		},
+		{
+			in:   "{foo=bar}",
+			want: "foo",
+		},
+		{
+			in:   "{foo=*}",
+			want: "foo",
+		},
+		{
+			in:   "test/{database=projects/*/databases/*}/documents/*/**",
+			want: "database",
+		},
+		{
+			in:   "{new_name_match=projects/*/instances/*/tables/*}",
+			want: "new_name_match",
+		},
+		{
+			in:   "profiles/{routing_id=*}",
+			want: "routing_id",
+		},
+	} {
+		if got := getHeaderName(tst.in); got != tst.want {
+			t.Errorf("getHeaderName(%v): got %v, want %v", tst.in, got, tst.want)
+		}
+	}
+}
diff --git a/internal/gengapic/testdata/method_GetAnotherThing.want b/internal/gengapic/testdata/method_GetAnotherThing.want
new file mode 100644
index 0000000..d419367
--- /dev/null
+++ b/internal/gengapic/testdata/method_GetAnotherThing.want
@@ -0,0 +1,49 @@
+func (c *fooGRPCClient) GetAnotherThing(ctx context.Context, req *mypackagepb.InputType, opts ...gax.CallOption) (*mypackagepb.OutputType, error) {
+	if _, ok := ctx.Deadline(); !ok && !c.disableDeadlines {
+		cctx, cancel := context.WithTimeout(ctx, 10000 * time.Millisecond)
+		defer cancel()
+		ctx = cctx
+	}
+	routingHeaders := ""
+	routingHeadersMap := make(map[string]string)
+	if reg := regexp.MustCompile("(.*)"); reg.MatchString(req.GetOther()) && len(url.QueryEscape(reg.FindStringSubmatch(req.GetOther())[1])) > 0 {
+		routingHeadersMap["other"] = url.QueryEscape(reg.FindStringSubmatch(req.GetOther())[1])
+	}
+	if reg := regexp.MustCompile("(?P<name>projects/[^/]+)/foos"); reg.MatchString(req.GetOther()) && len(url.QueryEscape(reg.FindStringSubmatch(req.GetOther())[1])) > 0 {
+		routingHeadersMap["name"] = url.QueryEscape(reg.FindStringSubmatch(req.GetOther())[1])
+	}
+	if reg := regexp.MustCompile("(?P<foo_name>projects/[^/]+)/bars/[^/]+(?:/.*)?"); reg.MatchString(req.GetAnother()) && len(url.QueryEscape(reg.FindStringSubmatch(req.GetAnother())[1])) > 0 {
+		routingHeadersMap["foo_name"] = url.QueryEscape(reg.FindStringSubmatch(req.GetAnother())[1])
+	}
+	if reg := regexp.MustCompile("(?P<foo_name>projects/[^/]+/foos/[^/]+)/bars/[^/]+(?:/.*)?"); reg.MatchString(req.GetAnother()) && len(url.QueryEscape(reg.FindStringSubmatch(req.GetAnother())[1])) > 0 {
+		routingHeadersMap["foo_name"] = url.QueryEscape(reg.FindStringSubmatch(req.GetAnother())[1])
+	}
+	if reg := regexp.MustCompile("(?P<foo_name>.*)"); reg.MatchString(req.GetAnother()) && len(url.QueryEscape(reg.FindStringSubmatch(req.GetAnother())[1])) > 0 {
+		routingHeadersMap["foo_name"] = url.QueryEscape(reg.FindStringSubmatch(req.GetAnother())[1])
+	}
+	if reg := regexp.MustCompile("(?P<nested_name>.*)"); reg.MatchString(req.GetFieldName().GetNested()) && len(url.QueryEscape(reg.FindStringSubmatch(req.GetFieldName().GetNested())[1])) > 0 {
+		routingHeadersMap["nested_name"] = url.QueryEscape(reg.FindStringSubmatch(req.GetFieldName().GetNested())[1])
+	}
+	if reg := regexp.MustCompile("(?P<part_of_nested>projects/[^/]+)/bars"); reg.MatchString(req.GetFieldName().GetNested()) && len(url.QueryEscape(reg.FindStringSubmatch(req.GetFieldName().GetNested())[1])) > 0 {
+		routingHeadersMap["part_of_nested"] = url.QueryEscape(reg.FindStringSubmatch(req.GetFieldName().GetNested())[1])
+	}
+	for headerName, headerValue := range routingHeadersMap {
+		routingHeaders = fmt.Sprintf("%s%s=%s&", routingHeaders, headerName, headerValue)
+	}
+	routingHeaders = strings.TrimSuffix(routingHeaders, "&")
+	md := metadata.Pairs("x-goog-request-params", routingHeaders)
+
+	ctx = insertMetadata(ctx, c.xGoogMetadata, md)
+	opts = append((*c.CallOptions).GetAnotherThing[0:len((*c.CallOptions).GetAnotherThing):len((*c.CallOptions).GetAnotherThing)], opts...)
+	var resp *mypackagepb.OutputType
+	err := gax.Invoke(ctx, func(ctx context.Context, settings gax.CallSettings) error {
+		var err error
+		resp, err = c.fooClient.GetAnotherThing(ctx, req, settings.GRPC...)
+		return err
+	}, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return resp, nil
+}
+
diff --git a/internal/gengapic/testdata/method_GetEmptyThing.want b/internal/gengapic/testdata/method_GetEmptyThing.want
index 3a099f8..8bdfa55 100644
--- a/internal/gengapic/testdata/method_GetEmptyThing.want
+++ b/internal/gengapic/testdata/method_GetEmptyThing.want
@@ -5,6 +5,7 @@ func (c *fooGRPCClient) GetEmptyThing(ctx context.Context, req *mypackagepb.Inpu
 		ctx = cctx
 	}
 	md := metadata.Pairs("x-goog-request-params", fmt.Sprintf("%s=%v&%s=%v&%s=%v&%s=%v&%s=%v&%s=%v", "field_name.nested", url.QueryEscape(req.GetFieldName().GetNested()), "other", url.QueryEscape(req.GetOther()), "another", url.QueryEscape(req.GetAnother()), "biz", url.QueryEscape(fmt.Sprintf("%g", req.GetBiz())), "top_level_enum", mypackagepb.TopLevelEnum_name[int32(req.GetTopLevelEnum())], "nested_enum", mypackagepb.InputType_NestedEnum_name[int32(req.GetNestedEnum())]))
+
 	ctx = insertMetadata(ctx, c.xGoogMetadata, md)
 	opts = append((*c.CallOptions).GetEmptyThing[0:len((*c.CallOptions).GetEmptyThing):len((*c.CallOptions).GetEmptyThing)], opts...)
 	err := gax.Invoke(ctx, func(ctx context.Context, settings gax.CallSettings) error {
diff --git a/internal/gengapic/testdata/method_GetManyOtherThings.want b/internal/gengapic/testdata/method_GetManyOtherThings.want
new file mode 100644
index 0000000..14cbe6a
--- /dev/null
+++ b/internal/gengapic/testdata/method_GetManyOtherThings.want
@@ -0,0 +1,90 @@
+func (c *fooGRPCClient) GetManyOtherThings(ctx context.Context, req *mypackagepb.PageInputType, opts ...gax.CallOption) *StringIterator {
+	ctx = insertMetadata(ctx, c.xGoogMetadata)
+	opts = append((*c.CallOptions).GetManyOtherThings[0:len((*c.CallOptions).GetManyOtherThings):len((*c.CallOptions).GetManyOtherThings)], opts...)
+	it := &StringIterator{}
+	req = proto.Clone(req).(*mypackagepb.PageInputType)
+	it.InternalFetch = func(pageSize int, pageToken string) ([]string, string, error) {
+		resp := &mypackagepb.PageOutputType{}
+		if pageToken != "" {
+			req.PageToken = pageToken
+		}
+		if pageSize > math.MaxInt32 {
+			req.PageSize = math.MaxInt32
+		} else if pageSize != 0 {
+			req.PageSize = int32(pageSize)
+		}
+		err := gax.Invoke(ctx, func(ctx context.Context, settings gax.CallSettings) error {
+			var err error
+			resp, err = c.fooClient.GetManyOtherThings(ctx, req, settings.GRPC...)
+			return err
+		}, opts...)
+		if err != nil {
+			return nil, "", err
+		}
+
+		it.Response = resp
+		return resp.GetItems(), resp.GetNextPageToken(), nil
+	}
+	fetch := func(pageSize int, pageToken string) (string, error) {
+		items, nextPageToken, err := it.InternalFetch(pageSize, pageToken)
+		if err != nil {
+			return "", err
+		}
+		it.items = append(it.items, items...)
+		return nextPageToken, nil
+	}
+
+	it.pageInfo, it.nextFunc = iterator.NewPageInfo(fetch, it.bufLen, it.takeBuf)
+	it.pageInfo.MaxSize = int(req.GetPageSize())
+	it.pageInfo.Token = req.GetPageToken()
+
+	return it
+}
+
+// StringIterator manages a stream of string.
+type StringIterator struct {
+	items    []string
+	pageInfo *iterator.PageInfo
+	nextFunc func() error
+
+	// Response is the raw response for the current page.
+	// It must be cast to the RPC response type.
+	// Calling Next() or InternalFetch() updates this value.
+	Response interface{}
+
+	// InternalFetch is for use by the Google Cloud Libraries only.
+	// It is not part of the stable interface of this package.
+	//
+	// InternalFetch returns results from a single call to the underlying RPC.
+	// The number of results is no greater than pageSize.
+	// If there are no more results, nextPageToken is empty and err is nil.
+	InternalFetch func(pageSize int, pageToken string) (results []string, nextPageToken string, err error)
+}
+
+// PageInfo supports pagination. See the google.golang.org/api/iterator package for details.
+func (it *StringIterator) PageInfo() *iterator.PageInfo {
+	return it.pageInfo
+}
+
+// Next returns the next result. Its second return value is iterator.Done if there are no more
+// results. Once Next returns Done, all subsequent calls will return Done.
+func (it *StringIterator) Next() (string, error) {
+	var item string
+	if err := it.nextFunc(); err != nil {
+		return item, err
+	}
+	item = it.items[0]
+	it.items = it.items[1:]
+	return item, nil
+}
+
+func (it *StringIterator) bufLen() int {
+	return len(it.items)
+}
+
+func (it *StringIterator) takeBuf() interface{} {
+	b := it.items
+	it.items = nil
+	return b
+}
+
diff --git a/internal/gengapic/testdata/method_GetManyThings.want b/internal/gengapic/testdata/method_GetManyThings.want
index e23bc68..6c2785c 100644
--- a/internal/gengapic/testdata/method_GetManyThings.want
+++ b/internal/gengapic/testdata/method_GetManyThings.want
@@ -1,5 +1,6 @@
 func (c *fooGRPCClient) GetManyThings(ctx context.Context, req *mypackagepb.PageInputType, opts ...gax.CallOption) *StringIterator {
 	md := metadata.Pairs("x-goog-request-params", fmt.Sprintf("%s=%v&%s=%v&%s=%v&%s=%v&%s=%v&%s=%v", "field_name.nested", url.QueryEscape(req.GetFieldName().GetNested()), "other", url.QueryEscape(req.GetOther()), "another", url.QueryEscape(req.GetAnother()), "biz", url.QueryEscape(fmt.Sprintf("%g", req.GetBiz())), "top_level_enum", mypackagepb.TopLevelEnum_name[int32(req.GetTopLevelEnum())], "nested_enum", mypackagepb.InputType_NestedEnum_name[int32(req.GetNestedEnum())]))
+
 	ctx = insertMetadata(ctx, c.xGoogMetadata, md)
 	opts = append((*c.CallOptions).GetManyThings[0:len((*c.CallOptions).GetManyThings):len((*c.CallOptions).GetManyThings)], opts...)
 	it := &StringIterator{}
diff --git a/internal/gengapic/testdata/method_GetManyThingsOptional.want b/internal/gengapic/testdata/method_GetManyThingsOptional.want
index 4a18977..4f633bc 100644
--- a/internal/gengapic/testdata/method_GetManyThingsOptional.want
+++ b/internal/gengapic/testdata/method_GetManyThingsOptional.want
@@ -1,5 +1,6 @@
 func (c *fooGRPCClient) GetManyThingsOptional(ctx context.Context, req *mypackagepb.PageInputTypeOptional, opts ...gax.CallOption) *StringIterator {
 	md := metadata.Pairs("x-goog-request-params", fmt.Sprintf("%s=%v&%s=%v&%s=%v&%s=%v&%s=%v&%s=%v", "field_name.nested", url.QueryEscape(req.GetFieldName().GetNested()), "other", url.QueryEscape(req.GetOther()), "another", url.QueryEscape(req.GetAnother()), "biz", url.QueryEscape(fmt.Sprintf("%g", req.GetBiz())), "top_level_enum", mypackagepb.TopLevelEnum_name[int32(req.GetTopLevelEnum())], "nested_enum", mypackagepb.InputType_NestedEnum_name[int32(req.GetNestedEnum())]))
+
 	ctx = insertMetadata(ctx, c.xGoogMetadata, md)
 	opts = append((*c.CallOptions).GetManyThingsOptional[0:len((*c.CallOptions).GetManyThingsOptional):len((*c.CallOptions).GetManyThingsOptional)], opts...)
 	it := &StringIterator{}
diff --git a/internal/gengapic/testdata/method_GetOneThing.want b/internal/gengapic/testdata/method_GetOneThing.want
index 7f19dc4..038d6ef 100644
--- a/internal/gengapic/testdata/method_GetOneThing.want
+++ b/internal/gengapic/testdata/method_GetOneThing.want
@@ -5,6 +5,7 @@ func (c *fooGRPCClient) GetOneThing(ctx context.Context, req *mypackagepb.InputT
 		ctx = cctx
 	}
 	md := metadata.Pairs("x-goog-request-params", fmt.Sprintf("%s=%v&%s=%v&%s=%v&%s=%v&%s=%v&%s=%v", "field_name.nested", url.QueryEscape(req.GetFieldName().GetNested()), "other", url.QueryEscape(req.GetOther()), "another", url.QueryEscape(req.GetAnother()), "biz", url.QueryEscape(fmt.Sprintf("%g", req.GetBiz())), "top_level_enum", mypackagepb.TopLevelEnum_name[int32(req.GetTopLevelEnum())], "nested_enum", mypackagepb.InputType_NestedEnum_name[int32(req.GetNestedEnum())]))
+
 	ctx = insertMetadata(ctx, c.xGoogMetadata, md)
 	opts = append((*c.CallOptions).GetOneThing[0:len((*c.CallOptions).GetOneThing):len((*c.CallOptions).GetOneThing)], opts...)
 	var resp *mypackagepb.OutputType
diff --git a/internal/gengapic/testdata/method_ServerThings.want b/internal/gengapic/testdata/method_ServerThings.want
index a920141..dd8752d 100644
--- a/internal/gengapic/testdata/method_ServerThings.want
+++ b/internal/gengapic/testdata/method_ServerThings.want
@@ -1,5 +1,6 @@
 func (c *fooGRPCClient) ServerThings(ctx context.Context, req *mypackagepb.InputType, opts ...gax.CallOption) (mypackagepb.Foo_ServerThingsClient, error) {
 	md := metadata.Pairs("x-goog-request-params", fmt.Sprintf("%s=%v&%s=%v&%s=%v&%s=%v&%s=%v&%s=%v", "field_name.nested", url.QueryEscape(req.GetFieldName().GetNested()), "other", url.QueryEscape(req.GetOther()), "another", url.QueryEscape(req.GetAnother()), "biz", url.QueryEscape(fmt.Sprintf("%g", req.GetBiz())), "top_level_enum", mypackagepb.TopLevelEnum_name[int32(req.GetTopLevelEnum())], "nested_enum", mypackagepb.InputType_NestedEnum_name[int32(req.GetNestedEnum())]))
+
 	ctx = insertMetadata(ctx, c.xGoogMetadata, md)
 	var resp mypackagepb.Foo_ServerThingsClient
 	err := gax.Invoke(ctx, func(ctx context.Context, settings gax.CallSettings) error {
diff --git a/showcase/echo_test.go b/showcase/echo_test.go
index 6d5d8c8..e4a252c 100644
--- a/showcase/echo_test.go
+++ b/showcase/echo_test.go
@@ -20,6 +20,8 @@ import (
 	"io"
 	"net/http"
 	"os"
+	"reflect"
+	"sort"
 	"strings"
 	"testing"
 	"time"
@@ -27,13 +29,16 @@ import (
 	"github.com/google/go-cmp/cmp"
 	showcase "github.com/googleapis/gapic-showcase/client"
 	showcasepb "github.com/googleapis/gapic-showcase/server/genproto"
+	gax "github.com/googleapis/gax-go/v2"
 	"google.golang.org/api/googleapi"
 	"google.golang.org/api/iterator"
 	"google.golang.org/api/option"
 	locationpb "google.golang.org/genproto/googleapis/cloud/location"
 	iampb "google.golang.org/genproto/googleapis/iam/v1"
 	spb "google.golang.org/genproto/googleapis/rpc/status"
+	grpc "google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
+	metadata "google.golang.org/grpc/metadata"
 	"google.golang.org/grpc/status"
 	"google.golang.org/protobuf/proto"
 	"google.golang.org/protobuf/types/known/durationpb"
@@ -94,6 +99,50 @@ func TestEcho_error(t *testing.T) {
 
 }
 
+// Test dynamic routing header generation. We cannot guarantee the order that headers are sent, so we check that the header sent contains the correct elements as opposed to checking
+// the header itself.
+func TestEchoHeader(t *testing.T) {
+	//defer check(t)
+	for _, tst := range []struct {
+		req  *showcasepb.EchoRequest
+		want []string
+	}{
+		{
+			req:  &showcasepb.EchoRequest{OtherHeader: "projects/123/instances/456"},
+			want: []string{"baz=projects%2F123%2Finstances%2F456", "qux=projects%2F123"},
+		},
+		{
+			req:  &showcasepb.EchoRequest{OtherHeader: "instances/456"},
+			want: []string{"baz=instances%2F456"},
+		},
+		{
+			req:  &showcasepb.EchoRequest{Header: "potato"},
+			want: []string{"header=potato", "routing_id=potato"},
+		},
+		{
+			req:  &showcasepb.EchoRequest{Header: "projects/123/instances/456"},
+			want: []string{"header=projects%2F123%2Finstances%2F456", "routing_id=projects%2F123%2Finstances%2F456", "super_id=projects%2F123", "table_name=projects%2F123%2Finstances%2F456", "instance_id=instances%2F456"},
+		},
+		{
+			req: &showcasepb.EchoRequest{
+				Header:      "regions/123/zones/456",
+				OtherHeader: "projects/123/instances/456",
+			},
+			want: []string{"baz=projects%2F123%2Finstances%2F456", "qux=projects%2F123", "table_name=regions%2F123%2Fzones%2F456", "header=regions%2F123%2Fzones%2F456", "routing_id=regions%2F123%2Fzones%2F456"},
+		},
+	} {
+		mdForHeaders := metadata.New(map[string]string{})
+		echo.Echo(context.Background(), tst.req, gax.WithGRPCOptions(grpc.Header(&mdForHeaders)))
+		got := mdForHeaders.Get("x-goog-request-params")
+		got = strings.Split(got[0], "&")
+		sort.Strings(got)
+		sort.Strings(tst.want)
+		if !reflect.DeepEqual(got, tst.want) {
+			t.Errorf("Echo() header = %q, want %q", got, tst.want)
+		}
+	}
+}
+
 // Chat, Collect, and Expand are streaming methods and don't have interesting REST semantics
 func TestExpand(t *testing.T) {
 	defer check(t)
diff --git a/showcase/main_test.go b/showcase/main_test.go
index 9be71a0..3590373 100644
--- a/showcase/main_test.go
+++ b/showcase/main_test.go
@@ -37,7 +37,7 @@ func init() {
 	registerIgnoreGoroutine("google.golang.org/grpc.(*addrConn).connect")
 }
 
-const showcaseSemver = "0.15.0"
+const showcaseSemver = "0.19.0"
 
 func TestMain(m *testing.M) {
 	flag.Parse()