Repository: googleapis/gapic-generator-go
Pull request #1124 (2022-09-01 23:44:26)

fix: regapic support for proto wkt in query params
Properly handle protobuf well known message types that have special JSON mappings e.g. `google.protobuf.FieldMask` when they appear as query parameters.

The list of well known types is defined here: https://developers.google.com/protocol-buffers/docs/proto3#json

Update gapic-showcase to v0.25.0 that exercises this code, and update the Identity service tests to run against REST as well, to exercise the FieldMask encoding.


Patch:
diff --git a/internal/gengapic/genrest.go b/internal/gengapic/genrest.go
index 4b86325..196f135 100644
--- a/internal/gengapic/genrest.go
+++ b/internal/gengapic/genrest.go
@@ -53,6 +53,23 @@ var gRPCToHTTP map[code.Code]string = map[code.Code]string{
 	code.Code_DATA_LOSS:           "http.StatusInternalServerError",
 }
 
+var wellKnownTypes = []string{
+	".google.protobuf.FieldMask",
+	".google.protobuf.Timestamp",
+	".google.protobuf.Duration",
+	".google.protobuf.DoubleValue",
+	".google.protobuf.FloatValue",
+	".google.protobuf.Int64Value",
+	".google.protobuf.UInt64Value",
+	".google.protobuf.Int32Value",
+	".google.protobuf.UInt32Value",
+	".google.protobuf.BoolValue",
+	".google.protobuf.StringValue",
+	".google.protobuf.BytesValue",
+	".google.protobuf.Value",
+	".google.protobuf.ListValue",
+}
+
 func lowcaseRestClientName(servName string) string {
 	if servName == "" {
 		return "restClient"
@@ -365,7 +382,7 @@ func (g *generator) getLeafs(msg *descriptor.DescriptorProto, excludedFields ...
 		m *descriptor.DescriptorProto,
 	) {
 		for _, field := range m.GetField() {
-			if field.GetType() == descriptor.FieldDescriptorProto_TYPE_MESSAGE {
+			if field.GetType() == fieldTypeMessage && !strContains(wellKnownTypes, field.GetTypeName()) {
 				handleMsg(field, stack)
 			} else {
 				handleLeaf(field, stack)
@@ -403,8 +420,25 @@ func (g *generator) generateQueryString(m *descriptor.MethodDescriptorProto) {
 		singularPrimitive := field.GetType() != fieldTypeMessage &&
 			field.GetType() != fieldTypeBytes &&
 			field.GetLabel() != fieldLabelRepeated
-		g.imports[pbinfo.ImportSpec{Path: "fmt"}] = true
-		paramAdd := fmt.Sprintf("params.Add(%q, fmt.Sprintf(%q, req%s))", lowerFirst(snakeToCamel(path)), "%v", accessor)
+
+		var paramAdd string
+		// Handle well known protobuf types with special JSON encodings.
+		if strContains(wellKnownTypes, field.GetTypeName()) {
+			b := strings.Builder{}
+			b.WriteString(fmt.Sprintf("%s, err := protojson.Marshal(req%s)\n", field.GetJsonName(), accessor))
+			b.WriteString("if err != nil {\n")
+			if m.GetOutputType() == emptyType {
+				b.WriteString(fmt.Sprintf("  return err\n"))
+			} else {
+				b.WriteString(fmt.Sprintf("  return nil, err\n"))
+			}
+			b.WriteString("}\n")
+			b.WriteString(fmt.Sprintf("params.Add(%q, string(%s))", lowerFirst(snakeToCamel(path)), field.GetJsonName()))
+			paramAdd = b.String()
+		} else {
+			paramAdd = fmt.Sprintf("params.Add(%q, fmt.Sprintf(%q, req%s))", lowerFirst(snakeToCamel(path)), "%v", accessor)
+			g.imports[pbinfo.ImportSpec{Path: "fmt"}] = true
+		}
 
 		// Only required, singular, primitive field types should be added regardless.
 		if required && singularPrimitive {
@@ -437,7 +471,11 @@ func (g *generator) generateQueryString(m *descriptor.MethodDescriptorProto) {
 				p(`if req%s != 0 {`, accessor)
 			}
 		}
-		p("    %s", paramAdd)
+
+		// Split on newline so that multi-line param adders will be formatted properly.
+		for _, s := range strings.Split(paramAdd, "\n") {
+			p("    %s", s)
+		}
 		p("}")
 	}
 
diff --git a/showcase/go.mod b/showcase/go.mod
index 640ab4a..8557d39 100644
--- a/showcase/go.mod
+++ b/showcase/go.mod
@@ -5,7 +5,7 @@ go 1.19
 require (
 	cloud.google.com/go v0.104.0
 	github.com/google/go-cmp v0.5.8
-	github.com/googleapis/gapic-showcase v0.24.0
+	github.com/googleapis/gapic-showcase v0.25.0
 	github.com/googleapis/gax-go/v2 v2.5.1
 	google.golang.org/api v0.95.0
 	google.golang.org/genproto v0.0.0-20220902135211-223410557253
diff --git a/showcase/go.sum b/showcase/go.sum
index 17df570..4645f9b 100644
--- a/showcase/go.sum
+++ b/showcase/go.sum
@@ -31,7 +31,6 @@ cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2Z
 cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
 cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc=
 cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34hIU=
-cloud.google.com/go v0.103.0/go.mod h1:vwLx1nqLrzLX/fpwSMOXmFIqBOyHsvHbnAdbGSJ+mKk=
 cloud.google.com/go v0.104.0 h1:gSmWO7DY1vOm0MVU6DNXM11BWHHsTUmsC5cv1fuW5X8=
 cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRYtA=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
@@ -214,8 +213,8 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
 github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
 github.com/googleapis/enterprise-certificate-proxy v0.1.0 h1:zO8WHNx/MYiAKJ3d5spxZXZE6KHmIQGQcAzwUzV7qQw=
 github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
-github.com/googleapis/gapic-showcase v0.24.0 h1:YDNng6apgYYbw5AKTUdBLQ8pk5QjNAdHRSvd5sdRZSs=
-github.com/googleapis/gapic-showcase v0.24.0/go.mod h1:pBv821ueIBdk6qC9nrBZePZYBG9VdutF2omq3dlp5mU=
+github.com/googleapis/gapic-showcase v0.25.0 h1:Ui32sbsvvmvpQok07xMwrAI0yDBlruLwcJ10Mn9CyNs=
+github.com/googleapis/gapic-showcase v0.25.0/go.mod h1:lkwLil+VrQ5vrOL8+rexI+VZSG2QmzS3W2jaygrGu58=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
@@ -512,7 +511,6 @@ golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j
 golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
 golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
 golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
-golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
 golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094 h1:2o1E+E8TpNLklK9nHiPiK1uzIYrIHt+cQx3ynCwq9V8=
 golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -528,7 +526,7 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -731,8 +729,9 @@ google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3
 google.golang.org/api v0.81.0/go.mod h1:FA6Mb/bZxj706H2j+j2d6mHEEaHBmbbWnkfvmorOCko=
 google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o=
 google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g=
-google.golang.org/api v0.86.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
-google.golang.org/api v0.89.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
+google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
+google.golang.org/api v0.93.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
+google.golang.org/api v0.94.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI=
 google.golang.org/api v0.95.0 h1:d1c24AAS01DYqXreBeuVV7ewY/U8Mnhh47pwtsgVtYg=
 google.golang.org/api v0.95.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
@@ -829,8 +828,9 @@ google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljW
 google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
-google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
-google.golang.org/genproto v0.0.0-20220725144611-272f38e5d71b/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc=
+google.golang.org/genproto v0.0.0-20220722212130-b98a9ff5e252/go.mod h1:GkXuJDJ6aQ7lnJcRF+SJVgFdQhypqgl3LB1C9vabdRE=
+google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
+google.golang.org/genproto v0.0.0-20220829175752-36a9c930ecbf/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
 google.golang.org/genproto v0.0.0-20220902135211-223410557253 h1:vXJMM8Shg7TGaYxZsQ++A/FOSlbDmDtWhS/o+3w/hj4=
 google.golang.org/genproto v0.0.0-20220902135211-223410557253/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=


Test patch:
diff --git a/internal/gengapic/genrest_test.go b/internal/gengapic/genrest_test.go
index ed3a367..d2f0a30 100644
--- a/internal/gengapic/genrest_test.go
+++ b/internal/gengapic/genrest_test.go
@@ -337,6 +337,18 @@ func TestLeafFields(t *testing.T) {
 		},
 	}
 
+	wellKnownMsg := &descriptor.DescriptorProto{
+		Name: proto.String("Update"),
+		Field: []*descriptor.FieldDescriptorProto{
+			{
+				Name:     proto.String("update_mask"),
+				Number:   proto.Int32(int32(0)),
+				Type:     typep(descriptor.FieldDescriptorProto_TYPE_MESSAGE),
+				TypeName: proto.String(".google.protobuf.FieldMask"),
+			},
+		},
+	}
+
 	file := &descriptor.FileDescriptorProto{
 		Package: proto.String("animalia.mollusca"),
 		Options: &descriptor.FileOptions{
@@ -349,6 +361,7 @@ func TestLeafFields(t *testing.T) {
 			complexMsg,
 			recursiveMsg,
 			overarchingMsg,
+			wellKnownMsg,
 		},
 	}
 	req := plugin.CodeGeneratorRequest{
@@ -406,6 +419,13 @@ func TestLeafFields(t *testing.T) {
 				"mass_kg": overarchingMsg.GetField()[1],
 			},
 		},
+		{
+			name: "well_known_message_test",
+			msg:  wellKnownMsg,
+			expected: map[string]*descriptor.FieldDescriptorProto{
+				"update_mask": wellKnownMsg.GetField()[0],
+			},
+		},
 	} {
 		actual := g.getLeafs(tst.msg, tst.excludedFields...)
 		if diff := cmp.Diff(actual, tst.expected, cmp.Comparer(proto.Equal)); diff != "" {
@@ -466,6 +486,25 @@ func TestGenRestMethod(t *testing.T) {
 	}
 	pagedFooResFQN := fmt.Sprintf(".%s.PagedFooResponse", pkg)
 
+	fooField := &descriptor.FieldDescriptorProto{
+		Name:     proto.String("foo"),
+		Type:     descriptor.FieldDescriptorProto_TYPE_MESSAGE.Enum(),
+		TypeName: proto.String(foofqn),
+	}
+
+	maskField := &descriptor.FieldDescriptorProto{
+		Name:     proto.String("update_mask"),
+		JsonName: proto.String("updateMask"),
+		Type:     descriptor.FieldDescriptorProto_TYPE_MESSAGE.Enum(),
+		TypeName: proto.String(".google.protobuf.FieldMask"),
+	}
+
+	updateReq := &descriptor.DescriptorProto{
+		Name:  proto.String("UpdateRequest"),
+		Field: []*descriptor.FieldDescriptorProto{fooField, maskField},
+	}
+	updateReqFqn := fmt.Sprintf(".%s.UpdateRequest", pkg)
+
 	nameOpts := &descriptor.FieldOptions{}
 	proto.SetExtension(nameOpts, extendedops.E_OperationField, extendedops.OperationResponseMapping_NAME)
 	nameField := &descriptor.FieldDescriptorProto{
@@ -600,6 +639,21 @@ func TestGenRestMethod(t *testing.T) {
 		Options:    httpBodyRPCOpt,
 	}
 
+	updateRPCOpt := &descriptor.MethodOptions{}
+	proto.SetExtension(updateRPCOpt, annotations.E_Http, &annotations.HttpRule{
+		Pattern: &annotations.HttpRule_Post{
+			Post: "/v1/foo",
+		},
+		Body: "foo",
+	})
+
+	updateRPC := &descriptor.MethodDescriptorProto{
+		Name:       proto.String("UpdateRPC"),
+		InputType:  proto.String(updateReqFqn),
+		OutputType: proto.String(foofqn),
+		Options:    updateRPCOpt,
+	}
+
 	s := &descriptor.ServiceDescriptorProto{
 		Name: proto.String("FooService"),
 	}
@@ -633,12 +687,14 @@ func TestGenRestMethod(t *testing.T) {
 				opS:          f,
 				opRPC:        f,
 				lroRPC:       f,
+				updateRPC:    f,
 				foo:          f,
 				s:            f,
 				pagedFooReq:  f,
 				pagedFooRes:  f,
 				lroDesc:      protodesc.ToFileDescriptorProto(longrunning.File_google_longrunning_operations_proto),
 				httpBodyDesc: protodesc.ToFileDescriptorProto(httpbody.File_google_api_httpbody_proto),
+				updateReq:    f,
 			},
 			ParentElement: map[pbinfo.ProtoType]pbinfo.ProtoType{
 				opRPC:           s,
@@ -649,9 +705,11 @@ func TestGenRestMethod(t *testing.T) {
 				clientStreamRPC: s,
 				lroRPC:          s,
 				httpBodyRPC:     s,
+				updateRPC:       s,
 				nameField:       op,
 				sizeField:       foo,
 				otherField:      foo,
+				maskField:       updateReq,
 			},
 			Type: map[string]pbinfo.ProtoType{
 				opfqn:          op,
@@ -661,6 +719,7 @@ func TestGenRestMethod(t *testing.T) {
 				pagedFooResFQN: pagedFooRes,
 				lroType:        lroDesc,
 				httpBodyType:   httpBodyDesc,
+				updateReqFqn:   updateReq,
 			},
 		},
 	}
@@ -789,6 +848,20 @@ func TestGenRestMethod(t *testing.T) {
 				{Name: "httpbodypb", Path: "google.golang.org/genproto/googleapis/api/httpbody"}: true,
 			},
 		},
+		{
+			name:    "update_rpc",
+			method:  updateRPC,
+			options: &options{restNumericEnum: true},
+			imports: map[pbinfo.ImportSpec]bool{
+				{Path: "bytes"}: true,
+				{Path: "fmt"}:   true,
+				{Path: "google.golang.org/protobuf/encoding/protojson"}: true,
+				{Path: "io/ioutil"}:                       true,
+				{Path: "google.golang.org/api/googleapi"}: true,
+				{Path: "net/url"}:                         true,
+				{Name: "foopb", Path: "google.golang.org/genproto/cloud/foo/v1"}: true,
+			},
+		},
 	} {
 		s.Method = []*descriptor.MethodDescriptorProto{tst.method}
 		g.opts = tst.options
diff --git a/internal/gengapic/testdata/rest_UpdateRPC.want b/internal/gengapic/testdata/rest_UpdateRPC.want
new file mode 100644
index 0000000..7d31bdf
--- /dev/null
+++ b/internal/gengapic/testdata/rest_UpdateRPC.want
@@ -0,0 +1,68 @@
+func (c *fooRESTClient) UpdateRPC(ctx context.Context, req *foopb.UpdateRequest, opts ...gax.CallOption) (*foopb.Foo, error) {
+	m := protojson.MarshalOptions{AllowPartial: true, UseEnumNumbers: true}
+	body := req.GetFoo()
+	jsonReq, err := m.Marshal(body)
+	if err != nil {
+		return nil, err
+	}
+
+	baseUrl, err := url.Parse(c.endpoint)
+	if err != nil {
+		return nil, err
+	}
+	baseUrl.Path += fmt.Sprintf("/v1/foo")
+
+	params := url.Values{}
+	params.Add("$alt", "json;enum-encoding=int")
+	if req.GetUpdateMask() != nil {
+		updateMask, err := protojson.Marshal(req.GetUpdateMask())
+		if err != nil {
+		  return nil, err
+		}
+		params.Add("updateMask", string(updateMask))
+	}
+
+	baseUrl.RawQuery = params.Encode()
+
+	// Build HTTP headers from client and context metadata.
+	headers := buildHeaders(ctx, c.xGoogMetadata, metadata.Pairs("Content-Type", "application/json"))
+	opts = append((*c.CallOptions).UpdateRPC[0:len((*c.CallOptions).UpdateRPC):len((*c.CallOptions).UpdateRPC)], opts...)
+	unm := protojson.UnmarshalOptions{AllowPartial: true, DiscardUnknown: true}
+	resp := &foopb.Foo{}
+	e := gax.Invoke(ctx, func(ctx context.Context, settings gax.CallSettings) error {
+		if settings.Path != "" {
+			baseUrl.Path = settings.Path
+		}
+		httpReq, err := http.NewRequest("POST", baseUrl.String(), bytes.NewReader(jsonReq))
+		if err != nil {
+			return err
+		}
+		httpReq = httpReq.WithContext(ctx)
+		httpReq.Header = headers
+
+		httpRsp, err := c.httpClient.Do(httpReq)
+		if err != nil{
+			return err
+		}
+		defer httpRsp.Body.Close()
+
+		if err = googleapi.CheckResponse(httpRsp); err != nil {
+			return err
+		}
+
+		buf, err := ioutil.ReadAll(httpRsp.Body)
+		if err != nil {
+			return err
+		}
+
+		if err := unm.Unmarshal(buf, resp); err != nil {
+			return maybeUnknownEnum(err)
+		}
+
+		return nil
+	}, opts...)
+	if e != nil {
+		return nil, e
+	}
+	return resp, nil
+}
diff --git a/showcase/compliance_test.go b/showcase/compliance_test.go
index 8a98141..137b631 100644
--- a/showcase/compliance_test.go
+++ b/showcase/compliance_test.go
@@ -32,6 +32,7 @@ import (
 	"google.golang.org/protobuf/proto"
 )
 
+// Client is initialized in main_test.go.
 var complianceClient *showcase.ComplianceClient
 
 // method is the function type that implements any of the Compliance.RepeatData* RPCs, which all have the same signature.
diff --git a/showcase/echo_test.go b/showcase/echo_test.go
index 1da67ac..e73082c 100644
--- a/showcase/echo_test.go
+++ b/showcase/echo_test.go
@@ -44,8 +44,11 @@ import (
 	"google.golang.org/protobuf/types/known/durationpb"
 )
 
-var echo *showcase.EchoClient
-var echoREST *showcase.EchoClient
+// Clients are initialized in main_test.go.
+var (
+	echo     *showcase.EchoClient
+	echoREST *showcase.EchoClient
+)
 
 func TestEcho(t *testing.T) {
 	defer check(t)
diff --git a/showcase/identity_test.go b/showcase/identity_test.go
index 14f05f7..6e23561 100644
--- a/showcase/identity_test.go
+++ b/showcase/identity_test.go
@@ -23,134 +23,142 @@ import (
 	showcasepb "github.com/googleapis/gapic-showcase/server/genproto"
 	"google.golang.org/api/iterator"
 	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/fieldmaskpb"
 )
 
-var identity *showcase.IdentityClient
+// Clients are initialized in main_test.go.
+var (
+	identity     *showcase.IdentityClient
+	identityREST *showcase.IdentityClient
+)
 
 func TestUserCRUD(t *testing.T) {
 	defer check(t)
 	ctx := context.Background()
 
-	create := &showcasepb.CreateUserRequest{
-		User: &showcasepb.User{
-			DisplayName: "Jane Doe",
-			Email:       "janedoe@example.com",
-			Nickname:    proto.String("Doe"),
-			HeightFeet:  proto.Float64(6.2),
-		},
-	}
-
-	usr, err := identity.CreateUser(ctx, create)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	want := create.GetUser()
-	if usr.GetName() == "" {
-		t.Errorf("CreateUser().Name was unexpectedly empty")
-	}
-	if usr.GetDisplayName() != want.GetDisplayName() {
-		t.Errorf("CreateUser().DisplayName = %q, want = %q", usr.GetDisplayName(), want.GetDisplayName())
-	}
-	if usr.GetEmail() != want.GetEmail() {
-		t.Errorf("CreateUser().Email = %q, want = %q", usr.GetEmail(), want.GetEmail())
-	}
-	if usr.GetCreateTime() == nil {
-		t.Errorf("CreateUser().CreateTime was unexpectedly empty")
-	}
-	if usr.GetUpdateTime() == nil {
-		t.Errorf("CreateUser().UpdateTime was unexpectedly empty")
-	}
-	if usr.GetNickname() != want.GetNickname() {
-		t.Errorf("CreateUser().Nickname = %q, want = %q", usr.GetNickname(), want.GetNickname())
-	}
-	if usr.GetHeightFeet() != want.GetHeightFeet() {
-		t.Errorf("CreateUser().HeightFeet = %f, want = %f", usr.GetHeightFeet(), want.GetHeightFeet())
-	}
-	if usr.Age != nil {
-		t.Errorf("CreateUser().Age was unexpectedly set to: %d", usr.GetAge())
-	}
-	if usr.EnableNotifications != nil {
-		t.Errorf("CreateUser().EnableNotifications was unexpectedly set to: %v", usr.GetEnableNotifications())
-	}
-
-	list := &showcasepb.ListUsersRequest{
-		PageSize: 5,
-	}
-
-	iter := identity.ListUsers(context.Background(), list)
-
-	if max := iter.PageInfo().MaxSize; max != int(list.PageSize) {
-		t.Errorf("PageInfo().MaxSize = %d, want %d", max, list.PageSize)
-	}
-
-	listed, err := iter.Next()
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if diff := cmp.Diff(listed, usr, cmp.Comparer(proto.Equal)); diff != "" {
-		t.Errorf("ListUsers() got=-, want=+:%s", diff)
-	}
-
-	get := &showcasepb.GetUserRequest{
-		Name: usr.GetName(),
-	}
-
-	got, err := identity.GetUser(ctx, get)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if diff := cmp.Diff(got, usr, cmp.Comparer(proto.Equal)); diff != "" {
-		t.Errorf("GetUser() got=-, want=+:%s", diff)
-	}
-
-	update := &showcasepb.UpdateUserRequest{
-		User: &showcasepb.User{
-			Name:                got.GetName(),
-			DisplayName:         got.GetDisplayName(),
-			Email:               "janedoe@jane.com",
-			HeightFeet:          proto.Float64(6.0),
-			EnableNotifications: proto.Bool(true),
-		},
-	}
-
-	updated, err := identity.UpdateUser(ctx, update)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if diff := cmp.Diff(updated, usr, cmp.Comparer(proto.Equal)); diff == "" {
-		t.Errorf("UpdateUser() users were the same, update failed")
-	}
-	if updated.GetEmail() == usr.GetEmail() {
-		t.Errorf("UpdateUser().Email was not updated as expected")
-	}
-	if updated.GetNickname() != usr.GetNickname() {
-		t.Errorf("UpdateUser().Nickname = %q, want = %q", updated.GetNickname(), usr.GetNickname())
-	}
-	if updated.GetHeightFeet() == usr.GetHeightFeet() {
-		t.Errorf("UpdateUser().HeightFeet was not updated as expected")
-	}
-	if updated.EnableNotifications == nil || !updated.GetEnableNotifications() {
-		t.Errorf("UpdateUser().EnableNotifications was not updated as expected")
-	}
-	if updated.Age != nil {
-		t.Errorf("UpdateUser().Age was unexpectedly updated")
-	}
-
-	err = identity.DeleteUser(ctx, &showcasepb.DeleteUserRequest{
-		Name: usr.GetName(),
-	})
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	iter = identity.ListUsers(ctx, &showcasepb.ListUsersRequest{})
-
-	_, err = iter.Next()
-	if err != iterator.Done {
-		t.Errorf("ListUsers() = %q, want %q", err, iterator.Done)
+	for _, client := range map[string]*showcase.IdentityClient{"grpc": identity, "rest": identityREST} {
+		create := &showcasepb.CreateUserRequest{
+			User: &showcasepb.User{
+				DisplayName: "Jane Doe",
+				Email:       "janedoe@example.com",
+				Nickname:    proto.String("Doe"),
+				HeightFeet:  proto.Float64(6.2),
+			},
+		}
+
+		usr, err := client.CreateUser(ctx, create)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		want := create.GetUser()
+		if usr.GetName() == "" {
+			t.Errorf("CreateUser().Name was unexpectedly empty")
+		}
+		if usr.GetDisplayName() != want.GetDisplayName() {
+			t.Errorf("CreateUser().DisplayName = %q, want = %q", usr.GetDisplayName(), want.GetDisplayName())
+		}
+		if usr.GetEmail() != want.GetEmail() {
+			t.Errorf("CreateUser().Email = %q, want = %q", usr.GetEmail(), want.GetEmail())
+		}
+		if usr.GetCreateTime() == nil {
+			t.Errorf("CreateUser().CreateTime was unexpectedly empty")
+		}
+		if usr.GetUpdateTime() == nil {
+			t.Errorf("CreateUser().UpdateTime was unexpectedly empty")
+		}
+		if usr.GetNickname() != want.GetNickname() {
+			t.Errorf("CreateUser().Nickname = %q, want = %q", usr.GetNickname(), want.GetNickname())
+		}
+		if usr.GetHeightFeet() != want.GetHeightFeet() {
+			t.Errorf("CreateUser().HeightFeet = %f, want = %f", usr.GetHeightFeet(), want.GetHeightFeet())
+		}
+		if usr.Age != nil {
+			t.Errorf("CreateUser().Age was unexpectedly set to: %d", usr.GetAge())
+		}
+		if usr.EnableNotifications != nil {
+			t.Errorf("CreateUser().EnableNotifications was unexpectedly set to: %v", usr.GetEnableNotifications())
+		}
+
+		list := &showcasepb.ListUsersRequest{
+			PageSize: 5,
+		}
+
+		iter := client.ListUsers(context.Background(), list)
+
+		if max := iter.PageInfo().MaxSize; max != int(list.PageSize) {
+			t.Errorf("PageInfo().MaxSize = %d, want %d", max, list.PageSize)
+		}
+
+		listed, err := iter.Next()
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if diff := cmp.Diff(listed, usr, cmp.Comparer(proto.Equal)); diff != "" {
+			t.Errorf("ListUsers() got=-, want=+:%s", diff)
+		}
+
+		get := &showcasepb.GetUserRequest{
+			Name: usr.GetName(),
+		}
+
+		got, err := client.GetUser(ctx, get)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if diff := cmp.Diff(got, usr, cmp.Comparer(proto.Equal)); diff != "" {
+			t.Errorf("GetUser() got=-, want=+:%s", diff)
+		}
+
+		update := &showcasepb.UpdateUserRequest{
+			User: &showcasepb.User{
+				Name:                got.GetName(),
+				DisplayName:         got.GetDisplayName(),
+				Email:               "janedoe@jane.com",
+				HeightFeet:          proto.Float64(6.0),
+				EnableNotifications: proto.Bool(true),
+			},
+			UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"email", "height_feet", "enable_notifications"}},
+		}
+
+		updated, err := client.UpdateUser(ctx, update)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if diff := cmp.Diff(updated, usr, cmp.Comparer(proto.Equal)); diff == "" {
+			t.Errorf("UpdateUser() users were the same, update failed")
+		}
+		if updated.GetEmail() == usr.GetEmail() {
+			t.Errorf("UpdateUser().Email was not updated as expected")
+		}
+		if updated.GetNickname() != usr.GetNickname() {
+			t.Errorf("UpdateUser().Nickname = %q, want = %q", updated.GetNickname(), usr.GetNickname())
+		}
+		if updated.GetHeightFeet() == usr.GetHeightFeet() {
+			t.Errorf("UpdateUser().HeightFeet was not updated as expected")
+		}
+		if updated.EnableNotifications == nil || !updated.GetEnableNotifications() {
+			t.Errorf("UpdateUser().EnableNotifications was not updated as expected")
+		}
+		if updated.Age != nil {
+			t.Errorf("UpdateUser().Age was unexpectedly updated")
+		}
+
+		err = client.DeleteUser(ctx, &showcasepb.DeleteUserRequest{
+			Name: usr.GetName(),
+		})
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		iter = client.ListUsers(ctx, &showcasepb.ListUsersRequest{})
+
+		_, err = iter.Next()
+		if err != iterator.Done {
+			t.Errorf("ListUsers() = %q, want %q", err, iterator.Done)
+		}
 	}
 }
diff --git a/showcase/main_test.go b/showcase/main_test.go
index c960733..fbe97cb 100644
--- a/showcase/main_test.go
+++ b/showcase/main_test.go
@@ -72,6 +72,12 @@ func TestMain(m *testing.M) {
 	}
 	defer identity.Close()
 
+	identityREST, err = showcase.NewIdentityRESTClient(ctx, restClientOpts...)
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer identityREST.Close()
+
 	sequenceClient, err = showcase.NewSequenceClient(ctx, opt)
 	if err != nil {
 		log.Fatal(err)
diff --git a/showcase/sequence_test.go b/showcase/sequence_test.go
index b6fb077..3b2a5ae 100644
--- a/showcase/sequence_test.go
+++ b/showcase/sequence_test.go
@@ -28,8 +28,11 @@ import (
 	"google.golang.org/protobuf/types/known/durationpb"
 )
 
-var sequenceClient *showcase.SequenceClient
-var sequenceRESTClient *showcase.SequenceClient
+// Clients are initialized in main_test.go.
+var (
+	sequenceClient     *showcase.SequenceClient
+	sequenceRESTClient *showcase.SequenceClient
+)
 
 func Test_Sequence_Empty(t *testing.T) {
 	defer check(t)