Repository: googleapis/api-linter
Pull request #1241 (2023-08-24 00:09:32)

feat(AIP-203): add resource name IDENTIFIER enforcement
Enforces AIP-203 guidance added in https://github.com/aip-dev/google.aip.dev/pull/1201 that the resource's name field must have `google.api.field_behavior = IDENTIFIER`.


Patch:
diff --git a/docs/rules/0203/resource-name-identifier.md b/docs/rules/0203/resource-name-identifier.md
new file mode 100644
index 0000000..22fba30
--- /dev/null
+++ b/docs/rules/0203/resource-name-identifier.md
@@ -0,0 +1,71 @@
+---
+rule:
+  aip: 203
+  name: [core, '0203', resource-name-identifier]
+  summary: Resource name field must have field behavior IDENTIFIER.
+permalink: /203/resource-name-identifier
+redirect_from:
+  - /0203/resource-name-identifier
+---
+
+# Resource name: IDENTIFIER
+
+This rule enforces that the field on a resource representing the resource's name
+is annotated with with `(google.api.field_behavior) = IDENTIFIER`, as mandated
+by [AIP-203][].
+
+## Details
+
+This rule looks at every resource message and complains if the resource name
+field is not annotated with  `(google.api.field_behavior) = IDENTIFER`.
+
+## Examples
+
+**Incorrect** code for this rule:
+
+```proto
+message Book {
+  option (google.api.resource) = {
+    type: "library.googleapis.com/Book"
+    pattern: "books/{book}"
+  };
+  // Missing IDENTIFIER field behavior.
+  string name = 1;
+}
+```
+
+**Correct** code for this rule:
+
+```proto
+// Correct.
+message Book {
+  option (google.api.resource) = {
+    type: "library.googleapis.com/Book"
+    pattern: "books/{book}"
+  };
+  string name = 1 [(google.api.field_behavior) = IDENTIFIER];
+}
+```
+
+## Disabling
+
+If you need to violate this rule, use a leading comment above the field.
+Remember to also include an [aip.dev/not-precedent][] comment explaining why.
+
+```proto
+message Book {
+  option (google.api.resource) = {
+    type: "library.googleapis.com/Book"
+    pattern: "books/{book}"
+  };
+  // (-- api-linter: core::0203::resource-name-identifier=disabled
+  //     aip.dev/not-precedent: We need to do this because reasons. --)
+  string name = 1;
+}
+```
+
+If you need to violate this rule for an entire file, place the comment at the
+top of the file.
+
+[aip-203]: https://aip.dev/203
+[aip.dev/not-precedent]: https://aip.dev/not-precedent
diff --git a/rules/aip0203/aip0203.go b/rules/aip0203/aip0203.go
index 5ffa3d7..12d4ca5 100644
--- a/rules/aip0203/aip0203.go
+++ b/rules/aip0203/aip0203.go
@@ -25,6 +25,7 @@ func AddRules(r lint.RuleRegistry) error {
 		203,
 		fieldBehaviorRequired,
 		unorderedListRepeated,
+		resourceNameIdentifier,
 		resourceIdentifierOnly,
 	)
 }
diff --git a/rules/aip0203/resource_name_identifier.go b/rules/aip0203/resource_name_identifier.go
new file mode 100644
index 0000000..3f42b0f
--- /dev/null
+++ b/rules/aip0203/resource_name_identifier.go
@@ -0,0 +1,42 @@
+// 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 aip0203
+
+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"
+	fpb "google.golang.org/genproto/googleapis/api/annotations"
+)
+
+var resourceNameIdentifier = &lint.MessageRule{
+	Name:   lint.NewRuleName(203, "resource-name-identifier"),
+	OnlyIf: utils.IsResource,
+	LintMessage: func(m *desc.MessageDescriptor) []lint.Problem {
+		f := m.FindFieldByName(utils.GetResourceNameField(utils.GetResource(m)))
+		fb := utils.GetFieldBehavior(f)
+		if len(fb) == 0 || !fb.Contains(fpb.FieldBehavior_IDENTIFIER.String()) {
+			return []lint.Problem{{
+				Message:    "resource name field must have field_behavior IDENTIFIER",
+				Descriptor: f,
+				Location:   locations.FieldOption(f, fpb.E_FieldBehavior),
+				Suggestion: "(google.api.field_behavior) = IDENTIFIER",
+			}}
+		}
+
+		return nil
+	},
+}


Test patch:
diff --git a/rules/aip0203/resource_name_identifier_test.go b/rules/aip0203/resource_name_identifier_test.go
new file mode 100644
index 0000000..436562f
--- /dev/null
+++ b/rules/aip0203/resource_name_identifier_test.go
@@ -0,0 +1,73 @@
+// Copyright 2020 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 aip0203
+
+import (
+	"testing"
+
+	"github.com/googleapis/api-linter/rules/internal/testutils"
+)
+
+func TestResourceNameIdentifier(t *testing.T) {
+	testCases := []struct {
+		name             string
+		Field, NameField string
+		problems         testutils.Problems
+	}{
+		{
+			name:     "Valid",
+			Field:    "string name = 1 [(google.api.field_behavior) = IDENTIFIER];",
+			problems: nil,
+		},
+		{
+			name:      "ValidNameField",
+			Field:     "string resource_name = 1 [(google.api.field_behavior) = IDENTIFIER];",
+			NameField: "resource_name",
+			problems:  nil,
+		},
+		{
+			name:     "InvalidNoFieldBehavior",
+			Field:    "string name = 1;",
+			problems: testutils.Problems{{Message: "field_behavior IDENTIFIER", Suggestion: "(google.api.field_behavior) = IDENTIFIER"}},
+		},
+		{
+			name:     "InvalidMissingIdentifier",
+			Field:    "string name = 1 [(google.api.field_behavior) = REQUIRED];",
+			problems: testutils.Problems{{Message: "field_behavior IDENTIFIER", Suggestion: "(google.api.field_behavior) = IDENTIFIER"}},
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.name, func(t *testing.T) {
+			file := testutils.ParseProto3Tmpl(t, `
+			import "google/api/field_behavior.proto";
+			import "google/api/resource.proto";
+				message Book {
+					option (google.api.resource) = {
+						type: "library.googleapis.com/Book"
+						pattern: "books/{book}"
+						name_field: "{{.NameField}}"
+					};
+
+					{{.Field}}
+				}`, test)
+			f := file.GetMessageTypes()[0].GetFields()[0]
+			problems := resourceNameIdentifier.Lint(file)
+			if diff := test.problems.SetDescriptor(f).Diff(problems); diff != "" {
+				t.Error(diff)
+			}
+		})
+	}
+}