Repository: googleapis/api-linter
Pull request #212 (2019-10-07 16:47:24)

[AIP-134] Add Update Method rules


Patch:
diff --git a/rules/aip0134/aip0134.go b/rules/aip0134/aip0134.go
new file mode 100644
index 0000000..e1ca2cf
--- /dev/null
+++ b/rules/aip0134/aip0134.go
@@ -0,0 +1,52 @@
+// Copyright 2019 Google LLC
+//
+// 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
+//
+//     https://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 aip0134 contains rules defined in https://aip.dev/134.
+package aip0134
+
+import (
+	"regexp"
+
+	"github.com/googleapis/api-linter/lint"
+	"github.com/jhump/protoreflect/desc"
+)
+
+// AddRules accepts a register function and registers each of
+// this AIP's rules to it.
+func AddRules(r lint.RuleRegistry) {
+	r.Register(
+		httpBody,
+		httpNameField,
+		httpVerb,
+		responseMessageName,
+		requestMessageName,
+		standardFields,
+		unknownFields,
+	)
+}
+
+var updateMethodRegexp = regexp.MustCompile("^Update(?:[A-Z]|$)")
+var updateReqMessageRegexp = regexp.MustCompile("^Update[A-Za-z0-9]*Request$")
+var updateURINameRegexp = regexp.MustCompile("\\{([A-Za-z0-9_]+)\\.name=[a-zA-Z/*]+\\}$")
+
+// Returns true if this is a AIP-134 Update method, false otherwise.
+func isUpdateMethod(m *desc.MethodDescriptor) bool {
+	methodName := m.GetName()
+	return updateMethodRegexp.MatchString(methodName)
+}
+
+// Returns true if this is an AIP-134 Update request message, false otherwise.
+func isUpdateRequestMessage(m *desc.MessageDescriptor) bool {
+	return updateReqMessageRegexp.MatchString(m.GetName())
+}
diff --git a/rules/aip0134/message_rules.go b/rules/aip0134/message_rules.go
new file mode 100644
index 0000000..7ad2ba2
--- /dev/null
+++ b/rules/aip0134/message_rules.go
@@ -0,0 +1,100 @@
+// Copyright 2019 Google LLC
+//
+// 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
+//
+//     https://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 aip0134
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/googleapis/api-linter/lint"
+	"github.com/jhump/protoreflect/desc"
+	"github.com/stoewer/go-strcase"
+)
+
+func extractResource(reqName string) string {
+	// Strips "Update" from the beginning and "Request" from the end.
+	return reqName[6:len(reqName)-7]
+}
+
+func fieldNameFromResource(resource string) string {
+	return strings.ToLower(strcase.SnakeCase(resource))
+}
+
+// The Update standard method should only have expected fields.
+var standardFields = &lint.MessageRule{
+	Name:   lint.NewRuleName("core", "0134", "request-message", "name-field"),
+	URI:    "https://aip.dev/134#request-message",
+	OnlyIf: isUpdateRequestMessage,
+	LintMessage: func(m *desc.MessageDescriptor) []lint.Problem {
+		r := extractResource(m.GetName())
+		fields := []struct {
+			name string
+			typ  string
+		}{
+			{fieldNameFromResource(r), r},
+			{"update_mask", "google.protobuf.FieldMask"},
+		}
+		// Rule check: Establish that expected fields are present.
+		for _, f := range fields {
+			// Rule check: Establish that the field is present.
+			field := m.FindFieldByName(f.name)
+			if field == nil {
+				return []lint.Problem{{
+					Message:    fmt.Sprintf("Method %s has no `%s` field", m.GetName(), f.name),
+					Descriptor: m,
+				}}
+			}
+
+			// Rule check: Ensure that the field is the correct type.
+			msgDesc := field.GetMessageType()
+			if msgDesc == nil || msgDesc.GetFullyQualifiedName() != f.typ {
+				return []lint.Problem{{
+					Message:    fmt.Sprintf("`%s` field on Update RPCs should be of type `%s`", f.name, f.typ),
+					Descriptor: field,
+				}}
+			}
+		}
+
+		return nil
+	},
+}
+
+// Update methods should not have unrecognized fields.
+var unknownFields = &lint.MessageRule{
+	Name:   lint.NewRuleName("core", "0134", "request-message", "unknown-fields"),
+	URI:    "https://aip.dev/134#request-message",
+	OnlyIf: isUpdateRequestMessage,
+	LintMessage: func(m *desc.MessageDescriptor) (problems []lint.Problem) {
+		resource := extractResource(m.GetName());
+		// Rule check: Establish that there are no unexpected fields.
+		allowedFields := map[string]struct{}{
+			fieldNameFromResource(resource): {}, // AIP-134
+			"update_mask":                   {}, // AIP-134
+		}
+		for _, field := range m.GetFields() {
+			if _, ok := allowedFields[string(field.GetName())]; !ok {
+				problems = append(problems, lint.Problem{
+					Message: fmt.Sprintf(
+						"Unexpected field: Update RPCs must only contain fields explicitly described in AIPs, not %q.",
+						string(field.GetName()),
+					),
+					Descriptor: field,
+				})
+			}
+		}
+
+		return
+	},
+}
diff --git a/rules/aip0134/method_rules.go b/rules/aip0134/method_rules.go
new file mode 100644
index 0000000..b45d162
--- /dev/null
+++ b/rules/aip0134/method_rules.go
@@ -0,0 +1,142 @@
+// Copyright 2019 Google LLC
+//
+// 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
+//
+//     https://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 aip0134
+
+import (
+	"fmt"
+
+	"github.com/googleapis/api-linter/rules/internal/utils"
+	"github.com/googleapis/api-linter/lint"
+	"github.com/jhump/protoreflect/desc"
+	"github.com/stoewer/go-strcase"
+)
+
+// Update methods should have a properly named Request message.
+var requestMessageName = &lint.MethodRule{
+	Name:   lint.NewRuleName("core", "0134", "request-message", "name"),
+	URI:    "https://aip.dev/134#guidance",
+	OnlyIf: isUpdateMethod,
+	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
+		// Rule check: Establish that for methods such as `UpdateFoo`, the request
+		// message is named `UpdateFooRequest`.
+		if got, want := m.GetInputType().GetName(), m.GetName()+"Request"; got != want {
+			return []lint.Problem{{
+				Message: fmt.Sprintf(
+					"Update RPCs should have a request message named after the RPC, such as %q.",
+					want,
+				),
+				Suggestion: want,
+				Descriptor: m,
+			}}
+		}
+
+		return nil
+	},
+}
+
+// Update methods should use the resource as the response message
+var responseMessageName = &lint.MethodRule{
+	Name:   lint.NewRuleName("core", "0134", "response-message", "name"),
+	URI:    "https://aip.dev/134#guidance",
+	OnlyIf: isUpdateMethod,
+	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
+		// Rule check: Establish that for methods such as `UpdateFoo`, the response
+		// message is `Foo` or `google.longrunning.Operation`.
+		got := m.GetOutputType().GetFullyQualifiedName()
+		want := []string{
+			m.GetName()[6:],
+			"google.longrunning.Operation",
+		}
+		for _, v := range want {
+			if got == v {
+				return nil
+			}
+		}
+		return []lint.Problem{{
+			Message: fmt.Sprintf(
+				"Update RPCs should have response message type in %q, not %q.",
+				want,
+				got,
+			),
+			Suggestion: want[0],
+			Descriptor: m,
+		}}
+	},
+}
+
+// Update methods should use the HTTP PATCH verb.
+var httpVerb = &lint.MethodRule{
+	Name:   lint.NewRuleName("core", "0134", "http-verb"),
+	URI:    "https://aip.dev/134#patch-and-put",
+	OnlyIf: isUpdateMethod,
+	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
+		// Rule check: Establish that the RPC uses HTTP PATCH.
+		for _, httpRule := range utils.GetHTTPRules(m) {
+			if httpRule.GetPatch() == "" {
+				return []lint.Problem{{
+					Message:    "Update methods must use the HTTP PATCH verb.",
+					Descriptor: m,
+				}}
+			}
+		}
+
+		return nil
+	},
+}
+
+// Update methods should have a proper HTTP pattern.
+var httpNameField = &lint.MethodRule{
+	Name:   lint.NewRuleName("core", "0134", "http-name"),
+	URI:    "https://aip.dev/134#guidance",
+	OnlyIf: isUpdateMethod,
+	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
+		fieldName := strcase.SnakeCase(m.GetName()[6:])
+		// Establish that the RPC has expected HTTP pattern.
+		for _, httpRule := range utils.GetHTTPRules(m) {
+			if uri := httpRule.GetPatch(); uri != "" {
+				matches := updateURINameRegexp.FindStringSubmatch(uri);
+				if matches == nil || matches[1] != fieldName {
+					return []lint.Problem{{
+						Message:    fmt.Sprintf("Update methods should include the `%s.name` field in the URI.", fieldName),
+						Descriptor: m,
+					}}
+				}
+			}
+		}
+
+		return nil
+	},
+}
+
+// Update methods should have an HTTP body.
+var httpBody = &lint.MethodRule{
+	Name:   lint.NewRuleName("core", "0134", "http-body"),
+	URI:    "https://aip.dev/134#guidance",
+	OnlyIf: isUpdateMethod,
+	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
+		fieldName := strcase.SnakeCase(m.GetName()[6:])
+		// Establish that the RPC has HTTP body equal to fieldName.
+		for _, httpRule := range utils.GetHTTPRules(m) {
+			if httpRule.GetBody() != fieldName {
+				return []lint.Problem{{
+					Message:    fmt.Sprintf("Update methods should have an HTTP body equal to `%q`.", fieldName),
+					Descriptor: m,
+				}}
+			}
+		}
+
+		return nil
+	},
+}


