Skip to content

Commit 20581c9

Browse files
authored
Update required-fields plugin (#7)
With this change, the plugin now also validates the name of the fields against a set of preferred field names. For instance, the linting will fail if you try to define a field named `updated_at` and will recommend you to use `last_modified_at`. At this moment I considered it isn't worth to add this functionality as a new plugin, in the future we could either rename the plugin or split the functionality.
1 parent bafb91f commit 20581c9

File tree

3 files changed

+162
-32
lines changed

3 files changed

+162
-32
lines changed

cmd/buf-plugin-required-fields/main.go

Lines changed: 104 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package main
1919

2020
import (
2121
"context"
22+
"fmt"
2223
"strings"
2324

2425
"buf.build/go/bufplugin/check"
@@ -37,6 +38,21 @@ const (
3738
requiredRequestFieldsOptionKey = "required_request_fields"
3839
)
3940

41+
// FieldValidator validates a single field.
42+
// Returns an error message and false if validation fails.
43+
type FieldValidator func(field protoreflect.FieldDescriptor) *ValidationError
44+
45+
// MessageValidator validates a message as a whole, based on the set of fields present in the message.
46+
// Returns an error message and false if validation fails.
47+
type MessageValidator func(message protoreflect.MessageDescriptor, messageFields map[string]bool) *ValidationError
48+
49+
// ValidationError represents a linting error and includes the error message and
50+
// the descriptor where the linting issue was found.
51+
type ValidationError struct {
52+
Message string
53+
Descriptor protoreflect.Descriptor
54+
}
55+
4056
var (
4157
requiredEntityFieldsRuleSpec = &check.RuleSpec{
4258
ID: requiredEntityFieldsRuleID,
@@ -68,35 +84,50 @@ var (
6884
crudMethodWithoutFullEntityPrefixes = []string{"List", "Get", "Delete"}
6985
defaultRequiredFields = []string{"id", "name", "account_id", "created_at"}
7086
defaultRequiredRequestFields = []string{"account_id"}
87+
preferredEntityFieldNames = map[string]string{
88+
"updated_at": "last_modified_at",
89+
"last_updated_at": "last_modified_at",
90+
"cloud_provider": "cloud_provider_id",
91+
"cloud_provider_region": "cloud_provider_region_id",
92+
"cloud_region": "cloud_provider_region_id",
93+
"cloud_region_id": "cloud_provider_region_id",
94+
}
7195
)
7296

7397
func main() {
7498
check.Main(spec)
7599
}
76100

101+
// checkEntityFields validates all entity-related messages in a file descriptor.
102+
// It applies:
103+
// - Field-level validators (e.g. preferred naming).
104+
// - Message-level validators (e.g. required fields).
77105
func checkEntityFields(ctx context.Context, responseWriter check.ResponseWriter, request check.Request, fileDescriptor descriptor.FileDescriptor) error {
78106
requiredFields, err := getRequiredEntityFields(request)
79107
if err != nil {
80108
return err
81109
}
82-
83110
for entityName := range extractEntityNames(fileDescriptor) {
84111
msg := fileDescriptor.ProtoreflectFileDescriptor().Messages().ByName(protoreflect.Name(entityName))
85112
if msg == nil {
86113
continue
87114
}
88-
missingFields := findMissingFields(msg, requiredFields)
89-
if len(missingFields) > 0 {
90-
responseWriter.AddAnnotation(
91-
check.WithMessagef("%q is missing required fields: %v", entityName, missingFields),
92-
check.WithDescriptor(msg),
93-
)
115+
errors := validateMessage(
116+
msg,
117+
[]FieldValidator{preferredFieldNamesValidator(preferredEntityFieldNames)},
118+
[]MessageValidator{missingFieldsValidator(requiredFields)},
119+
)
120+
121+
for _, err := range errors {
122+
responseWriter.AddAnnotation(check.WithMessage(err.Message), check.WithDescriptor(err.Descriptor))
94123
}
95124
}
96125

97126
return nil
98127
}
99128

129+
// checkRequestFields validates messages that end with "Request" and match a known
130+
// CRUD pattern (e.g., ListClustersRequest). It ensures these messages include required fields.
100131
func checkRequestFields(ctx context.Context, responseWriter check.ResponseWriter, request check.Request, messageDescriptor protoreflect.MessageDescriptor) error {
101132
msgName := string(messageDescriptor.Name())
102133
if !strings.HasSuffix(msgName, "Request") {
@@ -110,12 +141,11 @@ func checkRequestFields(ctx context.Context, responseWriter check.ResponseWriter
110141
requiredFields = defaultRequiredRequestFields
111142
}
112143
}
113-
missingFields := findMissingFields(messageDescriptor, requiredFields)
114-
if len(missingFields) > 0 {
115-
responseWriter.AddAnnotation(
116-
check.WithMessagef("%q is missing required fields: %v", msgName, missingFields),
117-
check.WithDescriptor(messageDescriptor),
118-
)
144+
errors := validateMessage(
145+
messageDescriptor, []FieldValidator{}, []MessageValidator{missingFieldsValidator(requiredFields)},
146+
)
147+
for _, err := range errors {
148+
responseWriter.AddAnnotation(check.WithMessage(err.Message), check.WithDescriptor(err.Descriptor))
119149
}
120150

121151
return nil
@@ -163,21 +193,73 @@ func inferEntityFromMethodName(methodName string) string {
163193
return ""
164194
}
165195

166-
// findMissingFields checks if a message contains all required fields.
167-
func findMissingFields(msg protoreflect.MessageDescriptor, requiredFields []string) []string {
168-
missingFields := []string{}
169-
fieldMap := make(map[string]bool)
196+
// validateMessage runs a set of field-level and message-level validators
197+
// against a protobuf message descriptor.
198+
//
199+
// Field-level validators are executed for each individual field in the message,
200+
// allowing checks like discouraged field names or naming conventions.
201+
//
202+
// Message-level validators are run once per message, and have access to the
203+
// full set of field names, enabling checks like required field presence.
204+
func validateMessage(msg protoreflect.MessageDescriptor, fieldValidators []FieldValidator, messageValidators []MessageValidator) []ValidationError {
205+
// missingFields := []string{}
206+
existingFields := make(map[string]bool)
170207
fields := msg.Fields()
208+
errors := []ValidationError{}
171209

172210
for i := 0; i < fields.Len(); i++ {
173211
field := fields.Get(i)
174-
fieldMap[string(field.Name())] = true
212+
fieldName := string(field.Name())
213+
existingFields[string(fieldName)] = true
214+
215+
for _, validator := range fieldValidators {
216+
if err := validator(field); err != nil {
217+
errors = append(errors, *err)
218+
}
219+
}
175220
}
176221

177-
for _, requiredField := range requiredFields {
178-
if !fieldMap[requiredField] {
179-
missingFields = append(missingFields, requiredField)
222+
for _, validator := range messageValidators {
223+
if err := validator(msg, existingFields); err != nil {
224+
errors = append(errors, *err)
180225
}
181226
}
182-
return missingFields
227+
228+
return errors
229+
}
230+
231+
// preferredFieldNamesValidator returns a FieldValidator that checks
232+
// if a given field name is discouraged and suggests the preferred one.
233+
func preferredFieldNamesValidator(preferredFieldNames map[string]string) FieldValidator {
234+
return func(field protoreflect.FieldDescriptor) *ValidationError {
235+
fieldName := string(field.Name())
236+
if suggestion, ok := preferredFieldNames[fieldName]; ok && suggestion != fieldName {
237+
return &ValidationError{
238+
Message: fmt.Sprintf("field %q is discouraged, use %q instead", fieldName, suggestion),
239+
Descriptor: field,
240+
}
241+
}
242+
return nil
243+
}
244+
}
245+
246+
// missingFieldsValidator returns a MessageValidator that ensures a message
247+
// contains all of the specified required fields.
248+
func missingFieldsValidator(requiredFields []string) MessageValidator {
249+
return func(message protoreflect.MessageDescriptor, messageFields map[string]bool) *ValidationError {
250+
messageName := string(message.Name())
251+
missingFields := []string{}
252+
for _, requiredField := range requiredFields {
253+
if !messageFields[requiredField] {
254+
missingFields = append(missingFields, requiredField)
255+
}
256+
}
257+
if len(missingFields) > 0 {
258+
return &ValidationError{
259+
Message: fmt.Sprintf("message %q is missing required fields: %v", messageName, missingFields),
260+
Descriptor: message,
261+
}
262+
}
263+
return nil
264+
}
183265
}

cmd/buf-plugin-required-fields/main_test.go

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,37 @@ func TestSimpleFailureWithOption(t *testing.T) {
4343
ExpectedAnnotations: []checktest.ExpectedAnnotation{
4444
{
4545
RuleID: requiredEntityFieldsRuleID,
46-
Message: "\"BookCategory\" is missing required fields: [category]",
46+
Message: "field \"updated_at\" is discouraged, use \"last_modified_at\" instead",
4747
FileLocation: &checktest.ExpectedFileLocation{
4848
FileName: "simple.proto",
49-
StartLine: 51,
49+
StartLine: 50,
50+
StartColumn: 4,
51+
EndLine: 50,
52+
EndColumn: 45,
53+
},
54+
},
55+
{
56+
RuleID: requiredEntityFieldsRuleID,
57+
Message: "message \"BookCategory\" is missing required fields: [category]",
58+
FileLocation: &checktest.ExpectedFileLocation{
59+
FileName: "simple.proto",
60+
StartLine: 53,
5061
StartColumn: 0,
51-
EndLine: 56,
62+
EndLine: 60,
5263
EndColumn: 1,
5364
},
5465
},
66+
{
67+
RuleID: requiredEntityFieldsRuleID,
68+
Message: "field \"last_updated_at\" is discouraged, use \"last_modified_at\" instead",
69+
FileLocation: &checktest.ExpectedFileLocation{
70+
FileName: "simple.proto",
71+
StartLine: 59,
72+
StartColumn: 4,
73+
EndLine: 59,
74+
EndColumn: 50,
75+
},
76+
},
5577
},
5678
}.Run(t)
5779
}
@@ -70,29 +92,51 @@ func TestSimpleFailure(t *testing.T) {
7092
ExpectedAnnotations: []checktest.ExpectedAnnotation{
7193
{
7294
RuleID: requiredEntityFieldsRuleID,
73-
Message: "\"Book\" is missing required fields: [id account_id created_at]",
95+
Message: "message \"Book\" is missing required fields: [id account_id created_at]",
7496
FileLocation: &checktest.ExpectedFileLocation{
7597
FileName: "simple.proto",
7698
StartLine: 42,
7799
StartColumn: 0,
78-
EndLine: 49,
100+
EndLine: 51,
79101
EndColumn: 1,
80102
},
81103
},
82104
{
83105
RuleID: requiredEntityFieldsRuleID,
84-
Message: "\"BookCategory\" is missing required fields: [name]",
106+
Message: "field \"updated_at\" is discouraged, use \"last_modified_at\" instead",
85107
FileLocation: &checktest.ExpectedFileLocation{
86108
FileName: "simple.proto",
87-
StartLine: 51,
109+
StartLine: 50,
110+
StartColumn: 4,
111+
EndLine: 50,
112+
EndColumn: 45,
113+
},
114+
},
115+
{
116+
RuleID: requiredEntityFieldsRuleID,
117+
Message: "message \"BookCategory\" is missing required fields: [name]",
118+
FileLocation: &checktest.ExpectedFileLocation{
119+
FileName: "simple.proto",
120+
StartLine: 53,
88121
StartColumn: 0,
89-
EndLine: 56,
122+
EndLine: 60,
90123
EndColumn: 1,
91124
},
92125
},
126+
{
127+
RuleID: requiredEntityFieldsRuleID,
128+
Message: "field \"last_updated_at\" is discouraged, use \"last_modified_at\" instead",
129+
FileLocation: &checktest.ExpectedFileLocation{
130+
FileName: "simple.proto",
131+
StartLine: 59,
132+
StartColumn: 4,
133+
EndLine: 59,
134+
EndColumn: 50,
135+
},
136+
},
93137
{
94138
RuleID: requiredRequestFieldsRuleID,
95-
Message: "\"ListBooksRequest\" is missing required fields: [account_id]",
139+
Message: "message \"ListBooksRequest\" is missing required fields: [account_id]",
96140
FileLocation: &checktest.ExpectedFileLocation{
97141
FileName: "simple.proto",
98142
StartLine: 17,
@@ -103,7 +147,7 @@ func TestSimpleFailure(t *testing.T) {
103147
},
104148
{
105149
RuleID: requiredRequestFieldsRuleID,
106-
Message: "\"GetBookRequest\" is missing required fields: [account_id]",
150+
Message: "message \"GetBookRequest\" is missing required fields: [account_id]",
107151
FileLocation: &checktest.ExpectedFileLocation{
108152
FileName: "simple.proto",
109153
StartLine: 25,

cmd/buf-plugin-required-fields/testdata/simple_failure/simple.proto

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,17 @@ message Book {
4747
// missing `created_at` field
4848
BookCategory category = 2;
4949
Publisher publisher = 3;
50+
// updated_at instead of last_modified_at
51+
google.protobuf.Timestamp updated_at = 4;
5052
}
5153

5254
message BookCategory {
5355
string id = 1;
5456
// missing `name` field
5557
string account_id = 2;
5658
google.protobuf.Timestamp created_at = 3;
59+
// last_updated_at instead of last_modified_at
60+
google.protobuf.Timestamp last_updated_at = 4;
5761
}
5862

5963
// this message does not have any related CRUD method, we don't consider it an entity and

0 commit comments

Comments
 (0)