Skip to content

Commit 092c8ef

Browse files
committed
Add buf-plugin-required-fields plugin
Add a plugin that checks that: - entity-related messages (e.g: Cluster) define a known set of common fields for the Qdrant Cloud API: `[id, name, account_id, created_at]`. - Request messages (e.g: ListClusters) define a known set of common fields for the Qdrant Cloud API: `[account_id]`.
1 parent 1d1276e commit 092c8ef

File tree

6 files changed

+399
-0
lines changed

6 files changed

+399
-0
lines changed
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Package main implements a plugin that checks that:
2+
// - entity-related messages (e.g: Cluster) define a known set of common fields
3+
// for the Qdrant Cloud API.
4+
// - Request messages (e.g: ListClusters) define a known set of common fields
5+
// for the Qdrant Cloud API.
6+
//
7+
// To use this plugin:
8+
//
9+
// # buf.yaml
10+
// version: v2
11+
// lint:
12+
// use:
13+
// - STANDARD # omit if you do not want to use the rules builtin to buf
14+
// - QDRANT_CLOUD_REQUIRED_ENTITY_FIELDS
15+
// - QDRANT_CLOUD_REQUIRED_REQUEST_FIELDS
16+
// plugins:
17+
// - plugin: buf-plugin-required-fields
18+
package main
19+
20+
import (
21+
"context"
22+
"strings"
23+
24+
"buf.build/go/bufplugin/check"
25+
"buf.build/go/bufplugin/check/checkutil"
26+
"buf.build/go/bufplugin/descriptor"
27+
"buf.build/go/bufplugin/info"
28+
"buf.build/go/bufplugin/option"
29+
pluralize "github.com/gertd/go-pluralize"
30+
"google.golang.org/protobuf/reflect/protoreflect"
31+
)
32+
33+
const (
34+
requiredEntityFieldsRuleID = "QDRANT_CLOUD_REQUIRED_ENTITY_FIELDS"
35+
requiredEntityFieldsOptionKey = "required_entity_fields"
36+
requiredRequestFieldsRuleID = "QDRANT_CLOUD_REQUIRED_REQUEST_FIELDS"
37+
requiredRequestFieldsOptionKey = "required_request_fields"
38+
)
39+
40+
var (
41+
requiredEntityFieldsRuleSpec = &check.RuleSpec{
42+
ID: requiredEntityFieldsRuleID,
43+
Default: true,
44+
Purpose: `Checks that all entity-related messages (e.g: Cluster) define a known set of fields for the Qdrant Cloud API.`,
45+
Type: check.RuleTypeLint,
46+
Handler: checkutil.NewFileRuleHandler(checkEntityFields, checkutil.WithoutImports()),
47+
}
48+
requiredRequestFieldsRuleSpec = &check.RuleSpec{
49+
ID: requiredRequestFieldsRuleID,
50+
Default: true,
51+
Purpose: `Checks that all request methods (e.g: ListClutersRequest) define a known set of fields for the Qdrant Cloud API.`,
52+
Type: check.RuleTypeLint,
53+
Handler: checkutil.NewMessageRuleHandler(checkRequestFields, checkutil.WithoutImports()),
54+
}
55+
spec = &check.Spec{
56+
Rules: []*check.RuleSpec{
57+
requiredEntityFieldsRuleSpec,
58+
requiredRequestFieldsRuleSpec,
59+
},
60+
Info: &info.Spec{
61+
Documentation: `A plugin that checks that entity-related messages define a known set of fields for the Qdrant Cloud API.`,
62+
SPDXLicenseID: "",
63+
LicenseURL: "",
64+
},
65+
}
66+
67+
crudMethodPrefixes = []string{"List", "Get", "Delete", "Update", "Create"}
68+
defaultRequiredFields = []string{"id", "name", "account_id", "created_at"}
69+
defaultRequiredRequestFields = []string{"account_id"}
70+
)
71+
72+
func main() {
73+
check.Main(spec)
74+
}
75+
76+
func checkEntityFields(ctx context.Context, responseWriter check.ResponseWriter, request check.Request, fileDescriptor descriptor.FileDescriptor) error {
77+
requiredFields, err := getRequiredEntityFields(request)
78+
if err != nil {
79+
return err
80+
}
81+
82+
for entityName := range extractEntityNames(fileDescriptor) {
83+
msg := fileDescriptor.ProtoreflectFileDescriptor().Messages().ByName(protoreflect.Name(entityName))
84+
if msg == nil {
85+
continue
86+
}
87+
missingFields := findMissingFields(msg, requiredFields)
88+
if len(missingFields) > 0 {
89+
responseWriter.AddAnnotation(
90+
check.WithMessagef("%q is missing required fields: %v", entityName, missingFields),
91+
check.WithDescriptor(msg),
92+
)
93+
}
94+
}
95+
96+
return nil
97+
}
98+
99+
func checkRequestFields(ctx context.Context, responseWriter check.ResponseWriter, request check.Request, messageDescriptor protoreflect.MessageDescriptor) error {
100+
msgName := string(messageDescriptor.Name())
101+
if !strings.HasSuffix(msgName, "Request") {
102+
return nil
103+
}
104+
var requiredFields []string
105+
if strings.HasPrefix(msgName, "List") || strings.HasPrefix(msgName, "Get") || strings.HasPrefix(msgName, "Delete") {
106+
requiredFields = defaultRequiredRequestFields
107+
} else {
108+
return nil
109+
}
110+
missingFields := findMissingFields(messageDescriptor, requiredFields)
111+
if len(missingFields) > 0 {
112+
responseWriter.AddAnnotation(
113+
check.WithMessagef("%q is missing required fields: %v", msgName, missingFields),
114+
check.WithDescriptor(messageDescriptor),
115+
)
116+
}
117+
118+
return nil
119+
}
120+
121+
// getRequiredEntityFields returns a list of required fields for a entity
122+
// message. It gets the values either from a plugin option or from the default
123+
// values.
124+
func getRequiredEntityFields(request check.Request) ([]string, error) {
125+
requiredFieldsOptionValue, err := option.GetStringSliceValue(request.Options(), requiredEntityFieldsOptionKey)
126+
if err != nil {
127+
return nil, err
128+
}
129+
if len(requiredFieldsOptionValue) > 0 {
130+
return requiredFieldsOptionValue, nil
131+
}
132+
return defaultRequiredFields, nil
133+
}
134+
135+
// extractEntityNames returns a set of entity names inferred from the name of
136+
// the service methods.
137+
// e.g: [ListBooks, GetBook] -> {Book}
138+
func extractEntityNames(fileDescriptor descriptor.FileDescriptor) map[string]struct{} {
139+
entityNames := make(map[string]struct{})
140+
services := fileDescriptor.FileDescriptorProto().GetService()
141+
for _, svc := range services {
142+
for _, method := range svc.Method {
143+
entityName := inferEntityFromMethodName(method.GetName())
144+
if entityName != "" {
145+
entityNames[entityName] = struct{}{}
146+
}
147+
}
148+
}
149+
return entityNames
150+
}
151+
152+
// inferEntityFromMethodName extracts the entity name by stripping CRUD prefixes
153+
func inferEntityFromMethodName(methodName string) string {
154+
p := pluralize.NewClient()
155+
for _, prefix := range crudMethodPrefixes {
156+
if strings.HasPrefix(methodName, prefix) {
157+
return p.Singular(strings.TrimPrefix(methodName, prefix))
158+
}
159+
}
160+
return ""
161+
}
162+
163+
// findMissingFields checks if a message contains all required fields.
164+
func findMissingFields(msg protoreflect.MessageDescriptor, requiredFields []string) []string {
165+
missingFields := []string{}
166+
fieldMap := make(map[string]bool)
167+
fields := msg.Fields()
168+
169+
for i := 0; i < fields.Len(); i++ {
170+
field := fields.Get(i)
171+
fieldMap[string(field.Name())] = true
172+
}
173+
174+
for _, requiredField := range requiredFields {
175+
if !fieldMap[requiredField] {
176+
missingFields = append(missingFields, requiredField)
177+
}
178+
}
179+
return missingFields
180+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"buf.build/go/bufplugin/check/checktest"
7+
)
8+
9+
func TestSpec(t *testing.T) {
10+
t.Parallel()
11+
checktest.SpecTest(t, spec)
12+
}
13+
14+
func TestSimpleSuccess(t *testing.T) {
15+
t.Parallel()
16+
17+
checktest.CheckTest{
18+
Request: &checktest.RequestSpec{
19+
Files: &checktest.ProtoFileSpec{
20+
DirPaths: []string{"testdata/simple_success"},
21+
FilePaths: []string{"simple.proto"},
22+
},
23+
},
24+
Spec: spec,
25+
}.Run(t)
26+
}
27+
28+
func TestSimpleFailureWithOption(t *testing.T) {
29+
t.Parallel()
30+
31+
checktest.CheckTest{
32+
Request: &checktest.RequestSpec{
33+
Files: &checktest.ProtoFileSpec{
34+
DirPaths: []string{"testdata/simple_failure"},
35+
FilePaths: []string{"simple.proto"},
36+
},
37+
RuleIDs: []string{requiredEntityFieldsRuleID},
38+
Options: map[string]any{
39+
requiredEntityFieldsOptionKey: []string{"category"},
40+
},
41+
},
42+
Spec: spec,
43+
ExpectedAnnotations: []checktest.ExpectedAnnotation{
44+
{
45+
RuleID: requiredEntityFieldsRuleID,
46+
Message: "\"BookCategory\" is missing required fields: [category]",
47+
FileLocation: &checktest.ExpectedFileLocation{
48+
FileName: "simple.proto",
49+
StartLine: 51,
50+
StartColumn: 0,
51+
EndLine: 56,
52+
EndColumn: 1,
53+
},
54+
},
55+
},
56+
}.Run(t)
57+
}
58+
59+
func TestSimpleFailure(t *testing.T) {
60+
t.Parallel()
61+
62+
checktest.CheckTest{
63+
Request: &checktest.RequestSpec{
64+
Files: &checktest.ProtoFileSpec{
65+
DirPaths: []string{"testdata/simple_failure"},
66+
FilePaths: []string{"simple.proto"},
67+
},
68+
},
69+
Spec: spec,
70+
ExpectedAnnotations: []checktest.ExpectedAnnotation{
71+
{
72+
RuleID: requiredEntityFieldsRuleID,
73+
Message: "\"Book\" is missing required fields: [id account_id created_at]",
74+
FileLocation: &checktest.ExpectedFileLocation{
75+
FileName: "simple.proto",
76+
StartLine: 42,
77+
StartColumn: 0,
78+
EndLine: 49,
79+
EndColumn: 1,
80+
},
81+
},
82+
{
83+
RuleID: requiredEntityFieldsRuleID,
84+
Message: "\"BookCategory\" is missing required fields: [name]",
85+
FileLocation: &checktest.ExpectedFileLocation{
86+
FileName: "simple.proto",
87+
StartLine: 51,
88+
StartColumn: 0,
89+
EndLine: 56,
90+
EndColumn: 1,
91+
},
92+
},
93+
{
94+
RuleID: requiredRequestFieldsRuleID,
95+
Message: "\"ListBooksRequest\" is missing required fields: [account_id]",
96+
FileLocation: &checktest.ExpectedFileLocation{
97+
FileName: "simple.proto",
98+
StartLine: 17,
99+
StartColumn: 0,
100+
EndLine: 19,
101+
EndColumn: 1,
102+
},
103+
},
104+
{
105+
RuleID: requiredRequestFieldsRuleID,
106+
Message: "\"GetBookRequest\" is missing required fields: [account_id]",
107+
FileLocation: &checktest.ExpectedFileLocation{
108+
FileName: "simple.proto",
109+
StartLine: 25,
110+
StartColumn: 0,
111+
EndLine: 28,
112+
EndColumn: 1,
113+
},
114+
},
115+
},
116+
}.Run(t)
117+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
syntax = "proto3";
2+
3+
package simple;
4+
5+
import "google/protobuf/timestamp.proto";
6+
7+
service BookService {
8+
rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {
9+
}
10+
11+
rpc GetBook(GetBookRequest) returns (GetBookResponse) {
12+
}
13+
14+
rpc CreateBookCategory(CreateBookCategoryRequest) returns (CreateBookCategoryResponse) {
15+
}
16+
}
17+
18+
message ListBooksRequest {
19+
// missing account_id field
20+
}
21+
22+
message ListBooksResponse {
23+
repeated Book items = 1;
24+
}
25+
26+
message GetBookRequest {
27+
// missing account_id field
28+
// missing book_id field
29+
}
30+
31+
message GetBookResponse {
32+
Book book = 1;
33+
}
34+
35+
message CreateBookCategoryRequest {
36+
BookCategory category = 1;
37+
}
38+
39+
message CreateBookCategoryResponse {
40+
BookCategory category = 1;
41+
}
42+
43+
message Book {
44+
// missing `id` field
45+
string name = 1;
46+
// missing `account_id` field
47+
// missing `created_at` field
48+
BookCategory category = 2;
49+
Publisher publisher = 3;
50+
}
51+
52+
message BookCategory {
53+
string id = 1;
54+
// missing `name` field
55+
string account_id = 2;
56+
google.protobuf.Timestamp created_at = 3;
57+
}
58+
59+
// this message does not have any related CRUD method, we don't consider it an entity and
60+
// required fields don't apply for it.
61+
message Publisher {
62+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
syntax = "proto3";
2+
3+
package simple;
4+
5+
import "google/protobuf/timestamp.proto";
6+
7+
service BookService {
8+
rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {
9+
}
10+
11+
rpc GetBook(GetBookRequest) returns (GetBookResponse) {
12+
}
13+
}
14+
15+
message ListBooksRequest {
16+
string account_id = 1;
17+
}
18+
19+
message ListBooksResponse {
20+
repeated Book items = 1;
21+
}
22+
23+
message GetBookRequest {
24+
string account_id = 1;
25+
26+
}
27+
28+
message GetBookResponse {
29+
Book book = 1;
30+
}
31+
32+
message Book {
33+
string id = 1;
34+
string account_id = 2;
35+
string name = 3;
36+
google.protobuf.Timestamp created_at = 4;
37+
}

0 commit comments

Comments
 (0)