@@ -19,6 +19,7 @@ package main
1919
2020import (
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+
4056var (
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
7397func 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).
77105func 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.
100131func 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}
0 commit comments