Repository: googleapis/api-linter
Pull request #1152 (2023-05-13 18:39:48)

feat(AIP-121): lint resource has a Get
from AIP-121:

  A resource must support at minimum Get:
  clients must be able to validate the state of resources
  after performing a mutation such as Create, Update, or Delete.

Refactoring a few methods into a new util method, since it's best to have the same detection logic on Create/Get/Update/Delete/List methods.

Related: #1104 

toumorokoshi: Thanks @noahdietz! addressed and re-requested

Patch:
diff --git a/docs/rules/0121/index.md b/docs/rules/0121/index.md
new file mode 100644
index 0000000..dc5f68a
--- /dev/null
+++ b/docs/rules/0121/index.md
@@ -0,0 +1,10 @@
+---
+aip_listing: 121
+permalink: /121/
+redirect_from:
+  - /0121/
+---
+
+# Resource names
+
+{% include linter-aip-listing.md aip=121 %}
diff --git a/docs/rules/0121/resource-must-support-get.md b/docs/rules/0121/resource-must-support-get.md
new file mode 100644
index 0000000..4a3f276
--- /dev/null
+++ b/docs/rules/0121/resource-must-support-get.md
@@ -0,0 +1,82 @@
+---
+rule:
+  aip: 121
+  name: [core, '0121', resource-must-support-get]
+  summary: All resource names must use camel case in collection identifiers.
+permalink: /121/resource-must-support-get
+redirect_from:
+  - /0121/resource-must-support-get
+---
+
+# Resource must support get
+
+This rule enforces that all resources support the Get operation as mandated in
+[AIP-121][].
+
+## Details
+
+This rule scans a service for Create, Update, and List methods for resources,
+and ensures each one has a Get method.
+
+## Examples
+
+**Incorrect** code for this rule:
+
+```proto
+// Incorrect.
+service Foo {
+  // Book has a create, but no Get method.
+  rpc CreateBook(CreateBookRequest) returns (Book) {};
+}
+
+message Book {
+  option (google.api.resource) = {
+    type: "library.googleapis.com/Book"
+    pattern: "books/{book}"
+  };
+}
+```
+
+**Correct** code for this rule:
+
+```proto
+// Correct.
+service Foo {
+  rpc CreateBook(CreateBookRequest) returns (Book) {};
+  rpc GetBook(GetBookRequest) returns (Book) {};
+}
+
+message Book {
+  option (google.api.resource) = {
+    type: "library.googleapis.com/Book"
+    pattern: "books/{book}"
+  };
+}
+```
+
+## Disabling
+
+If you need to violate this rule, use a leading comment above the service.
+Remember to also include an [aip.dev/not-precedent][] comment explaining why.
+
+```proto
+// (-- api-linter: core::0121::resource-must-support-get=disabled
+//     aip.dev/not-precedent: We need to do this because reasons. --)
+service Foo {
+  // Book has a create, but no Get method.
+  rpc CreateBook(CreateBookRequest) returns (Book) {};
+}
+
+message Book {
+  option (google.api.resource) = {
+    type: "library.googleapis.com/Book"
+    pattern: "books/{book}"
+  };
+}
+```
+
+If you need to violate this rule for an entire file, place the comment at the
+top of the file.
+
+[aip-121]: https://aip.dev/121
+[aip.dev/not-precedent]: https://aip.dev/not-precedent
\ No newline at end of file
diff --git a/rules/aip0121/aip0121.go b/rules/aip0121/aip0121.go
new file mode 100644
index 0000000..1dcc614
--- /dev/null
+++ b/rules/aip0121/aip0121.go
@@ -0,0 +1,29 @@
+// Copyright 2023 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 aip0121 contains rules defined in https://aip.dev/121.
+package aip0121
+
+import (
+	"github.com/googleapis/api-linter/lint"
+)
+
+// AddRules accepts a register function and registers each of
+// this AIP's rules to it.
+func AddRules(r lint.RuleRegistry) error {
+	return r.Register(
+		121,
+		resourceMustSupportGet,
+	)
+}
diff --git a/rules/aip0121/resource_must_support_get.go b/rules/aip0121/resource_must_support_get.go
new file mode 100644
index 0000000..01ac6b6
--- /dev/null
+++ b/rules/aip0121/resource_must_support_get.go
@@ -0,0 +1,65 @@
+// Copyright 2023 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 aip0121
+
+import (
+	"fmt"
+
+	"bitbucket.org/creachadair/stringset"
+	"github.com/googleapis/api-linter/lint"
+	"github.com/googleapis/api-linter/rules/internal/utils"
+	"github.com/jhump/protoreflect/desc"
+)
+
+var resourceMustSupportGet = &lint.ServiceRule{
+	Name: lint.NewRuleName(121, "resource-must-support-get"),
+	LintService: func(s *desc.ServiceDescriptor) []lint.Problem {
+		problems := []lint.Problem{}
+		// Add the empty string to avoid adding multiple nil checks.
+		// Just say it's valid instead.
+		resourcesWithGet := stringset.New("")
+		resourcesWithOtherMethods := map[string]*desc.MessageDescriptor{}
+		// Iterate all RPCs and try to find resources. Mark the
+		// resources which have a Get method, and which ones do not.
+		for _, m := range s.GetMethods() {
+			if utils.IsGetMethod(m) {
+				t := utils.GetResource(m.GetOutputType()).GetType()
+				resourcesWithGet.Add(t)
+			} else if utils.IsCreateMethod(m) || utils.IsUpdateMethod(m) {
+				if msg := utils.GetResponseType(m); msg != nil {
+					t := utils.GetResource(msg).GetType()
+					resourcesWithOtherMethods[t] = msg
+				}
+			} else if utils.IsListMethod(m) {
+				if msg := utils.GetListResourceMessage(m); msg != nil {
+					t := utils.GetResource(msg).GetType()
+					resourcesWithOtherMethods[t] = msg
+				}
+			}
+		}
+
+		for t := range resourcesWithOtherMethods {
+			if !resourcesWithGet.Contains(t) {
+				problems = append(problems, lint.Problem{
+					Message: fmt.Sprintf(
+						"Missing Standard Get method for resource %q", t,
+					),
+					Descriptor: s,
+				})
+			}
+		}
+		return problems
+	},
+}
diff --git a/rules/aip0131/aip0131.go b/rules/aip0131/aip0131.go
index 4905b3d..3a674f2 100644
--- a/rules/aip0131/aip0131.go
+++ b/rules/aip0131/aip0131.go
@@ -44,19 +44,9 @@ func AddRules(r lint.RuleRegistry) error {
 }
 
 var (
-	getMethodRegexp     = regexp.MustCompile("^Get(?:[A-Z]|$)")
 	getReqMessageRegexp = regexp.MustCompile("^Get[A-Za-z0-9]*Request$")
 )
 
