diff --git a/cmd/buf-plugin-required-fields/main.go b/cmd/buf-plugin-required-fields/main.go new file mode 100644 index 0000000..b6afb6d --- /dev/null +++ b/cmd/buf-plugin-required-fields/main.go @@ -0,0 +1,183 @@ +// Package main implements a plugin that checks that: +// - entity-related messages (e.g: Cluster) define a known set of common fields +// for the Qdrant Cloud API. Default values: id, name, account_id, created_at +// - Request messages (e.g: ListClustersRequest) define a known set of common fields +// for the Qdrant Cloud API. Default values: account_id +// +// To use this plugin: +// +// # buf.yaml +// version: v2 +// lint: +// use: +// - STANDARD # omit if you do not want to use the rules builtin to buf +// - QDRANT_CLOUD_REQUIRED_ENTITY_FIELDS +// - QDRANT_CLOUD_REQUIRED_REQUEST_FIELDS +// plugins: +// - plugin: buf-plugin-required-fields +package main + +import ( + "context" + "strings" + + "buf.build/go/bufplugin/check" + "buf.build/go/bufplugin/check/checkutil" + "buf.build/go/bufplugin/descriptor" + "buf.build/go/bufplugin/info" + "buf.build/go/bufplugin/option" + pluralize "github.com/gertd/go-pluralize" + "google.golang.org/protobuf/reflect/protoreflect" +) + +const ( + requiredEntityFieldsRuleID = "QDRANT_CLOUD_REQUIRED_ENTITY_FIELDS" + requiredEntityFieldsOptionKey = "required_entity_fields" + requiredRequestFieldsRuleID = "QDRANT_CLOUD_REQUIRED_REQUEST_FIELDS" + requiredRequestFieldsOptionKey = "required_request_fields" +) + +var ( + requiredEntityFieldsRuleSpec = &check.RuleSpec{ + ID: requiredEntityFieldsRuleID, + Default: true, + Purpose: `Checks that all entity-related messages (e.g: Cluster) define a known set of fields for the Qdrant Cloud API.`, + Type: check.RuleTypeLint, + Handler: checkutil.NewFileRuleHandler(checkEntityFields, checkutil.WithoutImports()), + } + requiredRequestFieldsRuleSpec = &check.RuleSpec{ + ID: requiredRequestFieldsRuleID, + Default: true, + Purpose: `Checks that all request methods (e.g: ListClustersRequest) define a known set of fields for the Qdrant Cloud API.`, + Type: check.RuleTypeLint, + Handler: checkutil.NewMessageRuleHandler(checkRequestFields, checkutil.WithoutImports()), + } + spec = &check.Spec{ + Rules: []*check.RuleSpec{ + requiredEntityFieldsRuleSpec, + requiredRequestFieldsRuleSpec, + }, + Info: &info.Spec{ + Documentation: `A plugin that checks that entity-related messages define a known set of fields for the Qdrant Cloud API.`, + SPDXLicenseID: "", + LicenseURL: "", + }, + } + + crudMethodPrefixes = []string{"List", "Get", "Delete", "Update", "Create"} + crudMethodWithoutFullEntityPrefixes = []string{"List", "Get", "Delete"} + defaultRequiredFields = []string{"id", "name", "account_id", "created_at"} + defaultRequiredRequestFields = []string{"account_id"} +) + +func main() { + check.Main(spec) +} + +func checkEntityFields(ctx context.Context, responseWriter check.ResponseWriter, request check.Request, fileDescriptor descriptor.FileDescriptor) error { + requiredFields, err := getRequiredEntityFields(request) + if err != nil { + return err + } + + for entityName := range extractEntityNames(fileDescriptor) { + msg := fileDescriptor.ProtoreflectFileDescriptor().Messages().ByName(protoreflect.Name(entityName)) + if msg == nil { + continue + } + missingFields := findMissingFields(msg, requiredFields) + if len(missingFields) > 0 { + responseWriter.AddAnnotation( + check.WithMessagef("%q is missing required fields: %v", entityName, missingFields), + check.WithDescriptor(msg), + ) + } + } + + return nil +} + +func checkRequestFields(ctx context.Context, responseWriter check.ResponseWriter, request check.Request, messageDescriptor protoreflect.MessageDescriptor) error { + msgName := string(messageDescriptor.Name()) + if !strings.HasSuffix(msgName, "Request") { + return nil + } + var requiredFields []string + // For Create/Update methods it would be useful to check for the + // `{entity}_id` field. We could add it later as an improvement. + for _, prefix := range crudMethodWithoutFullEntityPrefixes { + if strings.HasPrefix(msgName, prefix) { + requiredFields = defaultRequiredRequestFields + } + } + missingFields := findMissingFields(messageDescriptor, requiredFields) + if len(missingFields) > 0 { + responseWriter.AddAnnotation( + check.WithMessagef("%q is missing required fields: %v", msgName, missingFields), + check.WithDescriptor(messageDescriptor), + ) + } + + return nil +} + +// getRequiredEntityFields returns a list of required fields for a entity +// message. It gets the values either from a plugin option or from the default +// values. +func getRequiredEntityFields(request check.Request) ([]string, error) { + requiredFieldsOptionValue, err := option.GetStringSliceValue(request.Options(), requiredEntityFieldsOptionKey) + if err != nil { + return nil, err + } + if len(requiredFieldsOptionValue) > 0 { + return requiredFieldsOptionValue, nil + } + return defaultRequiredFields, nil +} + +// extractEntityNames returns a set of entity names inferred from the name of +// the service methods. +// e.g: [ListBooks, GetBook] -> {Book} +func extractEntityNames(fileDescriptor descriptor.FileDescriptor) map[string]struct{} { + entityNames := make(map[string]struct{}) + services := fileDescriptor.FileDescriptorProto().GetService() + for _, svc := range services { + for _, method := range svc.Method { + entityName := inferEntityFromMethodName(method.GetName()) + if entityName != "" { + entityNames[entityName] = struct{}{} + } + } + } + return entityNames +} + +// inferEntityFromMethodName extracts the entity name by stripping CRUD prefixes +func inferEntityFromMethodName(methodName string) string { + p := pluralize.NewClient() + for _, prefix := range crudMethodPrefixes { + if strings.HasPrefix(methodName, prefix) { + return p.Singular(strings.TrimPrefix(methodName, prefix)) + } + } + return "" +} + +// findMissingFields checks if a message contains all required fields. +func findMissingFields(msg protoreflect.MessageDescriptor, requiredFields []string) []string { + missingFields := []string{} + fieldMap := make(map[string]bool) + fields := msg.Fields() + + for i := 0; i < fields.Len(); i++ { + field := fields.Get(i) + fieldMap[string(field.Name())] = true + } + + for _, requiredField := range requiredFields { + if !fieldMap[requiredField] { + missingFields = append(missingFields, requiredField) + } + } + return missingFields +} diff --git a/cmd/buf-plugin-required-fields/main_test.go b/cmd/buf-plugin-required-fields/main_test.go new file mode 100644 index 0000000..970a9e5 --- /dev/null +++ b/cmd/buf-plugin-required-fields/main_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "testing" + + "buf.build/go/bufplugin/check/checktest" +) + +func TestSpec(t *testing.T) { + t.Parallel() + checktest.SpecTest(t, spec) +} + +func TestSimpleSuccess(t *testing.T) { + t.Parallel() + + checktest.CheckTest{ + Request: &checktest.RequestSpec{ + Files: &checktest.ProtoFileSpec{ + DirPaths: []string{"testdata/simple_success"}, + FilePaths: []string{"simple.proto"}, + }, + }, + Spec: spec, + }.Run(t) +} + +func TestSimpleFailureWithOption(t *testing.T) { + t.Parallel() + + checktest.CheckTest{ + Request: &checktest.RequestSpec{ + Files: &checktest.ProtoFileSpec{ + DirPaths: []string{"testdata/simple_failure"}, + FilePaths: []string{"simple.proto"}, + }, + RuleIDs: []string{requiredEntityFieldsRuleID}, + Options: map[string]any{ + requiredEntityFieldsOptionKey: []string{"category"}, + }, + }, + Spec: spec, + ExpectedAnnotations: []checktest.ExpectedAnnotation{ + { + RuleID: requiredEntityFieldsRuleID, + Message: "\"BookCategory\" is missing required fields: [category]", + FileLocation: &checktest.ExpectedFileLocation{ + FileName: "simple.proto", + StartLine: 51, + StartColumn: 0, + EndLine: 56, + EndColumn: 1, + }, + }, + }, + }.Run(t) +} + +func TestSimpleFailure(t *testing.T) { + t.Parallel() + + checktest.CheckTest{ + Request: &checktest.RequestSpec{ + Files: &checktest.ProtoFileSpec{ + DirPaths: []string{"testdata/simple_failure"}, + FilePaths: []string{"simple.proto"}, + }, + }, + Spec: spec, + ExpectedAnnotations: []checktest.ExpectedAnnotation{ + { + RuleID: requiredEntityFieldsRuleID, + Message: "\"Book\" is missing required fields: [id account_id created_at]", + FileLocation: &checktest.ExpectedFileLocation{ + FileName: "simple.proto", + StartLine: 42, + StartColumn: 0, + EndLine: 49, + EndColumn: 1, + }, + }, + { + RuleID: requiredEntityFieldsRuleID, + Message: "\"BookCategory\" is missing required fields: [name]", + FileLocation: &checktest.ExpectedFileLocation{ + FileName: "simple.proto", + StartLine: 51, + StartColumn: 0, + EndLine: 56, + EndColumn: 1, + }, + }, + { + RuleID: requiredRequestFieldsRuleID, + Message: "\"ListBooksRequest\" is missing required fields: [account_id]", + FileLocation: &checktest.ExpectedFileLocation{ + FileName: "simple.proto", + StartLine: 17, + StartColumn: 0, + EndLine: 19, + EndColumn: 1, + }, + }, + { + RuleID: requiredRequestFieldsRuleID, + Message: "\"GetBookRequest\" is missing required fields: [account_id]", + FileLocation: &checktest.ExpectedFileLocation{ + FileName: "simple.proto", + StartLine: 25, + StartColumn: 0, + EndLine: 28, + EndColumn: 1, + }, + }, + }, + }.Run(t) +} diff --git a/cmd/buf-plugin-required-fields/testdata/simple_failure/simple.proto b/cmd/buf-plugin-required-fields/testdata/simple_failure/simple.proto new file mode 100644 index 0000000..a38c6c4 --- /dev/null +++ b/cmd/buf-plugin-required-fields/testdata/simple_failure/simple.proto @@ -0,0 +1,62 @@ +syntax = "proto3"; + +package simple; + +import "google/protobuf/timestamp.proto"; + +service BookService { + rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) { + } + + rpc GetBook(GetBookRequest) returns (GetBookResponse) { + } + + rpc CreateBookCategory(CreateBookCategoryRequest) returns (CreateBookCategoryResponse) { + } +} + +message ListBooksRequest { + // missing account_id field +} + +message ListBooksResponse { + repeated Book items = 1; +} + +message GetBookRequest { + // missing account_id field + // missing book_id field +} + +message GetBookResponse { + Book book = 1; +} + +message CreateBookCategoryRequest { + BookCategory category = 1; +} + +message CreateBookCategoryResponse { + BookCategory category = 1; +} + +message Book { + // missing `id` field + string name = 1; + // missing `account_id` field + // missing `created_at` field + BookCategory category = 2; + Publisher publisher = 3; +} + +message BookCategory { + string id = 1; + // missing `name` field + string account_id = 2; + google.protobuf.Timestamp created_at = 3; +} + +// this message does not have any related CRUD method, we don't consider it an entity and +// required fields don't apply for it. +message Publisher { +} diff --git a/cmd/buf-plugin-required-fields/testdata/simple_success/simple.proto b/cmd/buf-plugin-required-fields/testdata/simple_success/simple.proto new file mode 100644 index 0000000..ab82c2d --- /dev/null +++ b/cmd/buf-plugin-required-fields/testdata/simple_success/simple.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package simple; + +import "google/protobuf/timestamp.proto"; + +service BookService { + rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) { + } + + rpc GetBook(GetBookRequest) returns (GetBookResponse) { + } +} + +message ListBooksRequest { + string account_id = 1; +} + +message ListBooksResponse { + repeated Book items = 1; +} + +message GetBookRequest { + string account_id = 1; + +} + +message GetBookResponse { + Book book = 1; +} + +message Book { + string id = 1; + string account_id = 2; + string name = 3; + google.protobuf.Timestamp created_at = 4; +} diff --git a/go.mod b/go.mod index 6137a18..1626c6a 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.1 require ( buf.build/go/bufplugin v0.8.0 + github.com/gertd/go-pluralize v0.2.1 github.com/qdrant/qdrant-cloud-public-api v0.0.0-20250328115247-eb8f8e124f26 google.golang.org/genproto/googleapis/api v0.0.0-20250127172529-29210b9bc287 google.golang.org/protobuf v1.36.6 diff --git a/go.sum b/go.sum index 5adcaee..a613b24 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA= +github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk= github.com/google/cel-go v0.23.1 h1:91ThhEZlBcE5rB2adBVXqvDoqdL8BG2oyhd0bK1I/r4= github.com/google/cel-go v0.23.1/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=