Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions rules/aip0231/request_names_field.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ package aip0231

import (
"fmt"
"strings"

"github.com/gertd/go-pluralize"
"github.com/googleapis/api-linter/v2/lint"
"github.com/googleapis/api-linter/v2/locations"
"github.com/googleapis/api-linter/v2/rules/internal/utils"
"google.golang.org/protobuf/reflect/protoreflect"
)

Expand Down Expand Up @@ -78,10 +79,10 @@ var namesField = &lint.MessageRule{
}

// Rule check: Ensure that the standard get request message field is the
// correct type. Note: Use m.Name()[8:len(m.Name())-7]) to retrieve
// the resource name from the batch get request, for example:
// "BatchGetBooksRequest" -> "Books"
rightTypeName := fmt.Sprintf("Get%sRequest", pluralize.NewClient().Singular(string(m.Name())[8:len(m.Name())-7]))
// correct type. Note: Retrieve the resource name from the batch get
// request, for example: "BatchGetBooksRequest" -> "Books"
pluralName := strings.TrimPrefix(strings.TrimSuffix(string(m.Name()), "Request"), "BatchGet")
rightTypeName := fmt.Sprintf("Get%sRequest", utils.ResourceSingular(pluralName, m))
if getReqMsg != nil && (getReqMsg.Message() == nil || getReqMsg.Message().Name() != protoreflect.Name(rightTypeName)) {
problems = append(problems, lint.Problem{
Message: fmt.Sprintf(`The "requests" field on Batch Get Request should be a %q type`, rightTypeName),
Expand Down
6 changes: 3 additions & 3 deletions rules/aip0231/response_resource_field.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import (
"fmt"
"strings"

"github.com/gertd/go-pluralize"
"github.com/googleapis/api-linter/v2/lint"
"github.com/googleapis/api-linter/v2/rules/internal/utils"
"google.golang.org/protobuf/reflect/protoreflect"
)

Expand All @@ -29,8 +29,8 @@ var resourceField = &lint.MessageRule{
OnlyIf: isBatchGetResponseMessage,
LintMessage: func(m protoreflect.MessageDescriptor) []lint.Problem {
// The singular form the resource message name; the first letter capitalized.
plural := strings.TrimSuffix(strings.TrimPrefix(string(m.Name()), "BatchGet"), "Response")
resourceMsgName := pluralize.NewClient().Singular(plural)
pluralName := strings.TrimSuffix(strings.TrimPrefix(string(m.Name()), "BatchGet"), "Response")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with other rules in this PR (including the request rule in this package), consider using TrimPrefix on the result of TrimSuffix.

Suggested change
pluralName := strings.TrimSuffix(strings.TrimPrefix(string(m.Name()), "BatchGet"), "Response")
pluralName := strings.TrimPrefix(strings.TrimSuffix(string(m.Name()), "Response"), "BatchGet")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9c9e3a5 — reordered to TrimPrefix(TrimSuffix(...)) to match all other rules.

resourceMsgName := utils.ResourceSingular(pluralName, m)

for i := 0; i < m.Fields().Len(); i++ {
fieldDesc := m.Fields().Get(i)
Expand Down
6 changes: 3 additions & 3 deletions rules/aip0233/request_requests_field.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ import (
"fmt"
"strings"

"github.com/gertd/go-pluralize"
"github.com/googleapis/api-linter/v2/lint"
"github.com/googleapis/api-linter/v2/locations"
"github.com/googleapis/api-linter/v2/rules/internal/utils"
"google.golang.org/protobuf/reflect/protoreflect"
)

Expand Down Expand Up @@ -52,8 +52,8 @@ var requestRequestsField = &lint.MessageRule{
// Rule check: Ensure that the standard create request message field is the
// correct type. Note: Retrieve the resource name from the the batch create
// request, for example: "BatchCreateBooksRequest" -> "Books"
rightTypeName := fmt.Sprintf("Create%sRequest",
pluralize.NewClient().Singular(strings.TrimPrefix(strings.TrimSuffix(string(m.Name()), "Request"), "BatchCreate")))
pluralName := strings.TrimPrefix(strings.TrimSuffix(string(m.Name()), "Request"), "BatchCreate")
rightTypeName := fmt.Sprintf("Create%sRequest", utils.ResourceSingular(pluralName, m))
if requests.Message() == nil || requests.Message().Name() != protoreflect.Name(rightTypeName) {
problems = append(problems, lint.Problem{
Message: fmt.Sprintf(`The "requests" field on Batch Create Request should be a %q type`, rightTypeName),
Expand Down
5 changes: 3 additions & 2 deletions rules/aip0233/response_resource_field.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import (
"fmt"
"strings"

"github.com/gertd/go-pluralize"
"github.com/googleapis/api-linter/v2/lint"
"github.com/googleapis/api-linter/v2/rules/internal/utils"
"google.golang.org/protobuf/reflect/protoreflect"
)

Expand All @@ -31,7 +31,8 @@ var responseResourceField = &lint.MessageRule{
// the singular form the resource name, the first letter is Capitalized.
// Note: Retrieve the resource name from the the batch create response,
// for example: "BatchCreateBooksResponse" -> "Books"
resourceMsgName := pluralize.NewClient().Singular(strings.TrimPrefix(strings.TrimSuffix(string(m.Name()), "Response"), "BatchCreate"))
pluralName := strings.TrimPrefix(strings.TrimSuffix(string(m.Name()), "Response"), "BatchCreate")
resourceMsgName := utils.ResourceSingular(pluralName, m)

for i := 0; i < m.Fields().Len(); i++ {
fieldDesc := m.Fields().Get(i)
Expand Down
6 changes: 3 additions & 3 deletions rules/aip0234/request_requests_field.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ import (
"fmt"
"strings"

"github.com/gertd/go-pluralize"
"github.com/googleapis/api-linter/v2/lint"
"github.com/googleapis/api-linter/v2/locations"
"github.com/googleapis/api-linter/v2/rules/internal/utils"
"google.golang.org/protobuf/reflect/protoreflect"
)

Expand Down Expand Up @@ -52,8 +52,8 @@ var requestRequestsField = &lint.MessageRule{
// Rule check: Ensure that the standard update request message field is the
// correct type. Note: Retrieve the resource name from the the batch update
// request, for example: "BatchUpdateBooksRequest" -> "Books"
rightTypeName := fmt.Sprintf("Update%sRequest",
pluralize.NewClient().Singular(strings.TrimPrefix(strings.TrimSuffix(string(m.Name()), "Request"), "BatchUpdate")))
pluralName := strings.TrimPrefix(strings.TrimSuffix(string(m.Name()), "Request"), "BatchUpdate")
rightTypeName := fmt.Sprintf("Update%sRequest", utils.ResourceSingular(pluralName, m))
if requests.Message() == nil || requests.Message().Name() != protoreflect.Name(rightTypeName) {
problems = append(problems, lint.Problem{
Message: fmt.Sprintf(`The "requests" field on Batch Update Request should be a %q type`, rightTypeName),
Expand Down
57 changes: 57 additions & 0 deletions rules/aip0234/request_requests_field_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,60 @@ func TestRequestRequestsField(t *testing.T) {
})
}
}

func TestRequestRequestsFieldResourceAnnotation(t *testing.T) {
// Test that the rule respects google.api.resource singular annotation
// for words that go-pluralize would incorrectly singularize (e.g.
// "Metadata" -> "Metadatum").
tests := []struct {
testName string
Field string
problems testutils.Problems
}{
{
testName: "Valid-ResourceSingularMetadata",
Field: "repeated UpdateImpressionMetadataRequest requests",
problems: testutils.Problems{},
},
{
testName: "Invalid-WrongTypeWithResourceSingular",
Field: "repeated int32 requests",
problems: testutils.Problems{{
Suggestion: "UpdateImpressionMetadataRequest",
}},
},
}

for _, test := range tests {
t.Run(test.testName, func(t *testing.T) {
file := testutils.ParseProto3Tmpl(t, `
import "google/api/resource.proto";

message BatchUpdateImpressionMetadataRequest {
{{.Field}} = 1;
}
message UpdateImpressionMetadataRequest {}
message ImpressionMetadata {
option (google.api.resource) = {
type: "example.googleapis.com/ImpressionMetadata"
pattern: "dataProviders/{data_provider}/impressionMetadata/{impression_metadata}"
singular: "impressionMetadata"
plural: "impressionMetadata"
};
}
`, test)

var problemDesc protoreflect.Descriptor
if requests := file.Messages().Get(0).Fields().ByName("requests"); requests != nil {
problemDesc = requests
} else {
problemDesc = file.Messages().Get(0)
}

problems := requestRequestsField.Lint(file)
if diff := test.problems.SetDescriptor(problemDesc).Diff(problems); diff != "" {
t.Error(diff)
}
})
}
}
5 changes: 3 additions & 2 deletions rules/aip0234/response_resource_field.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import (
"fmt"
"strings"

"github.com/gertd/go-pluralize"
"github.com/googleapis/api-linter/v2/lint"
"github.com/googleapis/api-linter/v2/rules/internal/utils"
"google.golang.org/protobuf/reflect/protoreflect"
)

Expand All @@ -31,7 +31,8 @@ var responseResourceField = &lint.MessageRule{
// the singular form the resource name, the first letter is Capitalized.
// Note: Retrieve the resource name from the the batch update response,
// for example: "BatchUpdateBooksResponse" -> "Books"
resourceMsgName := pluralize.NewClient().Singular(strings.TrimPrefix(strings.TrimSuffix(string(m.Name()), "Response"), "BatchUpdate"))
pluralName := strings.TrimPrefix(strings.TrimSuffix(string(m.Name()), "Response"), "BatchUpdate")
resourceMsgName := utils.ResourceSingular(pluralName, m)

for i := 0; i < m.Fields().Len(); i++ {
fieldDesc := m.Fields().Get(i)
Expand Down
57 changes: 57 additions & 0 deletions rules/aip0234/response_resource_field_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,60 @@ func TestResponseResourceField(t *testing.T) {
})
}
}

func TestResponseResourceFieldResourceAnnotation(t *testing.T) {
// Test that the rule respects google.api.resource singular annotation
// for words that go-pluralize would incorrectly singularize.
tests := []struct {
testName string
Field string
problems testutils.Problems
problemDesc func(m protoreflect.MessageDescriptor) protoreflect.Descriptor
}{
{
testName: "Valid-ResourceSingularMetadata",
Field: "repeated ImpressionMetadata impression_metadata",
problems: testutils.Problems{},
},
{
testName: "MissingField-ResourceSingularMetadata",
Field: "string response",
problems: testutils.Problems{{Message: `no "ImpressionMetadata" type field`}},
problemDesc: func(m protoreflect.MessageDescriptor) protoreflect.Descriptor {
return m
},
},
}

for _, test := range tests {
t.Run(test.testName, func(t *testing.T) {
file := testutils.ParseProto3Tmpl(t, `
import "google/api/resource.proto";

message BatchUpdateImpressionMetadataResponse {
{{.Field}} = 1;
}
message ImpressionMetadata {
option (google.api.resource) = {
type: "example.googleapis.com/ImpressionMetadata"
pattern: "dataProviders/{data_provider}/impressionMetadata/{impression_metadata}"
singular: "impressionMetadata"
plural: "impressionMetadata"
};
}
`, test)

m := file.Messages().Get(0)

var problemDesc protoreflect.Descriptor = m.Fields().Get(0)
if test.problemDesc != nil {
problemDesc = test.problemDesc(m)
}

problems := responseResourceField.Lint(file)
if diff := test.problems.SetDescriptor(problemDesc).Diff(problems); diff != "" {
t.Error(diff)
}
})
}
}
11 changes: 6 additions & 5 deletions rules/aip0235/request_names_field.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ package aip0235

import (
"fmt"
"strings"

"github.com/gertd/go-pluralize"
"github.com/googleapis/api-linter/v2/lint"
"github.com/googleapis/api-linter/v2/locations"
"github.com/googleapis/api-linter/v2/rules/internal/utils"
"google.golang.org/protobuf/reflect/protoreflect"
)

Expand Down Expand Up @@ -78,10 +79,10 @@ var requestNamesField = &lint.MessageRule{
}

// Rule check: Ensure that the standard delete request message field is the
// correct type. Note: Use m.Name()[11:len(m.Name())-7]) to retrieve
// the resource name from the batch delete request, for example:
// "BatchDeleteBooksRequest" -> "Books"
rightTypeName := fmt.Sprintf("Delete%sRequest", pluralize.NewClient().Singular(string(m.Name())[11:len(m.Name())-7]))
// correct type. Note: Retrieve the resource name from the batch delete
// request, for example: "BatchDeleteBooksRequest" -> "Books"
pluralName := strings.TrimPrefix(strings.TrimSuffix(string(m.Name()), "Request"), "BatchDelete")
rightTypeName := fmt.Sprintf("Delete%sRequest", utils.ResourceSingular(pluralName, m))
if deleteReqMsg != nil && (deleteReqMsg.Message() == nil || deleteReqMsg.Message().Name() != protoreflect.Name(rightTypeName)) {
problems = append(problems, lint.Problem{
Message: fmt.Sprintf(`The "requests" field on Batch Delete Request should be a %q type`, rightTypeName),
Expand Down
5 changes: 3 additions & 2 deletions rules/aip0235/response_resource_field.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import (
"fmt"
"strings"

"github.com/gertd/go-pluralize"
"github.com/googleapis/api-linter/v2/lint"
"github.com/googleapis/api-linter/v2/rules/internal/utils"
"google.golang.org/protobuf/reflect/protoreflect"
)

Expand All @@ -31,7 +31,8 @@ var responseResourceField = &lint.MessageRule{
// the singular form the resource name, the first letter is Capitalized.
// Note: Retrieve the resource name from the the batch update response,
// for example: "BatchDeleteBooksResponse" -> "Books"
resourceMsgName := pluralize.NewClient().Singular(strings.TrimPrefix(strings.TrimSuffix(string(m.Name()), "Response"), "BatchDelete"))
pluralName := strings.TrimPrefix(strings.TrimSuffix(string(m.Name()), "Response"), "BatchDelete")
resourceMsgName := utils.ResourceSingular(pluralName, m)

for i := 0; i < m.Fields().Len(); i++ {
fieldDesc := m.Fields().Get(i)
Expand Down
69 changes: 69 additions & 0 deletions rules/internal/utils/string_pluralize.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ package utils

import (
"github.com/gertd/go-pluralize"
"github.com/stoewer/go-strcase"
"google.golang.org/protobuf/reflect/protoreflect"
)

var pluralizeClient = pluralize.NewClient()
Expand All @@ -32,3 +34,70 @@ func ToPlural(s string) string {
func ToSingular(s string) string {
return pluralizeClient.Singular(s)
}

// ResourceSingular returns the singular form of a resource name extracted from
// a batch method message name (e.g. "Books" from "BatchUpdateBooksRequest").
//
// It searches for a resource message with a google.api.resource annotation
// whose singular matches the expected singular of pluralName. This search
// covers messages in the same file as well as directly imported files, since
// the resource message is commonly defined in a separate file from the service
// and request/response messages.
//
// If a matching resource annotation is found, its singular is returned (in
// UpperCamelCase). Otherwise, it falls back to the go-pluralize library.
//
// This avoids incorrect singularization of words like "Metadata" (which
// go-pluralize converts to "Metadatum" using Latin grammar rules).
func ResourceSingular(pluralName string, m protoreflect.MessageDescriptor) string {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use a different function name as I think this is a bit misleading alongside other utilities like GetResourceSingular (which arguably should've been named ResourceSingular).

This might be something like DeriveResourceSingular as it tries to find a resource from a derived name and then get the singular.

if f := m.ParentFile(); f != nil {
if s := findResourceSingularInFile(pluralName, f); s != "" {
return s
}
// Also search directly imported files, since the resource message
// is often defined in a separate file (e.g. impression_metadata.proto)
// from the service file (impression_metadata_service.proto).
imports := f.Imports()
for i := 0; i < imports.Len(); i++ {
if s := findResourceSingularInFile(pluralName, imports.Get(i).FileDescriptor); s != "" {
return s
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation only searches direct imports for the resource annotation. In complex proto structures, the resource might be defined in a file that is imported transitively. Consider implementing a recursive search to ensure the resource annotation is found even if it's not in a directly imported file.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout. Intentionally keeping this as direct imports only — in standard proto layouts the resource message is in a sibling file directly imported by the service file, so single-depth is sufficient. Recursive search would add complexity for a case we haven't seen in practice. Updated the comment to make this intentional, and added a multi-file test (TestResourceSingularImportedFile) that exercises the import search path. See 9c9e3a5.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On reflection, agreed — implemented recursive search with cycle detection via a visited set in 027891b. Also added that exercises a 3-file chain (service.proto → common.proto → resource.proto) to verify the annotation is found through transitive imports.

}

return pluralizeClient.Singular(pluralName)
}

// findResourceSingularInFile searches all messages in a file for a resource
// whose singular annotation matches the given pluralName.
func findResourceSingularInFile(pluralName string, f protoreflect.FileDescriptor) string {
if f == nil {
return ""
}
for i := 0; i < f.Messages().Len(); i++ {
msg := f.Messages().Get(i)
res := GetResource(msg)
if res == nil {
continue
}
s := GetResourceSingular(res)
if s == "" {
continue
}
// The singular from the annotation is typically lowerCamelCase
// (e.g. "impressionMetadata"). Convert to UpperCamelCase for
// comparison with the message name.
upperSingular := strcase.UpperCamelCase(s)
// Check if the pluralName matches: either the singular itself
// (for uncountable nouns like "Metadata" where plural == singular)
// or the go-pluralize plural of the singular.
if pluralName == upperSingular || pluralName == pluralizeClient.Plural(upperSingular) {
return upperSingular
}
// Also check if the message name matches the plural portion.
if string(msg.Name()) == pluralName {
return upperSingular
}
}
return ""
}