-// Returns true if this is a AIP-131 Get method, false otherwise.
-func isGetMethod(m *desc.MethodDescriptor) bool {
-	methodName := m.GetName()
-	if methodName == "GetIamPolicy" {
-		return false
-	}
-	return getMethodRegexp.MatchString(methodName)
-}
-
 // Returns true if this is an AIP-131 Get request message, false otherwise.
 func isGetRequestMessage(m *desc.MessageDescriptor) bool {
 	return getReqMessageRegexp.MatchString(m.GetName())
diff --git a/rules/aip0131/http_body.go b/rules/aip0131/http_body.go
index d1f3e17..a469214 100644
--- a/rules/aip0131/http_body.go
+++ b/rules/aip0131/http_body.go
@@ -22,6 +22,6 @@ import (
 // Get methods should not have an HTTP body.
 var httpBody = &lint.MethodRule{
 	Name:       lint.NewRuleName(131, "http-body"),
-	OnlyIf:     isGetMethod,
+	OnlyIf:     utils.IsGetMethod,
 	LintMethod: utils.LintNoHTTPBody,
 }
diff --git a/rules/aip0131/http_method.go b/rules/aip0131/http_method.go
index 83684ca..256e389 100644
--- a/rules/aip0131/http_method.go
+++ b/rules/aip0131/http_method.go
@@ -22,6 +22,6 @@ import (
 // Get methods should use the HTTP GET verb.
 var httpMethod = &lint.MethodRule{
 	Name:       lint.NewRuleName(131, "http-method"),
-	OnlyIf:     isGetMethod,
+	OnlyIf:     utils.IsGetMethod,
 	LintMethod: utils.LintHTTPMethod("GET"),
 }
diff --git a/rules/aip0131/http_uri_name.go b/rules/aip0131/http_uri_name.go
index a96a2cd..af75d3d 100644
--- a/rules/aip0131/http_uri_name.go
+++ b/rules/aip0131/http_uri_name.go
@@ -22,6 +22,6 @@ import (
 // Get methods should have a proper HTTP pattern.
 var httpNameField = &lint.MethodRule{
 	Name:       lint.NewRuleName(131, "http-uri-name"),
-	OnlyIf:     isGetMethod,
+	OnlyIf:     utils.IsGetMethod,
 	LintMethod: utils.LintHTTPURIHasNameVariable,
 }
diff --git a/rules/aip0131/method_signature.go b/rules/aip0131/method_signature.go
index 1eeca6a..896aee6 100644
--- a/rules/aip0131/method_signature.go
+++ b/rules/aip0131/method_signature.go
@@ -26,7 +26,7 @@ import (
 
 var methodSignature = &lint.MethodRule{
 	Name:   lint.NewRuleName(131, "method-signature"),
-	OnlyIf: isGetMethod,
+	OnlyIf: utils.IsGetMethod,
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		signatures := utils.GetMethodSignatures(m)
 
diff --git a/rules/aip0131/request_message_name.go b/rules/aip0131/request_message_name.go
index 6d40715..ff3031f 100644
--- a/rules/aip0131/request_message_name.go
+++ b/rules/aip0131/request_message_name.go
@@ -22,6 +22,6 @@ import (
 // Get messages should have a properly named Request message.
 var requestMessageName = &lint.MethodRule{
 	Name:       lint.NewRuleName(131, "request-message-name"),
-	OnlyIf:     isGetMethod,
+	OnlyIf:     utils.IsGetMethod,
 	LintMethod: utils.LintMethodHasMatchingRequestName,
 }
diff --git a/rules/aip0131/response_message_name.go b/rules/aip0131/response_message_name.go
index 5038422..578760e 100644
--- a/rules/aip0131/response_message_name.go
+++ b/rules/aip0131/response_message_name.go
@@ -19,13 +19,14 @@ import (
 
 	"github.com/googleapis/api-linter/lint"
 	"github.com/googleapis/api-linter/locations"
+	"github.com/googleapis/api-linter/rules/internal/utils"
 	"github.com/jhump/protoreflect/desc"
 )
 
 // Get messages should use the resource as the response message
 var responseMessageName = &lint.MethodRule{
 	Name:   lint.NewRuleName(131, "response-message-name"),
-	OnlyIf: isGetMethod,
+	OnlyIf: utils.IsGetMethod,
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		// Rule check: Establish that for methods such as `GetFoo`, the response
 		// message is named `Foo`.
diff --git a/rules/aip0132/aip0132.go b/rules/aip0132/aip0132.go
index df4be4b..3070378 100644
--- a/rules/aip0132/aip0132.go
+++ b/rules/aip0132/aip0132.go
@@ -47,16 +47,10 @@ func AddRules(r lint.RuleRegistry) error {
 }
 
 var (
-	listMethodRegexp      = regexp.MustCompile("^List(?:[A-Z]|$)")
 	listReqMessageRegexp  = regexp.MustCompile("^List[A-Za-z0-9]*Request$")
 	listRespMessageRegexp = regexp.MustCompile("^List([A-Za-z0-9]*)Response$")
 )
 
-// Return true if this is an AIP-132 List method, false otherwise.
-func isListMethod(m *desc.MethodDescriptor) bool {
-	return listMethodRegexp.MatchString(m.GetName()) && !aip0162.IsListRevisionsMethod(m)
-}
-
 // Return true if this is an AIP-132 List request message, false otherwise.
 func isListRequestMessage(m *desc.MessageDescriptor) bool {
 	return listReqMessageRegexp.MatchString(m.GetName()) && !aip0162.IsListRevisionsRequestMessage(m)
diff --git a/rules/aip0132/http_body.go b/rules/aip0132/http_body.go
index 9f37298..cd6ac9d 100644
--- a/rules/aip0132/http_body.go
+++ b/rules/aip0132/http_body.go
@@ -22,6 +22,6 @@ import (
 // List methods should not have an HTTP body.
 var httpBody = &lint.MethodRule{
 	Name:       lint.NewRuleName(132, "http-body"),
-	OnlyIf:     isListMethod,
+	OnlyIf:     utils.IsListMethod,
 	LintMethod: utils.LintNoHTTPBody,
 }
diff --git a/rules/aip0132/http_method.go b/rules/aip0132/http_method.go
index 1f57b3e..0633a5f 100644
--- a/rules/aip0132/http_method.go
+++ b/rules/aip0132/http_method.go
@@ -22,6 +22,6 @@ import (
 // List methods should use the HTTP GET verb.
 var httpMethod = &lint.MethodRule{
 	Name:       lint.NewRuleName(132, "http-method"),
-	OnlyIf:     isListMethod,
+	OnlyIf:     utils.IsListMethod,
 	LintMethod: utils.LintHTTPMethod("GET"),
 }
diff --git a/rules/aip0132/http_uri_parent.go b/rules/aip0132/http_uri_parent.go
index 4ef0d3a..2be671f 100644
--- a/rules/aip0132/http_uri_parent.go
+++ b/rules/aip0132/http_uri_parent.go
@@ -24,7 +24,7 @@ import (
 var httpURIParent = &lint.MethodRule{
 	Name: lint.NewRuleName(132, "http-uri-parent"),
 	OnlyIf: func(m *desc.MethodDescriptor) bool {
-		return isListMethod(m) && m.GetInputType().FindFieldByName("parent") != nil
+		return utils.IsListMethod(m) && m.GetInputType().FindFieldByName("parent") != nil
 	},
 	LintMethod: utils.LintHTTPURIHasParentVariable,
 }
diff --git a/rules/aip0132/method_signature.go b/rules/aip0132/method_signature.go
index db4ee2d..dfa67a4 100644
--- a/rules/aip0132/method_signature.go
+++ b/rules/aip0132/method_signature.go
@@ -27,7 +27,7 @@ import (
 var methodSignature = &lint.MethodRule{
 	Name: lint.NewRuleName(132, "method-signature"),
 	OnlyIf: func(m *desc.MethodDescriptor) bool {
-		return isListMethod(m) && m.GetInputType().FindFieldByName("parent") != nil
+		return utils.IsListMethod(m) && m.GetInputType().FindFieldByName("parent") != nil
 	},
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		signatures := utils.GetMethodSignatures(m)
diff --git a/rules/aip0132/request_message_name.go b/rules/aip0132/request_message_name.go
index fd6a4cc..4d4d9ed 100644
--- a/rules/aip0132/request_message_name.go
+++ b/rules/aip0132/request_message_name.go
@@ -22,6 +22,6 @@ import (
 // List messages should have a properly named Request message.
 var requestMessageName = &lint.MethodRule{
 	Name:       lint.NewRuleName(132, "request-message-name"),
-	OnlyIf:     isListMethod,
+	OnlyIf:     utils.IsListMethod,
 	LintMethod: utils.LintMethodHasMatchingRequestName,
 }
diff --git a/rules/aip0132/resource_reference_type.go b/rules/aip0132/resource_reference_type.go
index 066d7e5..d8eb936 100644
--- a/rules/aip0132/resource_reference_type.go
+++ b/rules/aip0132/resource_reference_type.go
@@ -29,13 +29,12 @@ var resourceReferenceType = &lint.MethodRule{
 	OnlyIf: func(m *desc.MethodDescriptor) bool {
 		p := m.GetInputType().FindFieldByName("parent")
 
-		repeated := utils.GetRepeatedMessageFields(m.GetOutputType())
 		var resource *annotations.ResourceDescriptor
-		if len(repeated) > 0 {
-			resource = utils.GetResource(repeated[0].GetMessageType())
+		resourceField := utils.GetListResourceMessage(m)
+		if resourceField != nil {
+			resource = utils.GetResource(resourceField)
 		}
-
-		return isListMethod(m) && p != nil && utils.GetResourceReference(p) != nil && resource != nil
+		return utils.IsListMethod(m) && p != nil && utils.GetResourceReference(p) != nil && resource != nil
 	},
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		// The first repeated message field must be the paginated resource.
diff --git a/rules/aip0132/response_message_name.go b/rules/aip0132/response_message_name.go
index 3751178..b1fbd05 100644
--- a/rules/aip0132/response_message_name.go
+++ b/rules/aip0132/response_message_name.go
@@ -22,6 +22,6 @@ import (
 // List messages should use a `ListFoosResponse` response message.
 var responseMessageName = &lint.MethodRule{
 	Name:       lint.NewRuleName(132, "response-message-name"),
-	OnlyIf:     isListMethod,
+	OnlyIf:     utils.IsListMethod,
 	LintMethod: utils.LintMethodHasMatchingResponseName,
 }
diff --git a/rules/aip0133/aip0133.go b/rules/aip0133/aip0133.go
index 98f7592..66a126a 100644
--- a/rules/aip0133/aip0133.go
+++ b/rules/aip0133/aip0133.go
@@ -51,15 +51,9 @@ func AddRules(r lint.RuleRegistry) error {
 }
 
 var (
-	createMethodRegexp     = regexp.MustCompile("^Create(?:[A-Z]|$)")
 	createReqMessageRegexp = regexp.MustCompile("^Create[A-Za-z0-9]*Request$")
 )
 
-// Returns true if this is a AIP-133 Create method, false otherwise.
-func isCreateMethod(m *desc.MethodDescriptor) bool {
-	return createMethodRegexp.MatchString(m.GetName())
-}
-
 // Returns true if this is an AIP-133 Get request message, false otherwise.
 func isCreateRequestMessage(m *desc.MessageDescriptor) bool {
 	return createReqMessageRegexp.MatchString(m.GetName())
diff --git a/rules/aip0133/http_body.go b/rules/aip0133/http_body.go
index 909a4bc..5fa0fc2 100644
--- a/rules/aip0133/http_body.go
+++ b/rules/aip0133/http_body.go
@@ -27,7 +27,7 @@ import (
 // Create methods should have an HTTP body, and the body value should be resource.
 var httpBody = &lint.MethodRule{
 	Name:   lint.NewRuleName(133, "http-body"),
-	OnlyIf: isCreateMethod,
+	OnlyIf: utils.IsCreateMethod,
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		resourceMsgName := getResourceMsgName(m)
 		resourceFieldName := strings.ToLower(resourceMsgName)
diff --git a/rules/aip0133/http_method.go b/rules/aip0133/http_method.go
index e11bbe4..b639484 100644
--- a/rules/aip0133/http_method.go
+++ b/rules/aip0133/http_method.go
@@ -22,6 +22,6 @@ import (
 // Create methods should use the HTTP POST verb.
 var httpMethod = &lint.MethodRule{
 	Name:       lint.NewRuleName(133, "http-method"),
-	OnlyIf:     isCreateMethod,
+	OnlyIf:     utils.IsCreateMethod,
 	LintMethod: utils.LintHTTPMethod("POST"),
 }
diff --git a/rules/aip0133/http_uri_parent.go b/rules/aip0133/http_uri_parent.go
index 0981a5a..dad22b4 100644
--- a/rules/aip0133/http_uri_parent.go
+++ b/rules/aip0133/http_uri_parent.go
@@ -40,7 +40,7 @@ var httpURIParent = &lint.MethodRule{
 			}
 		}
 
-		return isCreateMethod(m) && !hasNoParent(res)
+		return utils.IsCreateMethod(m) && !hasNoParent(res)
 	},
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		if problems := utils.LintHTTPURIHasParentVariable(m); problems != nil {
diff --git a/rules/aip0133/http_uri_resource.go b/rules/aip0133/http_uri_resource.go
index 4fbe73a..433cf53 100644
--- a/rules/aip0133/http_uri_resource.go
+++ b/rules/aip0133/http_uri_resource.go
@@ -28,7 +28,7 @@ import (
 var httpURIResource = &lint.MethodRule{
 	Name: lint.NewRuleName(133, "http-uri-resource"),
 	OnlyIf: func(m *desc.MethodDescriptor) bool {
-		return isCreateMethod(m) && len(utils.GetHTTPRules(m)) > 0
+		return utils.IsCreateMethod(m) && len(utils.GetHTTPRules(m)) > 0
 	},
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		problems := []lint.Problem{}
diff --git a/rules/aip0133/method_signature.go b/rules/aip0133/method_signature.go
index e237d05..3e2b0f2 100644
--- a/rules/aip0133/method_signature.go
+++ b/rules/aip0133/method_signature.go
@@ -28,7 +28,7 @@ import (
 
 var methodSignature = &lint.MethodRule{
 	Name:   lint.NewRuleName(133, "method-signature"),
-	OnlyIf: isCreateMethod,
+	OnlyIf: utils.IsCreateMethod,
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		signatures := utils.GetMethodSignatures(m)
 
diff --git a/rules/aip0133/request_message_name.go b/rules/aip0133/request_message_name.go
index a435373..504541b 100644
--- a/rules/aip0133/request_message_name.go
+++ b/rules/aip0133/request_message_name.go
@@ -22,6 +22,6 @@ import (
 // Create method should have a properly named input message.
 var inputName = &lint.MethodRule{
 	Name:       lint.NewRuleName(133, "request-message-name"),
-	OnlyIf:     isCreateMethod,
+	OnlyIf:     utils.IsCreateMethod,
 	LintMethod: utils.LintMethodHasMatchingRequestName,
 }
diff --git a/rules/aip0133/resource_reference_type.go b/rules/aip0133/resource_reference_type.go
index 733d1e5..55f76d5 100644
--- a/rules/aip0133/resource_reference_type.go
+++ b/rules/aip0133/resource_reference_type.go
@@ -26,23 +26,15 @@ import (
 var resourceReferenceType = &lint.MethodRule{
 	Name: lint.NewRuleName(133, "resource-reference-type"),
 	OnlyIf: func(m *desc.MethodDescriptor) bool {
-		// Return type of the RPC.
-		ot := m.GetOutputType()
-		if ot.GetName() == "Operation" {
-			ot = utils.GetResponseType(m)
-		}
-
+		ot := utils.GetResponseType(m)
 		// Unresolvable response_type for an Operation results in nil here.
 		resource := utils.GetResource(ot)
 		p := m.GetInputType().FindFieldByName("parent")
-		return isCreateMethod(m) && p != nil && utils.GetResourceReference(p) != nil && resource != nil
+		return utils.IsCreateMethod(m) && p != nil && utils.GetResourceReference(p) != nil && resource != nil
 	},
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		// Return type of the RPC.
-		ot := m.GetOutputType()
-		if ot.GetName() == "Operation" {
-			ot = utils.GetResponseType(m)
-		}
+		ot := utils.GetResponseType(m)
 		resource := utils.GetResource(ot)
 		parent := m.GetInputType().FindFieldByName("parent")
 		ref := utils.GetResourceReference(parent)
diff --git a/rules/aip0133/response_lro.go b/rules/aip0133/response_lro.go
index ce4a63f..39c000c 100644
--- a/rules/aip0133/response_lro.go
+++ b/rules/aip0133/response_lro.go
@@ -24,7 +24,7 @@ import (
 var responseLRO = &lint.MethodRule{
 	Name: lint.NewRuleName(133, "response-lro"),
 	OnlyIf: func(m *desc.MethodDescriptor) bool {
-		return isCreateMethod(m) && utils.IsDeclarativeFriendlyMethod(m)
+		return utils.IsCreateMethod(m) && utils.IsDeclarativeFriendlyMethod(m)
 	},
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		if m.GetOutputType().GetFullyQualifiedName() != "google.longrunning.Operation" {
diff --git a/rules/aip0133/response_message_name.go b/rules/aip0133/response_message_name.go
index d9c36bd..6d5a405 100644
--- a/rules/aip0133/response_message_name.go
+++ b/rules/aip0133/response_message_name.go
@@ -26,7 +26,7 @@ import (
 // Create method should use the resource as the output message
 var outputName = &lint.MethodRule{
 	Name:   lint.NewRuleName(133, "response-message-name"),
-	OnlyIf: isCreateMethod,
+	OnlyIf: utils.IsCreateMethod,
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		want := getResourceMsgName(m)
 
diff --git a/rules/aip0134/aip0134.go b/rules/aip0134/aip0134.go
index f0ef0e4..f93ad75 100644
--- a/rules/aip0134/aip0134.go
+++ b/rules/aip0134/aip0134.go
@@ -47,16 +47,9 @@ func AddRules(r lint.RuleRegistry) error {
 }
 
 var (
-	updateMethodRegexp     = regexp.MustCompile("^Update(?:[A-Z]|$)")
 	updateReqMessageRegexp = regexp.MustCompile("^Update[A-Za-z0-9]*Request$")
 )
 
-// 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/http_body.go b/rules/aip0134/http_body.go
index acb9643..168af39 100644
--- a/rules/aip0134/http_body.go
+++ b/rules/aip0134/http_body.go
@@ -27,7 +27,7 @@ import (
 // Update methods should have an HTTP body.
 var httpBody = &lint.MethodRule{
 	Name:   lint.NewRuleName(134, "http-body"),
-	OnlyIf: isUpdateMethod,
+	OnlyIf: utils.IsUpdateMethod,
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		fieldName := strcase.SnakeCase(m.GetName()[6:])
 		// Establish that the RPC has HTTP body equal to fieldName.
diff --git a/rules/aip0134/http_method.go b/rules/aip0134/http_method.go
index cb68dde..09a5900 100644
--- a/rules/aip0134/http_method.go
+++ b/rules/aip0134/http_method.go
@@ -22,6 +22,6 @@ import (
 // Update methods should use the HTTP PATCH verb.
 var httpMethod = &lint.MethodRule{
 	Name:       lint.NewRuleName(134, "http-method"),
-	OnlyIf:     isUpdateMethod,
+	OnlyIf:     utils.IsUpdateMethod,
 	LintMethod: utils.LintHTTPMethod("PATCH"),
 }
diff --git a/rules/aip0134/http_uri_name.go b/rules/aip0134/http_uri_name.go
index e414425..e6bb6d7 100644
--- a/rules/aip0134/http_uri_name.go
+++ b/rules/aip0134/http_uri_name.go
@@ -26,7 +26,7 @@ import (
 // Update methods should have a proper HTTP pattern.
 var httpNameField = &lint.MethodRule{
 	Name:   lint.NewRuleName(134, "http-uri-name"),
-	OnlyIf: isUpdateMethod,
+	OnlyIf: utils.IsUpdateMethod,
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		fieldName := strcase.SnakeCase(m.GetName()[6:])
 		want := fmt.Sprintf("%s.name", fieldName)
diff --git a/rules/aip0134/method_signature.go b/rules/aip0134/method_signature.go
index f869088..7305a72 100644
--- a/rules/aip0134/method_signature.go
+++ b/rules/aip0134/method_signature.go
@@ -28,7 +28,7 @@ import (
 
 var methodSignature = &lint.MethodRule{
 	Name:   lint.NewRuleName(134, "method-signature"),
-	OnlyIf: isUpdateMethod,
+	OnlyIf: utils.IsUpdateMethod,
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		signatures := utils.GetMethodSignatures(m)
 		want := []string{
diff --git a/rules/aip0134/request_message_name.go b/rules/aip0134/request_message_name.go
index e1858fe..be7a653 100644
--- a/rules/aip0134/request_message_name.go
+++ b/rules/aip0134/request_message_name.go
@@ -22,6 +22,6 @@ import (
 // Update methods should have a properly named Request message.
 var requestMessageName = &lint.MethodRule{
 	Name:       lint.NewRuleName(134, "request-message-name"),
-	OnlyIf:     isUpdateMethod,
+	OnlyIf:     utils.IsUpdateMethod,
 	LintMethod: utils.LintMethodHasMatchingRequestName,
 }
diff --git a/rules/aip0134/response_lro.go b/rules/aip0134/response_lro.go
index e9b779c..5204d27 100644
--- a/rules/aip0134/response_lro.go
+++ b/rules/aip0134/response_lro.go
@@ -24,7 +24,7 @@ import (
 var responseLRO = &lint.MethodRule{
 	Name: lint.NewRuleName(134, "response-lro"),
 	OnlyIf: func(m *desc.MethodDescriptor) bool {
-		return isUpdateMethod(m) && utils.IsDeclarativeFriendlyMethod(m)
+		return utils.IsUpdateMethod(m) && utils.IsDeclarativeFriendlyMethod(m)
 	},
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		if m.GetOutputType().GetFullyQualifiedName() != "google.longrunning.Operation" {
diff --git a/rules/aip0134/response_message_name.go b/rules/aip0134/response_message_name.go
index 3ae44f8..dcdc378 100644
--- a/rules/aip0134/response_message_name.go
+++ b/rules/aip0134/response_message_name.go
@@ -27,7 +27,7 @@ import (
 // Update methods should use the resource as the response message
 var responseMessageName = &lint.MethodRule{
 	Name:   lint.NewRuleName(134, "response-message-name"),
-	OnlyIf: isUpdateMethod,
+	OnlyIf: utils.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`.
diff --git a/rules/aip0162/aip0162.go b/rules/aip0162/aip0162.go
index 406c825..e899663 100644
--- a/rules/aip0162/aip0162.go
+++ b/rules/aip0162/aip0162.go
@@ -140,18 +140,11 @@ func isDeleteRevisionRequestMessage(m *desc.MessageDescriptor) bool {
 }
 
 var (
-	listRevisionsMethodRegexp      = regexp.MustCompile(`^List(?:[A-Za-z0-9]+)Revisions$`)
 	listRevisionsReqMessageRegexp  = regexp.MustCompile(`^List(?:[A-Za-z0-9]+)RevisionsRequest$`)
 	listRevisionsRespMessageRegexp = regexp.MustCompile(`^List(?:[A-Za-z0-9]+)RevisionsResponse$`)
 	listRevisionsURINameRegexp     = regexp.MustCompile(`:listRevisions$`)
 )
 
-// IsListRevisionsMethod returns true if this is an AIP-162 List Revisions method,
-// false otherwise.
-func IsListRevisionsMethod(m *desc.MethodDescriptor) bool {
-	return listRevisionsMethodRegexp.MatchString(m.GetName())
-}
-
 // IsListRevisionsRequestMessage returns true if this is an AIP-162 List
 // Revisions request message, false otherwise.
 func IsListRevisionsRequestMessage(m *desc.MessageDescriptor) bool {
diff --git a/rules/aip0162/list_revisions_http_body.go b/rules/aip0162/list_revisions_http_body.go
index 65c8d57..20a8386 100644
--- a/rules/aip0162/list_revisions_http_body.go
+++ b/rules/aip0162/list_revisions_http_body.go
@@ -22,6 +22,6 @@ import (
 // List Revisions methods should have no HTTP body.
 var listRevisionsHTTPBody = &lint.MethodRule{
 	Name:       lint.NewRuleName(162, "list-revisions-http-body"),
-	OnlyIf:     IsListRevisionsMethod,
+	OnlyIf:     utils.IsListRevisionsMethod,
 	LintMethod: utils.LintNoHTTPBody,
 }
diff --git a/rules/aip0162/list_revisions_http_method.go b/rules/aip0162/list_revisions_http_method.go
index f02c852..b17d599 100644
--- a/rules/aip0162/list_revisions_http_method.go
+++ b/rules/aip0162/list_revisions_http_method.go
@@ -22,6 +22,6 @@ import (
 // List Revisions methods should use the HTTP GET method.
 var listRevisionsHTTPMethod = &lint.MethodRule{
 	Name:       lint.NewRuleName(162, "list-revisions-http-method"),
-	OnlyIf:     IsListRevisionsMethod,
+	OnlyIf:     utils.IsListRevisionsMethod,
 	LintMethod: utils.LintHTTPMethod("GET"),
 }
diff --git a/rules/aip0162/list_revisions_http_uri_suffix.go b/rules/aip0162/list_revisions_http_uri_suffix.go
index 42a4fb1..aaf8cbc 100644
--- a/rules/aip0162/list_revisions_http_uri_suffix.go
+++ b/rules/aip0162/list_revisions_http_uri_suffix.go
@@ -24,7 +24,7 @@ import (
 // List Revisions methods should have a proper HTTP pattern.
 var listRevisionsHTTPURISuffix = &lint.MethodRule{
 	Name:   lint.NewRuleName(162, "list-revisions-http-uri-suffix"),
-	OnlyIf: IsListRevisionsMethod,
+	OnlyIf: utils.IsListRevisionsMethod,
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		for _, httpRule := range utils.GetHTTPRules(m) {
 			if !listRevisionsURINameRegexp.MatchString(httpRule.URI) {
diff --git a/rules/aip0162/list_revisions_request_message_name.go b/rules/aip0162/list_revisions_request_message_name.go
index ebabe73..f8a513a 100644
--- a/rules/aip0162/list_revisions_request_message_name.go
+++ b/rules/aip0162/list_revisions_request_message_name.go
@@ -22,6 +22,6 @@ import (
 // List Revisions messages should have a properly named request message.
 var listRevisionsRequestMessageName = &lint.MethodRule{
 	Name:       lint.NewRuleName(162, "list-revisions-request-message-name"),
-	OnlyIf:     IsListRevisionsMethod,
+	OnlyIf:     utils.IsListRevisionsMethod,
 	LintMethod: utils.LintMethodHasMatchingRequestName,
 }
diff --git a/rules/aip0162/list_revisions_response_message_name.go b/rules/aip0162/list_revisions_response_message_name.go
index 671fb12..63e72f5 100644
--- a/rules/aip0162/list_revisions_response_message_name.go
+++ b/rules/aip0162/list_revisions_response_message_name.go
@@ -21,6 +21,6 @@ import (
 
 var listRevisionsResponseMessageName = &lint.MethodRule{
 	Name:       lint.NewRuleName(162, "list-revisions-response-message-name"),
-	OnlyIf:     IsListRevisionsMethod,
+	OnlyIf:     utils.IsListRevisionsMethod,
 	LintMethod: utils.LintMethodHasMatchingResponseName,
 }
diff --git a/rules/aip0233/resource_reference_type.go b/rules/aip0233/resource_reference_type.go
index 8bb4a5b..87f1aaa 100644
--- a/rules/aip0233/resource_reference_type.go
+++ b/rules/aip0233/resource_reference_type.go
@@ -27,10 +27,7 @@ var resourceReferenceType = &lint.MethodRule{
 	Name: lint.NewRuleName(233, "resource-reference-type"),
 	OnlyIf: func(m *desc.MethodDescriptor) bool {
 		// Return type of the RPC.
-		ot := m.GetOutputType()
-		if ot.GetName() == "Operation" {
-			ot = utils.GetResponseType(m)
-		}
+		ot := utils.GetResponseType(m)
 
 		// First repeated message field must be annotated with google.api.resource.
 		repeated := utils.GetRepeatedMessageFields(ot)
@@ -45,10 +42,7 @@ var resourceReferenceType = &lint.MethodRule{
 	},
 	LintMethod: func(m *desc.MethodDescriptor) []lint.Problem {
 		// Return type of the RPC.
-		ot := m.GetOutputType()
-		if ot.GetName() == "Operation" {
-			ot = utils.GetResponseType(m)
-		}
+		ot := utils.GetResponseType(m)
 		repeated := utils.GetRepeatedMessageFields(ot)
 		resMsg := repeated[0].GetMessageType()
 
diff --git a/rules/internal/utils/extension.go b/rules/internal/utils/extension.go
index 5228943..16bdbfa 100644
--- a/rules/internal/utils/extension.go
+++ b/rules/internal/utils/extension.go
@@ -50,9 +50,9 @@ func GetOperationInfo(m *desc.MethodDescriptor) *lrpb.OperationInfo {
 	return nil
 }
 
-// GetResponseType returns the message referred to by the
+// GetOperationResponseType returns the message referred to by the
 // (google.longrunning.operation_info).response_type annotation.
-func GetResponseType(m *desc.MethodDescriptor) *desc.MessageDescriptor {
+func GetOperationResponseType(m *desc.MethodDescriptor) *desc.MessageDescriptor {
 	if m == nil {
 		return nil
 	}
@@ -65,6 +65,25 @@ func GetResponseType(m *desc.MethodDescriptor) *desc.MessageDescriptor {
 	return typ
 }
 
+// GetResponseType returns the OutputType if the response is
+// not an LRO, or the ResponseType otherwise.
+func GetResponseType(m *desc.MethodDescriptor) *desc.MessageDescriptor {
+	if m == nil {
+		return nil
+	}
+
+	ot := m.GetOutputType()
+	if !isLongRunningOperation(ot) {
+		return ot
+	}
+
+	return GetOperationResponseType(m)
+}
+
+func isLongRunningOperation(m *desc.MessageDescriptor) bool {
+	return m.GetFile().GetPackage() == "google.longrunning" && m.GetName() == "Operation"
+}
+
 // GetMetadataType returns the message referred to by the
 // (google.longrunning.operation_info).metadata_type annotation.
 func GetMetadataType(m *desc.MethodDescriptor) *desc.MessageDescriptor {
@@ -150,7 +169,7 @@ func GetResourceReference(f *desc.FieldDescriptor) *apb.ResourceReference {
 
 // FindResource returns first resource of type matching the reference param.
 // resource Type name being referenced. It looks within a given file and its
-// depenedncies, it cannot search within the entire protobuf package.
+// depenedencies, it cannot search within the entire protobuf package.
 // This is especially useful for resolving google.api.resource_reference
 // annotations.
 func FindResource(reference string, file *desc.FileDescriptor) *apb.ResourceDescriptor {
diff --git a/rules/internal/utils/method.go b/rules/internal/utils/method.go
new file mode 100644
index 0000000..3681752
--- /dev/null
+++ b/rules/internal/utils/method.go
@@ -0,0 +1,69 @@
+// Copyright 2023 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 utils
+
+import (
+	"regexp"
+
+	"github.com/jhump/protoreflect/desc"
+)
+
+var (
+	createMethodRegexp        = regexp.MustCompile("^Create(?:[A-Z]|$)")
+	getMethodRegexp           = regexp.MustCompile("^Get(?:[A-Z]|$)")
+	listMethodRegexp          = regexp.MustCompile("^List(?:[A-Z]|$)")
+	listRevisionsMethodRegexp = regexp.MustCompile(`^List(?:[A-Za-z0-9]+)Revisions$`)
+	updateMethodRegexp        = regexp.MustCompile("^Update(?:[A-Z]|$)")
+)
+
+// IsCreateMethod returns true if this is a AIP-133 Create method.
+func IsCreateMethod(m *desc.MethodDescriptor) bool {
+	return createMethodRegexp.MatchString(m.GetName())
+}
+
+// IsGetMethod returns true if this is a AIP-131 Get method.
+func IsGetMethod(m *desc.MethodDescriptor) bool {
+	methodName := m.GetName()
+	if methodName == "GetIamPolicy" {
+		return false
+	}
+	return getMethodRegexp.MatchString(methodName)
+}
+
+// IsListMethod return true if this is an AIP-132 List method
+func IsListMethod(m *desc.MethodDescriptor) bool {
+	return listMethodRegexp.MatchString(m.GetName()) && !IsListRevisionsMethod(m)
+}
+
+// IsListRevisionsMethod returns true if this is an AIP-162 List Revisions method,
+// false otherwise.
+func IsListRevisionsMethod(m *desc.MethodDescriptor) bool {
+	return listRevisionsMethodRegexp.MatchString(m.GetName())
+}
+
+// IsUpdateMethod returns true if this is a AIP-134 Update method
+func IsUpdateMethod(m *desc.MethodDescriptor) bool {
+	methodName := m.GetName()
+	return updateMethodRegexp.MatchString(methodName)
+}
+
+// GetListResourceMessage returns the resource for a list method,
+// nil otherwise.
+func GetListResourceMessage(m *desc.MethodDescriptor) *desc.MessageDescriptor {
+	repeated := GetRepeatedMessageFields(m.GetOutputType())
+	if len(repeated) > 0 {
+		return repeated[0].GetMessageType()
+	}
+	return nil
+}
diff --git a/rules/rules.go b/rules/rules.go
index 3b4b06c..f2a3916 100644
--- a/rules/rules.go
+++ b/rules/rules.go
@@ -51,6 +51,7 @@ package rules
 
 import (
 	"github.com/googleapis/api-linter/lint"
+	"github.com/googleapis/api-linter/rules/aip0121"
 	"github.com/googleapis/api-linter/rules/aip0122"
 	"github.com/googleapis/api-linter/rules/aip0123"
 	"github.com/googleapis/api-linter/rules/aip0124"
@@ -98,6 +99,7 @@ import (
 type addRulesFuncType func(lint.RuleRegistry) error
 
 var aipAddRulesFuncs = []addRulesFuncType{
+	aip0121.AddRules,
 	aip0122.AddRules,
 	aip0123.AddRules,
 	aip0124.AddRules,


Test patch:
diff --git a/rules/aip0121/aip0121_test.go b/rules/aip0121/aip0121_test.go
new file mode 100644
index 0000000..c031f5e
--- /dev/null
+++ b/rules/aip0121/aip0121_test.go
@@ -0,0 +1,28 @@
+// Copyright 2023 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 aip0121 contains rules defined in https://aip.dev/121.
+package aip0121
+
+import (
+	"testing"
+
+	"github.com/googleapis/api-linter/lint"
+)
+
+func TestAddRules(t *testing.T) {
+	if err := AddRules(lint.NewRuleRegistry()); err != nil {
+		t.Errorf("AddRules got an error: %v", err)
+	}
+}
diff --git a/rules/aip0121/resource_must_support_get_test.go b/rules/aip0121/resource_must_support_get_test.go
new file mode 100644
index 0000000..25134dd
--- /dev/null
+++ b/rules/aip0121/resource_must_support_get_test.go
@@ -0,0 +1,135 @@
+// Copyright 2023 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 aip0121
+
+import (
+	"testing"
+
+	"github.com/googleapis/api-linter/lint"
+	"github.com/googleapis/api-linter/rules/internal/testutils"
+)
+
+// TestResourceMustSupportGet tests the resourceMustSupportGet
+// lint rule by declaring a service proto, then declaring a
+// google.api.resource message, then declaring non-Get
+// methods.
+func TestResourceMustSupportGet(t *testing.T) {
+	for _, test := range []struct {
+		name     string
+		RPCs     string
+		problems testutils.Problems
+	}{
+		{"ValidCreateGet", `
+			rpc GetBook(GetBookRequest) returns (Book) {};
+			rpc CreateBook(CreateBookRequest) returns (Book) {};
+		`, nil},
+		{"ValidCreateGetLRO", `
+			rpc GetBook(GetBookRequest) returns (Book) {};
+			rpc CreateBook(CreateBookRequest) returns (google.longrunning.Operation) {
+				option (google.longrunning.operation_info) = {
+					response_type: "Book"
+				};
+			};
+		`, nil},
+		{"ValidListGet", `
+			rpc GetBook(GetBookRequest) returns (Book) {};
+			rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {};
+		`, nil},
+		{"ValidUpdateGet", `
+			rpc GetBook(GetBookRequest) returns (Book) {};
+			rpc UpdateBook(UpdateBookRequest) returns (Book) {};
+		`, nil},
+		{"InvalidCreateOnly", `
+			rpc CreateBook(CreateBookRequest) returns (Book) {};
+		`, []lint.Problem{
+			{Message: `resource "library.googleapis.com/Book"`},
+		}},
+		{"InvalidCreateOnlyLRO", `
+			rpc CreateBook(CreateBookRequest) returns (google.longrunning.Operation) {
+				option (google.longrunning.operation_info) = {
+					response_type: "Book"
+				};
+			};
+		`, []lint.Problem{
+			{Message: `resource "library.googleapis.com/Book"`},
+		}},
+		{"InvalidUpdateOnly", `
+			rpc UpdateBook(UpdateBookRequest) returns (Book) {};
+		`, []lint.Problem{
+			{Message: `resource "library.googleapis.com/Book"`},
+		}},
+		{"InvalidListOnly", `
+			rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {};
+		`, []lint.Problem{
+			{Message: `resource "library.googleapis.com/Book"`},
+		}},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			file := testutils.ParseProto3Tmpl(t, `
+				import "google/api/resource.proto";
+				import "google/longrunning/operations.proto";
+				import "google/protobuf/field_mask.proto";
+				service Foo {
+					{{.RPCs}}
+				}
+
+				// This is at the top to make it retrievable
+				// by the test code.
+				message Book {
+					option (google.api.resource) = {
+						type: "library.googleapis.com/Book"
+						pattern: "books/{book}"
+						singular: "book"
+						plural: "books"
+					};
+				}
+
+				message CreateBookRequest {
+					// The parent resource where this book will be created.
+					// Format: publishers/{publisher}
+					string parent = 1;
+
+					// The book to create.
+					Book book = 2;
+				}
+
+				message GetBookRequest {
+					string name = 1;
+				}
+
+				message UpdateBookRequest {
+					Book book = 1;
+					google.protobuf.FieldMask update_mask = 2;
+				}
+
+				 message ListBooksRequest {
+					string parent = 1;
+					int32 page_size = 2;
+					string page_token = 3;
+				 }
+
+				 message ListBooksResponse {
+					repeated Book books = 1;
+					string next_page_token = 2;
+				 }
+			`, test)
+			s := file.GetServices()[0]
+			got := resourceMustSupportGet.Lint(file)
+			if diff := test.problems.SetDescriptor(s).Diff(got); diff != "" {
+				t.Error(diff)
+			}
+		})
+	}
+}
diff --git a/rules/internal/utils/extension_test.go b/rules/internal/utils/extension_test.go
index 06d8d3e..80f5237 100644
--- a/rules/internal/utils/extension_test.go
+++ b/rules/internal/utils/extension_test.go
@@ -151,7 +151,7 @@ func TestGetOperationInfoResponseType(t *testing.T) {
 				message WriteBookResponse {}
 			`, test)
 
-			typ := GetResponseType(fd.GetServices()[0].GetMethods()[0])
+			typ := GetOperationResponseType(fd.GetServices()[0].GetMethods()[0])
 
 			if validType := typ != nil; validType != test.valid {
 				t.Fatalf("Expected valid(%v) response_type message", test.valid)
@@ -342,7 +342,7 @@ func TestFindResource(t *testing.T) {
 					type: "library.googleapis.com/Shelf"
 					pattern: "shelves/{shelf}"
 				};
-				
+
 				string name = 1;
 
 				repeated Book books = 2;
@@ -398,3 +398,76 @@ func TestSplitResourceTypeName(t *testing.T) {
 		})
 	}
 }
+
+func TestGetOutputOrLROResponseMessage(t *testing.T) {
+	for _, test := range []struct {
+		name string
+		RPCs string
+		want string
+	}{
+		{"BookOutputType", `
+			rpc CreateBook(CreateBookRequest) returns (Book) {};
+		`, "Book"},
+		{"BespokeOperationResource", `
+			rpc CreateBook(CreateBookRequest) returns (Operation) {};
+		`, "Operation"},
+		{"LROBookResponse", `
+			rpc CreateBook(CreateBookRequest) returns (google.longrunning.Operation) {
+				option (google.longrunning.operation_info) = {
+					response_type: "Book"
+				};
+		};
+		`, "Book"},
+		{"LROMissingResponse", `
+			rpc CreateBook(CreateBookRequest) returns (google.longrunning.Operation) {
+		};
+		`, ""},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			file := testutils.ParseProto3Tmpl(t, `
+				import "google/api/resource.proto";
+				import "google/longrunning/operations.proto";
+				import "google/protobuf/field_mask.proto";
+				service Foo {
+					{{.RPCs}}
+				}
+
+				// This is at the top to make it retrievable
+				// by the test code.
+				message Book {
+					option (google.api.resource) = {
+						type: "library.googleapis.com/Book"
+						pattern: "books/{book}"
+						singular: "book"
+						plural: "books"
+					};
+				}
+
+				message CreateBookRequest {
+					// The parent resource where this book will be created.
+					// Format: publishers/{publisher}
+					string parent = 1;
+
+					// The book to create.
+					Book book = 2;
+				}
+
+				// bespoke operation message (not an LRO)
+				message Operation {
+				}
+			`, test)
+			method := file.GetServices()[0].GetMethods()[0]
+			resp := GetResponseType(method)
+			got := ""
+			if resp != nil {
+				got = resp.GetName()
+			}
+			if got != test.want {
+				t.Errorf(
+					"GetOutputOrLROResponseMessage got %q, want %q",
+					got, test.want,
+				)
+			}
+		})
+	}
+}
diff --git a/rules/internal/utils/method_test.go b/rules/internal/utils/method_test.go
new file mode 100644
index 0000000..eff2c4e
--- /dev/null
+++ b/rules/internal/utils/method_test.go
@@ -0,0 +1,279 @@
+// Copyright 2023 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 utils
+
+import (
+	"testing"
+
+	"github.com/googleapis/api-linter/rules/internal/testutils"
+)
+
+func TestIsCreateMethod(t *testing.T) {
+	for _, test := range []struct {
+		name string
+		RPCs string
+		want bool
+	}{
+		{"ValidBook", `
+			rpc CreateBook(CreateBookRequest) returns (Book) {};
+		`, true},
+		{"InvalidNonCreate", `
+			rpc GenerateBook(CreateBookRequest) returns (Book) {};
+		`, false},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			file := testutils.ParseProto3Tmpl(t, `
+				import "google/api/resource.proto";
+				import "google/protobuf/field_mask.proto";
+				service Foo {
+					{{.RPCs}}
+				}
+
+				// This is at the top to make it retrievable
+				// by the test code.
+				message Book {
+					option (google.api.resource) = {
+						type: "library.googleapis.com/Book"
+						pattern: "books/{book}"
+						singular: "book"
+						plural: "books"
+					};
+				}
+
+				message CreateBookRequest {
+					// The parent resource where this book will be created.
+					// Format: publishers/{publisher}
+					string parent = 1;
+
+					// The book to create.
+					Book book = 2;
+				}
+			`, test)
+			method := file.GetServices()[0].GetMethods()[0]
+			got := IsCreateMethod(method)
+			if got != test.want {
+				t.Errorf("IsCreateMethod got %v, want %v", got, test.want)
+			}
+		})
+	}
+}
+
+func TestIsUpdateMethod(t *testing.T) {
+	for _, test := range []struct {
+		name string
+		RPCs string
+		want bool
+	}{
+		{"ValidBook", `
+			rpc UpdateBook(UpdateBookRequest) returns (Book) {};
+		`, true},
+		{"InvalidNonUpdate", `
+			rpc UpsertBook(UpdateBookRequest) returns (Book) {};
+		`, false},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			file := testutils.ParseProto3Tmpl(t, `
+				import "google/api/resource.proto";
+				import "google/protobuf/field_mask.proto";
+				service Foo {
+					{{.RPCs}}
+				}
+
+				// This is at the top to make it retrievable
+				// by the test code.
+				message Book {
+					option (google.api.resource) = {
+						type: "library.googleapis.com/Book"
+						pattern: "books/{book}"
+						singular: "book"
+						plural: "books"
+					};
+				}
+
+				message UpdateBookRequest {
+					Book book = 1;
+					google.protobuf.FieldMask update_mask = 2;
+				}
+			`, test)
+			method := file.GetServices()[0].GetMethods()[0]
+			got := IsUpdateMethod(method)
+			if got != test.want {
+				t.Errorf("IsUpdateMethod got %v, want %v", got, test.want)
+			}
+		})
+	}
+}
+
+func TestIsListMethod(t *testing.T) {
+	for _, test := range []struct {
+		name string
+		RPCs string
+		want bool
+	}{
+		{"ValidList", `
+			rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {};
+		`, true},
+		{"InvalidListRevisionsMethod", `
+			rpc ListBookRevisions(ListBooksRequest) returns (ListBooksResponse) {};
+		`, false},
+		{"InvalidNonList", `
+			rpc EnumerateBooks(ListBooksRequest) returns (ListBooksResponse) {};
+		`, false},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			file := testutils.ParseProto3Tmpl(t, `
+				import "google/api/resource.proto";
+				import "google/protobuf/field_mask.proto";
+				service Foo {
+					{{.RPCs}}
+				}
+
+				// This is at the top to make it retrievable
+				// by the test code.
+				message Book {
+					option (google.api.resource) = {
+						type: "library.googleapis.com/Book"
+						pattern: "books/{book}"
+						singular: "book"
+						plural: "books"
+					};
+				}
+
+				message ListBooksRequest {
+					string parent = 1;
+					int32 page_size = 2;
+					string page_token = 3;
+				}
+
+				message ListBooksResponse {
+					repeated Book books = 1;
+					string next_page_token = 2;
+				}
+			`, test)
+			method := file.GetServices()[0].GetMethods()[0]
+			got := IsListMethod(method)
+			if got != test.want {
+				t.Errorf("IsListMethod got %v, want %v", got, test.want)
+			}
+		})
+	}
+}
+
+func TestIsListRevisionsMethod(t *testing.T) {
+	for _, test := range []struct {
+		name string
+		RPCs string
+		want bool
+	}{
+		{"ValidListRevisionsMethod", `
+			rpc ListBookRevisions(ListBooksRequest) returns (ListBooksResponse) {};
+		`, true},
+		{"InvalidList", `
+			rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {};
+		`, false},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			file := testutils.ParseProto3Tmpl(t, `
+				import "google/api/resource.proto";
+				import "google/protobuf/field_mask.proto";
+				service Foo {
+					{{.RPCs}}
+				}
+
+				// This is at the top to make it retrievable
+				// by the test code.
+				message Book {
+					option (google.api.resource) = {
+						type: "library.googleapis.com/Book"
+						pattern: "books/{book}"
+						singular: "book"
+						plural: "books"
+					};
+				}
+
+				message ListBooksRequest {
+					string parent = 1;
+					int32 page_size = 2;
+					string page_token = 3;
+				}
+
+				message ListBooksResponse {
+					repeated Book books = 1;
+					string next_page_token = 2;
+				}
+			`, test)
+			method := file.GetServices()[0].GetMethods()[0]
+			got := IsListRevisionsMethod(method)
+			if got != test.want {
+				t.Errorf("IsListRevisionsMethod got %v, want %v", got, test.want)
+			}
+		})
+	}
+}
+
+func TestGetListResourceMessage(t *testing.T) {
+	for _, test := range []struct {
+		name string
+		RPCs string
+		want string
+	}{
+		{"ValidBooks", `
+			rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {};
+		`, "Book"},
+		{"InvalidNotListMethod", `
+			rpc GetBook(ListBooksRequest) returns (Book) {};
+		`, ""},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			file := testutils.ParseProto3Tmpl(t, `
+				import "google/api/resource.proto";
+				import "google/protobuf/field_mask.proto";
+				service Foo {
+					{{.RPCs}}
+				}
+
+				// This is at the top to make it retrievable
+				// by the test code.
+				message Book {
+					option (google.api.resource) = {
+						type: "library.googleapis.com/Book"
+						pattern: "books/{book}"
+						singular: "book"
+						plural: "books"
+					};
+				}
+
+				message ListBooksRequest {
+					string parent = 1;
+					int32 page_size = 2;
+					string page_token = 3;
+				}
+
+				message ListBooksResponse {
+					repeated Book books = 1;
+					string next_page_token = 2;
+				}
+			`, test)
+			method := file.GetServices()[0].GetMethods()[0]
+			message := GetListResourceMessage(method)
+			got := ""
+			if message != nil {
+				got = message.GetName()
+			}
+			if got != test.want {
+				t.Errorf("GetListResourceMessage got %q, want %q", got, test.want)
+			}
+		})
+	}
+}