Test patch:
diff --git a/rules/aip0134/aip0134_test.go b/rules/aip0134/aip0134_test.go
new file mode 100644
index 0000000..c88534b
--- /dev/null
+++ b/rules/aip0134/aip0134_test.go
@@ -0,0 +1,32 @@
+// Copyright 2019 Google LLC
+//
+// 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
+//
+//     https://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 aip0134
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/googleapis/api-linter/lint"
+)
+
+func TestAddRules(t *testing.T) {
+	rules := make(lint.RuleRegistry)
+	AddRules(rules)
+	for ruleName := range rules {
+		if !strings.HasPrefix(string(ruleName), "core::0134") {
+			t.Errorf("Rule %s is not namespaced to core::0134.", ruleName)
+		}
+	}
+}
diff --git a/rules/aip0134/message_rules_test.go b/rules/aip0134/message_rules_test.go
new file mode 100644
index 0000000..63e504c
--- /dev/null
+++ b/rules/aip0134/message_rules_test.go
@@ -0,0 +1,160 @@
+// Copyright 2019 Google LLC
+//
+// 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
+//
+//     https://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 aip0134
+
+import (
+	"testing"
+
+	"github.com/googleapis/api-linter/rules/internal/testutils"
+	"github.com/jhump/protoreflect/desc"
+	"github.com/jhump/protoreflect/desc/builder"
+	fpb "google.golang.org/genproto/protobuf/field_mask"
+)
+
+func TestStandardFields(t *testing.T) {
+	// Get the correct message type for google.protobuf.FieldMask.
+	fieldMask, err := desc.LoadMessageDescriptorForMessage(&fpb.FieldMask{})
+	if err != nil {
+		t.Fatalf("Unable to load the field mask message.")
+	}
+
+	// Set up the testing permutations.
+	tests := []struct {
+		testName    string
+		messageName string
+		fieldName   string
+		problems    testutils.Problems
+	}{
+		// We use BigBook instead of Book in order to test correct casing logic
+		{"Valid", "UpdateBigBookRequest", "big_book", testutils.Problems{}},
+		{"NoResource", "UpdateBigBookRequest", "id", testutils.Problems{{Message: "book"}}},
+		{"Irrelevant", "AcquireBigBookRequest", "id", testutils.Problems{}},
+	}
+
+	// Run each test individually.
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			// Create an appropriate message descriptor.
+			bookMsg := builder.NewMessage("BigBook")
+			message, err := builder.NewMessage(test.messageName).AddField(
+				builder.NewField(test.fieldName, builder.FieldTypeMessage(bookMsg)),
+			).AddField(
+				builder.NewField("update_mask", builder.FieldTypeImportedMessage(fieldMask)),
+			).Build()
+			if err != nil {
+				t.Fatalf("Could not build %s message.", test.messageName)
+			}
+
+			// Run the lint rule, and establish that it returns the correct problems.
+			problems := standardFields.Lint(message.GetFile())
+			if diff := test.problems.SetDescriptor(message).Diff(problems); diff != "" {
+				t.Errorf("Problems did not match: %v", diff)
+			}
+		})
+	}
+}
+
+func TestStandardFieldsMissingUpdateMask(t *testing.T) {
+	// Create an appropriate message descriptor.
+	bookMsg := builder.NewMessage("Book")
+	message, err := builder.NewMessage("UpdateBookRequest").AddField(
+		builder.NewField("book", builder.FieldTypeMessage(bookMsg)),
+	).Build()
+	if err != nil {
+		t.Fatalf("Could not build descriptor.")
+	}
+
+	// Run the lint rule, and establish that it returns the correct
+	// number of problems.
+	wantProblems := testutils.Problems{{
+		Descriptor: message,
+		Message:    "Method UpdateBookRequest has no `update_mask` field",
+	}}
+	gotProblems := standardFields.Lint(message.GetFile())
+	if diff := wantProblems.Diff(gotProblems); diff != "" {
+		t.Errorf(diff)
+	}
+}
+
+func TestStandardFieldsInvalidType(t *testing.T) {
+	// Create an appropriate message descriptor.
+	parchmentMsg := builder.NewMessage("Parchment")
+	message, err := builder.NewMessage("UpdateBookRequest").AddField(
+		builder.NewField("book", builder.FieldTypeMessage(parchmentMsg)),
+	).Build()
+	if err != nil {
+		t.Fatalf("Could not build descriptor.")
+	}
+
+	// Run the lint rule, and establish that it returns the correct
+	// number of problems.
+	wantProblems := testutils.Problems{{
+		Descriptor: message.GetFields()[0],
+		Message:    "`book` field on Update RPCs should be of type `Book`",
+	}}
+	gotProblems := standardFields.Lint(message.GetFile())
+	if diff := wantProblems.Diff(gotProblems); diff != "" {
+		t.Errorf(diff)
+	}
+}
+
+func TestUnknownFields(t *testing.T) {
+	// Get the correct message type for google.protobuf.FieldMask.
+	fieldMask, err := desc.LoadMessageDescriptorForMessage(&fpb.FieldMask{})
+	if err != nil {
+		t.Fatalf("Unable to load the field mask message.")
+	}
+
+	// Set up the testing permutations.
+	tests := []struct {
+		testName    string
+		messageName string
+		fieldName   string
+		fieldType   *builder.FieldType
+		problems    testutils.Problems
+	}{
+		// Use BigBook instead of Book to test correct casing logic
+		{"UpdateMask", "UpdateBigBookRequest", "update_mask",
+			builder.FieldTypeImportedMessage(fieldMask), testutils.Problems{}},
+		{"Invalid", "UpdateBigBookRequest", "application_id",
+			builder.FieldTypeString(), testutils.Problems{{Message: "Unexpected field"}}},
+		{"InvalidCasing", "UpdateBigBookRequest", "bigbook",
+			builder.FieldTypeString(), testutils.Problems{{Message: "Unexpected field"}}},
+		{"Irrelevant", "AcquireBigBookRequest", "application_id",
+			builder.FieldTypeString(), testutils.Problems{}},
+	}
+
+	// Run each test individually.
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			// Create an appropriate message descriptor.
+			message, err := builder.NewMessage(test.messageName).AddField(
+				builder.NewField("big_book", builder.FieldTypeMessage(builder.NewMessage("BigBook"))),
+			).AddField(
+				builder.NewField(test.fieldName, test.fieldType),
+			).Build()
+			if err != nil {
+				t.Fatalf("Could not build UpdateBookRequest message.")
+			}
+
+			// Run the lint rule, and establish that it returns the correct problems.
+			wantProblems := test.problems.SetDescriptor(message.FindFieldByName(test.fieldName))
+			gotProblems := unknownFields.Lint(message.GetFile())
+			if diff := wantProblems.Diff(gotProblems); diff != "" {
+				t.Errorf(diff)
+			}
+		})
+	}
+}
diff --git a/rules/aip0134/method_rules_test.go b/rules/aip0134/method_rules_test.go
new file mode 100644
index 0000000..ca6b82f
--- /dev/null
+++ b/rules/aip0134/method_rules_test.go
@@ -0,0 +1,272 @@
+// Copyright 2019 Google LLC
+//
+// 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
+//
+//     https://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 aip0134
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/golang/protobuf/proto"
+	dpb "github.com/golang/protobuf/protoc-gen-go/descriptor"
+	"github.com/googleapis/api-linter/rules/internal/testutils"
+	"github.com/jhump/protoreflect/desc"
+	"github.com/jhump/protoreflect/desc/builder"
+	"google.golang.org/genproto/googleapis/api/annotations"
+	lro "google.golang.org/genproto/googleapis/longrunning"
+)
+
+func TestRequestMessageName(t *testing.T) {
+	// Set up the testing permutations.
+	tests := []struct {
+		testName       string
+		methodName     string
+		reqMessageName string
+		problems       testutils.Problems
+	}{
+		{"Valid", "UpdateBook", "UpdateBookRequest", testutils.Problems{}},
+		{"Invalid", "UpdateBook", "Book", testutils.Problems{{Suggestion: "UpdateBookRequest"}}},
+		{"Irrelevant", "AcquireBook", "Book", testutils.Problems{}},
+	}
+
+	// Run each test individually.
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			// Create a minimal service with a AIP-134 Update method
+			// (or with a different method, in the "Irrelevant" case).
+			service, err := builder.NewService("Library").AddMethod(builder.NewMethod(test.methodName,
+				builder.RpcTypeMessage(builder.NewMessage(test.reqMessageName), false),
+				builder.RpcTypeMessage(builder.NewMessage("Book"), false),
+			)).Build()
+			if err != nil {
+				t.Fatalf("Could not build %s method.", test.methodName)
+			}
+
+			// Run the lint rule, and establish that it returns the expected problems.
+			problems := requestMessageName.Lint(service.GetFile())
+			if diff := test.problems.SetDescriptor(service.GetMethods()[0]).Diff(problems); diff != "" {
+				t.Errorf(diff)
+			}
+		})
+	}
+}
+
+func TestResponseMessageName(t *testing.T) {
+	op, err := desc.LoadMessageDescriptorForMessage(&lro.Operation{})
+	if err != nil {
+		t.Fatalf("Unable to load the Operation message.")
+	}
+	opbldr, err := builder.FromMessage(op)
+	if err != nil {
+		t.Fatalf("Unable to construct builder from op desc.")
+	}
+
+	// Set up the testing permutations.
+	tests := []struct {
+		testName    string
+		methodName  string
+		respMessage *builder.MessageBuilder
+		problems    testutils.Problems
+	}{
+		{"ValidResource", "UpdateBook", builder.NewMessage("Book"), testutils.Problems{}},
+		{"ValidLRO", "UpdateBook", opbldr, testutils.Problems{}},
+		{"Invalid", "UpdateBook", builder.NewMessage("UpdateBookResponse"), testutils.Problems{{Suggestion: "Book"}}},
+		{"Irrelevant", "AcquireBook", builder.NewMessage("AcquireBookResponse"), testutils.Problems{}},
+	}
+
+	// Run each test individually.
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			// Create a minimal service with a AIP-134 Update method
+			// (or with a different method, in the "Irrelevant" case).
+			service, err := builder.NewService("Library").AddMethod(builder.NewMethod(test.methodName,
+				builder.RpcTypeMessage(builder.NewMessage("UpdateBookRequest"), false),
+				builder.RpcTypeMessage(test.respMessage, false),
+			)).Build()
+			if err != nil {
+				t.Fatalf("Could not build %s method.", test.methodName)
+			}
+
+			// Run the lint rule, and establish that it returns the correct
+			// number of problems.
+			problems := responseMessageName.Lint(service.GetFile())
+			if diff := test.problems.SetDescriptor(service.GetMethods()[0]).Diff(problems); diff != "" {
+				t.Errorf(diff)
+			}
+		})
+	}
+}
+
+func TestHttpVerb(t *testing.T) {
+	// Set up POST and PATCH HTTP annotations.
+	httpPost := &annotations.HttpRule{
+		Pattern: &annotations.HttpRule_Post{
+			Post: "/v1/{book.name=publishers/*/books/*}",
+		},
+	}
+	httpPatch := &annotations.HttpRule{
+		Pattern: &annotations.HttpRule_Patch{
+			Patch: "/v1/{book.name=publishers/*/books/*}",
+		},
+	}
+
+	// Set up testing permutations.
+	tests := []struct {
+		testName   string
+		httpRule   *annotations.HttpRule
+		methodName string
+		msg        string
+	}{
+		{"Valid", httpPatch, "UpdateBook", ""},
+		{"Invalid", httpPost, "UpdateBook", "HTTP PATCH"},
+		{"Irrelevant", httpPost, "AcquireBook", ""},
+	}
+
+	// Run each test.
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			// Create a MethodOptions with the annotation set.
+			opts := &dpb.MethodOptions{}
+			if err := proto.SetExtension(opts, annotations.E_Http, test.httpRule); err != nil {
+				t.Fatalf("Failed to set google.api.http annotation.")
+			}
+
+			// Create a minimal service with a AIP-134 Update method
+			// (or with a different method, in the "Irrelevant" case).
+			service, err := builder.NewService("Library").AddMethod(builder.NewMethod(test.methodName,
+				builder.RpcTypeMessage(builder.NewMessage("UpdateBookRequest"), false),
+				builder.RpcTypeMessage(builder.NewMessage("Book"), false),
+			).SetOptions(opts)).Build()
+			if err != nil {
+				t.Fatalf("Could not build %s method.", test.methodName)
+			}
+
+			// Run the method, ensure we get what we expect.
+			problems := httpVerb.Lint(service.GetFile())
+			if test.msg == "" && len(problems) > 0 {
+				t.Errorf("Got %v, expected no problems.", problems)
+			} else if test.msg != "" && !strings.Contains(problems[0].Message, test.msg) {
+				t.Errorf("Got %q, expected message containing %q", problems[0].Message, test.msg)
+			}
+		})
+	}
+}
+
+func TestHttpBody(t *testing.T) {
+	tests := []struct {
+		testName   string
+		body       string
+		methodName string
+		msg        string
+	}{
+		{"Valid", "book", "UpdateBook", ""},
+		{"InvalidFoo", "foo", "UpdateBook", "HTTP body"},
+		{"InvalidStar", "*", "UpdateBook", "HTTP body"},
+		{"InvalidEmpty", "", "UpdateBook", "HTTP body"},
+		{"Irrelevant", "*", "AcquireBook", ""},
+	}
+
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			// Create a MethodOptions with the annotation set.
+			opts := &dpb.MethodOptions{}
+			httpRule := &annotations.HttpRule{
+				Pattern: &annotations.HttpRule_Patch{
+					Patch: "/v1/{book.name=publishers/*/books/*}",
+				},
+				Body: test.body,
+			}
+			if err := proto.SetExtension(opts, annotations.E_Http, httpRule); err != nil {
+				t.Fatalf("Failed to set google.api.http annotation.")
+			}
+
+			// Create a minimal service with a AIP-134 Update method
+			// (or with a different method, in the "Irrelevant" case).
+			service, err := builder.NewService("Library").AddMethod(builder.NewMethod(test.methodName,
+				builder.RpcTypeMessage(builder.NewMessage("UpdateBookRequest"), false),
+				builder.RpcTypeMessage(builder.NewMessage("Book"), false),
+			).SetOptions(opts)).Build()
+			if err != nil {
+				t.Fatalf("Could not build %s method.", test.methodName)
+			}
+
+			// Run the method, ensure we get what we expect.
+			problems := httpBody.Lint(service.GetFile())
+			if test.msg == "" && len(problems) > 0 {
+				t.Errorf("Got %v, expected no problems.", problems)
+			} else if test.msg != "" && !strings.Contains(problems[0].Message, test.msg) {
+				t.Errorf("Got %q, expected message containing %q", problems[0].Message, test.msg)
+			}
+		})
+	}
+}
+
+func TestHttpNameField(t *testing.T) {
+	tests := []struct {
+		testName   string
+		uri        string
+		methodName string
+		msg        string
+	}{
+		{"Valid", "/v1/{big_book.name=publishers/*/books/*}",
+			"UpdateBigBook", ""},
+		{"InvalidNoUnderscore", "/v1/{bigbook.name=publishers/*/books/*}",
+			"UpdateBigBook", "`big_book.name` field"},
+		{"InvalidVarNameBook", "/v1/{big_book=publishers/*/books/*}",
+			"UpdateBigBook", "`big_book.name` field"},
+		{"InvalidVarNameName", "/v1/{name=publishers/*/books/*}",
+			"UpdateBigBook", "`big_book.name` field"},
+		{"InvalidVarNameReversed", "/v1/{name.big_book=publishers/*/books/*}",
+			"UpdateBigBook", "`big_book.name` field"},
+		{"NoVarName", "/v1/publishers/*/books/*",
+			"UpdateBigBook", "`big_book.name` field"},
+		{"Irrelevant", "/v1/{book=publishers/*/books/*}",
+			"AcquireBigBook", ""},
+	}
+
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			// Create a MethodOptions with the annotation set.
+			opts := &dpb.MethodOptions{}
+			httpRule := &annotations.HttpRule{
+				Pattern: &annotations.HttpRule_Patch{
+					Patch: test.uri,
+				},
+			}
+			if err := proto.SetExtension(opts, annotations.E_Http, httpRule); err != nil {
+				t.Fatalf("Failed to set google.api.http annotation.")
+			}
+
+			// Create a minimal service with a AIP-134 Update method
+			// (or with a different method, in the "Irrelevant" case).
+			service, err := builder.NewService("Library").AddMethod(builder.NewMethod(test.methodName,
+				builder.RpcTypeMessage(builder.NewMessage("UpdateBigBookRequest"), false),
+				builder.RpcTypeMessage(builder.NewMessage("BigBook"), false),
+			).SetOptions(opts)).Build()
+			if err != nil {
+				t.Fatalf("Could not build %s method.", test.methodName)
+			}
+
+			// Run the method, ensure we get what we expect.
+			problems := httpNameField.Lint(service.GetFile())
+			if test.msg == "" && len(problems) > 0 {
+				t.Errorf("Got %v, expected no problems.", problems)
+			} else if test.msg != "" && len(problems) == 0 {
+				t.Errorf("Got no problems, expected 1.")
+			} else if test.msg != "" && !strings.Contains(problems[0].Message, test.msg) {
+				t.Errorf("Got %q, expected message containing %q", problems[0].Message, test.msg)
+			}
+		})
+	}
+}