diff --git a/cmd/api-linter/cli.go b/cmd/api-linter/cli.go index 662229be..9984f848 100644 --- a/cmd/api-linter/cli.go +++ b/cmd/api-linter/cli.go @@ -20,15 +20,11 @@ import ( "fmt" "os" "strings" - "sync" "github.com/aep-dev/api-linter/internal" "github.com/aep-dev/api-linter/lint" - "github.com/jhump/protoreflect/desc" - "github.com/jhump/protoreflect/desc/protoparse" + lint_v2 "github.com/aep-dev/api-linter/lint/v2" "github.com/spf13/pflag" - "google.golang.org/protobuf/proto" - dpb "google.golang.org/protobuf/types/descriptorpb" "gopkg.in/yaml.v2" ) @@ -106,7 +102,7 @@ func newCli(args []string) *cli { } } -func (c *cli) lint(rules lint.RuleRegistry, configs lint.Configs) error { +func (c *cli) lint(rulesV1 lint.RuleRegistry, rulesV2 lint_v2.RuleRegistry, configs lint.Configs) error { // Print version and exit if asked. if c.VersionFlag { fmt.Printf("api-linter %s\n", internal.Version) @@ -137,60 +133,30 @@ func (c *cli) lint(rules lint.RuleRegistry, configs lint.Configs) error { configs = append(configs, lint.Config{ DisabledRules: c.DisabledRules, }) - // Prepare proto import lookup. - fs, err := loadFileDescriptors(c.ProtoDescPath...) + + // V1 Pipeline + resultsV1, err := c.runV1(rulesV1, configs) if err != nil { return err } - lookupImport := func(name string) (*desc.FileDescriptor, error) { - if f, found := fs[name]; found { - return f, nil - } - return nil, fmt.Errorf("%q is not found", name) - } - var errorsWithPos []protoparse.ErrorWithPos - var lock sync.Mutex - // Parse proto files into `protoreflect` file descriptors. - p := protoparse.Parser{ - ImportPaths: c.ProtoImportPaths, - IncludeSourceCodeInfo: true, - LookupImport: lookupImport, - ErrorReporter: func(errorWithPos protoparse.ErrorWithPos) error { - // Protoparse isn't concurrent right now but just to be safe for the future. - lock.Lock() - errorsWithPos = append(errorsWithPos, errorWithPos) - lock.Unlock() - // Continue parsing. The error returned will be protoparse.ErrInvalidSource. - return nil - }, - } - // Resolve file absolute paths to relative ones. - protoFiles, err := protoparse.ResolveFilenames(c.ProtoImportPaths, c.ProtoFiles...) - if err != nil { - return err + + // V2 Pipeline + var configsV2 lint_v2.Configs + for _, cfg := range configs { + configsV2 = append(configsV2, lint_v2.Config{ + IncludedPaths: cfg.IncludedPaths, + ExcludedPaths: cfg.ExcludedPaths, + EnabledRules: cfg.EnabledRules, + DisabledRules: cfg.DisabledRules, + }) } - fd, err := p.ParseFiles(protoFiles...) + resultsV2, err := c.runV2(rulesV2, configsV2) if err != nil { - if err == protoparse.ErrInvalidSource { - if len(errorsWithPos) == 0 { - return errors.New("got protoparse.ErrInvalidSource but no ErrorWithPos errors") - } - // TODO: There's multiple ways to deal with this but this prints all the errors at least - errStrings := make([]string, len(errorsWithPos)) - for i, errorWithPos := range errorsWithPos { - errStrings[i] = errorWithPos.Error() - } - return errors.New(strings.Join(errStrings, "\n")) - } - return err + return fmt.Errorf("V2 pipeline failed: %w", err) } - // Create a linter to lint the file descriptors. - l := lint.New(rules, configs, lint.Debug(c.DebugFlag), lint.IgnoreCommentDisables(c.IgnoreCommentDisablesFlag)) - results, err := l.LintProtos(fd...) - if err != nil { - return err - } + // Combine results from both pipelines, preserving file order. + results := combineResponses(resultsV1, resultsV2) // Determine the output for writing the results. // Stdout is the default output. @@ -226,37 +192,124 @@ func (c *cli) lint(rules lint.RuleRegistry, configs lint.Configs) error { return nil } -func anyProblems(results []lint.Response) bool { - for i := range results { - if len(results[i].Problems) > 0 { +func anyProblems(results []combinedResponse) bool { + for _, r := range results { + if r.hasProblems() { return true } } return false } -func loadFileDescriptors(filePaths ...string) (map[string]*desc.FileDescriptor, error) { - fds := []*dpb.FileDescriptorProto{} - for _, filePath := range filePaths { - fs, err := readFileDescriptorSet(filePath) - if err != nil { - return nil, err +// combinedResponse holds lint results from both V1 and V2 pipelines for a +// single file. It preserves the concrete problem types so that custom +// MarshalJSON/MarshalYAML methods on each Problem type are invoked correctly. +type combinedResponse struct { + FilePath string + ProblemsV1 []lint.Problem + ProblemsV2 []lint_v2.Problem +} + +// MarshalJSON produces output compatible with the original lint.Response format. +func (r combinedResponse) MarshalJSON() ([]byte, error) { + problems := make([]interface{}, 0, len(r.ProblemsV1)+len(r.ProblemsV2)) + for _, p := range r.ProblemsV1 { + problems = append(problems, p) + } + for _, p := range r.ProblemsV2 { + problems = append(problems, p) + } + return json.Marshal(struct { + FilePath string `json:"file_path"` + Problems []interface{} `json:"problems"` + }{r.FilePath, problems}) +} + +// MarshalYAML produces output compatible with the original lint.Response format. +func (r combinedResponse) MarshalYAML() (interface{}, error) { + problems := make([]interface{}, 0, len(r.ProblemsV1)+len(r.ProblemsV2)) + for _, p := range r.ProblemsV1 { + problems = append(problems, p) + } + for _, p := range r.ProblemsV2 { + problems = append(problems, p) + } + return struct { + FilePath string `yaml:"file_path"` + Problems []interface{} `yaml:"problems"` + }{r.FilePath, problems}, nil +} + +func (r combinedResponse) hasProblems() bool { + return len(r.ProblemsV1) > 0 || len(r.ProblemsV2) > 0 +} + +// problemInfo provides a unified view of a problem for format functions +// (github, summary) that need to access problem fields directly. +type problemInfo struct { + RuleID string + Message string + Span []int32 // From Location.Span; nil if no location. + RuleURI string +} + +// allProblems returns a unified list of problem info from both V1 and V2 problems. +func (r combinedResponse) allProblems() []problemInfo { + result := make([]problemInfo, 0, len(r.ProblemsV1)+len(r.ProblemsV2)) + for _, p := range r.ProblemsV1 { + var span []int32 + if p.Location != nil { + span = p.Location.Span } - fds = append(fds, fs.GetFile()...) + result = append(result, problemInfo{ + RuleID: string(p.RuleID), + Message: p.Message, + Span: span, + RuleURI: p.GetRuleURI(), + }) } - return desc.CreateFileDescriptors(fds) + for _, p := range r.ProblemsV2 { + var span []int32 + if p.Location != nil { + span = p.Location.Span + } + result = append(result, problemInfo{ + RuleID: string(p.RuleID), + Message: p.Message, + Span: span, + RuleURI: p.GetRuleURI(), + }) + } + return result } -func readFileDescriptorSet(filePath string) (*dpb.FileDescriptorSet, error) { - in, err := os.ReadFile(filePath) - if err != nil { - return nil, err +// combineResponses merges V1 and V2 responses by file path, preserving +// the input file ordering (V1 order first, then any V2-only files). +func combineResponses(v1 []lint.Response, v2 []lint_v2.Response) []combinedResponse { + order := make([]string, 0) + byFile := make(map[string]*combinedResponse) + + for _, r := range v1 { + if _, ok := byFile[r.FilePath]; !ok { + order = append(order, r.FilePath) + byFile[r.FilePath] = &combinedResponse{FilePath: r.FilePath} + } + byFile[r.FilePath].ProblemsV1 = append(byFile[r.FilePath].ProblemsV1, r.Problems...) } - fs := &dpb.FileDescriptorSet{} - if err := proto.Unmarshal(in, fs); err != nil { - return nil, err + + for _, r := range v2 { + if _, ok := byFile[r.FilePath]; !ok { + order = append(order, r.FilePath) + byFile[r.FilePath] = &combinedResponse{FilePath: r.FilePath} + } + byFile[r.FilePath].ProblemsV2 = append(byFile[r.FilePath].ProblemsV2, r.Problems...) + } + + result := make([]combinedResponse, 0, len(order)) + for _, fp := range order { + result = append(result, *byFile[fp]) } - return fs, nil + return result } var outputFormatFuncs = map[string]formatFunc{ @@ -265,7 +318,7 @@ var outputFormatFuncs = map[string]formatFunc{ "json": json.Marshal, "github": func(i interface{}) ([]byte, error) { switch v := i.(type) { - case []lint.Response: + case []combinedResponse: return formatGitHubActionOutput(v), nil default: return json.Marshal(v) @@ -273,7 +326,7 @@ var outputFormatFuncs = map[string]formatFunc{ }, "summary": func(i interface{}) ([]byte, error) { switch v := i.(type) { - case []lint.Response: + case []combinedResponse: return printSummaryTable(v) case listedRules: return v.printSummaryTable() diff --git a/cmd/api-linter/cli_v1.go b/cmd/api-linter/cli_v1.go new file mode 100644 index 00000000..5cc7f536 --- /dev/null +++ b/cmd/api-linter/cli_v1.go @@ -0,0 +1,106 @@ +// Copyright 2019 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 main + +import ( + "errors" + "fmt" + "os" + "strings" + "sync" + + "github.com/aep-dev/api-linter/lint" + "github.com/jhump/protoreflect/desc" + "github.com/jhump/protoreflect/desc/protoparse" + "google.golang.org/protobuf/proto" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +func (c *cli) runV1(rulesV1 lint.RuleRegistry, configs lint.Configs) ([]lint.Response, error) { + // Prepare proto import lookup. + fs, err := loadFileDescriptors(c.ProtoDescPath...) + if err != nil { + return nil, err + } + lookupImport := func(name string) (*desc.FileDescriptor, error) { + if f, found := fs[name]; found { + return f, nil + } + return nil, fmt.Errorf("%q is not found", name) + } + var errorsWithPos []protoparse.ErrorWithPos + var lock sync.Mutex + // Parse proto files into `protoreflect` file descriptors. + p := protoparse.Parser{ + ImportPaths: c.ProtoImportPaths, + IncludeSourceCodeInfo: true, + LookupImport: lookupImport, + ErrorReporter: func(errorWithPos protoparse.ErrorWithPos) error { + // Protoparse isn't concurrent right now but just to be safe for the future. + lock.Lock() + errorsWithPos = append(errorsWithPos, errorWithPos) + lock.Unlock() + // Continue parsing. The error returned will be protoparse.ErrInvalidSource. + return nil + }, + } + // Resolve file absolute paths to relative ones. + protoFiles, err := protoparse.ResolveFilenames(c.ProtoImportPaths, c.ProtoFiles...) + if err != nil { + return nil, err + } + fd, err := p.ParseFiles(protoFiles...) + if err != nil { + if err == protoparse.ErrInvalidSource { + if len(errorsWithPos) == 0 { + return nil, errors.New("got protoparse.ErrInvalidSource but no ErrorWithPos errors") + } + errStrings := make([]string, len(errorsWithPos)) + for i, errorWithPos := range errorsWithPos { + errStrings[i] = errorWithPos.Error() + } + return nil, errors.New(strings.Join(errStrings, "\n")) + } + return nil, err + } + + // Create a linter to lint the file descriptors. + l := lint.New(rulesV1, configs, lint.Debug(c.DebugFlag), lint.IgnoreCommentDisables(c.IgnoreCommentDisablesFlag)) + return l.LintProtos(fd...) +} + +func loadFileDescriptors(filePaths ...string) (map[string]*desc.FileDescriptor, error) { + fds := []*dpb.FileDescriptorProto{} + for _, filePath := range filePaths { + fs, err := readFileDescriptorSet(filePath) + if err != nil { + return nil, err + } + fds = append(fds, fs.GetFile()...) + } + return desc.CreateFileDescriptors(fds) +} + +func readFileDescriptorSet(filePath string) (*dpb.FileDescriptorSet, error) { + in, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + fs := &dpb.FileDescriptorSet{} + if err := proto.Unmarshal(in, fs); err != nil { + return nil, err + } + return fs, nil +} diff --git a/cmd/api-linter/cli_v2.go b/cmd/api-linter/cli_v2.go new file mode 100644 index 00000000..06bcea14 --- /dev/null +++ b/cmd/api-linter/cli_v2.go @@ -0,0 +1,140 @@ +// Copyright 2026 The AEP Authors +// +// 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 main + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/bufbuild/protocompile" + "github.com/bufbuild/protocompile/linker" + "github.com/bufbuild/protocompile/reporter" + lint_v2 "github.com/aep-dev/api-linter/lint/v2" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +func (c *cli) runV2(rulesV2 lint_v2.RuleRegistry, configs lint_v2.Configs) ([]lint_v2.Response, error) { + // Create resolver for descriptor sets. + descResolver, err := loadFileDescriptorsAsResolver(c.ProtoDescPath...) + if err != nil { + return nil, err + } + + // Create resolver for source files. + imports := resolveImports(c.ProtoImportPaths) + sourceResolver := &protocompile.SourceResolver{ + ImportPaths: imports, + } + + resolvers := []protocompile.Resolver{sourceResolver} + if descResolver != nil { + resolvers = append(resolvers, descResolver) + } + + var collectedErrors []error + rep := reporter.NewReporter(func(err reporter.ErrorWithPos) error { + collectedErrors = append(collectedErrors, err) + return nil + }, nil) + + compiler := protocompile.Compiler{ + Resolver: protocompile.WithStandardImports(protocompile.CompositeResolver(resolvers)), + SourceInfoMode: protocompile.SourceInfoExtraOptionLocations, + Reporter: rep, + } + + var compiledFiles linker.Files + for _, protoFile := range c.ProtoFiles { + f, err := compiler.Compile(context.Background(), protoFile) + if len(collectedErrors) > 0 { + errStrings := make([]string, len(collectedErrors)) + for i, e := range collectedErrors { + errStrings[i] = e.Error() + } + return nil, errors.New(strings.Join(errStrings, "\n")) + } + if err != nil { + return nil, err + } + compiledFiles = append(compiledFiles, f...) + } + + var fileDescriptors []protoreflect.FileDescriptor + for _, f := range compiledFiles { + fileDescriptors = append(fileDescriptors, f) + } + + l := lint_v2.New(rulesV2, configs, lint_v2.Debug(c.DebugFlag), lint_v2.IgnoreCommentDisables(c.IgnoreCommentDisablesFlag)) + return l.LintProtos(fileDescriptors...) +} + +func resolveImports(imports []string) []string { + if len(imports) == 0 { + return []string{"."} + } + return imports +} + +type v2Resolver struct { + files *protoregistry.Files +} + +func (r *v2Resolver) FindFileByPath(path string) (protocompile.SearchResult, error) { + fd, err := r.files.FindFileByPath(path) + if err != nil { + return protocompile.SearchResult{}, err + } + return protocompile.SearchResult{Desc: fd}, nil +} + +func loadFileDescriptorsAsResolver(filePaths ...string) (protocompile.Resolver, error) { + if len(filePaths) == 0 { + return nil, nil + } + + fdsSet := make(map[string]*dpb.FileDescriptorProto) + for _, filePath := range filePaths { + in, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + fs := &dpb.FileDescriptorSet{} + if err := proto.Unmarshal(in, fs); err != nil { + return nil, err + } + for _, fd := range fs.GetFile() { + if _, exists := fdsSet[fd.GetName()]; !exists { + fdsSet[fd.GetName()] = fd + } + } + } + + fds := &dpb.FileDescriptorSet{} + for _, fd := range fdsSet { + fds.File = append(fds.File, fd) + } + files, err := protodesc.NewFiles(fds) + if err != nil { + return nil, fmt.Errorf("failed to create protoregistry.Files: %w", err) + } + return &v2Resolver{files: files}, nil +} diff --git a/cmd/api-linter/github_actions.go b/cmd/api-linter/github_actions.go index e8d7157b..ec07e53f 100644 --- a/cmd/api-linter/github_actions.go +++ b/cmd/api-linter/github_actions.go @@ -18,36 +18,34 @@ import ( "bytes" "fmt" "strings" - - "github.com/aep-dev/api-linter/lint" ) // formatGitHubActionOutput returns lint errors in GitHub actions format. -func formatGitHubActionOutput(responses []lint.Response) []byte { +func formatGitHubActionOutput(responses []combinedResponse) []byte { var buf bytes.Buffer for _, response := range responses { - for _, problem := range response.Problems { + for _, problem := range response.allProblems() { // lint example: // ::error file={name},line={line},endLine={endLine},title={title}::{message} // https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message fmt.Fprintf(&buf, "::error file=%s", response.FilePath) - if problem.Location != nil { + if problem.Span != nil { // Some findings are *line level* and only have start positions but no // starting column. Construct a switch fallthrough to emit as many of // the location indicators are included. - switch len(problem.Location.Span) { + switch len(problem.Span) { case 4: - fmt.Fprintf(&buf, ",endColumn=%d", problem.Location.Span[3]) + fmt.Fprintf(&buf, ",endColumn=%d", problem.Span[3]) fallthrough case 3: - fmt.Fprintf(&buf, ",endLine=%d", problem.Location.Span[2]) + fmt.Fprintf(&buf, ",endLine=%d", problem.Span[2]) fallthrough case 2: - fmt.Fprintf(&buf, ",col=%d", problem.Location.Span[1]) + fmt.Fprintf(&buf, ",col=%d", problem.Span[1]) fallthrough case 1: - fmt.Fprintf(&buf, ",line=%d", problem.Location.Span[0]) + fmt.Fprintf(&buf, ",line=%d", problem.Span[0]) } } @@ -55,9 +53,9 @@ func formatGitHubActionOutput(responses []lint.Response) []byte { // linter rules. In order to prevent confusion, replace the double colon // with two Armenian full stops which are indistinguishable to my eye. runeThatLooksLikeTwoColonsButIsActuallyTwoArmenianFullStops := "։։" - title := strings.ReplaceAll(string(problem.RuleID), "::", runeThatLooksLikeTwoColonsButIsActuallyTwoArmenianFullStops) + title := strings.ReplaceAll(problem.RuleID, "::", runeThatLooksLikeTwoColonsButIsActuallyTwoArmenianFullStops) message := strings.ReplaceAll(problem.Message, "\n", "\\n") - uri := problem.GetRuleURI() + uri := problem.RuleURI if uri != "" { message += "\\n\\n" + uri } diff --git a/cmd/api-linter/github_actions_test.go b/cmd/api-linter/github_actions_test.go index 2645d5f8..7f22a162 100644 --- a/cmd/api-linter/github_actions_test.go +++ b/cmd/api-linter/github_actions_test.go @@ -25,20 +25,20 @@ import ( func TestFormatGitHubActionOutput(t *testing.T) { tests := []struct { name string - data []lint.Response + data []combinedResponse want string }{ { name: "Empty input", - data: []lint.Response{}, + data: []combinedResponse{}, want: "", }, { name: "Example with partial location specifics", - data: []lint.Response{ + data: []combinedResponse{ { FilePath: "example.proto", - Problems: []lint.Problem{ + ProblemsV1: []lint.Problem{ { RuleID: "line::col::endLine::endColumn", Message: "line, column, endline, and endColumn", @@ -78,10 +78,10 @@ func TestFormatGitHubActionOutput(t *testing.T) { }, { name: "Example with location specifics", - data: []lint.Response{ + data: []combinedResponse{ { FilePath: "example.proto", - Problems: []lint.Problem{ + ProblemsV1: []lint.Problem{ { RuleID: "core::naming_formats::field_names", Location: &descriptorpb.SourceCodeInfo_Location{ @@ -104,30 +104,30 @@ func TestFormatGitHubActionOutput(t *testing.T) { }, { name: "Example with a couple of responses", - data: []lint.Response{ + data: []combinedResponse{ { FilePath: "example.proto", - Problems: []lint.Problem{ + ProblemsV1: []lint.Problem{ {RuleID: "core::naming_formats::field_names"}, {RuleID: "core::naming_formats::field_names"}, }, }, { FilePath: "example2.proto", - Problems: []lint.Problem{ + ProblemsV1: []lint.Problem{ {RuleID: "core::0131::request_message::name"}, {RuleID: "core::0132::response_message::name"}, }, }, { FilePath: "example3.proto", - Problems: []lint.Problem{ + ProblemsV1: []lint.Problem{ {RuleID: "core::naming_formats::field_names"}, }, }, { FilePath: "example4.proto", - Problems: []lint.Problem{ + ProblemsV1: []lint.Problem{ {RuleID: "core::naming_formats::field_names"}, {RuleID: "core::0132::response_message::name"}, }, diff --git a/cmd/api-linter/main.go b/cmd/api-linter/main.go index a5ba9859..7e722abd 100644 --- a/cmd/api-linter/main.go +++ b/cmd/api-linter/main.go @@ -21,18 +21,24 @@ import ( "os" "github.com/aep-dev/api-linter/lint" + lint_v2 "github.com/aep-dev/api-linter/lint/v2" "github.com/aep-dev/api-linter/rules" + rules_v2 "github.com/aep-dev/api-linter/rules/v2" ) var ( - globalRules = lint.NewRuleRegistry() + globalRulesV1 = lint.NewRuleRegistry() + globalRulesV2 = lint_v2.NewRuleRegistry() globalConfigs = defaultConfigs() ) func init() { - if err := rules.Add(globalRules); err != nil { + if err := rules.Add(globalRulesV1); err != nil { log.Fatalf("error when registering rules: %v", err) } + if err := rules_v2.Add(globalRulesV2); err != nil { + log.Fatalf("error when registering V2 rules: %v", err) + } } func main() { @@ -43,7 +49,7 @@ func main() { func runCLI(args []string) error { c := newCli(args) - return c.lint(globalRules, globalConfigs) + return c.lint(globalRulesV1, globalRulesV2, globalConfigs) } // Enable all rules by default. diff --git a/cmd/api-linter/rules.go b/cmd/api-linter/rules.go index 819be238..011de115 100644 --- a/cmd/api-linter/rules.go +++ b/cmd/api-linter/rules.go @@ -39,7 +39,7 @@ func (r listedRules) printSummaryTable() ([]byte, error) { func outputRules(formatType string) error { rules := listedRules{} - for id := range globalRules { + for id := range globalRulesV1 { rules = append(rules, listedRule{ Name: id, }) diff --git a/cmd/api-linter/summary.go b/cmd/api-linter/summary.go index 1827bd27..c022014e 100644 --- a/cmd/api-linter/summary.go +++ b/cmd/api-linter/summary.go @@ -19,12 +19,11 @@ import ( "fmt" "sort" - "github.com/aep-dev/api-linter/lint" "github.com/olekukonko/tablewriter" ) // printSummaryTable returns a summary table of violation counts. -func printSummaryTable(responses []lint.Response) ([]byte, error) { +func printSummaryTable(responses []combinedResponse) ([]byte, error) { s := createSummary(responses) data := []summary{} @@ -53,16 +52,14 @@ func printSummaryTable(responses []lint.Response) ([]byte, error) { return buf.Bytes(), nil } -func createSummary(responses []lint.Response) map[string]map[string]int { +func createSummary(responses []combinedResponse) map[string]map[string]int { summary := make(map[string]map[string]int) for _, r := range responses { - filePath := string(r.FilePath) - for _, p := range r.Problems { - ruleID := string(p.RuleID) - if summary[ruleID] == nil { - summary[ruleID] = make(map[string]int) + for _, p := range r.allProblems() { + if summary[p.RuleID] == nil { + summary[p.RuleID] = make(map[string]int) } - summary[ruleID][filePath]++ + summary[p.RuleID][r.FilePath]++ } } return summary diff --git a/cmd/api-linter/summary_test.go b/cmd/api-linter/summary_test.go index 052dd99c..ac325f67 100644 --- a/cmd/api-linter/summary_test.go +++ b/cmd/api-linter/summary_test.go @@ -24,38 +24,38 @@ import ( func TestCreateSummary(t *testing.T) { tests := []struct { name string - data []lint.Response + data []combinedResponse wantSummary map[string]map[string]int }{{ name: "Empty input", - data: []lint.Response{}, + data: []combinedResponse{}, wantSummary: make(map[string]map[string]int), }, { name: "Example with a couple of responses", - data: []lint.Response{ + data: []combinedResponse{ { FilePath: "example.proto", - Problems: []lint.Problem{ + ProblemsV1: []lint.Problem{ {RuleID: "core::naming_formats::field_names"}, {RuleID: "core::naming_formats::field_names"}, }, }, { FilePath: "example2.proto", - Problems: []lint.Problem{ + ProblemsV1: []lint.Problem{ {RuleID: "core::0131::request_message::name"}, {RuleID: "core::0132::response_message::name"}, }, }, { FilePath: "example3.proto", - Problems: []lint.Problem{ + ProblemsV1: []lint.Problem{ {RuleID: "core::naming_formats::field_names"}, }, }, { FilePath: "example4.proto", - Problems: []lint.Problem{ + ProblemsV1: []lint.Problem{ {RuleID: "core::naming_formats::field_names"}, {RuleID: "core::0132::response_message::name"}, }, diff --git a/cmd/buf-plugin-aep/main.go b/cmd/buf-plugin-aep/main.go index aa95ff31..7a6c279e 100644 --- a/cmd/buf-plugin-aep/main.go +++ b/cmd/buf-plugin-aep/main.go @@ -1,18 +1,15 @@ package main import ( - "context" - "errors" "fmt" "log" "strings" "buf.build/go/bufplugin/check" - "buf.build/go/bufplugin/descriptor" "github.com/aep-dev/api-linter/lint" + lint_v2 "github.com/aep-dev/api-linter/lint/v2" "github.com/aep-dev/api-linter/rules" - "github.com/jhump/protoreflect/desc" - "google.golang.org/protobuf/reflect/protoreflect" + rules_v2 "github.com/aep-dev/api-linter/rules/v2" ) const ( @@ -20,8 +17,6 @@ const ( aepCoreCategoryID = "AEP_CORE" ) -type fileDescriptorsContextKey struct{} - func main() { spec, err := newSpec() if err != nil { @@ -33,18 +28,35 @@ func main() { } func newSpec() (*check.Spec, error) { - ruleRegistry := lint.NewRuleRegistry() - if err := rules.Add(ruleRegistry); err != nil { + ruleRegistryV1 := lint.NewRuleRegistry() + if err := rules.Add(ruleRegistryV1); err != nil { + return nil, err + } + ruleRegistryV2 := lint_v2.NewRuleRegistry() + if err := rules_v2.Add(ruleRegistryV2); err != nil { return nil, err } - ruleSpecs := make([]*check.RuleSpec, 0, len(ruleRegistry)) - for _, protoRule := range ruleRegistry { - ruleSpec, err := newRuleSpec(protoRule) + + ruleSpecs := make([]*check.RuleSpec, 0, len(ruleRegistryV1)+len(ruleRegistryV2)) + + // Add V1 Rules + for _, protoRule := range ruleRegistryV1 { + ruleSpec, err := newRuleSpecV1(protoRule) + if err != nil { + return nil, err + } + ruleSpecs = append(ruleSpecs, ruleSpec) + } + + // Add V2 Rules + for _, protoRule := range ruleRegistryV2 { + ruleSpec, err := newRuleSpecV2(protoRule) if err != nil { return nil, err } ruleSpecs = append(ruleSpecs, ruleSpec) } + return &check.Spec{ Rules: ruleSpecs, Categories: []*check.CategorySpec{ @@ -61,13 +73,24 @@ func newSpec() (*check.Spec, error) { }, nil } -func newRuleSpec(protoRule lint.ProtoRule) (*check.RuleSpec, error) { +func newRuleSpecV1(protoRule lint.ProtoRule) (*check.RuleSpec, error) { ruleName := protoRule.GetName() if !ruleName.IsValid() { return nil, fmt.Errorf("lint.RuleName is invalid: %q", ruleName) } + return createRuleSpec(string(ruleName), fmt.Sprintf("Checks AEP rule %s.", ruleName), newRuleHandlerV1(protoRule)) +} - split := strings.Split(string(ruleName), "::") +func newRuleSpecV2(protoRule lint_v2.ProtoRule) (*check.RuleSpec, error) { + ruleName := protoRule.GetName() + if !ruleName.IsValid() { + return nil, fmt.Errorf("lint.RuleName is invalid: %q", ruleName) + } + return createRuleSpec(string(ruleName), fmt.Sprintf("Checks AEP rule %s.", ruleName), newRuleHandlerV2(protoRule)) +} + +func createRuleSpec(ruleName string, purpose string, handler check.RuleHandler) (*check.RuleSpec, error) { + split := strings.Split(ruleName, "::") if len(split) != 3 { return nil, fmt.Errorf("unknown lint.RuleName format, expected three parts split by '::' : %q", ruleName) } @@ -90,87 +113,8 @@ func newRuleSpec(protoRule lint.ProtoRule) (*check.RuleSpec, error) { ID: ruleID, CategoryIDs: categoryIDs, Default: true, - Purpose: fmt.Sprintf("Checks AEP rule %s.", ruleName), + Purpose: purpose, Type: check.RuleTypeLint, - Handler: newRuleHandler(protoRule), + Handler: handler, }, nil } - -func newRuleHandler(protoRule lint.ProtoRule) check.RuleHandler { - return check.RuleHandlerFunc( - func(ctx context.Context, responseWriter check.ResponseWriter, request check.Request) error { - fileDescriptors, _ := ctx.Value(fileDescriptorsContextKey{}).([]*desc.FileDescriptor) - for _, fileDescriptor := range fileDescriptors { - for _, problem := range protoRule.Lint(fileDescriptor) { - if err := addProblem(responseWriter, problem); err != nil { - return err - } - } - } - return nil - }, - ) -} - -func addProblem(responseWriter check.ResponseWriter, problem lint.Problem) error { - addAnnotationOptions := []check.AddAnnotationOption{ - check.WithMessage(problem.Message), - } - descriptor := problem.Descriptor - if descriptor == nil { - // This should never happen. - return errors.New("got nil problem.Descriptor") - } - fileDescriptor := descriptor.GetFile() - if fileDescriptor == nil { - // If we do not have a FileDescriptor, we cannot report a location. - responseWriter.AddAnnotation(addAnnotationOptions...) - return nil - } - // If a location is available from the problem, we use that directly. - if location := problem.Location; location != nil { - addAnnotationOptions = append( - addAnnotationOptions, - check.WithFileNameAndSourcePath( - fileDescriptor.GetName(), - protoreflect.SourcePath(location.GetPath()), - ), - ) - } else { - // Otherwise we check the source info for the descriptor from the problem. - if location := descriptor.GetSourceInfo(); location != nil { - addAnnotationOptions = append( - addAnnotationOptions, - check.WithFileNameAndSourcePath( - fileDescriptor.GetName(), - protoreflect.SourcePath(location.GetPath()), - ), - ) - } - } - responseWriter.AddAnnotation(addAnnotationOptions...) - return nil -} - -func before(ctx context.Context, request check.Request) (context.Context, check.Request, error) { - fileDescriptors, err := nonImportFileDescriptorsForFileDescriptors(request.FileDescriptors()) - if err != nil { - return nil, nil, err - } - ctx = context.WithValue(ctx, fileDescriptorsContextKey{}, fileDescriptors) - return ctx, request, nil -} - -func nonImportFileDescriptorsForFileDescriptors(fileDescriptors []descriptor.FileDescriptor) ([]*desc.FileDescriptor, error) { - if len(fileDescriptors) == 0 { - return nil, nil - } - reflectFileDescriptors := make([]protoreflect.FileDescriptor, 0, len(fileDescriptors)) - for _, fileDescriptor := range fileDescriptors { - if fileDescriptor.IsImport() { - continue - } - reflectFileDescriptors = append(reflectFileDescriptors, fileDescriptor.ProtoreflectFileDescriptor()) - } - return desc.WrapFiles(reflectFileDescriptors) -} diff --git a/cmd/buf-plugin-aep/v1.go b/cmd/buf-plugin-aep/v1.go new file mode 100644 index 00000000..2ab253f3 --- /dev/null +++ b/cmd/buf-plugin-aep/v1.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "errors" + + "buf.build/go/bufplugin/check" + "buf.build/go/bufplugin/descriptor" + "github.com/aep-dev/api-linter/lint" + "github.com/jhump/protoreflect/desc" + "google.golang.org/protobuf/reflect/protoreflect" +) + +type fileDescriptorsContextKey struct{} + +func newRuleHandlerV1(protoRule lint.ProtoRule) check.RuleHandler { + return check.RuleHandlerFunc( + func(ctx context.Context, responseWriter check.ResponseWriter, request check.Request) error { + fileDescriptors, _ := ctx.Value(fileDescriptorsContextKey{}).([]*desc.FileDescriptor) + for _, fileDescriptor := range fileDescriptors { + for _, problem := range protoRule.Lint(fileDescriptor) { + if err := addProblemV1(responseWriter, problem); err != nil { + return err + } + } + } + return nil + }, + ) +} + +func addProblemV1(responseWriter check.ResponseWriter, problem lint.Problem) error { + addAnnotationOptions := []check.AddAnnotationOption{ + check.WithMessage(problem.Message), + } + descriptor := problem.Descriptor + if descriptor == nil { + // This should never happen. + return errors.New("got nil problem.Descriptor") + } + fileDescriptor := descriptor.GetFile() + if fileDescriptor == nil { + // If we do not have a FileDescriptor, we cannot report a location. + responseWriter.AddAnnotation(addAnnotationOptions...) + return nil + } + // If a location is available from the problem, we use that directly. + if location := problem.Location; location != nil { + addAnnotationOptions = append( + addAnnotationOptions, + check.WithFileNameAndSourcePath( + fileDescriptor.GetName(), + protoreflect.SourcePath(location.GetPath()), + ), + ) + } else { + // Otherwise we check the source info for the descriptor from the problem. + if location := descriptor.GetSourceInfo(); location != nil { + addAnnotationOptions = append( + addAnnotationOptions, + check.WithFileNameAndSourcePath( + fileDescriptor.GetName(), + protoreflect.SourcePath(location.GetPath()), + ), + ) + } + } + responseWriter.AddAnnotation(addAnnotationOptions...) + return nil +} + +func before(ctx context.Context, request check.Request) (context.Context, check.Request, error) { + fileDescriptors, err := nonImportFileDescriptorsForFileDescriptors(request.FileDescriptors()) + if err != nil { + return nil, nil, err + } + ctx = context.WithValue(ctx, fileDescriptorsContextKey{}, fileDescriptors) + return ctx, request, nil +} + +func nonImportFileDescriptorsForFileDescriptors(fileDescriptors []descriptor.FileDescriptor) ([]*desc.FileDescriptor, error) { + if len(fileDescriptors) == 0 { + return nil, nil + } + reflectFileDescriptors := make([]protoreflect.FileDescriptor, 0, len(fileDescriptors)) + for _, fileDescriptor := range fileDescriptors { + if fileDescriptor.IsImport() { + continue + } + reflectFileDescriptors = append(reflectFileDescriptors, fileDescriptor.ProtoreflectFileDescriptor()) + } + return desc.WrapFiles(reflectFileDescriptors) +} diff --git a/cmd/buf-plugin-aep/v2.go b/cmd/buf-plugin-aep/v2.go new file mode 100644 index 00000000..3dbfa830 --- /dev/null +++ b/cmd/buf-plugin-aep/v2.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "errors" + + "buf.build/go/bufplugin/check" + lint_v2 "github.com/aep-dev/api-linter/lint/v2" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func newRuleHandlerV2(protoRule lint_v2.ProtoRule) check.RuleHandler { + return check.RuleHandlerFunc( + func(ctx context.Context, responseWriter check.ResponseWriter, request check.Request) error { + for _, fileDescriptor := range request.FileDescriptors() { + if fileDescriptor.IsImport() { + continue + } + // bufplugin provides protoreflect descriptors natively! + for _, problem := range protoRule.Lint(fileDescriptor.ProtoreflectFileDescriptor()) { + if err := addProblemV2(responseWriter, problem); err != nil { + return err + } + } + } + return nil + }, + ) +} + +func addProblemV2(responseWriter check.ResponseWriter, problem lint_v2.Problem) error { + addAnnotationOptions := []check.AddAnnotationOption{ + check.WithMessage(problem.Message), + } + descriptor := problem.Descriptor + if descriptor == nil { + return errors.New("got nil problem.Descriptor") + } + fileDescriptor := descriptor.ParentFile() + if fileDescriptor == nil { + responseWriter.AddAnnotation(addAnnotationOptions...) + return nil + } + + if location := problem.Location; location != nil { + addAnnotationOptions = append( + addAnnotationOptions, + check.WithFileNameAndSourcePath( + fileDescriptor.Path(), + protoreflect.SourcePath(location.GetPath()), + ), + ) + } else { + // Use V2 SourceLocations + loc := fileDescriptor.SourceLocations().ByDescriptor(descriptor) + addAnnotationOptions = append( + addAnnotationOptions, + check.WithFileNameAndSourcePath( + fileDescriptor.Path(), + protoreflect.SourcePath(loc.Path), + ), + ) + } + responseWriter.AddAnnotation(addAnnotationOptions...) + return nil +} diff --git a/go.mod b/go.mod index b8c72413..a347dadd 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,10 @@ require ( bitbucket.org/creachadair/stringset v0.0.14 buf.build/gen/go/aep/api/protocolbuffers/go v1.36.10-20251109183837-26a011a354ee.1 buf.build/go/bufplugin v0.9.0 + cloud.google.com/go/iam v1.5.3 cloud.google.com/go/longrunning v0.7.0 github.com/bmatcuk/doublestar/v4 v4.9.1 + github.com/bufbuild/protocompile v0.14.1 github.com/gertd/go-pluralize v0.2.1 github.com/google/go-cmp v0.7.0 github.com/jhump/protoreflect v1.17.0 @@ -19,6 +21,7 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -29,7 +32,6 @@ require ( buf.build/go/spdx v0.2.0 // indirect cel.dev/expr v0.24.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/bufbuild/protocompile v0.14.1 // indirect github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/cel-go v0.26.1 // indirect diff --git a/go.sum b/go.sum index 87eac563..4d8049b0 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,5 @@ bitbucket.org/creachadair/stringset v0.0.14 h1:t1ejQyf8utS4GZV/4fM+1gvYucggZkfhb+tMobDxYOE= bitbucket.org/creachadair/stringset v0.0.14/go.mod h1:Ej8fsr6rQvmeMDf6CCWMWGb14H9mz8kmDgPPTdiVT0w= -buf.build/gen/go/aep/api/protocolbuffers/go v1.36.10-20251016045117-f9844266f27f.1 h1:jK64CGldQTGX2ESi+v41uERp+z28OFqGyAn/T/Qs8Q0= -buf.build/gen/go/aep/api/protocolbuffers/go v1.36.10-20251016045117-f9844266f27f.1/go.mod h1:JOZpZ+zS3G2OKO02hKlEazAGR5X9uVpFyeCamXBXg5c= -buf.build/gen/go/aep/api/protocolbuffers/go v1.36.10-20251102152130-5f3e69139afa.1 h1:SEKgDODwWdsVL9nnOPkFBlZ9wAuO7kRQNobSZb5JR7I= -buf.build/gen/go/aep/api/protocolbuffers/go v1.36.10-20251102152130-5f3e69139afa.1/go.mod h1:JOZpZ+zS3G2OKO02hKlEazAGR5X9uVpFyeCamXBXg5c= buf.build/gen/go/aep/api/protocolbuffers/go v1.36.10-20251109183837-26a011a354ee.1 h1:hvHgJH3scOHSRm9nC9JOdesPF9yc7Fl281TNQe60SL0= buf.build/gen/go/aep/api/protocolbuffers/go v1.36.10-20251109183837-26a011a354ee.1/go.mod h1:JOZpZ+zS3G2OKO02hKlEazAGR5X9uVpFyeCamXBXg5c= buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.10-20250718181942-e35f9b667443.1 h1:FzJGrb8r7vir+P3zJ5Ebey8p54LYTYtQsrM/U35YO9Q= @@ -20,6 +16,8 @@ buf.build/go/spdx v0.2.0 h1:IItqM0/cMxvFJJumcBuP8NrsIzMs/UYjp/6WSpq8LTw= buf.build/go/spdx v0.2.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8= cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E= cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= diff --git a/internal/v2/version.go b/internal/v2/version.go new file mode 100644 index 00000000..fd367f33 --- /dev/null +++ b/internal/v2/version.go @@ -0,0 +1,17 @@ +// 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 internal + +// Version is the current tagged release of the library. +const Version = "2.2.0" diff --git a/lint/v2/config.go b/lint/v2/config.go new file mode 100644 index 00000000..9838fccb --- /dev/null +++ b/lint/v2/config.go @@ -0,0 +1,164 @@ +// Copyright 2019 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 lint + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" + "gopkg.in/yaml.v3" +) + +// Configs determine if a rule is enabled or not on a file path. +type Configs []Config + +// Config stores rule configurations for certain files +// that the file path must match any of the included paths +// but none of the excluded ones. +type Config struct { + // Explicitly specify the input file paths in scope of this config. + // If omitted, it applies to all input file paths. + IncludedPaths []string `json:"included_paths" yaml:"included_paths"` + + // Explicitly specify the input files paths to exclude from using this + // config. If omitted, none of the input file paths are excluded. + ExcludedPaths []string `json:"excluded_paths" yaml:"excluded_paths"` + + // The fully-qualifed rule name of a rule to enable as part of this config. + // Can be one of the following formats: + // + // - an individual rule: `core::0203::field-behavior-required` + // - an entire AIP rule group: `core::0203` + // - an entire AIP category: `core` + // - all rules: `all` + EnabledRules []string `json:"enabled_rules" yaml:"enabled_rules"` + + // The fully-qualifed rule name of a rule to disable as part of this config. + // Can be one of the following formats: + // + // - an individual rule: `core::0203::field-behavior-required` + // - an entire AIP rule group: `core::0203` + // - an entire AIP category: `core` + // - all rules: `all` + DisabledRules []string `json:"disabled_rules" yaml:"disabled_rules"` +} + +// ReadConfigsFromFile reads Configs from a file. +// It supports JSON(.json) and YAML(.yaml or .yml) files. +func ReadConfigsFromFile(path string) (Configs, error) { + var parse func(io.Reader) (Configs, error) + switch filepath.Ext(path) { + case ".json": + parse = ReadConfigsJSON + case ".yaml", ".yml": + parse = ReadConfigsYAML + } + if parse == nil { + return nil, fmt.Errorf("reading Configs: unsupported format `%q` with file path `%q`", filepath.Ext(path), path) + } + + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("readConfig: %s", err.Error()) + } + defer f.Close() + + return parse(f) +} + +// ReadConfigsJSON reads Configs from a JSON file. +func ReadConfigsJSON(f io.Reader) (Configs, error) { + b, err := io.ReadAll(f) + if err != nil { + return nil, err + } + var c Configs + if err := json.Unmarshal(b, &c); err != nil { + return nil, err + } + return c, nil +} + +// ReadConfigsYAML reads Configs from a YAML(.yml or .yaml) file. +func ReadConfigsYAML(f io.Reader) (Configs, error) { + b, err := io.ReadAll(f) + if err != nil { + return nil, err + } + var c Configs + if err := yaml.Unmarshal(b, &c); err != nil { + return nil, err + } + return c, nil +} + +// IsRuleEnabled returns true if a rule is enabled by the configs. +func (configs Configs) IsRuleEnabled(rule string, path string) bool { + // Enabled by default if the rule does not belong to one of the default + // disabled groups. Otherwise, needs to be explicitly enabled. + enabled := !matchRule(rule, defaultDisabledRules...) + for _, c := range configs { + if c.matchPath(path) { + if matchRule(rule, c.DisabledRules...) { + enabled = false + } + if matchRule(rule, c.EnabledRules...) { + enabled = true + } + } + } + + return enabled +} + +func (c Config) matchPath(path string) bool { + if matchPath(path, c.ExcludedPaths...) { + return false + } + return len(c.IncludedPaths) == 0 || matchPath(path, c.IncludedPaths...) +} + +func matchPath(path string, pathPatterns ...string) bool { + path = filepath.ToSlash(path) + for _, pattern := range pathPatterns { + pattern = filepath.ToSlash(pattern) + if matched, _ := doublestar.Match(pattern, path); matched { + return true + } + } + return false +} + +func matchRule(rule string, rulePrefixes ...string) bool { + rule = strings.ToLower(rule) + for _, prefix := range rulePrefixes { + prefix = strings.ToLower(prefix) + prefix = strings.TrimSuffix(prefix, nameSeparator) // "core::" -> "core" + prefix = strings.TrimPrefix(prefix, nameSeparator) // "::http-body" -> "http-body" + if prefix == "all" || + prefix == rule || + strings.HasPrefix(rule, prefix+nameSeparator) || // e.g., "core" matches "core::http-body", but not "core-rules::http-body" + strings.HasSuffix(rule, nameSeparator+prefix) || // e.g., "http-body" matches "core::http-body", but not "core::google-http-body" + strings.Contains(rule, nameSeparator+prefix+nameSeparator) { // e.g., "http-body" matches "core::http-body::post", but not "core::google-http-body::post" + return true + } + } + return false +} diff --git a/lint/v2/config_test.go b/lint/v2/config_test.go new file mode 100644 index 00000000..a85630fa --- /dev/null +++ b/lint/v2/config_test.go @@ -0,0 +1,483 @@ +// Copyright 2019 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 lint + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestRuleConfigs_IsRuleEnabled(t *testing.T) { + enabled := true + disabled := false + + tests := []struct { + name string + configs Configs + path string + rule string + want bool + }{ + {"EmptyConfig", nil, "a", "b", enabled}, + { + "NoConfigMatched_Enabled", + Configs{ + { + IncludedPaths: []string{"a.proto"}, + DisabledRules: []string{"testrule"}, + }, + }, + "b.proto", + "testrule", + enabled, + }, + { + "PathMatched_DisabledRulesNotMatch_Enabled", + Configs{ + { + IncludedPaths: []string{"a.proto"}, + DisabledRules: []string{"somerule"}, + }, + }, + "a.proto", + "testrule", + enabled, + }, + { + "PathExactMatched_DisabledRulesMatched_Disabled", + Configs{ + { + IncludedPaths: []string{"a.proto"}, + DisabledRules: []string{"somerule", "testrule"}, + }, + }, + "a.proto", + "testrule", + disabled, + }, + { + "PathExactMatched_DisabledRulesMatchedAll_Disabled", + Configs{ + { + IncludedPaths: []string{"a.proto"}, + DisabledRules: []string{"all"}, + }, + }, + "a.proto", + "testrule", + disabled, + }, + { + "PathDoubleStartMatched_DisabledRulesMatched_Disabled", + Configs{ + { + IncludedPaths: []string{"a/**/*.proto"}, + DisabledRules: []string{"somerule", "testrule"}, + }, + }, + "a/with/long/sub/dir/etc/ory/e.proto", + "testrule", + disabled, + }, + { + "PathMatched_DisabledRulesPrefixMatched_Disabled", + Configs{ + { + IncludedPaths: []string{"a/b/c.proto"}, + DisabledRules: []string{"parent"}, + }, + }, + "a/b/c.proto", + "parent::test_rule", + disabled, + }, + { + "PathMatched_DisabledRulesSuffixMatched_Disabled", + Configs{ + { + IncludedPaths: []string{"a/b/c.proto"}, + DisabledRules: []string{"child"}, + }, + }, + "a/b/c.proto", + "parent::child", + disabled, + }, + { + "PathMatched_DisabledRulesMiddleMatched_Disabled", + Configs{ + { + IncludedPaths: []string{"a/b/c.proto"}, + DisabledRules: []string{"middle"}, + }, + }, + "a/b/c.proto", + "parent::middle::child", + disabled, + }, + { + "EmptyIncludePath_ConfigMatched_DisabledRulesMatched_Disabled", + Configs{ + { + DisabledRules: []string{"testrule"}, + }, + }, + "a.proto", + "testrule", + disabled, + }, + { + "ExcludedPathMatch_ConfigNotMatched_DisabledRulesMatched_Enabled", + Configs{ + { + ExcludedPaths: []string{"a.proto"}, + DisabledRules: []string{"testrule"}, + }, + }, + "a.proto", + "testrule", + enabled, + }, + { + "TwoConfigs_Override_Enabled", + Configs{ + { + DisabledRules: []string{"testrule"}, + }, + { + EnabledRules: []string{"testrule::a"}, + }, + }, + "a.proto", + "testrule::a", + enabled, + }, + { + "TwoConfigs_Override_Disabled", + Configs{ + { + EnabledRules: []string{"testrule"}, + }, + { + DisabledRules: []string{"testrule::a"}, + }, + }, + "a.proto", + "testrule::a", + disabled, + }, + { + "TwoConfigs_DoubleEnable_Enabled", + Configs{ + { + EnabledRules: []string{"testrule"}, + }, + { + EnabledRules: []string{"testrule::a"}, + }, + }, + "a.proto", + "testrule::a", + enabled, + }, + { + "TwoConfigs_DoubleDisabled_Disabled", + Configs{ + { + DisabledRules: []string{"testrule"}, + }, + { + DisabledRules: []string{"testrule::a"}, + }, + }, + "a.proto", + "testrule::a", + disabled, + }, + { + "NoConfigMatched_DefaultDisabled", + Configs{ + { + IncludedPaths: []string{"a.proto"}, + DisabledRules: []string{"testrule"}, + }, + }, + "b.proto", + "cloud::25164::generic-fields", + disabled, + }, + { + "ConfigMatched_DefaultDisabled_Enabled", + Configs{ + { + IncludedPaths: []string{"a.proto"}, + EnabledRules: []string{"cloud"}, + }, + }, + "a.proto", + "cloud::25164::generic-fields", + enabled, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := test.configs.IsRuleEnabled(test.rule, test.path) + + if got != test.want { + t.Errorf("IsRuleEnabled: got %t, but want %t", got, test.want) + } + }) + } +} + +type errReader int + +func (errReader) Read(p []byte) (int, error) { + return 0, errors.New("test error") +} + +func TestReadConfigsJSONReaderError(t *testing.T) { + if _, err := ReadConfigsJSON(errReader(0)); err == nil { + t.Error("ReadConfigsJSON expects an error") + } +} + +func TestReadConfigsJSONFormatError(t *testing.T) { + invalidJSON := ` + [ + { + "included_paths": ["path_a"], + "excluded_paths": ["path_b"], + "disabled_rules": ["rule_a", "rule_b"], + "enabled_rules": ["rule_c", "rule_d"] + } + ` + + if _, err := ReadConfigsJSON(strings.NewReader(invalidJSON)); err == nil { + t.Error("ReadConfigsJSON expects an error") + } +} + +func TestReadConfigsJSON(t *testing.T) { + content := ` + [ + { + "included_paths": ["path_a"], + "excluded_paths": ["path_b"], + "disabled_rules": ["rule_a", "rule_b"], + "enabled_rules": ["rule_c", "rule_d"] + } + ] + ` + + configs, err := ReadConfigsJSON(strings.NewReader(content)) + if err != nil { + t.Errorf("ReadConfigsJSON returns error: %v", err) + } + + expected := Configs{ + { + IncludedPaths: []string{"path_a"}, + ExcludedPaths: []string{"path_b"}, + DisabledRules: []string{"rule_a", "rule_b"}, + EnabledRules: []string{"rule_c", "rule_d"}, + }, + } + if !reflect.DeepEqual(configs, expected) { + t.Errorf("ReadConfigsJSON returns %v, but want %v", configs, expected) + } +} + +func TestReadConfigsYAMLReaderError(t *testing.T) { + if _, err := ReadConfigsYAML(errReader(0)); err == nil { + t.Error("ReadConfigsYAML expects an error") + } +} + +func TestReadConfigsYAMLFormatError(t *testing.T) { + invalidYAML := ` + [ + { + "included_paths": ["path_a"], + "excluded_paths": ["path_b"], + "disabled_rules": ["rule_a", "rule_b"], + "enabled_rules": ["rule_c", "rule_d"] + } + ` + + if _, err := ReadConfigsYAML(strings.NewReader(invalidYAML)); err == nil { + t.Error("ReadConfigsYAML expects an error") + } +} + +func TestReadConfigsYAML(t *testing.T) { + content := ` +--- +- included_paths: + - 'path_a' + excluded_paths: + - 'path_b' + disabled_rules: + - 'rule_a' + - 'rule_b' + enabled_rules: + - 'rule_c' + - 'rule_d' +` + + configs, err := ReadConfigsYAML(strings.NewReader(content)) + if err != nil { + t.Errorf("ReadConfigsYAML returns error: %v", err) + } + + expected := Configs{ + { + IncludedPaths: []string{"path_a"}, + ExcludedPaths: []string{"path_b"}, + DisabledRules: []string{"rule_a", "rule_b"}, + EnabledRules: []string{"rule_c", "rule_d"}, + }, + } + if !reflect.DeepEqual(configs, expected) { + t.Errorf("ReadConfigsYAML returns %v, but want %v", configs, expected) + } +} + +func TestReadConfigsFromFile(t *testing.T) { + expectedConfigs := Configs{ + { + IncludedPaths: []string{"path_a"}, + ExcludedPaths: []string{"path_b"}, + DisabledRules: []string{"rule_a", "rule_b"}, + EnabledRules: []string{"rule_c", "rule_d"}, + }, + } + + jsonConfigsText := ` + [ + { + "included_paths": ["path_a"], + "excluded_paths": ["path_b"], + "disabled_rules": ["rule_a", "rule_b"], + "enabled_rules": ["rule_c", "rule_d"] + } + ] + ` + jsonConfigsFile := createTempFile(t, "test.json", jsonConfigsText) + defer os.Remove(jsonConfigsFile) + + yamlConfigsText := ` +--- +- included_paths: + - 'path_a' + excluded_paths: + - 'path_b' + disabled_rules: + - 'rule_a' + - 'rule_b' + enabled_rules: + - 'rule_c' + - 'rule_d' +` + yamlConfigsFile := createTempFile(t, "test.yaml", yamlConfigsText) + defer os.Remove(yamlConfigsFile) + + tests := []struct { + name string + filePath string + configs Configs + hasErr bool + }{ + { + "JSON file", + jsonConfigsFile, + expectedConfigs, + false, + }, + { + "YAML file", + yamlConfigsFile, + expectedConfigs, + false, + }, + { + "Invalid file extension", + "test.abc", + nil, + true, + }, + { + "File not existed", + "not-existed-file.json", + nil, + true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + configs, err := ReadConfigsFromFile(test.filePath) + if (err != nil) != test.hasErr { + t.Errorf("ReadConfigsFromFile got error %v, but want %v", err, test.hasErr) + } + if err != nil { + if !reflect.DeepEqual(configs, test.configs) { + t.Errorf("ReadConfigsFromFile got configs %v, but want %v", configs, test.configs) + } + } + }) + } +} + +func TestMatch(t *testing.T) { + unixTests := []struct { + name string + path string + patterns []string + want bool + }{ + {"single wildcard", "a/foo.proto", []string{"a/?.proto"}, false}, + {"single wildcard match", "a/b.proto", []string{"a/?.proto"}, true}, + {"double wildcard deep", "a/b/c/d.proto", []string{"a/**/*.proto"}, true}, + {"no match", "x/y.proto", []string{"a/**/*.proto"}, false}, + {"multiple patterns, one match", "a/b.proto", []string{"c/*.proto", "a/*.proto"}, true}, + {"pattern with leading slash", "/a/b.proto", []string{"/a/**/*.proto"}, true}, + {"no specific file extension", "a/b", []string{"a/**"}, true}, + {"pattern with directory name", "a/b/c.proto", []string{"a/b/*.proto"}, true}, + } + + for _, test := range unixTests { + t.Run(test.name, func(t *testing.T) { + if got := matchPath(test.path, test.patterns...); got != test.want { + t.Errorf("matchPath(%q, %q) = %v, want %v", test.path, test.patterns, got, test.want) + } + }) + } +} + +func createTempFile(t *testing.T, name, content string) string { + dir, err := os.MkdirTemp("", "config_tests") + if err != nil { + t.Fatal(err) + } + filePath := filepath.Join(dir, name) + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return filePath +} diff --git a/lint/v2/lint.go b/lint/v2/lint.go new file mode 100644 index 00000000..7b4ad77f --- /dev/null +++ b/lint/v2/lint.go @@ -0,0 +1,136 @@ +// Copyright 2019 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 lint provides lint functions for Google APIs that register rules and user configurations, +// apply those rules to a lint request, and produce lint results. +package lint + +import ( + "errors" + "fmt" + "runtime/debug" + "strings" + + "google.golang.org/protobuf/reflect/protoreflect" +) + +// Linter checks API files and returns a list of detected problems. +type Linter struct { + rules RuleRegistry + configs Configs + debug bool + ignoreCommentDisables bool +} + +// LinterOption prvoides the ability to configure the Linter. +type LinterOption func(l *Linter) + +// Debug is a LinterOption for setting if debug mode is on. +func Debug(debug bool) LinterOption { + return func(l *Linter) { + l.debug = debug + } +} + +// IgnoreCommentDisables sets the flag for ignoring comments which disable rules. +func IgnoreCommentDisables(ignoreCommentDisables bool) LinterOption { + return func(l *Linter) { + l.ignoreCommentDisables = ignoreCommentDisables + } +} + +// New creates and returns a linter with the given rules and configs. +func New(rules RuleRegistry, configs Configs, opts ...LinterOption) *Linter { + l := &Linter{ + rules: rules, + configs: configs, + } + + for _, opt := range opts { + opt(l) + } + + return l +} + +// LintProtos checks protobuf files and returns a list of problems or an error. +func (l *Linter) LintProtos(files ...protoreflect.FileDescriptor) ([]Response, error) { + var responses []Response + for _, proto := range files { + resp, err := l.lintFileDescriptor(proto) + if err != nil { + return nil, err + } + responses = append(responses, resp) + } + return responses, nil +} + +// run executes rules on the request. +// +// It uses the proto file path to determine which rules will +// be applied to the request, according to the list of Linter +// configs. +func (l *Linter) lintFileDescriptor(fd protoreflect.FileDescriptor) (Response, error) { + resp := Response{ + FilePath: fd.Path(), + Problems: []Problem{}, + } + var errMessages []string + + for name, rule := range l.rules { + // Run the linter rule against this file, and throw away any problems + // which should have been disabled. + if l.configs.IsRuleEnabled(string(name), fd.Path()) { + if problems, err := l.runAndRecoverFromPanics(rule, fd); err == nil { + for _, p := range problems { + if p.Descriptor == nil { + errMessages = append(errMessages, fmt.Sprintf("rule %q missing required Descriptor in returned Problem", rule.GetName())) + continue + } + if ruleIsEnabled(rule, p.Descriptor, p.Location, aliasMap, l.ignoreCommentDisables) { + p.RuleID = rule.GetName() + resp.Problems = append(resp.Problems, p) + } + } + } else { + errMessages = append(errMessages, err.Error()) + } + } + } + + var err error + if len(errMessages) != 0 { + err = errors.New(strings.Join(errMessages, "; ")) + } + + return resp, err +} + +func (l *Linter) runAndRecoverFromPanics(rule ProtoRule, fd protoreflect.FileDescriptor) (probs []Problem, err error) { + defer func() { + if r := recover(); r != nil { + if l.debug { + debug.PrintStack() + } + if rerr, ok := r.(error); ok { + err = rerr + } else { + err = fmt.Errorf("panic occurred during rule execution: %v", r) + } + } + }() + + return rule.Lint(fd), nil +} diff --git a/lint/v2/lint_test.go b/lint/v2/lint_test.go new file mode 100644 index 00000000..2333a6d3 --- /dev/null +++ b/lint/v2/lint_test.go @@ -0,0 +1,211 @@ +// Copyright 2019 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 lint + +import ( + "fmt" + "reflect" + "strings" + "testing" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" +) + +func TestLinter_run(t *testing.T) { + fd, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("protofile.proto"), + }, nil) + if err != nil { + t.Fatalf("Failed to build a file descriptor.") + } + defaultConfigs := Configs{} + + testRuleName := NewRuleName(111, "test-rule") + ruleProblems := []Problem{{ + Message: "rule1_problem", + Descriptor: fd, + category: "", + RuleID: testRuleName, + }} + + tests := []struct { + testName string + configs Configs + problems []Problem + }{ + {"Empty", Configs{}, []Problem{}}, + { + "NonMatchingFile", + append( + defaultConfigs, + Config{ + IncludedPaths: []string{"nofile"}, + }, + ), + ruleProblems, + }, + { + "NonMatchingRule", + append( + defaultConfigs, + Config{ + DisabledRules: []string{"foo::bar"}, + }, + ), + ruleProblems, + }, + { + "DisabledRule", + append( + defaultConfigs, + Config{ + DisabledRules: []string{string(testRuleName)}, + }, + ), + []Problem{}, + }, + } + + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + rules := NewRuleRegistry() + err := rules.Register(111, &FileRule{ + Name: NewRuleName(111, "test-rule"), + LintFile: func(f protoreflect.FileDescriptor) []Problem { + return test.problems + }, + }) + if err != nil { + t.Fatal(err) + } + l := New(rules, test.configs) + + // Actually run the linter. + resp, _ := l.lintFileDescriptor(fd) + + // Assert that we got the problems we expected. + if !reflect.DeepEqual(resp.Problems, test.problems) { + t.Errorf("Expected %v, got %v.", resp.Problems, test.problems) + } + }) + } +} + +func TestLinter_LintProtos_RulePanics(t *testing.T) { + fd, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + }, nil) + if err != nil { + t.Fatalf("Failed to build the file descriptor.") + } + testAIP := 111 + + tests := []struct { + testName string + rule ProtoRule + }{ + { + testName: "Panic", + rule: &FileRule{ + Name: NewRuleName(testAIP, "panic"), + LintFile: func(_ protoreflect.FileDescriptor) []Problem { + panic("panic") + }, + }, + }, + { + testName: "PanicError", + rule: &FileRule{ + Name: NewRuleName(testAIP, "panic-error"), + LintFile: func(_ protoreflect.FileDescriptor) []Problem { + panic(fmt.Errorf("panic")) + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + rules := NewRuleRegistry() + err := rules.Register(testAIP, test.rule) + if err != nil { + t.Fatalf("Failed to create Rules: %q", err) + } + + // Instantiate a linter with the given rule. + l := New(rules, nil) + + _, err = l.LintProtos(fd) + if err == nil || !strings.Contains(err.Error(), "panic") { + t.Fatalf("Expected error with panic, got %q", err) + } + }) + } +} + +func TestLinter_debug(t *testing.T) { + tests := []struct { + name string + debug bool + }{ + { + name: "debug", + debug: true, + }, + { + name: "do not debug", + debug: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + l := New(NewRuleRegistry(), nil, Debug(test.debug)) + + if a, e := l.debug, test.debug; a != e { + t.Errorf("got debug %v wanted debug %v", a, e) + } + }) + } +} + +func TestLinter_IgnoreCommentDisables(t *testing.T) { + tests := []struct { + name string + ignoreCommentDisables bool + }{ + { + name: "ignoreCommentDisables", + ignoreCommentDisables: true, + }, + { + name: "do not ignoreCommentDisables", + ignoreCommentDisables: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + l := New(NewRuleRegistry(), nil, IgnoreCommentDisables(test.ignoreCommentDisables)) + + if a, e := l.ignoreCommentDisables, test.ignoreCommentDisables; a != e { + t.Errorf("got ignoreCommentDisables %v wanted ignoreCommentDisables %v", a, e) + } + }) + } +} diff --git a/lint/v2/problem.go b/lint/v2/problem.go new file mode 100644 index 00000000..ea7d24b2 --- /dev/null +++ b/lint/v2/problem.go @@ -0,0 +1,189 @@ +// Copyright 2019 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 lint + +import ( + "encoding/json" + + "google.golang.org/protobuf/reflect/protoreflect" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +// Problem contains information about a result produced by an API Linter. +// +// All rules return []Problem. Most lint rules return 0 or 1 problems, but +// occasionally there are rules that may return more than one. +type Problem struct { + // Message provides a short description of the problem. + // This should be no more than a single sentence. + Message string + + // Suggestion provides a suggested fix, if applicable. + // + // This integrates with certain IDEs to provide "push-button" fixes, + // so these need to be machine-readable, not just human-readable. + // Additionally, when setting `Suggestion`, one should almost always set + // `Location` also, to ensure that the text being replaced is sufficiently + // precise. + Suggestion string + + // Descriptor provides the descriptor related to the problem. This must be + // set on every Problem. + // + // If `Location` is not specified, then the starting location of + // the descriptor is used as the location of the problem. + Descriptor protoreflect.Descriptor + + // Location provides the location of the problem. + // + // If unset, the location of the descriptor is used. + // This should almost always be set if `Suggestion` is set. The best way to + // do this is by using the helper methods in `location.go`. + Location *dpb.SourceCodeInfo_Location + + // RuleID provides the ID of the rule that this problem belongs to. + // DO NOT SET: The linter sets this automatically. + RuleID RuleName // FIXME: Make this private (cmd/summary_cli.go is the challenge). + + // The category for this problem, based on user configuration. + category string + + //nolint:unused // field is required to prevent positional parameters + noPositional struct{} +} + +// MarshalJSON defines how to represent a Problem in JSON. +func (p Problem) MarshalJSON() ([]byte, error) { + return json.Marshal(p.marshal()) +} + +// MarshalYAML defines how to represent a Problem in YAML. +func (p Problem) MarshalYAML() (interface{}, error) { + return p.marshal(), nil +} + +// Marshal defines how to represent a serialized Problem. +func (p Problem) marshal() interface{} { + var fl fileLocation + if p.Location != nil { + // If Location is set, use it. + fl = fileLocationFromPBLocation(p.Location, p.Descriptor) + } else if p.Descriptor != nil { + // Otherwise, use the descriptor's location. + // This is the protobuf-go idiomatic way to get the source location. + // Note: ParentFile() called on a FileDescriptor returns itself. + loc := p.Descriptor.ParentFile().SourceLocations().ByDescriptor(p.Descriptor) + fl = fileLocation{ + Path: p.Descriptor.ParentFile().Path(), + Start: position{ + Line: loc.StartLine + 1, + Column: loc.StartColumn + 1, + }, + End: position{ + Line: loc.EndLine + 1, + Column: loc.EndColumn, + }, + } + } else { + // Default location if no descriptor. + fl = fileLocationFromPBLocation(nil, nil) + } + + // Return a marshal-able structure. + return struct { + Message string `json:"message" yaml:"message"` + Suggestion string `json:"suggestion,omitempty" yaml:"suggestion,omitempty"` + Location fileLocation `json:"location" yaml:"location"` + RuleID RuleName `json:"rule_id" yaml:"rule_id"` + RuleDocURI string `json:"rule_doc_uri" yaml:"rule_doc_uri"` + Category string `json:"category,omitempty" yaml:"category,omitempty"` + }{ + p.Message, + p.Suggestion, + fl, + p.RuleID, + p.GetRuleURI(), + p.category, + } +} + +// GetRuleURI returns a URI to learn more about the problem. +func (p Problem) GetRuleURI() string { + return getRuleURL(string(p.RuleID), ruleURLMappings) +} + +// position describes a one-based position in a source code file. +// They are one-indexed, as a human counts lines or columns. +type position struct { + Line int `json:"line_number" yaml:"line_number"` + Column int `json:"column_number" yaml:"column_number"` +} + +// fileLocation describes a location in a source code file. +// +// Note: Positions are one-indexed, as a human counts lines or columns +// in a file. +type fileLocation struct { + Start position `json:"start_position" yaml:"start_position"` + End position `json:"end_position" yaml:"end_position"` + Path string `json:"path" yaml:"path"` +} + +// fileLocationFromPBLocation returns a new fileLocation object based on a +// protocol buffer SourceCodeInfo_Location +func fileLocationFromPBLocation(l *dpb.SourceCodeInfo_Location, d protoreflect.Descriptor) fileLocation { + // Spans are guaranteed by protobuf to have either three or four ints. + span := []int32{0, 0, 1} + if l != nil { + span = l.Span + } + + var fl fileLocation + if d != nil { + fl = fileLocation{Path: d.ParentFile().Path()} + } + + // If `span` has four ints; they correspond to + // [start line, start column, end line, end column]. + // + // We add one because spans are zero-indexed, but not to the end column + // because we want the ending position to be inclusive and not exclusive. + if len(span) == 4 { + fl.Start = position{ + Line: int(span[0]) + 1, + Column: int(span[1]) + 1, + } + fl.End = position{ + Line: int(span[2]) + 1, + Column: int(span[3]), + } + return fl + } + + // Okay, `span` has three ints; they correspond to + // [start line, start column, end column]. + // + // We add one because spans are zero-indexed, but not to the end column + // because we want the ending position to be inclusive and not exclusive. + fl.Start = position{ + Line: int(span[0]) + 1, + Column: int(span[1]) + 1, + } + fl.End = position{ + Line: int(span[0]) + 1, + Column: int(span[2]), + } + return fl +} diff --git a/lint/v2/problem_test.go b/lint/v2/problem_test.go new file mode 100644 index 00000000..d079d9fb --- /dev/null +++ b/lint/v2/problem_test.go @@ -0,0 +1,135 @@ +// Copyright 2019 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 lint + +import ( + "encoding/json" + "strings" + "testing" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + dpb "google.golang.org/protobuf/types/descriptorpb" + "gopkg.in/yaml.v3" +) + +func TestProblemJSON(t *testing.T) { + problem := &Problem{ + Message: "foo bar", + Location: &dpb.SourceCodeInfo_Location{Span: []int32{2, 0, 42}}, + RuleID: "core::0131", + } + serialized, err := json.Marshal(problem) + if err != nil { + t.Fatalf("Could not marshal Problem to JSON.") + } + tests := []struct { + testName string + token string + }{ + {"Message", `"message":"foo bar"`}, + {"LineNumber", `"line_number":3`}, + {"ColumnNumberStart", `"column_number":1`}, + {"ColumnNumberEnd", `"column_number":42`}, + {"RuleID", `"rule_id":"core::0131"`}, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + if !strings.Contains(string(serialized), test.token) { + t.Errorf("Got\n%v\nExpected `%s` to be present.", string(serialized), test.token) + } + }) + } +} + +func TestProblemYAML(t *testing.T) { + problem := &Problem{ + Message: "foo bar", + Location: &dpb.SourceCodeInfo_Location{Span: []int32{2, 0, 5, 70}}, + RuleID: "core::0131", + } + serialized, err := yaml.Marshal(problem) + if err != nil { + t.Fatalf("Could not marshal Problem to YAML.") + } + tests := []struct { + testName string + token string + }{ + {"Message", `message: foo bar`}, + {"LineNumberStart", `line_number: 3`}, + {"LintNumberEnd", `line_number: 6`}, + {"ColumnNumberStart", `column_number: 1`}, + {"ColumnNumberEnd", `column_number: 70`}, + {"RuleID", `rule_id: core::0131`}, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + if !strings.Contains(string(serialized), test.token) { + t.Errorf("Got\n%v\nExpected `%s` to be present.", string(serialized), test.token) + } + }) + } +} + +func TestProblemDescriptor(t *testing.T) { + fd, err := protodesc.NewFile(&dpb.FileDescriptorProto{ + Name: proto.String("foo.proto"), + MessageType: []*dpb.DescriptorProto{ + { + Name: proto.String("Foo"), + }, + }, + SourceCodeInfo: &dpb.SourceCodeInfo{ + Location: []*dpb.SourceCodeInfo_Location{ + { + Path: []int32{4, 0}, // message_type 0 + Span: []int32{42, 0, 79}, + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("%v", err) + } + m := fd.Messages().Get(0) + problem := &Problem{ + Message: "foo bar", + Descriptor: m, + RuleID: "core::0131", + } + serialized, err := yaml.Marshal(problem) + if err != nil { + t.Fatalf("Could not marshal Problem to YAML.") + } + tests := []struct { + testName string + token string + }{ + {"Message", `message: foo bar`}, + {"LineNumber", `line_number: 43`}, + {"ColumnNumberStart", `column_number: 1`}, + {"ColumnNumberEnd", `column_number: 79`}, + {"RuleID", `rule_id: core::0131`}, + {"Path", `path: foo.proto`}, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + if !strings.Contains(string(serialized), test.token) { + t.Errorf("Got\n%v\nExpected `%s` to be present.", string(serialized), test.token) + } + }) + } +} diff --git a/lint/v2/response.go b/lint/v2/response.go new file mode 100644 index 00000000..ba171849 --- /dev/null +++ b/lint/v2/response.go @@ -0,0 +1,21 @@ +// Copyright 2019 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 lint + +// Response describes the result returned by a rule. +type Response struct { + FilePath string `json:"file_path" yaml:"file_path"` + Problems []Problem `json:"problems" yaml:"problems"` +} diff --git a/lint/v2/rule.go b/lint/v2/rule.go new file mode 100644 index 00000000..707b717f --- /dev/null +++ b/lint/v2/rule.go @@ -0,0 +1,479 @@ +// Copyright 2019 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 lint + +import ( + "regexp" + "strings" + + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +// ProtoRule defines a lint rule that checks Google Protobuf APIs. +// +// Anything that satisfies this interface can be used as a rule, +// but most rule authors will want to use the implementations provided. +// +// Rules must only report errors in the file under which they are being run +// (not imported files). +type ProtoRule interface { + // GetName returns the name of the rule. + GetName() RuleName + + // Lint accepts a FileDescriptor and lints it, + // returning a slice of Problem objects it finds. + Lint(protoreflect.FileDescriptor) []Problem +} + +// FileRule defines a lint rule that checks a file as a whole. +type FileRule struct { + Name RuleName + + // LintFile accepts a FileDescriptor and lints it, returning a slice of + // Problems it finds. + LintFile func(protoreflect.FileDescriptor) []Problem + + // OnlyIf accepts a FileDescriptor and determines whether this rule + // is applicable. + OnlyIf func(protoreflect.FileDescriptor) bool + + //nolint:unused // field is required to prevent positional parameters + noPositional struct{} +} + +// GetName returns the name of the rule. +func (r *FileRule) GetName() RuleName { + return r.Name +} + +// Lint forwards the FileDescriptor to the LintFile method defined on the +// FileRule. +func (r *FileRule) Lint(fd protoreflect.FileDescriptor) []Problem { + if r.OnlyIf == nil || r.OnlyIf(fd) { + return r.LintFile(fd) + } + return nil +} + +// MessageRule defines a lint rule that is run on each message in the file. +// +// Both top-level messages and nested messages are visited. +type MessageRule struct { + Name RuleName + + // LintMessage accepts a MessageDescriptor and lints it, returning a slice + // of Problems it finds. + LintMessage func(protoreflect.MessageDescriptor) []Problem + + // OnlyIf accepts a MessageDescriptor and determines whether this rule + // is applicable. + OnlyIf func(protoreflect.MessageDescriptor) bool + + //nolint:unused // field is required to prevent positional parameters + noPositional struct{} +} + +// GetName returns the name of the rule. +func (r *MessageRule) GetName() RuleName { + return r.Name +} + +// Lint visits every message in the file, and runs `LintMessage`. +// +// If an `OnlyIf` function is provided on the rule, it is run against each +// message, and if it returns false, the `LintMessage` function is not called. +func (r *MessageRule) Lint(fd protoreflect.FileDescriptor) []Problem { + problems := []Problem{} + + // Iterate over each message and process rules for each message. + for _, message := range GetAllMessages(fd) { + if r.OnlyIf == nil || r.OnlyIf(message) { + problems = append(problems, r.LintMessage(message)...) + } + } + return problems +} + +// FieldRule defines a lint rule that is run on each field within a file. +type FieldRule struct { + Name RuleName + + // LintField accepts a FieldDescriptor and lints it, returning a slice of + // Problems it finds. + LintField func(protoreflect.FieldDescriptor) []Problem + + // OnlyIf accepts a FieldDescriptor and determines whether this rule + // is applicable. + OnlyIf func(protoreflect.FieldDescriptor) bool + + //nolint:unused // field is required to prevent positional parameters + noPositional struct{} +} + +// GetName returns the name of the rule. +func (r *FieldRule) GetName() RuleName { + return r.Name +} + +// Lint visits every field in the file and runs `LintField`. +// +// If an `OnlyIf` function is provided on the rule, it is run against each +// field, and if it returns false, the `LintField` function is not called. +func (r *FieldRule) Lint(fd protoreflect.FileDescriptor) []Problem { + problems := []Problem{} + + // Iterate over each message and process rules for each field in that + // message. + for _, message := range GetAllMessages(fd) { + for i := 0; i < message.Fields().Len(); i++ { + field := message.Fields().Get(i) + if r.OnlyIf == nil || r.OnlyIf(field) { + problems = append(problems, r.LintField(field)...) + } + } + } + return problems +} + +// ServiceRule defines a lint rule that is run on each service. +type ServiceRule struct { + Name RuleName + + // LintService accepts a ServiceDescriptor and lints it. + LintService func(protoreflect.ServiceDescriptor) []Problem + + // OnlyIf accepts a ServiceDescriptor and determines whether this rule + // is applicable. + OnlyIf func(protoreflect.ServiceDescriptor) bool + + //nolint:unused // field is required to prevent positional parameters + noPositional struct{} +} + +// GetName returns the name of the rule. +func (r *ServiceRule) GetName() RuleName { + return r.Name +} + +// Lint visits every service in the file and runs `LintService`. +// +// If an `OnlyIf` function is provided on the rule, it is run against each +// service, and if it returns false, the `LintService` function is not called. +func (r *ServiceRule) Lint(fd protoreflect.FileDescriptor) []Problem { + problems := []Problem{} + for i := 0; i < fd.Services().Len(); i++ { + service := fd.Services().Get(i) + if r.OnlyIf == nil || r.OnlyIf(service) { + problems = append(problems, r.LintService(service)...) + } + } + return problems +} + +// MethodRule defines a lint rule that is run on each method. +type MethodRule struct { + Name RuleName + + // LintMethod accepts a MethodDescriptor and lints it. + LintMethod func(protoreflect.MethodDescriptor) []Problem + + // OnlyIf accepts a MethodDescriptor and determines whether this rule + // is applicable. + OnlyIf func(protoreflect.MethodDescriptor) bool + + //nolint:unused // field is required to prevent positional parameters + noPositional struct{} +} + +// GetName returns the name of the rule. +func (r *MethodRule) GetName() RuleName { + return r.Name +} + +// Lint visits every method in the file and runs `LintMethod`. +// +// If an `OnlyIf` function is provided on the rule, it is run against each +// method, and if it returns false, the `LintMethod` function is not called. +func (r *MethodRule) Lint(fd protoreflect.FileDescriptor) []Problem { + problems := []Problem{} + for i := 0; i < fd.Services().Len(); i++ { + service := fd.Services().Get(i) + for j := 0; j < service.Methods().Len(); j++ { + method := service.Methods().Get(j) + if r.OnlyIf == nil || r.OnlyIf(method) { + problems = append(problems, r.LintMethod(method)...) + } + } + } + return problems +} + +// EnumRule defines a lint rule that is run on each enum. +type EnumRule struct { + Name RuleName + + // LintEnum accepts a EnumDescriptor and lints it. + LintEnum func(protoreflect.EnumDescriptor) []Problem + + // OnlyIf accepts an EnumDescriptor and determines whether this rule + // is applicable. + OnlyIf func(protoreflect.EnumDescriptor) bool + + //nolint:unused // field is required to prevent positional parameters + noPositional struct{} +} + +// GetName returns the name of the rule. +func (r *EnumRule) GetName() RuleName { + return r.Name +} + +// Lint visits every enum in the file and runs `LintEnum`. +// +// If an `OnlyIf` function is provided on the rule, it is run against each +// enum, and if it returns false, the `LintEnum` function is not called. +func (r *EnumRule) Lint(fd protoreflect.FileDescriptor) []Problem { + problems := []Problem{} + + // Lint all enums, either at the top of the file, or nested within messages. + for _, enum := range getAllEnums(fd) { + if r.OnlyIf == nil || r.OnlyIf(enum) { + problems = append(problems, r.LintEnum(enum)...) + } + } + return problems +} + +// EnumValueRule defines a lint rule that is run on each enum value. +type EnumValueRule struct { + Name RuleName + + // LintEnumValue accepts a EnumValueDescriptor and lints it. + LintEnumValue func(protoreflect.EnumValueDescriptor) []Problem + + // OnlyIf accepts an EnumValueDescriptor and determines whether this rule + // is applicable. + OnlyIf func(protoreflect.EnumValueDescriptor) bool + + //nolint:unused // field is required to prevent positional parameters + noPositional struct{} +} + +// GetName returns the name of the rule. +func (r *EnumValueRule) GetName() RuleName { + return r.Name +} + +// Lint visits every enum value in the file and runs `LintEnum`. +// +// If an `OnlyIf` function is provided on the rule, it is run against each +// enum value, and if it returns false, the `LintEnum` function is not called. +func (r *EnumValueRule) Lint(fd protoreflect.FileDescriptor) []Problem { + problems := []Problem{} + + // Lint all enums, either at the top of the file, or nested within messages. + for _, enum := range getAllEnums(fd) { + for i := 0; i < enum.Values().Len(); i++ { + value := enum.Values().Get(i) + if r.OnlyIf == nil || r.OnlyIf(value) { + problems = append(problems, r.LintEnumValue(value)...) + } + } + } + return problems +} + +// DescriptorRule defines a lint rule that is run on every descriptor +// in the file (but not the file itself). +type DescriptorRule struct { + Name RuleName + + // LintDescriptor accepts a generic descriptor and lints it. + // + // Note: Unless the descriptor is typecast to a more specific type, + // only a subset of methods are available to it. + LintDescriptor func(protoreflect.Descriptor) []Problem + + // OnlyIf accepts a Descriptor and determines whether this rule + // is applicable. + OnlyIf func(protoreflect.Descriptor) bool + + //nolint:unused // field is required to prevent positional parameters + noPositional struct{} +} + +// GetName returns the name of the rule. +func (r *DescriptorRule) GetName() RuleName { + return r.Name +} + +// Lint visits every descriptor in the file and runs `LintDescriptor`. +// +// It visits every service, method, message, field, enum, and enum value. +// This order is not guaranteed. It does NOT visit the file itself. +func (r *DescriptorRule) Lint(fd protoreflect.FileDescriptor) []Problem { + problems := []Problem{} + + // Iterate over all services and methods. + for i := 0; i < fd.Services().Len(); i++ { + service := fd.Services().Get(i) + if r.OnlyIf == nil || r.OnlyIf(service) { + problems = append(problems, r.LintDescriptor(service)...) + } + for j := 0; j < service.Methods().Len(); j++ { + method := service.Methods().Get(j) + if r.OnlyIf == nil || r.OnlyIf(method) { + problems = append(problems, r.LintDescriptor(method)...) + } + } + } + + // Iterate over all messages, and all fields within each message. + for _, message := range GetAllMessages(fd) { + if r.OnlyIf == nil || r.OnlyIf(message) { + problems = append(problems, r.LintDescriptor(message)...) + } + for i := 0; i < message.Fields().Len(); i++ { + field := message.Fields().Get(i) + if r.OnlyIf == nil || r.OnlyIf(field) { + problems = append(problems, r.LintDescriptor(field)...) + } + } + } + + // Iterate over all enums and enum values. + for _, enum := range getAllEnums(fd) { + if r.OnlyIf == nil || r.OnlyIf(enum) { + problems = append(problems, r.LintDescriptor(enum)...) + } + for i := 0; i < enum.Values().Len(); i++ { + value := enum.Values().Get(i) + if r.OnlyIf == nil || r.OnlyIf(value) { + problems = append(problems, r.LintDescriptor(value)...) + } + } + } + + // Done; return the full set of problems. + return problems +} + +var disableRuleNameRegex = regexp.MustCompile(`api-linter:\s*(.+)\s*=\s*disabled`) + +func extractDisabledRuleName(commentLine string) string { + match := disableRuleNameRegex.FindStringSubmatch(commentLine) + if len(match) > 0 { + return match[1] + } + return "" +} + +func getLeadingComments(d protoreflect.Descriptor) string { + loc := d.ParentFile().SourceLocations().ByDescriptor(d) + return loc.LeadingComments +} + +// GetAllMessages returns a slice with every message (not just top-level +// messages) in the file. +func GetAllMessages(f protoreflect.FileDescriptor) (messages []protoreflect.MessageDescriptor) { + for i := 0; i < f.Messages().Len(); i++ { + msg := f.Messages().Get(i) + messages = append(messages, msg) + messages = append(messages, getAllNestedMessages(msg)...) + } + return messages +} + +// getAllNestedMessages returns a slice with the given message descriptor as well +// as all nested message descriptors, traversing to arbitrary depth. +func getAllNestedMessages(m protoreflect.MessageDescriptor) (messages []protoreflect.MessageDescriptor) { + for i := 0; i < m.Messages().Len(); i++ { + nested := m.Messages().Get(i) + if !nested.IsMapEntry() { // Don't include the synthetic message type that represents an entry in a map field. + messages = append(messages, nested) + } + messages = append(messages, getAllNestedMessages(nested)...) + } + return messages +} + +// getAllEnums returns a slice with every enum (not just top-level enums) +// in the file. +func getAllEnums(f protoreflect.FileDescriptor) (enums []protoreflect.EnumDescriptor) { + // Append all enums at the top level. + for i := 0; i < f.Enums().Len(); i++ { + enums = append(enums, f.Enums().Get(i)) + } + + // Append all enums nested within messages. + for _, m := range GetAllMessages(f) { + for i := 0; i < m.Enums().Len(); i++ { + enums = append(enums, m.Enums().Get(i)) + } + } + + return +} + +// fileHeader attempts to get the comment at the top of the file, but it +// is on a best effort basis because protobuf is inconsistent. +// +// Taken from https://github.com/jhump/protoreflect/issues/215 +func fileHeader(fd protoreflect.FileDescriptor) string { + var firstLoc *dpb.SourceCodeInfo_Location + var firstSpan int64 + + // File level comments should only be comments identified on either + // syntax (12), package (2), option (8), or import (3) statements. + allowedPaths := map[int32]struct{}{2: {}, 3: {}, 8: {}, 12: {}} + + // Iterate over locations in the file descriptor looking for + // what we think is a file-level comment. + fdp := protodesc.ToFileDescriptorProto(fd) + if fdp.SourceCodeInfo == nil { + return "" + } + for _, curr := range fdp.GetSourceCodeInfo().GetLocation() { + // Skip locations that have no comments. + if curr.LeadingComments == nil && len(curr.LeadingDetachedComments) == 0 { + continue + } + // Skip locations that are not allowed because they should never be + // mistaken for file-level comments. + if len(curr.GetPath()) > 0 { + if _, ok := allowedPaths[curr.GetPath()[0]]; !ok { + continue + } + } + currSpan := asPos(curr.Span) + if firstLoc == nil || currSpan < firstSpan { + firstLoc = curr + firstSpan = currSpan + } + } + if firstLoc == nil { + return "" + } + if len(firstLoc.LeadingDetachedComments) > 0 { + return strings.Join(firstLoc.LeadingDetachedComments, "\n") + } + return firstLoc.GetLeadingComments() +} + +func asPos(span []int32) int64 { + return (int64(span[0]) << 32) + int64(span[1]) +} diff --git a/lint/v2/rule_aliases.go b/lint/v2/rule_aliases.go new file mode 100644 index 00000000..8a40addf --- /dev/null +++ b/lint/v2/rule_aliases.go @@ -0,0 +1,16 @@ +package lint + +// aliasMap stores legacy names for some rules. +// At Google, we inject rule-alias mapping into this map. +// Example: +// We will compile an addition file -- "google_rule_aliases.go". +// ```````````````````````````````````````````````````````````` +// package lint +// +// func init() { +// aliasMap["core::0140::lower-snake"] = "naming-format" +// aliasMap["core::0140::enum-names::abbreviations"] = "abbreviations" +// } +// +// ```````````````````````````````````````````````````````````` +var aliasMap = map[string]string{} diff --git a/lint/v2/rule_enabled.go b/lint/v2/rule_enabled.go new file mode 100644 index 00000000..13bab0da --- /dev/null +++ b/lint/v2/rule_enabled.go @@ -0,0 +1,139 @@ +// Copyright 2019 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 lint + +import ( + "strings" + + "google.golang.org/protobuf/reflect/protoreflect" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +// defaultDisabledRules is the list of rules or groups that are by default +// disabled, because they are scoped to a very specific set of AIPs. +var defaultDisabledRules = []string{"cloud"} + +// deprecationRules are rules specifically designed to lint deprecated elements. +var deprecationRules = []string{"core::0192::deprecated-comment"} + +// Disable rules for deprecated descriptors, except for those designed to lint +// deprecated descriptors. +func disableDeprecated(d protoreflect.Descriptor, rule string) bool { + // Exception to rule, because it is designed to be applied to deprecated + // elements. + if matchRule(rule, deprecationRules...) { + return false + } + + switch v := d.(type) { + case protoreflect.EnumDescriptor: + return v.Options().(*dpb.EnumOptions).GetDeprecated() + case protoreflect.EnumValueDescriptor: + return v.Options().(*dpb.EnumValueOptions).GetDeprecated() + case protoreflect.FieldDescriptor: + return v.Options().(*dpb.FieldOptions).GetDeprecated() + case protoreflect.FileDescriptor: + return v.Options().(*dpb.FileOptions).GetDeprecated() + case protoreflect.MessageDescriptor: + return v.Options().(*dpb.MessageOptions).GetDeprecated() + case protoreflect.MethodDescriptor: + return v.Options().(*dpb.MethodOptions).GetDeprecated() + case protoreflect.ServiceDescriptor: + return v.Options().(*dpb.ServiceOptions).GetDeprecated() + } + return false +} + +// Provide methods that are able to disable rules. +// +// This pattern is a hook for internal extension, by creating an additional +// file in this package that can add an additional check: +// +// func disableForInternalReason(d protoreflect.Descriptor) bool { ... } +// +// func init() { +// descriptorDisableChecks = append(descriptorDisableChecks, disableForInternalReason) +// } +var descriptorDisableChecks = []func(d protoreflect.Descriptor, rule string) bool{ + disableDeprecated, +} + +// ruleIsEnabled returns true if the rule is enabled (not disabled by the comments +// for the given descriptor or its file), false otherwise. +// +// Note, if the given source code location is not nil, it will be used to +// augment the set of commentLines. +func ruleIsEnabled(rule ProtoRule, d protoreflect.Descriptor, l *dpb.SourceCodeInfo_Location, + aliasMap map[string]string, ignoreCommentDisables bool) bool { + // If the rule is disabled because of something on the descriptor itself + // (e.g. a deprecated annotation), address that. + for _, mustDisable := range descriptorDisableChecks { + // The only thing the disable functions can do is force a rule to + // be disabled. (They can not force a rule to be enabled.) + if mustDisable(d, string(rule.GetName())) { + return false + } + } + + if !ignoreCommentDisables { + if ruleIsDisabledByComments(rule, d, l, aliasMap) { + return false + } + } + + // The rule may have been disabled on a parent. (For example, a field rule + // may be disabled at the message level to cover all fields in the message). + // + // Do not pass the source code location here, the source location in relation + // to the parent is not helpful. + if parent := d.Parent(); parent != nil { + return ruleIsEnabled(rule, parent, nil, aliasMap, ignoreCommentDisables) + } + + return true +} + +// ruleIsDisabledByComments returns true if the rule has been disabled +// by comments in the file or leading the element. +func ruleIsDisabledByComments(rule ProtoRule, d protoreflect.Descriptor, l *dpb.SourceCodeInfo_Location, aliasMap map[string]string) bool { + // Some rules have a legacy name. We add it to the check list. + ruleName := string(rule.GetName()) + names := []string{ruleName, aliasMap[ruleName]} + + commentLines := []string{} + if l != nil { + commentLines = append(commentLines, strings.Split(l.GetLeadingComments(), "\n")...) + } + if f, ok := d.(protoreflect.FileDescriptor); ok { + commentLines = append(commentLines, strings.Split(fileHeader(f), "\n")...) + } else { + commentLines = append(commentLines, strings.Split(getLeadingComments(d), "\n")...) + } + disabledRules := []string{} + for _, commentLine := range commentLines { + r := extractDisabledRuleName(commentLine) + if r != "" { + disabledRules = append(disabledRules, r) + } + } + + for _, name := range names { + if matchRule(name, disabledRules...) { + return true + } + } + + return false +} diff --git a/lint/v2/rule_enabled_test.go b/lint/v2/rule_enabled_test.go new file mode 100644 index 00000000..d8159e49 --- /dev/null +++ b/lint/v2/rule_enabled_test.go @@ -0,0 +1,258 @@ +// Copyright 2019 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 lint + +import ( + "testing" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +func TestRuleIsEnabled(t *testing.T) { + // Create a no-op rule, which we can check enabled status on. + rule := &FileRule{ + Name: RuleName("a::b::c"), + LintFile: func(fd protoreflect.FileDescriptor) []Problem { + return []Problem{} + }, + } + + aliases := map[string]string{ + "a::b::c": "d::e::f", + } + + // Create appropriate test permutations. + tests := []struct { + testName string + fileComment string + messageComment string + enabled bool + }{ + {"Enabled", "", "", true}, + {"FileDisabled", "api-linter: a::b::c=disabled", "", false}, + {"MessageDisabled", "", "api-linter: a::b::c=disabled", false}, + {"NameNotMatch", "", "api-linter: other=disabled", true}, + {"RegexpNotMatch", "", "api-lint: a::b::c=disabled", true}, + {"AliasDisabled", "", "api-linter: d::e::f=disabled", false}, + {"FileComments_PrefixMatched_Disabled", "api-linter: a=disabled", "", false}, + {"FileComments_MiddleMatched_Disabled", "api-linter: b=disabled", "", false}, + {"FileComments_SuffixMatched_Disabled", "api-linter: c=disabled", "", false}, + {"FileComments_MultipleLinesMatched_Disabled", "api-linter: x=disabled\napi-linter: a=disabled", "", false}, + {"MessageComments_PrefixMatched_Disabled", "", "api-linter: a=disabled", false}, + {"MessageComments_MiddleMatched_Disabled", "", "api-linter: b=disabled", false}, + {"MessageComments_SuffixMatched_Disabled", "", "api-linter: c=disabled", false}, + {"MessageComments_MultipleLinesMatched_Disabled", "", "api-linter: x=disabled\napi-linter: a=disabled", false}, + } + + // Run the specific tests individually. + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + f, err := protodesc.NewFile(&dpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + Package: proto.String("test"), + MessageType: []*dpb.DescriptorProto{ + { + Name: proto.String("MyMessage"), + }, + }, + SourceCodeInfo: &dpb.SourceCodeInfo{ + Location: []*dpb.SourceCodeInfo_Location{ + { + Path: []int32{2}, // package + Span: []int32{1, 1, 1, 1}, + LeadingComments: proto.String(test.fileComment), + }, + { + Path: []int32{4, 0}, // message_type 0 + Span: []int32{1, 1, 1, 1}, + LeadingComments: proto.String(test.messageComment), + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("Error building test message: %v", err) + } + if got, want := ruleIsEnabled(rule, f.Messages().Get(0), nil, aliases, false), test.enabled; got != want { + t.Errorf("Expected the test rule to return %v from ruleIsEnabled, got %v", want, got) + } + if !test.enabled { + if got, want := ruleIsEnabled(rule, f.Messages().Get(0), nil, aliases, true), true; got != want { + t.Errorf("Expected the test rule with ignoreCommentDisables true to return %v from ruleIsEnabled, got %v", want, got) + } + } + }) + } +} + +func TestRuleIsEnabledFirstMessage(t *testing.T) { + // Create a no-op rule, which we can check enabled status on. + rule := &FileRule{ + Name: RuleName("test"), + LintFile: func(fd protoreflect.FileDescriptor) []Problem { + return []Problem{} + }, + } + + // Build a proto and check that ruleIsEnabled does the right thing. + f, err := protodesc.NewFile(&dpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + MessageType: []*dpb.DescriptorProto{ + { + Name: proto.String("FirstMessage"), + }, + { + Name: proto.String("SecondMessage"), + }, + }, + SourceCodeInfo: &dpb.SourceCodeInfo{ + Location: []*dpb.SourceCodeInfo_Location{ + { + Path: []int32{4, 0}, // message_type 0 + Span: []int32{1, 1, 1, 1}, + LeadingComments: proto.String("api-linter: test=disabled"), + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("Error building test file: %q", err) + } + if got, want := ruleIsEnabled(rule, f.Messages().Get(0), nil, nil, false), false; got != want { + t.Errorf("Expected the first message to return %v from ruleIsEnabled, got %v", want, got) + } + if got, want := ruleIsEnabled(rule, f.Messages().Get(1), nil, nil, false), true; got != want { + t.Errorf("Expected the second message to return %v from ruleIsEnabled, got %v", want, got) + } +} + +func TestRuleIsEnabledParent(t *testing.T) { + // Create a rule that we can check enabled status on. + rule := &FieldRule{ + Name: RuleName("test"), + LintField: func(f protoreflect.FieldDescriptor) []Problem { + return nil + }, + } + + // Build a proto with two messages, one of which disables the rule. + f, err := protodesc.NewFile(&dpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + MessageType: []*dpb.DescriptorProto{ + { + Name: proto.String("Foo"), + Field: []*dpb.FieldDescriptorProto{ + { + Name: proto.String("foo"), + Number: proto.Int32(1), + Type: dpb.FieldDescriptorProto_TYPE_BOOL.Enum(), + }, + }, + }, + { + Name: proto.String("Bar"), + Field: []*dpb.FieldDescriptorProto{ + { + Name: proto.String("bar"), + Number: proto.Int32(1), + Type: dpb.FieldDescriptorProto_TYPE_BOOL.Enum(), + }, + }, + }, + }, + SourceCodeInfo: &dpb.SourceCodeInfo{ + Location: []*dpb.SourceCodeInfo_Location{ + { + Path: []int32{4, 0}, // message_type 0 + Span: []int32{1, 1, 1, 1}, + LeadingComments: proto.String("api-linter: test=disabled"), + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("Error building test file: %q", err) + } + if got, want := ruleIsEnabled(rule, f.Messages().Get(0).Fields().Get(0), nil, nil, false), false; got != want { + t.Errorf("Expected the foo field to return %v from ruleIsEnabled; got %v", want, got) + } + if got, want := ruleIsEnabled(rule, f.Messages().Get(1).Fields().Get(0), nil, nil, false), true; got != want { + t.Errorf("Expected the bar field to return %v from ruleIsEnabled; got %v", want, got) + } +} + +func TestRuleIsEnabledDeprecated(t *testing.T) { + // Create a generalRule that we can check enabled status on. + generalRule := &FieldRule{ + Name: RuleName("test"), + LintField: func(f protoreflect.FieldDescriptor) []Problem { + return nil + }, + } + deprecationRule := &FieldRule{ + Name: RuleName("core::0192::deprecated-comment"), + LintField: func(f protoreflect.FieldDescriptor) []Problem { + return nil + }, + } + + for _, test := range []struct { + name string + rule ProtoRule + msgDeprecated bool + fieldDeprecated bool + enabled bool + }{ + {"Both", generalRule, true, true, false}, + {"Message", generalRule, true, false, false}, + {"Field", generalRule, false, true, false}, + {"Neither", generalRule, false, false, true}, + {"DeprecationRule", deprecationRule, false, true, true}, + } { + t.Run(test.name, func(t *testing.T) { + // Build a proto with a message and field, possibly deprecated. + f, err := protodesc.NewFile(&dpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + MessageType: []*dpb.DescriptorProto{ + { + Name: proto.String("Foo"), + Options: &dpb.MessageOptions{ + Deprecated: proto.Bool(test.msgDeprecated), + }, + Field: []*dpb.FieldDescriptorProto{ + { + Name: proto.String("bar"), + Number: proto.Int32(1), + Type: dpb.FieldDescriptorProto_TYPE_BOOL.Enum(), + Options: &dpb.FieldOptions{ + Deprecated: proto.Bool(test.fieldDeprecated), + }, + }, + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("Error building test file: %q", err) + } + if got, want := ruleIsEnabled(test.rule, f.Messages().Get(0).Fields().Get(0), nil, nil, false), test.enabled; got != want { + t.Errorf("Expected the bar field to return %v from ruleIsEnabled; got %v", want, got) + } + }) + } +} diff --git a/lint/v2/rule_groups.go b/lint/v2/rule_groups.go new file mode 100644 index 00000000..8ff240dc --- /dev/null +++ b/lint/v2/rule_groups.go @@ -0,0 +1,59 @@ +package lint + +import "fmt" + +// A list of functions, each of which returns the group name for the given AIP +// number and if no group is found, returns an empty string. +// NOTE: the list will be evaluated in the FILO order. +// +// At Google, we inject additional group naming functions into this list. +// Example: google_aip_groups.go +// package lint +// +// func init() { +// aipGroups = append(aipGroups, aipInternalGroup) +// } +// +// func aipInternalGroup(aip int) string { +// if aip > 9000 { +// return "internal" +// } +// return "" +// } +var aipGroups = []func(int) string{ + aipCoreGroup, + aipClientLibrariesGroup, + aipCloudGroup, +} + +func aipCoreGroup(aip int) string { + if aip > 0 && aip < 1000 { + return "core" + } + return "" +} + +func aipClientLibrariesGroup(aip int) string { + if aip >= 4200 && aip <= 4299 { + return "client-libraries" + } + return "" +} + +func aipCloudGroup(aip int) string { + if (aip >= 2500 && aip <= 2599) || (aip >= 25000 && aip <= 25999) { + return "cloud" + } + return "" +} + +// getRuleGroup takes an AIP number and returns the appropriate group. +// It panics if no group is found. +func getRuleGroup(aip int, groups []func(int) string) string { + for i := len(groups) - 1; i >= 0; i-- { + if group := groups[i](aip); group != "" { + return group + } + } + panic(fmt.Sprintf("Invalid AIP number %d: no available group.", aip)) +} diff --git a/lint/v2/rule_groups_test.go b/lint/v2/rule_groups_test.go new file mode 100644 index 00000000..6c169d8f --- /dev/null +++ b/lint/v2/rule_groups_test.go @@ -0,0 +1,89 @@ +package lint + +import ( + "testing" +) + +func TestAIPCoreGroup(t *testing.T) { + tests := []struct { + name string + aip int + group string + }{ + {"InCoreGroup", 1, "core"}, + {"NotInCoreGroup_AIP<=0", 0, ""}, + {"NotInCoreGroup_AIP>=1000", 1000, ""}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := aipCoreGroup(test.aip); got != test.group { + t.Errorf("aipCoreGroup(%d) got %s, but want %s", test.aip, got, test.group) + } + }) + } +} + +func TestAIPClientLibrariesGroup(t *testing.T) { + tests := []struct { + name string + aip int + group string + }{ + {"InClientLibrariesGroup", 4232, "client-libraries"}, + {"NotInClientLibrariesGroup_AIP>=4300", 4300, ""}, + {"NotInClientLibrariesGroup_AIP<4200", 4000, ""}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := aipClientLibrariesGroup(test.aip); got != test.group { + t.Errorf("aipClientLibrariesGroup(%d) got %s, but want %s", test.aip, got, test.group) + } + }) + } +} + +func TestGetRuleGroupPanic(t *testing.T) { + var groups []func(int) string + defer func() { + if r := recover(); r == nil { + t.Errorf("getRuleGroup did not panic") + } + }() + getRuleGroup(0, groups) +} + +func TestGetRuleGroup(t *testing.T) { + groupOne := func(aip int) string { + if aip == 1 { + return "ONE" + } + return "" + } + groupTwo := func(aip int) string { + if aip == 2 { + return "TWO" + } + return "" + } + groups := []func(int) string{ + groupOne, + groupTwo, + } + + tests := []struct { + name string + aip int + group string + }{ + {"GroupOne", 1, "ONE"}, + {"GroupTwo", 2, "TWO"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := getRuleGroup(test.aip, groups); got != test.group { + t.Errorf("getRuleGroup(%d) got %s, but want %s", test.aip, got, test.group) + } + }) + } +} diff --git a/lint/v2/rule_name.go b/lint/v2/rule_name.go new file mode 100644 index 00000000..f07258de --- /dev/null +++ b/lint/v2/rule_name.go @@ -0,0 +1,61 @@ +// Copyright 2019 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 lint + +import ( + "fmt" + "regexp" + "strings" +) + +// RuleName is an identifier for a rule. Allowed characters include a-z, 0-9, -. +// +// The namespace separator :: is allowed between RuleName segments +// (for example, my-namespace::my-rule). +type RuleName string + +const nameSeparator string = "::" + +var ruleNameValidator = regexp.MustCompile("^([a-z0-9][a-z0-9-]*(::[a-z0-9][a-z0-9-]*)?)+$") + +// NewRuleName creates a RuleName from an AIP number and a unique name within +// that AIP. +func NewRuleName(aip int, name string) RuleName { + return RuleName(strings.Join([]string{ + getRuleGroup(aip, aipGroups), + fmt.Sprintf("%04d", aip), + name, + }, nameSeparator)) +} + +// IsValid checks if a RuleName is syntactically valid. +func (r RuleName) IsValid() bool { + return r != "" && ruleNameValidator.Match([]byte(r)) +} + +// HasPrefix returns true if r contains prefix as a namespace. prefix parameters can be "::" delimited +// or specified as independent parameters. +// For example: +// +// r := NewRuleName("foo", "bar", "baz") // string(r) == "foo::bar::baz" +// +// r.HasPrefix("foo::bar") == true +// r.HasPrefix("foo", "bar") == true +// r.HasPrefix("foo", "bar", "baz") == true // matches the entire string +// r.HasPrefix("foo", "ba") == false // prefix must end on a delimiter +func (r RuleName) HasPrefix(prefix ...string) bool { + s := strings.Join(prefix, nameSeparator) + return string(r) == s || strings.HasPrefix(string(r), s+nameSeparator) +} diff --git a/lint/v2/rule_name_test.go b/lint/v2/rule_name_test.go new file mode 100644 index 00000000..f9ff35e5 --- /dev/null +++ b/lint/v2/rule_name_test.go @@ -0,0 +1,112 @@ +// Copyright 2019 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 lint + +import ( + "testing" +) + +func TestRuleNameValid(t *testing.T) { + tests := []struct { + testName string + ruleName RuleName + }{ + {"Lower", "aip"}, + {"LowerNumber", "aip0121"}, + {"LowerNumberKebab", "aip-0121"}, + {"Namespaced", "aip::0121"}, + {"NamespacedHyphen", "core::aip-0121"}, + } + + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + if !test.ruleName.IsValid() { + t.Errorf("Rule name %q is invalid; want valid.", test.ruleName) + } + }) + } +} + +func TestRuleNameInvalid(t *testing.T) { + tests := []struct { + testName string + ruleName RuleName + }{ + {"EmptyString", ""}, + {"TripleColon", "a:::b"}, + {"QuadrupleColon", "a::::b"}, + {"CapitalLetter", "A"}, + {"LeadingDoubleColon", "::my-rule"}, + {"TrailingDoubleColon", "my-namespace::"}, + {"LeadingHyphen", "-core::aip-0131"}, + {"LeadingSegmentHyphen", "core::-aip-0131"}, + {"OnlyHyphen", "-"}, + {"SingleColon", "core:aip-0131"}, + {"Underscore", "core::aip_0131"}, + {"CamelCase", "myRule"}, + {"PascalCase", "MyRule"}, + } + + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + if test.ruleName.IsValid() { + t.Errorf("Rule name %q is valid; want invalid.", test.ruleName) + } + }) + } +} + +func TestNewRuleName(t *testing.T) { + tests := []struct { + testName string + aip int + name string + want string + }{ + {"ZeroPad", 131, "http-method", "core::0131::http-method"}, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + rn := NewRuleName(test.aip, test.name) + if got := string(rn); got != test.want { + t.Errorf("Got %q, expected %q.", got, test.want) + } + }) + } +} + +func TestRuleName_HasPrefix(t *testing.T) { + tests := []struct { + r RuleName + prefix []string + hasPrefix bool + }{ + {"a::b::c", []string{"a", "b"}, true}, + {"a::b::c", []string{"a"}, true}, + {"a::b::c", []string{"a::b"}, true}, + {"a::b::c::d", []string{"a::b", "c"}, true}, + {"a::b::c", []string{"a::b::c"}, true}, + {"ab::b::c", []string{"a"}, false}, + } + + for _, test := range tests { + if test.r.HasPrefix(test.prefix...) != test.hasPrefix { + t.Errorf( + "%q.HasPrefix(%v)=%t; want %t", + test.r, test.prefix, test.r.HasPrefix(test.prefix...), test.hasPrefix, + ) + } + } +} diff --git a/lint/v2/rule_registry.go b/lint/v2/rule_registry.go new file mode 100644 index 00000000..6c0e21ac --- /dev/null +++ b/lint/v2/rule_registry.go @@ -0,0 +1,56 @@ +// Copyright 2019 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 lint + +import ( + "errors" + "fmt" +) + +// RuleRegistry is a registry for registering and looking up rules. +type RuleRegistry map[RuleName]ProtoRule + +var ( + errInvalidRuleName = errors.New("not a valid rule name") + errInvalidRuleGroup = errors.New("invalid rule group") + errDuplicatedRuleName = errors.New("duplicate rule name") +) + +// Register registers the list of rules of the same AIP. +// Return an error if any of the rules is found duplicate in the registry. +func (r RuleRegistry) Register(aip int, rules ...ProtoRule) error { + rulePrefix := getRuleGroup(aip, aipGroups) + nameSeparator + fmt.Sprintf("%04d", aip) + for _, rl := range rules { + if !rl.GetName().IsValid() { + return errInvalidRuleName + } + + if !rl.GetName().HasPrefix(rulePrefix) { + return errInvalidRuleGroup + } + + if _, found := r[rl.GetName()]; found { + return errDuplicatedRuleName + } + + r[rl.GetName()] = rl + } + return nil +} + +// NewRuleRegistry creates a new rule registry. +func NewRuleRegistry() RuleRegistry { + return make(RuleRegistry) +} diff --git a/lint/v2/rule_registry_test.go b/lint/v2/rule_registry_test.go new file mode 100644 index 00000000..3d881b60 --- /dev/null +++ b/lint/v2/rule_registry_test.go @@ -0,0 +1,67 @@ +// Copyright 2019 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 lint + +import ( + "testing" +) + +func TestRuleRegistryRegister(t *testing.T) { + tests := []struct { + name string + aip int + ruleNames []RuleName + err error + }{ + { + name: "Registered_Okay", + aip: 111, + ruleNames: []RuleName{NewRuleName(111, "a"), NewRuleName(111, "b")}, + err: nil, + }, + { + name: "InvalidRuleName", + aip: 111, + ruleNames: []RuleName{NewRuleName(111, "")}, + err: errInvalidRuleName, + }, + { + name: "InvalidRuleGroup", + aip: 111, + ruleNames: []RuleName{NewRuleName(100, "a")}, + err: errInvalidRuleGroup, + }, + { + name: "Duplicated", + aip: 111, + ruleNames: []RuleName{NewRuleName(111, "a"), NewRuleName(111, "a")}, + err: errDuplicatedRuleName, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + rules := []ProtoRule{} + for _, name := range test.ruleNames { + rules = append(rules, &FileRule{Name: name}) + } + + registry := NewRuleRegistry() + err := registry.Register(test.aip, rules...) + if err != test.err { + t.Errorf("Register(): got %v, but want %v", err, test.err) + } + }) + } +} diff --git a/lint/v2/rule_test.go b/lint/v2/rule_test.go new file mode 100644 index 00000000..2be77882 --- /dev/null +++ b/lint/v2/rule_test.go @@ -0,0 +1,548 @@ +// Copyright 2019 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 lint + +import ( + "reflect" + "testing" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" +) + +func TestFileRule(t *testing.T) { + // Create a file descriptor with nothing in it. + fd, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + }, nil) + if err != nil { + t.Fatalf("Could not build file descriptor: %q", err) + } + + // Iterate over the tests and run them. + for _, test := range makeLintRuleTests(fd) { + t.Run(test.testName, func(t *testing.T) { + rule := &FileRule{ + Name: RuleName("test"), + OnlyIf: func(fd protoreflect.FileDescriptor) bool { + return fd.Path() == "test.proto" + }, + LintFile: func(fd protoreflect.FileDescriptor) []Problem { + return test.problems + }, + } + + // Run the rule and assert that we got what we expect. + test.runRule(rule, fd, t) + }) + } +} + +func TestMessageRule(t *testing.T) { + // Create a file descriptor with two messages in it. + fd, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Book"), + }, + { + Name: proto.String("Author"), + }, + }, + }, nil) + if err != nil { + t.Fatalf("Failed to build file descriptor.") + } + + // Iterate over the tests and run them. + for _, test := range makeLintRuleTests(fd.Messages().Get(1)) { + t.Run(test.testName, func(t *testing.T) { + // Create the message rule. + rule := &MessageRule{ + Name: RuleName("test"), + OnlyIf: func(m protoreflect.MessageDescriptor) bool { + return m.Name() == "Author" + }, + LintMessage: func(m protoreflect.MessageDescriptor) []Problem { + return test.problems + }, + } + + // Run the rule and assert that we got what we expect. + test.runRule(rule, fd, t) + }) + } +} + +// Establish that nested messages are tested. +func TestMessageRuleNested(t *testing.T) { + // Create a file descriptor with a message and nested message in it. + fd, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Book"), + NestedType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Author"), + }, + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("Failed to build file descriptor.") + } + + // Iterate over the tests and run them. + for _, test := range makeLintRuleTests(fd.Messages().Get(0).Messages().Get(0)) { + t.Run(test.testName, func(t *testing.T) { + // Create the message rule. + rule := &MessageRule{ + Name: RuleName("test"), + OnlyIf: func(m protoreflect.MessageDescriptor) bool { + return m.Name() == "Author" + }, + LintMessage: func(m protoreflect.MessageDescriptor) []Problem { + return test.problems + }, + } + + // Run the rule and assert that we got what we expect. + test.runRule(rule, fd, t) + }) + } +} + +func TestFieldRule(t *testing.T) { + // Create a file descriptor with one message and two fields in that message. + fd, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Book"), + Field: []*descriptorpb.FieldDescriptorProto{ + { + Name: proto.String("title"), + Number: proto.Int32(1), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + }, + { + Name: proto.String("edition_count"), + Number: proto.Int32(2), + Type: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum(), + }, + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("Failed to build file descriptor.") + } + + // Iterate over the tests and run them. + for _, test := range makeLintRuleTests(fd.Messages().Get(0).Fields().Get(1)) { + t.Run(test.testName, func(t *testing.T) { + // Create the field rule. + rule := &FieldRule{ + Name: RuleName("test"), + OnlyIf: func(f protoreflect.FieldDescriptor) bool { + return f.Name() == "edition_count" + }, + LintField: func(f protoreflect.FieldDescriptor) []Problem { + return test.problems + }, + } + + // Run the rule and assert that we got what we expect. + test.runRule(rule, fd, t) + }) + } +} + +func TestServiceRule(t *testing.T) { + // Create a file descriptor with a service. + fd, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: proto.String("Library"), + }, + }, + }, nil) + if err != nil { + t.Fatalf("Failed to build a file descriptor: %q", err) + } + + // Iterate over the tests and run them. + for _, test := range makeLintRuleTests(fd.Services().Get(0)) { + t.Run(test.testName, func(t *testing.T) { + // Create the service rule. + rule := &ServiceRule{ + Name: RuleName("test"), + LintService: func(s protoreflect.ServiceDescriptor) []Problem { + return test.problems + }, + } + + // Run the rule and assert that we got what we expect. + test.runRule(rule, fd, t) + }) + } +} + +func TestMethodRule(t *testing.T) { + // Create a file descriptor with a service. + fd, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Book"), + }, + { + Name: proto.String("GetBookRequest"), + }, + { + Name: proto.String("CreateBookRequest"), + }, + }, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: proto.String("Library"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: proto.String("GetBook"), + InputType: proto.String("GetBookRequest"), + OutputType: proto.String("Book"), + }, + { + Name: proto.String("CreateBook"), + InputType: proto.String("CreateBookRequest"), + OutputType: proto.String("Book"), + }, + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("Failed to build a file descriptor: %q", err) + } + + // Iterate over the tests and run them. + for _, test := range makeLintRuleTests(fd.Services().Get(0).Methods().Get(1)) { + t.Run(test.testName, func(t *testing.T) { + // Create the method rule. + rule := &MethodRule{ + Name: RuleName("test"), + OnlyIf: func(m protoreflect.MethodDescriptor) bool { + return m.Name() == "CreateBook" + }, + LintMethod: func(m protoreflect.MethodDescriptor) []Problem { + return test.problems + }, + } + + // Run the rule and assert that we got what we expect. + test.runRule(rule, fd, t) + }) + } +} + +func TestEnumRule(t *testing.T) { + // Create a file descriptor with top-level enums. + fd, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + EnumType: []*descriptorpb.EnumDescriptorProto{ + { + Name: proto.String("Format"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + { + Name: proto.String("PDF"), + Number: proto.Int32(0), + }, + }, + }, + { + Name: proto.String("Edition"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + { + Name: proto.String("PUBLISHER_ONLY"), + Number: proto.Int32(0), + }, + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("Error building test proto:%s ", err) + } + + for _, test := range makeLintRuleTests(fd.Enums().Get(1)) { + t.Run(test.testName, func(t *testing.T) { + // Create the enum rule. + rule := &EnumRule{ + Name: RuleName("test"), + OnlyIf: func(e protoreflect.EnumDescriptor) bool { + return e.Name() == "Edition" + }, + LintEnum: func(e protoreflect.EnumDescriptor) []Problem { + return test.problems + }, + } + + // Run the rule and assert that we got what we expect. + test.runRule(rule, fd, t) + }) + } +} + +func TestEnumValueRule(t *testing.T) { + // Create a file descriptor with a top-level enum with values. + fd, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + EnumType: []*descriptorpb.EnumDescriptorProto{ + { + Name: proto.String("Format"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + { + Name: proto.String("YAML"), + Number: proto.Int32(0), + }, + { + Name: proto.String("JSON"), + Number: proto.Int32(1), + }, + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("Error building test proto:%s ", err) + } + + for _, test := range makeLintRuleTests(fd.Enums().Get(0).Values().Get(1)) { + t.Run(test.testName, func(t *testing.T) { + // Create the enum value rule. + rule := &EnumValueRule{ + Name: RuleName("test"), + OnlyIf: func(e protoreflect.EnumValueDescriptor) bool { + return e.Name() == "JSON" + }, + LintEnumValue: func(e protoreflect.EnumValueDescriptor) []Problem { + return test.problems + }, + } + + // Run the rule and assert that we got what we expect. + test.runRule(rule, fd, t) + }) + } +} + +func TestEnumRuleNested(t *testing.T) { + // Create a file descriptor with top-level enums. + fd, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Book"), + EnumType: []*descriptorpb.EnumDescriptorProto{ + { + Name: proto.String("Format"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + { + Name: proto.String("PDF"), + Number: proto.Int32(0), + }, + }, + }, + { + Name: proto.String("Edition"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + { + Name: proto.String("PUBLISHER_ONLY"), + Number: proto.Int32(0), + }, + }, + }, + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("Error building test proto:%s ", err) + } + + for _, test := range makeLintRuleTests(fd.Messages().Get(0).Enums().Get(1)) { + t.Run(test.testName, func(t *testing.T) { + // Create the enum rule. + rule := &EnumRule{ + Name: RuleName("test"), + OnlyIf: func(e protoreflect.EnumDescriptor) bool { + return e.Name() == "Edition" + }, + LintEnum: func(e protoreflect.EnumDescriptor) []Problem { + return test.problems + }, + } + + // Run the rule and assert that we got what we expect. + test.runRule(rule, fd, t) + }) + } +} + +func TestDescriptorRule(t *testing.T) { + // Create a file with one of everything in it. + fd, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("library.proto"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Book"), + Field: []*descriptorpb.FieldDescriptorProto{ + { + Name: proto.String("name"), + Number: proto.Int32(1), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + }, + }, + EnumType: []*descriptorpb.EnumDescriptorProto{ + { + Name: proto.String("Format"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + { + Name: proto.String("FORMAT_UNSPECIFIED"), + Number: proto.Int32(0), + }, + { + Name: proto.String("PAPERBACK"), + Number: proto.Int32(1), + }, + }, + }, + }, + NestedType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Author"), + }, + }, + }, + }, + EnumType: []*descriptorpb.EnumDescriptorProto{ + { + Name: proto.String("State"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + { + Name: proto.String("AVAILABLE"), + Number: proto.Int32(0), + }, + }, + }, + }, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: proto.String("Library"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: proto.String("ConjureBook"), + InputType: proto.String("Book"), + OutputType: proto.String("Book"), + }, + }, + }, + }, + }, nil) + if err != nil { + t.Fatalf("%v", err) + } + + // Create a rule that lets us verify that each descriptor was visited. + visited := make(map[string]protoreflect.Descriptor) + rule := &DescriptorRule{ + Name: RuleName("test"), + OnlyIf: func(d protoreflect.Descriptor) bool { + return d.Name() != "FORMAT_UNSPECIFIED" + }, + LintDescriptor: func(d protoreflect.Descriptor) []Problem { + visited[string(d.Name())] = d + return nil + }, + } + + // Run the rule. + rule.Lint(fd) + + // Verify that each descriptor was visited. + // We do not care what order they were visited in. + wantDescriptors := []string{ + "Author", "Book", "ConjureBook", "Format", "PAPERBACK", + "name", "Library", "State", "AVAILABLE", + } + if got, want := rule.GetName(), "test"; string(got) != want { + t.Errorf("Got name %q, wanted %q", got, want) + } + if got, want := len(visited), len(wantDescriptors); got != want { + t.Errorf("Got %d descriptors, wanted %d.", got, want) + } + for _, name := range wantDescriptors { + if _, ok := visited[name]; !ok { + t.Errorf("Missing descriptor %q.", name) + } + } +} + +type lintRuleTest struct { + testName string + problems []Problem +} + +// runRule runs a rule within a test environment. +func (test *lintRuleTest) runRule(rule ProtoRule, fd protoreflect.FileDescriptor, t *testing.T) { + // Establish that the metadata methods work. + if got, want := string(rule.GetName()), string(RuleName("test")); got != want { + t.Errorf("Got %q for GetName(), expected %q", got, want) + } + + // Run the rule's lint function on the file descriptor + // and assert that we got what we expect. + if got, want := rule.Lint(fd), test.problems; !reflect.DeepEqual(got, want) { + t.Errorf("Got %v problems; expected %v.", got, want) + } +} + +// makeLintRuleTests generates boilerplate tests that are consistent for +// each type of rule. +func makeLintRuleTests(d protoreflect.Descriptor) []lintRuleTest { + return []lintRuleTest{ + {"NoProblems", []Problem{}}, + {"OneProblem", []Problem{{ + Message: "There was a problem.", + Descriptor: d, + }}}, + {"TwoProblems", []Problem{ + { + Message: "This was the first problem.", + Descriptor: d, + }, + { + Message: "This was the second problem.", + Descriptor: d, + }, + }}, + } +} diff --git a/lint/v2/rule_urls.go b/lint/v2/rule_urls.go new file mode 100644 index 00000000..8bdb8bc3 --- /dev/null +++ b/lint/v2/rule_urls.go @@ -0,0 +1,54 @@ +package lint + +import "strings" + +// A list of mapping functions, each of which returns the rule URL for +// the given rule name, and if not found, return an empty string. +// +// At Google, we inject additional rule URL mappings into this list. +// Example: google_rule_url_mappings.go +// package lint +// +// func init() { +// ruleURLMappings = append(ruleURLMappings, internalRuleURLMapping) +// } +// +// func internalRuleURLMapping(ruleName string) string { +// ... +// } +var ruleURLMappings = []func(string) string{ + coreRuleURL, + clientLibrariesRuleURL, + cloudRuleURL, +} + +func coreRuleURL(ruleName string) string { + return groupURL(ruleName, "core") +} + +func clientLibrariesRuleURL(ruleName string) string { + return groupURL(ruleName, "client-libraries") +} + +func cloudRuleURL(ruleName string) string { + return groupURL(ruleName, "cloud") +} + +func groupURL(ruleName, groupName string) string { + base := "https://linter.aip.dev/" + nameParts := strings.Split(ruleName, "::") // e.g., client-libraries::0122::camel-case-uris -> ["client-libraries", "0122", "camel-case-uris"] + if len(nameParts) == 0 || nameParts[0] != groupName { + return "" + } + path := strings.TrimPrefix(strings.Join(nameParts[1:], "/"), "0") + return base + path +} + +func getRuleURL(ruleName string, nameURLMappings []func(string) string) string { + for i := len(nameURLMappings) - 1; i >= 0; i-- { + if url := nameURLMappings[i](ruleName); url != "" { + return url + } + } + return "" +} diff --git a/lint/v2/rule_urls_test.go b/lint/v2/rule_urls_test.go new file mode 100644 index 00000000..5eeda9b1 --- /dev/null +++ b/lint/v2/rule_urls_test.go @@ -0,0 +1,96 @@ +package lint + +import ( + "testing" +) + +func TestCoreRuleURL(t *testing.T) { + tests := []struct { + name string + rule string + url string + }{ + {"CoreRule", "core::0122::camel-case-uris", "https://linter.aip.dev/122/camel-case-uris"}, + {"NotCoreRule", "test::0122::camel-case-uris", ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := coreRuleURL(test.rule); got != test.url { + t.Errorf("coreRuleURL(%s) got %s, but want %s", test.name, got, test.url) + } + }) + } +} + +func TestClientLibrariesRuleURL(t *testing.T) { + tests := []struct { + name string + rule string + url string + }{ + {"ClientLibrariesRule", "client-libraries::4232::repeated-fields", "https://linter.aip.dev/4232/repeated-fields"}, + {"NotClientLibrariesRule", "test::0122::camel-case-uris", ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := clientLibrariesRuleURL(test.rule); got != test.url { + t.Errorf("clientLibrariesRuleUrl(%s) got %s, but want %s", test.name, got, test.url) + } + }) + } +} + +func TestCloudRuleURL(t *testing.T) { + tests := []struct { + name string + rule string + url string + }{ + {"CloudRule", "cloud::2500::generic-fields", "https://linter.aip.dev/2500/generic-fields"}, + {"NotCloudRule", "test::0122::camel-case-uris", ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := cloudRuleURL(test.rule); got != test.url { + t.Errorf("cloudRuleUrl(%s) got %s, but want %s", test.name, got, test.url) + } + }) + } +} + +func TestGetRuleURL(t *testing.T) { + mapping1 := func(name string) string { + if name == "one" { + return "ONE" + } + return "" + } + mapping2 := func(name string) string { + if name == "two" { + return "TWO" + } + return "" + } + ruleURLMappings := []func(string) string{mapping1, mapping2} + + tests := []struct { + name string + ruleName string + ruleURL string + }{ + {"MappingOne", "one", "ONE"}, + {"MappingTwo", "two", "TWO"}, + {"NoMapping", "zero", ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := getRuleURL(test.ruleName, ruleURLMappings); got != test.ruleURL { + t.Errorf("getRuleURL(%s) got %s, but want %s", test.ruleName, got, test.ruleURL) + } + }) + } +} diff --git a/locations/v2/descriptor_locations.go b/locations/v2/descriptor_locations.go new file mode 100644 index 00000000..2a930d9d --- /dev/null +++ b/locations/v2/descriptor_locations.go @@ -0,0 +1,28 @@ +// Copyright 2019 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 locations + +import ( + "google.golang.org/protobuf/reflect/protoreflect" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +// DescriptorName returns the precise location for a descriptor's name. +// +// This works for any descriptor, regardless of type (message, field, etc.). +func DescriptorName(d protoreflect.Descriptor) *dpb.SourceCodeInfo_Location { + // All descriptors seem to have `string name = 1`, so this conveniently works. + return pathLocation(d, 1) +} diff --git a/locations/v2/descriptor_locations_test.go b/locations/v2/descriptor_locations_test.go new file mode 100644 index 00000000..1cd9cc21 --- /dev/null +++ b/locations/v2/descriptor_locations_test.go @@ -0,0 +1,57 @@ +// Copyright 2019 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 locations + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func TestDescriptorName(t *testing.T) { + f := parse(t, ` + message Foo { + string bar = 1; + map baz = 2; + } + `) + + tests := []struct { + testName string + d protoreflect.Descriptor + wantSpan []int32 + }{ + {"Message", f.Messages().Get(0), []int32{2, 8, 11}}, + {"Field", f.Messages().Get(0).Fields().Get(0), []int32{3, 9, 12}}, + {"MapField", f.Messages().Get(0).Fields().Get(1), []int32{4, 22, 25}}, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + if diff := cmp.Diff(DescriptorName(test.d).Span, test.wantSpan); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestFileDescriptorName(t *testing.T) { + f := parse(t, ` + message Foo {} + `) + if got := DescriptorName(f); got != nil { + t.Errorf("%v", got) + } +} diff --git a/locations/v2/field_locations.go b/locations/v2/field_locations.go new file mode 100644 index 00000000..4c2db0fc --- /dev/null +++ b/locations/v2/field_locations.go @@ -0,0 +1,54 @@ +// Copyright 2019 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 locations + +import ( + apb "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/reflect/protoreflect" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +// FieldOption returns the precise location for the given extension definition on +// the given field. This is useful for writing rules against custom extensions. +// +// Example: locations.FieldOption(field, fieldbehaviorpb.E_FieldBehavior) +func FieldOption(f protoreflect.FieldDescriptor, e protoreflect.ExtensionType) *dpb.SourceCodeInfo_Location { + return pathLocation(f, 8, int(e.TypeDescriptor().Number())) // FieldDescriptor.options == 8 +} + +// FieldResourceReference returns the precise location for a field's +// resource reference annotation. +func FieldResourceReference(f protoreflect.FieldDescriptor) *dpb.SourceCodeInfo_Location { + return pathLocation(f, 8, int(apb.E_ResourceReference.TypeDescriptor().Number())) // FieldDescriptor.options == 8 +} + +// FieldBehavior returns the precise location for a field's +// field_behavior annotation. +func FieldBehavior(f protoreflect.FieldDescriptor) *dpb.SourceCodeInfo_Location { + return pathLocation(f, 8, int(apb.E_FieldBehavior.TypeDescriptor().Number())) // FieldDescriptor.options == 8 +} + +// FieldType returns the precise location for a field's type. +func FieldType(f protoreflect.FieldDescriptor) *dpb.SourceCodeInfo_Location { + if f.Message() != nil || f.Enum() != nil { + return pathLocation(f, 6) // FieldDescriptor.type_name == 6 + } + return pathLocation(f, 5) // FieldDescriptor.type == 5 +} + +// FieldLabel returns the precise location for a field's label. +func FieldLabel(f protoreflect.FieldDescriptor) *dpb.SourceCodeInfo_Location { + return pathLocation(f, 4) // FieldDescriptor.label == 4 +} diff --git a/locations/v2/field_locations_test.go b/locations/v2/field_locations_test.go new file mode 100644 index 00000000..039798e1 --- /dev/null +++ b/locations/v2/field_locations_test.go @@ -0,0 +1,104 @@ +// Copyright 2019 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 locations + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + apb "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func TestFieldLocations(t *testing.T) { + f := parse(t, ` + message Book { + string name = 1; + Author author = 2; + } + message Author {} + `) + tests := []struct { + name string + field protoreflect.FieldDescriptor + span []int32 + }{ + {"Primitive", f.Messages().Get(0).Fields().Get(0), []int32{3, 2, 8}}, + {"Composite", f.Messages().Get(0).Fields().Get(1), []int32{4, 2, 8}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + l := FieldType(test.field) + if diff := cmp.Diff(l.GetSpan(), test.span); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestFieldLabel(t *testing.T) { + f := parse(t, ` + message Book { + string name = 1; + optional string author = 2; + } + `) + for _, test := range []struct { + name string + field protoreflect.FieldDescriptor + span []int32 + }{ + {"Present", f.Messages().Get(0).Fields().Get(1), []int32{4, 8, 16}}, + {"Absent", f.Messages().Get(0).Fields().Get(0), nil}, + } { + t.Run(test.name, func(t *testing.T) { + l := FieldLabel(test.field) + if diff := cmp.Diff(l.GetSpan(), test.span); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestFieldResourceReference(t *testing.T) { + f := parse(t, ` + import "google/api/resource.proto"; + message GetBookRequest { + string name = 1 [(google.api.resource_reference) = { + type: "library.googleapis.com/Book" + }]; + } + `) + loc := FieldResourceReference(f.Messages().Get(0).Fields().Get(0)) + // resource_reference annotation location is roughly line 4, column 19. + if diff := cmp.Diff(loc.GetSpan(), []int32{4, 19, 6, 3}); diff != "" { + t.Error(diff) + } +} + +func TestFieldOption(t *testing.T) { + f := parse(t, ` + import "google/api/resource.proto"; + message GetBookRequest { + string name = 1 [(google.api.resource_reference) = { + type: "library.googleapis.com/Book" + }]; + } + `) + loc := FieldOption(f.Messages().Get(0).Fields().Get(0), apb.E_ResourceReference) + if diff := cmp.Diff(loc.GetSpan(), []int32{4, 19, 6, 3}); diff != "" { + t.Error(diff) + } +} diff --git a/locations/v2/file_locations.go b/locations/v2/file_locations.go new file mode 100644 index 00000000..4749eaed --- /dev/null +++ b/locations/v2/file_locations.go @@ -0,0 +1,91 @@ +// Copyright 2019 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 locations + +import ( + apb "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/reflect/protoreflect" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +// FileSyntax returns the location of the syntax definition in a file descriptor. +// +// If the location can not be found (for example, because there is no syntax +// statement), it returns nil. +func FileSyntax(f protoreflect.FileDescriptor) *dpb.SourceCodeInfo_Location { + return pathLocation(f, 12) // FileDescriptor.syntax == 12 +} + +// FilePackage returns the location of the package definition in a file descriptor. +// +// If the location can not be found (for example, because there is no package +// statement), it returns nil. +func FilePackage(f protoreflect.FileDescriptor) *dpb.SourceCodeInfo_Location { + return pathLocation(f, 2) // FileDescriptor.package == 2 +} + +// FileCsharpNamespace returns the location of the csharp_namespace file option +// in a file descriptor. +// +// If the location can not be found (for example, because there is no +// csharp_namespace option), it returns nil. +func FileCsharpNamespace(f protoreflect.FileDescriptor) *dpb.SourceCodeInfo_Location { + return pathLocation(f, 8, 37) // 8 == options, 37 == csharp_namespace +} + +// FileJavaPackage returns the location of the java_package file option +// in a file descriptor. +// +// If the location can not be found (for example, because there is no +// java_package option), it returns nil. +func FileJavaPackage(f protoreflect.FileDescriptor) *dpb.SourceCodeInfo_Location { + return pathLocation(f, 8, 1) // 8 == options, 1 == java_package +} + +// FilePhpNamespace returns the location of the php_namespace file option +// in a file descriptor. +// +// If the location can not be found (for example, because there is no +// php_namespace option), it returns nil. +func FilePhpNamespace(f protoreflect.FileDescriptor) *dpb.SourceCodeInfo_Location { + return pathLocation(f, 8, 41) // 8 == options, 41 == php_namespace +} + +// FileRubyPackage returns the location of the ruby_package file option +// in a file descriptor. +// +// If the location can not be found (for example, because there is no +// ruby_package option), it returns nil. +func FileRubyPackage(f protoreflect.FileDescriptor) *dpb.SourceCodeInfo_Location { + return pathLocation(f, 8, 45) // 8 == options, 45 == ruby_package +} + +// FileResourceDefinition returns the precise location of the `google.api.resource_definition` +// annotation. +func FileResourceDefinition(f protoreflect.FileDescriptor, index int) *dpb.SourceCodeInfo_Location { + // 8 == options + return pathLocation(f, 8, int(apb.E_ResourceDefinition.TypeDescriptor().Number()), index) +} + +// FileImport returns the location of the import on the given `index`, or `nil` +// if no import with such `index` is found. +func FileImport(f protoreflect.FileDescriptor, index int) *dpb.SourceCodeInfo_Location { + return pathLocation(f, 3, index) // 3 == dependency +} + +// FileCCEnableArenas returns the location of the `cc_enable_arenas` option. +func FileCCEnableArenas(f protoreflect.FileDescriptor) *dpb.SourceCodeInfo_Location { + return pathLocation(f, 8, 31) // 8 == (file) options, 31 == cc_enable_arenas +} diff --git a/locations/v2/file_locations_test.go b/locations/v2/file_locations_test.go new file mode 100644 index 00000000..7f0daaa1 --- /dev/null +++ b/locations/v2/file_locations_test.go @@ -0,0 +1,155 @@ +// Copyright 2019 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 locations + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +func TestLocations(t *testing.T) { + f := parse(t, ` + // proto3 rules! + syntax = "proto3"; + + import "google/api/resource.proto"; + + package google.api.linter; + + option csharp_namespace = "Google.Api.Linter"; + option java_package = "com.google.api.linter"; + option php_namespace = "Google\\Api\\Linter"; + option ruby_package = "Google::Api::Linter"; + option cc_enable_arenas = false; + + message Foo { + string bar = 1; + } + `) + + // Test the file location functions. + t.Run("File", func(t *testing.T) { + tests := []struct { + testName string + fx func(f protoreflect.FileDescriptor) *dpb.SourceCodeInfo_Location + idxFx func(f protoreflect.FileDescriptor, i int) *dpb.SourceCodeInfo_Location + idx int + wantSpan []int32 + }{ + { + testName: "Syntax", + fx: FileSyntax, + wantSpan: []int32{1, 0, int32(len("syntax = \"proto3\";"))}, + }, + { + testName: "Package", + fx: FilePackage, + wantSpan: []int32{5, 0, int32(len("package google.api.linter;"))}, + }, + { + testName: "CsharpNamespace", + fx: FileCsharpNamespace, + wantSpan: []int32{7, 0, int32(len(`option csharp_namespace = "Google.Api.Linter";`))}, + }, + { + testName: "JavaPackage", + fx: FileJavaPackage, + wantSpan: []int32{8, 0, int32(len(`option java_package = "com.google.api.linter";`))}, + }, + { + testName: "PhpNamespace", + fx: FilePhpNamespace, + wantSpan: []int32{9, 0, int32(len(`option php_namespace = "Google\\Api\\Linter";`))}, + }, + { + testName: "RubyPackage", + fx: FileRubyPackage, + wantSpan: []int32{10, 0, int32(len(`option ruby_package = "Google::Api::Linter";`))}, + }, + { + testName: "Import", + idxFx: FileImport, + idx: 0, + wantSpan: []int32{3, 0, int32(len(`import "google/api/resource.proto";`))}, + }, + { + testName: "CCEnableArenas", + fx: FileCCEnableArenas, + wantSpan: []int32{11, 0, int32(len(`option cc_enable_arenas = false;`))}, + }, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + var l *dpb.SourceCodeInfo_Location + if test.fx != nil { + l = test.fx(f) + } else { + l = test.idxFx(f, test.idx) + } + if diff := cmp.Diff(l.Span, test.wantSpan); diff != "" { + t.Error(diff) + } + }) + } + }) + + // Test bogus locations. + t.Run("Bogus", func(t *testing.T) { + tests := []struct { + testName string + path []int + }{ + {"NotFound", []int{6, 0}}, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + if loc := pathLocation(f, test.path...); loc != nil { + t.Errorf("%v", loc) + } + }) + } + }) +} + +func TestMissingLocations(t *testing.T) { + fdp := &dpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + MessageType: []*dpb.DescriptorProto{{Name: proto.String("Foo")}}, + } + f, err := protodesc.NewFile(fdp, new(protoregistry.Files)) + if err != nil { + t.Fatalf("%v", err) + } + tests := []struct { + testName string + fx func(f protoreflect.FileDescriptor) *dpb.SourceCodeInfo_Location + }{ + {"Syntax", FileSyntax}, + {"Package", FilePackage}, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + if loc := test.fx(f); loc != nil { + t.Errorf("Expected nil location, got %v", loc) + } + }) + } +} diff --git a/locations/v2/locations.go b/locations/v2/locations.go new file mode 100644 index 00000000..a95743d7 --- /dev/null +++ b/locations/v2/locations.go @@ -0,0 +1,119 @@ +// Copyright 2019 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 locations provides functions to get the location of a particular part +// of a descriptor, allowing Problems to be attached to just a descriptor's +// name, type, etc.. This allows for better auto-replacement functionality in +// code review tools. +// +// All functions in this package accept a descriptor and return a +// protobuf SourceCodeInfo_Location object, which can be passed directly +// to the Location property on Problem. +package locations + +import ( + "sync" + + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +// pathLocation returns the precise location for a given descriptor and path. +// It combines the path of the descriptor itself with any path provided appended. +func pathLocation(d protoreflect.Descriptor, path ...int) *dpb.SourceCodeInfo_Location { + fullPath := d.ParentFile().SourceLocations().ByDescriptor(d).Path + for _, i := range path { + fullPath = append(fullPath, int32(i)) + } + return sourceInfoRegistry.sourceInfo(d.ParentFile()).findLocation(fullPath) +} + +type sourceInfo struct { + // infoMu protects the info map + infoMu sync.Mutex + info map[string]*dpb.SourceCodeInfo_Location +} + +func newSourceInfo() *sourceInfo { + return &sourceInfo{ + info: map[string]*dpb.SourceCodeInfo_Location{}, + } +} + +// findLocation returns the Location for a given path. +func (si *sourceInfo) findLocation(path []int32) *dpb.SourceCodeInfo_Location { + si.infoMu.Lock() + defer si.infoMu.Unlock() + + // If the path exists in the source info registry, return that object. + if loc, ok := si.info[strPath(path)]; ok { + return loc + } + + // We could not find the path; return nil. + return nil +} + +// The source map registry is a singleton that computes a source map for +// any file descriptor that it is given, but then caches it to avoid computing +// the source map for the same file descriptors over and over. +type sourceInfoRegistryType struct { + // registryMu protects the registry map + registryMu sync.Mutex + registry map[protoreflect.FileDescriptor]*sourceInfo +} + +func newSourceInfoRegistryType() *sourceInfoRegistryType { + return &sourceInfoRegistryType{ + registry: map[protoreflect.FileDescriptor]*sourceInfo{}, + } +} + +// Each location has a path defined as an []int32, but we can not +// use slices as keys, so compile them into a string. +func strPath(segments []int32) (p string) { + for i, segment := range segments { + if i > 0 { + p += "," + } + p += string(segment) + } + return +} + +// sourceInfo compiles the source info object for a given file descriptor. +// It also caches this into a registry, so subsequent calls using the same +// descriptor will return the same object. +func (sir *sourceInfoRegistryType) sourceInfo(fd protoreflect.FileDescriptor) *sourceInfo { + sir.registryMu.Lock() + defer sir.registryMu.Unlock() + answer, ok := sir.registry[fd] + if !ok { + answer = newSourceInfo() + + // This file descriptor does not yet have a source info map. + // Compile one. + for _, loc := range protodesc.ToFileDescriptorProto(fd).GetSourceCodeInfo().GetLocation() { + answer.info[strPath(loc.Path)] = loc + } + + // Now that we calculated all of this, cache it on the registry so it + // does not need to be calculated again. + sir.registry[fd] = answer + } + return answer +} + +var sourceInfoRegistry = newSourceInfoRegistryType() diff --git a/locations/v2/locations_test.go b/locations/v2/locations_test.go new file mode 100644 index 00000000..461c2187 --- /dev/null +++ b/locations/v2/locations_test.go @@ -0,0 +1,96 @@ +// Copyright 2019 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 locations + +import ( + "context" + "strings" + "sync" + "testing" + + "github.com/bufbuild/protocompile" + "github.com/lithammer/dedent" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + + // These imports cause the common protos to be registered with + // the protocol buffer registry, and therefore make the call to + // `proto.FileDescriptor` work for the imported files. + _ "cloud.google.com/go/longrunning/autogen/longrunningpb" + _ "google.golang.org/genproto/googleapis/api/annotations" +) + +func parse(t *testing.T, s string) protoreflect.FileDescriptor { + t.Helper() + s = strings.TrimSpace(dedent.Dedent(s)) + if !strings.Contains(s, "syntax = ") { + s = "syntax = \"proto3\";\n\n" + s + } + + // Resolver for our in-memory test file + testFileResolver := &protocompile.SourceResolver{ + Accessor: protocompile.SourceAccessorFromMap(map[string]string{ + "test.proto": s, + }), + } + + // Resolver for standard imports (like google/api/annotations.proto) + importResolver := protocompile.ResolverFunc(func(path string) (protocompile.SearchResult, error) { + fd, err := protoregistry.GlobalFiles.FindFileByPath(path) + if err != nil { + return protocompile.SearchResult{}, err + } + return protocompile.SearchResult{Desc: fd}, nil + }) + + compiler := protocompile.Compiler{ + Resolver: protocompile.CompositeResolver{testFileResolver, importResolver}, + SourceInfoMode: protocompile.SourceInfoStandard, + } + + fds, err := compiler.Compile(context.Background(), "test.proto") + if err != nil { + t.Fatalf("%v", err) + } + + return fds[0] +} + +func TestSourceInfo_Concurrency(t *testing.T) { + fd := parse(t, ` + syntax = "proto3"; + package foo.bar; + `) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + FileSyntax(fd) + }() + + wg.Add(1) + go func() { + defer wg.Done() + FilePackage(fd) + }() + + wg.Add(1) + go func() { + defer wg.Done() + FileImport(fd, 0) + }() + wg.Wait() +} diff --git a/locations/v2/message_locations.go b/locations/v2/message_locations.go new file mode 100644 index 00000000..1fa16a61 --- /dev/null +++ b/locations/v2/message_locations.go @@ -0,0 +1,27 @@ +// Copyright 2019 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 locations + +import ( + apb "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/reflect/protoreflect" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +// MessageResource returns the precise location of the `google.api.resource` +// annotation. +func MessageResource(m protoreflect.MessageDescriptor) *dpb.SourceCodeInfo_Location { + return pathLocation(m, 7, int(apb.E_Resource.TypeDescriptor().Number())) // MessageDescriptor.options == 7 +} diff --git a/locations/v2/message_locations_test.go b/locations/v2/message_locations_test.go new file mode 100644 index 00000000..a8133a04 --- /dev/null +++ b/locations/v2/message_locations_test.go @@ -0,0 +1,37 @@ +// Copyright 2019 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 locations + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestMessageResource(t *testing.T) { + f := parse(t, ` + import "google/api/resource.proto"; + message Book { + option (google.api.resource) = { + type: "library.googleapis.com/Book" + pattern: "publishers/{publisher}/books/{book}" + }; + } + `) + loc := MessageResource(f.Messages().Get(0)) + if diff := cmp.Diff(loc.GetSpan(), []int32{4, 2, 7, 4}); diff != "" { + t.Error(diff) + } +} diff --git a/locations/v2/method_locations.go b/locations/v2/method_locations.go new file mode 100644 index 00000000..7b2330ed --- /dev/null +++ b/locations/v2/method_locations.go @@ -0,0 +1,55 @@ +// Copyright 2019 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 locations + +import ( + lrpb "cloud.google.com/go/longrunning/autogen/longrunningpb" + apb "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/reflect/protoreflect" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +// MethodRequestType returns the precise location of the method's input type. +func MethodRequestType(m protoreflect.MethodDescriptor) *dpb.SourceCodeInfo_Location { + return pathLocation(m, 2) // MethodDecriptor.input_type == 2 +} + +// MethodResponseType returns the precise location of the method's output type. +func MethodResponseType(m protoreflect.MethodDescriptor) *dpb.SourceCodeInfo_Location { + return pathLocation(m, 3) // MethodDescriptor.output_type == 3 +} + +// MethodHTTPRule returns the precise location of the method's `google.api.http` +// rule, if any. +func MethodHTTPRule(m protoreflect.MethodDescriptor) *dpb.SourceCodeInfo_Location { + return MethodOption(m, int(apb.E_Http.TypeDescriptor().Number())) +} + +// MethodOperationInfo returns the precise location of the method's +// `google.longrunning.operation_info` annotation, if any. +func MethodOperationInfo(m protoreflect.MethodDescriptor) *dpb.SourceCodeInfo_Location { + return MethodOption(m, int(lrpb.E_OperationInfo.TypeDescriptor().Number())) +} + +// MethodSignature returns the precise location of the method's +// `google.api.method_signature` annotation, if any. +func MethodSignature(m protoreflect.MethodDescriptor, index int) *dpb.SourceCodeInfo_Location { + return pathLocation(m, 4, int(apb.E_MethodSignature.TypeDescriptor().Number()), index) // MethodDescriptor.options == 4 +} + +// MethodOption returns the precise location of the method's option with the given field number, if any. +func MethodOption(m protoreflect.MethodDescriptor, fieldNumber int) *dpb.SourceCodeInfo_Location { + return pathLocation(m, 4, fieldNumber) // MethodDescriptor.options == 4 +} diff --git a/locations/v2/method_locations_test.go b/locations/v2/method_locations_test.go new file mode 100644 index 00000000..0d978640 --- /dev/null +++ b/locations/v2/method_locations_test.go @@ -0,0 +1,150 @@ +// Copyright 2019 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 locations + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestMethodRequestType(t *testing.T) { + f := parse(t, ` + service Library { + rpc GetBook(GetBookRequest) returns (Book); + } + message GetBookRequest {} + message Book {} + `) + loc := MethodRequestType(f.Services().Get(0).Methods().Get(0)) + // Three character span: line, start column, end column. + if diff := cmp.Diff(loc.GetSpan(), []int32{3, 14, 28}); diff != "" { + t.Error(diff) + } +} + +func TestMethodResponseType(t *testing.T) { + f := parse(t, ` + service Library { + rpc GetBook(GetBookRequest) returns (Book); + } + message GetBookRequest {} + message Book {} + `) + loc := MethodResponseType(f.Services().Get(0).Methods().Get(0)) + // Three character span: line, start column, end column. + if diff := cmp.Diff(loc.GetSpan(), []int32{3, 39, 43}); diff != "" { + t.Error(diff) + } +} + +func TestMethodHTTPRule(t *testing.T) { + f := parse(t, ` + import "google/api/annotations.proto"; + service Library { + rpc GetBook(GetBookRequest) returns (Book) { + option (google.api.http) = { + get: "/v1/{name=publishers/*/books/*}" + }; + } + } + message GetBookRequest{} + message Book {} + `) + loc := MethodHTTPRule(f.Services().Get(0).Methods().Get(0)) + // Four character span: start line, start column, end line, end column. + if diff := cmp.Diff(loc.GetSpan(), []int32{5, 4, 7, 6}); diff != "" { + t.Error(diff) + } +} + +func TestMethodOperationInfo(t *testing.T) { + f := parse(t, ` + import "google/longrunning/operations.proto"; + service Library { + rpc WriteBook(WriteBookRequest) returns (google.longrunning.Operation) { + option (google.longrunning.operation_info) = { + response_type: "WriteBookResponse" + metadata_type: "WriteBookMetadata" + }; + } + } + message WriteBookRequest {} + `) + loc := MethodOperationInfo(f.Services().Get(0).Methods().Get(0)) + // Four character span: start line, start column, end line, end column. + if diff := cmp.Diff(loc.GetSpan(), []int32{5, 4, 8, 6}); diff != "" { + t.Error(diff) + } +} + +func TestMethodSignature(t *testing.T) { + f := parse(t, ` + import "google/api/client.proto"; + service Library { + rpc GetBook(GetBookRequest) returns (Book) { + option (google.api.method_signature) = "name"; + option (google.api.method_signature) = "name,read_mask"; + } + } + message GetBookRequest{} + message Book {} + `) + for _, test := range []struct { + name string + index int + want []int32 + }{ + {"First", 0, []int32{5, 4, 50}}, + {"Second", 1, []int32{6, 4, 60}}, + } { + loc := MethodSignature(f.Services().Get(0).Methods().Get(0), test.index) + // Four character span: start line, start column, end line, end column. + if diff := cmp.Diff(loc.GetSpan(), test.want); diff != "" { + t.Error(diff) + } + } +} + +func TestMethodOption(t *testing.T) { + f := parse(t, ` + service Library { + rpc GetBook(GetBookRequest) returns (Book) { + option deprecated = true; + } + rpc UpdateBook(UpdateBookRequest) returns (Book) {} + } + message GetBookRequest{} + message Book {} + message UpdateBookRequest {} + `) + + for _, test := range []struct { + name string + methodIdx int + want []int32 + }{ + {"OptionSet", 0, []int32{4, 4, 29}}, + {"OptionNotSet", 1, nil}, + } { + t.Run(test.name, func(t *testing.T) { + // field number of the deprecated option == 33 + loc := MethodOption(f.Services().Get(0).Methods().Get(test.methodIdx), 33) + if diff := cmp.Diff(loc.GetSpan(), test.want); diff != "" { + t.Errorf("Diff: %s", diff) + } + }) + } +} diff --git a/rules/internal/data/v2/data.go b/rules/internal/data/v2/data.go new file mode 100644 index 00000000..5ef73134 --- /dev/null +++ b/rules/internal/data/v2/data.go @@ -0,0 +1,33 @@ +// Copyright 2019 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 data contains constants used in multiple AIP rules. +package data + +import "bitbucket.org/creachadair/stringset" + +// Conjunctions is a set of conjunctions. +var Conjunctions = stringset.New("and", "or") + +// Prepositions is a set of prepositions. +var Prepositions = stringset.New( + "after", "at", "before", "between", "but", "by", "except", + "for", "from", "in", "including", "into", "of", "over", "since", "to", + "toward", "under", "upon", "with", "within", "without", +) + +// ---------------------------------------------------------------------------- +// IMPORTANT: Make sure you update docs/_includes/prepositions.md if you +// update the set of prepositions. +// ---------------------------------------------------------------------------- diff --git a/rules/internal/data/v2/data_test.go b/rules/internal/data/v2/data_test.go new file mode 100644 index 00000000..4b44af46 --- /dev/null +++ b/rules/internal/data/v2/data_test.go @@ -0,0 +1,25 @@ +// Copyright 2019 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 data + +import "testing" + +// "per" is a special exception to the preposition rule, per AIP-140 +// (but it would be easy for a well-meaning person to add it to the set). +func TestPrepositionsExcludesPer(t *testing.T) { + if Prepositions.Contains("per") { + t.Errorf("`per` should be an acceptable preposition.") + } +} diff --git a/rules/internal/testutils/v2/parse.go b/rules/internal/testutils/v2/parse.go new file mode 100644 index 00000000..773308e0 --- /dev/null +++ b/rules/internal/testutils/v2/parse.go @@ -0,0 +1,149 @@ +// Copyright 2019 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 testutils + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" + "text/template" + + "github.com/bufbuild/protocompile" + "github.com/lithammer/dedent" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + + // These imports cause the common protos to be registered with + // the protocol buffer registry, and therefore make the call to + // `proto.FileDescriptor` work for the imported files. + _ "cloud.google.com/go/iam/apiv1/iampb" + _ "cloud.google.com/go/longrunning/autogen/longrunningpb" + _ "google.golang.org/genproto/googleapis/api/annotations" + _ "google.golang.org/genproto/googleapis/api/httpbody" + _ "google.golang.org/genproto/googleapis/type/date" + _ "google.golang.org/genproto/googleapis/type/datetime" + _ "google.golang.org/genproto/googleapis/type/timeofday" +) + +// ParseProtoStrings parses a map representing a proto files, and returns +// a slice of FileDescriptors. +// +// It dedents the string before parsing. +func ParseProtoStrings(t *testing.T, src map[string]string) map[string]protoreflect.FileDescriptor { + t.Helper() + filenames := []string{} + for k, v := range src { + filenames = append(filenames, k) + src[k] = strings.TrimSpace(dedent.Dedent(v)) + } + + // Create a resolver for the in-memory files. + memResolver := &protocompile.SourceResolver{ + Accessor: protocompile.SourceAccessorFromMap(src), + } + + // Create a resolver for imports. + importResolver := protocompile.ResolverFunc(func(path string) (protocompile.SearchResult, error) { + fd, err := protoregistry.GlobalFiles.FindFileByPath(path) + if err != nil { + return protocompile.SearchResult{}, err + } + return protocompile.SearchResult{Desc: fd}, nil + }) + + compiler := protocompile.Compiler{ + Resolver: protocompile.WithStandardImports(protocompile.CompositeResolver{memResolver, importResolver}), + SourceInfoMode: protocompile.SourceInfoStandard, + } + fds, err := compiler.Compile(context.Background(), filenames...) + if err != nil { + t.Fatalf("%v", err) + } + + answer := map[string]protoreflect.FileDescriptor{} + for _, fd := range fds { + answer[fd.Path()] = fd + } + return answer +} + +// ParseProto3String parses a string representing a proto file, and returns +// a FileDescriptor. +// +// It adds the `syntax = "proto3";` line to the beginning of the file and +// chooses a filename, and then calls ParseProtoStrings. +func ParseProto3String(t *testing.T, src string) protoreflect.FileDescriptor { + t.Helper() + return ParseProtoStrings(t, map[string]string{ + "test.proto": fmt.Sprintf( + "syntax = \"proto3\";\n\n%s", + strings.TrimSpace(dedent.Dedent(src)), + ), + })["test.proto"] +} + +// ParseProtoString parses a string representing a proto file, and returns +// a FileDescriptor. +// +// It dedents the string before parsing. +func ParseProtoString(t *testing.T, src string) protoreflect.FileDescriptor { + t.Helper() + return ParseProtoStrings(t, map[string]string{"test.proto": src})["test.proto"] +} + +// ParseProto3Tmpl parses a template string representing a proto file, and +// returns a FileDescriptor. +// +// It parses the template using Go's text/template Parse function, and then +// calls ParseProto3String. +func ParseProto3Tmpl(t *testing.T, src string, data interface{}) protoreflect.FileDescriptor { + t.Helper() + return ParseProto3Tmpls(t, map[string]string{ + "test.proto": src, + }, data)["test.proto"] +} + +// ParseProto3Tmpls parses template strings representing a proto file, +// and returns FileDescriptors. +// +// It parses the template using Go's text/template Parse function, and then +// calls ParseProto3Strings. +func ParseProto3Tmpls(t *testing.T, srcs map[string]string, data interface{}) map[string]protoreflect.FileDescriptor { + t.Helper() + strs := map[string]string{} + for fn, src := range srcs { + // Create a new template object. + tmpl, err := template.New("test").Parse(src) + if err != nil { + t.Fatalf("Unable to parse Go template: %v", err) + } + + // Execute the template and write the results to a bytes representing + // the desired proto. + var protoBytes bytes.Buffer + err = tmpl.Execute(&protoBytes, data) + if err != nil { + t.Fatalf("Unable to execute Go template: %v", err) + } + + // Add the proto to the map to send to parse strings. + strs[fn] = fmt.Sprintf("syntax = %q;\n\n%s", "proto3", protoBytes.String()) + } + + // Parse the proto as a string. + return ParseProtoStrings(t, strs) +} diff --git a/rules/internal/testutils/v2/parse_test.go b/rules/internal/testutils/v2/parse_test.go new file mode 100644 index 00000000..27182b3b --- /dev/null +++ b/rules/internal/testutils/v2/parse_test.go @@ -0,0 +1,237 @@ +// Copyright 2019 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 testutils + +import ( + "sync" + "testing" + + "google.golang.org/protobuf/reflect/protoreflect" +) + +func TestParseProtoStrings(t *testing.T) { + fd := ParseProtoStrings(t, map[string]string{"test.proto": ` + syntax = "proto3"; + + import "google/protobuf/timestamp.proto"; + + message Foo { + int32 bar = 1; + int64 baz = 2; + } + + message Spam { + string eggs = 2; + google.protobuf.Timestamp create_time = 3; + } + `})["test.proto"] + if fd.Syntax() != protoreflect.Proto3 { + t.Errorf("Expected a proto3 file descriptor.") + } + tests := []struct { + name string + descriptor protoreflect.Descriptor + }{ + {"Foo", fd.Messages().Get(0)}, + {"bar", fd.Messages().Get(0).Fields().Get(0)}, + {"baz", fd.Messages().Get(0).Fields().Get(1)}, + {"Spam", fd.Messages().Get(1)}, + {"eggs", fd.Messages().Get(1).Fields().Get(0)}, + {"create_time", fd.Messages().Get(1).Fields().Get(1)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got, want := string(test.descriptor.Name()), test.name; got != want { + t.Errorf("Got %q, expected %q.", got, want) + } + }) + } +} + +func TestParseProtoStringsError(t *testing.T) { + canary := &testing.T{} + + // t.Fatalf will exit the goroutine, so to test this, + // we run the test in a different goroutine. + wg := &sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + ParseProtoStrings(canary, map[string]string{"test.proto": ` + syntax = "proto3"; + message Foo {} + The quick brown fox jumped over the lazy dogs. + `}) + }() + wg.Wait() + + // Verify that the testing.T object was given a failure. + if !canary.Failed() { + t.Errorf("Expected syntax error to cause a fatal error.") + } +} + +func TestParseProtoString(t *testing.T) { + fd := ParseProtoString(t, ` + syntax = "proto3"; + + import "google/protobuf/timestamp.proto"; + + message Foo { + int32 bar = 1; + int64 baz = 2; + } + + message Spam { + string eggs = 2; + google.protobuf.Timestamp create_time = 3; + } + `) + if fd.Syntax() != protoreflect.Proto3 { + t.Errorf("Expected a proto3 file descriptor.") + } + tests := []struct { + name string + descriptor protoreflect.Descriptor + }{ + {"Foo", fd.Messages().Get(0)}, + {"bar", fd.Messages().Get(0).Fields().Get(0)}, + {"baz", fd.Messages().Get(0).Fields().Get(1)}, + {"Spam", fd.Messages().Get(1)}, + {"eggs", fd.Messages().Get(1).Fields().Get(0)}, + {"create_time", fd.Messages().Get(1).Fields().Get(1)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got, want := string(test.descriptor.Name()), test.name; got != want { + t.Errorf("Got %q, expected %q.", got, want) + } + }) + } +} + +func TestParseProtoStringError(t *testing.T) { + canary := &testing.T{} + + // t.Fatalf will exit the goroutine, so to test this, + // we run the test in a different goroutine. + wg := &sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + ParseProtoString(canary, ` + syntax = "proto3"; + message Foo {} + The quick brown fox jumped over the lazy dogs. + `) + }() + wg.Wait() + + // Verify that the testing.T object was given a failure. + if !canary.Failed() { + t.Errorf("Expected syntax error to cause a fatal error.") + } +} + +func TestParseProto3String(t *testing.T) { + fd := ParseProto3String(t, ` + message Foo { + int32 bar = 1; + int64 baz = 2; + } + + message Spam { + string eggs = 2; + } + `) + if fd.Syntax() != protoreflect.Proto3 { + t.Errorf("Expected a proto3 file descriptor.") + } +} + +func TestParseProto3Tmpl(t *testing.T) { + tests := []struct { + MessageName string + Field1Name string + Field2Name string + }{ + {"Book", "title", "author"}, + {"Foo", "bar", "baz"}, + } + for _, test := range tests { + t.Run(test.MessageName, func(t *testing.T) { + fd := ParseProto3Tmpl(t, ` + message {{.MessageName}} { + string {{.Field1Name}} = 1; + string {{.Field2Name}} = 2; + } + `, test) + if fd.Syntax() != protoreflect.Proto3 { + t.Errorf("Expected a proto3 file descriptor.") + } + msg := fd.Messages().Get(0) + if got, want := string(msg.Name()), test.MessageName; got != want { + t.Errorf("Got %q for message name, expected %q.", got, want) + } + for i, fn := range []string{test.Field1Name, test.Field2Name} { + if got, want := string(msg.Fields().Get(i).Name()), fn; got != want { + t.Errorf("Got %q for field name %d; expected %q.", got, i+1, want) + } + } + }) + } +} + +func TestParseProto3TmplSyntaxError(t *testing.T) { + canary := &testing.T{} + + // t.Fatalf will exit the goroutine, so to test this, + // we run the test in a different goroutine. + wg := &sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + ParseProto3Tmpl(canary, ` + message {{.InvalidTmplVariable[0]}} {} + `, struct{}{}) + }() + wg.Wait() + + // Verify that the testing.T object was given a failure. + if !canary.Failed() { + t.Errorf("Expected syntax error to cause a fatal error.") + } +} + +func TestParseProto3TmplDataError(t *testing.T) { + canary := &testing.T{} + + // t.Fatalf will exit the goroutine, so to test this, + // we run the test in a different goroutine. + wg := &sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + ParseProto3Tmpl(canary, ` + message {{.MissingVariable}} {} + `, struct{}{}) + }() + wg.Wait() + + // Verify that the testing.T object was given a failure. + if !canary.Failed() { + t.Errorf("Expected missing data to cause a fatal error.") + } +} diff --git a/rules/internal/testutils/v2/problems.go b/rules/internal/testutils/v2/problems.go new file mode 100644 index 00000000..1f361a39 --- /dev/null +++ b/rules/internal/testutils/v2/problems.go @@ -0,0 +1,74 @@ +// Copyright 2019 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 testutils + +import ( + "strings" + + "github.com/google/go-cmp/cmp" + "github.com/aep-dev/api-linter/lint/v2" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// Problems is a slice of individual Problem objects. +type Problems []lint.Problem + +// Diff determines whether a Problem is sufficiently similar to another +// to be considered equivalent, and returns a diff otherwise. +// +// This is intended for unit tests and is intentially generous on what +// constitutes equality. +func (problems Problems) Diff(other []lint.Problem) string { + // If the problems differ in length, they are by definition unequal. + if len(problems) != len(other) { + return cmp.Diff(problems, other) + } + + // Iterate over the individual problems and determine whether they are + // sufficiently equivalent. + for i := range problems { + x, y := problems[i], other[i] + + // The descriptors must exactly match, otherwise the problems are unequal. + if x.Descriptor != y.Descriptor { + return cmp.Diff(problems, other) + } + + // The suggestions, if present, must exactly match. + if x.Suggestion != y.Suggestion { + return cmp.Diff(problems, other) + } + + // When comparing messages, we want to know if the test string is a + // substring of the actual one. + if !strings.Contains(y.Message, x.Message) { + return cmp.Diff(problems, other) + } + } + + // These sets of problems are sufficiently equal. + return "" +} + +// SetDescriptor sets the given descriptor to every Problem in the slice and +// returns the slice back. +// +// This is intended primarily for use in unit tests. +func (problems Problems) SetDescriptor(d protoreflect.Descriptor) Problems { + for i := range problems { + problems[i].Descriptor = d + } + return problems +} diff --git a/rules/internal/testutils/v2/problems_test.go b/rules/internal/testutils/v2/problems_test.go new file mode 100644 index 00000000..20f3d1c9 --- /dev/null +++ b/rules/internal/testutils/v2/problems_test.go @@ -0,0 +1,129 @@ +// Copyright 2019 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 testutils + +import ( + "testing" + + . "github.com/aep-dev/api-linter/lint/v2" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + dpb "google.golang.org/protobuf/types/descriptorpb" +) + +func TestDiffEquivalent(t *testing.T) { + // Build a message for the descriptor test. + fileProto := &dpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + MessageType: []*dpb.DescriptorProto{ + { + Name: proto.String("Foo"), + }, + }, + } + file, err := protodesc.NewFile(fileProto, nil) + if err != nil { + t.Fatalf("Failed to create file descriptor: %v", err) + } + m := file.Messages().Get(0) + + // Declare a series of tests that should all be equal. + tests := []struct { + name string + x Problems + y []Problem + }{ + {"NilNil", nil, nil}, + {"ProblemNil", Problems{}, nil}, + {"Descriptor", Problems{{Descriptor: m}}, []Problem{{Descriptor: m}}}, + {"Suggestion", Problems{{Suggestion: "foo"}}, []Problem{{Suggestion: "foo"}}}, + {"MessageExact", Problems{{Message: "foo"}}, []Problem{{Message: "foo"}}}, + {"MessageSubstr", Problems{{Message: "foo"}}, []Problem{{Message: "foo bar"}}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if diff := test.x.Diff(test.y); diff != "" { + t.Errorf("Problems were unequal (x, y):\n%v", diff) + } + }) + } +} + +func TestDiffNotEquivalent(t *testing.T) { + // Build a message for the descriptor test. + fileProto := &dpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + MessageType: []*dpb.DescriptorProto{ + { + Name: proto.String("Foo"), + }, + { + Name: proto.String("Bar"), + }, + }, + } + file, err := protodesc.NewFile(fileProto, nil) + if err != nil { + t.Fatalf("Failed to create file descriptor: %v", err) + } + m1 := file.Messages().Get(0) + m2 := file.Messages().Get(1) + + // Declare a series of tests that should all be equal. + tests := []struct { + name string + x Problems + y []Problem + }{ + {"ProblemNil", Problems{{Descriptor: m1}}, nil}, + {"EmptyProblemNil", Problems{{}}, nil}, + {"LengthMismatch", Problems{{}}, []Problem{{}, {}}}, + {"Descriptor", Problems{{Descriptor: m1}}, []Problem{{Descriptor: m2}}}, + {"Suggestion", Problems{{Suggestion: "foo"}}, []Problem{{Suggestion: "bar"}}}, + {"Message", Problems{{Message: "foo"}}, []Problem{{Message: "bar"}}}, + {"MessageSuperstr", Problems{{Message: "foo bar"}}, []Problem{{Message: "foo"}}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if diff := test.x.Diff(test.y); diff == "" { + t.Errorf("Got no diff (x, y); expected one.") + } + }) + } +} + +func TestSetDescriptor(t *testing.T) { + fileProto := &dpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + MessageType: []*dpb.DescriptorProto{ + { + Name: proto.String("Foo"), + }, + }, + } + file, err := protodesc.NewFile(fileProto, nil) + if err != nil { + t.Fatalf("Failed to create file descriptor: %v", err) + } + m := file.Messages().Get(0) + problems := Problems{{}, {}, {}}.SetDescriptor(m) + for _, p := range problems { + if p.Descriptor != m { + t.Errorf("Got %v, expected %v", p.Descriptor, m) + } + } +} diff --git a/rules/internal/utils/v2/casing.go b/rules/internal/utils/v2/casing.go new file mode 100644 index 00000000..8d24a12f --- /dev/null +++ b/rules/internal/utils/v2/casing.go @@ -0,0 +1,67 @@ +// 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 utils + +// ToUpperCamelCase returns the UpperCamelCase of a string, including removing +// delimiters (_,-,., ) and using them to denote a new word. +func ToUpperCamelCase(s string) string { + return toCamelCase(s, true, false) +} + +// ToLowerCamelCase returns the lowerCamelCase of a string, including removing +// delimiters (_,-,., ) and using them to denote a new word. +func ToLowerCamelCase(s string) string { + return toCamelCase(s, false, true) +} + +func toCamelCase(s string, makeNextUpper bool, makeNextLower bool) string { + asLower := make([]rune, 0, len(s)) + for _, r := range s { + if isLower(r) { + if makeNextUpper { + r = r & '_' // make uppercase + } + asLower = append(asLower, r) + } else if isUpper(r) { + if makeNextLower { + r = r | ' ' // make lowercase + } + asLower = append(asLower, r) + } else if isNumber(r) { + asLower = append(asLower, r) + } + makeNextUpper = false + makeNextLower = false + + if r == '-' || r == '_' || r == ' ' || r == '.' { + // handle snake case scenarios, which generally indicates + // a delimited word. + makeNextUpper = true + } + } + return string(asLower) +} + +func isUpper(r rune) bool { + return ('A' <= r && r <= 'Z') +} + +func isNumber(r rune) bool { + return ('0' <= r && r <= '9') +} + +func isLower(r rune) bool { + return ('a' <= r && r <= 'z') +} diff --git a/rules/internal/utils/v2/casing_test.go b/rules/internal/utils/v2/casing_test.go new file mode 100644 index 00000000..1cdbff8b --- /dev/null +++ b/rules/internal/utils/v2/casing_test.go @@ -0,0 +1,139 @@ +// 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 utils + +import "testing" + +func TestToLowerCamelCase(t *testing.T) { + for _, test := range []struct { + name string + input string + want string + }{ + { + name: "OneWord", + input: "Foo", + want: "foo", + }, + { + name: "OneWordNoop", + input: "foo", + want: "foo", + }, + { + name: "TwoWords", + input: "bookShelf", + want: "bookShelf", + }, + { + name: "WithDash", + input: "book-shelf", + want: "bookShelf", + }, + { + name: "WithNumbers", + input: "universe42love", + want: "universe42love", + }, + { + name: "WithUnderscore", + input: "book_shelf", + want: "bookShelf", + }, + { + name: "WithUnderscore", + input: "book_shelf", + want: "bookShelf", + }, + { + name: "WithSpaces", + input: "book shelf", + want: "bookShelf", + }, + { + name: "WithPeriods", + input: "book.shelf", + want: "bookShelf", + }, + } { + t.Run(test.name, func(t *testing.T) { + got := ToLowerCamelCase(test.input) + if got != test.want { + t.Errorf("ToLowerCamelCase(%q) = %q, got %q", test.input, test.want, got) + } + }) + } +} + +func TestToUpperCamelCase(t *testing.T) { + for _, test := range []struct { + name string + input string + want string + }{ + { + name: "OneWord", + input: "foo", + want: "Foo", + }, + { + name: "OneWordNoop", + input: "Foo", + want: "Foo", + }, + { + name: "TwoWords", + input: "bookShelf", + want: "BookShelf", + }, + { + name: "WithDash", + input: "book-shelf", + want: "BookShelf", + }, + { + name: "WithNumbers", + input: "universe42love", + want: "Universe42love", + }, + { + name: "WithUnderscore", + input: "Book_shelf", + want: "BookShelf", + }, + { + name: "WithUnderscore", + input: "Book_shelf", + want: "BookShelf", + }, + { + name: "WithSpaces", + input: "Book shelf", + want: "BookShelf", + }, + { + name: "WithPeriods", + input: "book.shelf", + want: "BookShelf", + }, + } { + t.Run(test.name, func(t *testing.T) { + got := ToUpperCamelCase(test.input) + if got != test.want { + t.Errorf("ToLowerCamelCase(%q) = %q, got %q", test.input, test.want, got) + } + }) + } +} diff --git a/rules/internal/utils/v2/comments.go b/rules/internal/utils/v2/comments.go new file mode 100644 index 00000000..72a69ddc --- /dev/null +++ b/rules/internal/utils/v2/comments.go @@ -0,0 +1,56 @@ +// 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 utils + +import "strings" + +// SeparateInternalComments splits the given comment block into "external" and +// "internal" comments based on https://google.aip.dev/192#internal-comments. +func SeparateInternalComments(comments ...string) struct { + Internal []string + External []string +} { + answer := struct { + Internal []string + External []string + }{} + for _, c := range comments { + for len(c) > 0 { + // Anything before the `(--` is external string. + open := strings.SplitN(c, "(--", 2) + if ex := strings.TrimSpace(open[0]); ex != "" { + answer.External = append(answer.External, ex) + } + if len(open) > 1 { + c = strings.TrimSpace(open[1]) + } else { + break + } + + // Now that the opening component is tokenized, anything before + // the `--)` is internal string. + close := strings.SplitN(c, "--)", 2) + if in := strings.TrimSpace(close[0]); in != "" { + answer.Internal = append(answer.Internal, in) + } + if len(close) > 1 { + c = strings.TrimSpace(close[1]) + } else { + break + } + } + } + return answer +} diff --git a/rules/internal/utils/v2/comments_test.go b/rules/internal/utils/v2/comments_test.go new file mode 100644 index 00000000..932eee36 --- /dev/null +++ b/rules/internal/utils/v2/comments_test.go @@ -0,0 +1,64 @@ +// 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 utils + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestSeparateInternalComments(t *testing.T) { + for _, tst := range []struct { + name string + in string + wantExternal string + wantInternal string + }{ + { + "external only", + "Hello,\nWorld!", + "Hello,\nWorld!", + "", + }, + { + "internal only", + "(-- Hello,\nInternal! --)", + "", + "Hello,\nInternal!", + }, + { + "mixed", + "Hello,\nWorld!\n(-- We come\nin peace --)\nWhat planet is this?", + "Hello,\nWorld!\nWhat planet is this?", + "We come\nin peace", + }, + } { + t.Run(tst.name, func(t *testing.T) { + got := SeparateInternalComments(tst.in) + // Join them for ease of diffing. + gotExternal := strings.Join(got.External, "\n") + gotInternal := strings.Join(got.Internal, "\n") + + if diff := cmp.Diff(gotExternal, tst.wantExternal); diff != "" { + t.Errorf("External: got(-),want(+):\n%s", diff) + } + if diff := cmp.Diff(gotInternal, tst.wantInternal); diff != "" { + t.Errorf("Internal: got(-),want(+):\n%s", diff) + } + }) + } +} diff --git a/rules/internal/utils/v2/common_lints.go b/rules/internal/utils/v2/common_lints.go new file mode 100644 index 00000000..40da5099 --- /dev/null +++ b/rules/internal/utils/v2/common_lints.go @@ -0,0 +1,318 @@ +// 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 utils + +import ( + "fmt" + "strings" + + "github.com/aep-dev/api-linter/lint/v2" + "github.com/aep-dev/api-linter/locations/v2" + "github.com/stoewer/go-strcase" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// LintFieldPresent returns a problem if the given message does not have the given field. +func LintFieldPresent(m protoreflect.MessageDescriptor, field string) (protoreflect.FieldDescriptor, []lint.Problem) { + f := m.Fields().ByName(protoreflect.Name(field)) + if f == nil { + return nil, []lint.Problem{{ + Message: fmt.Sprintf("Message `%s` has no `%s` field.", m.Name(), field), + Descriptor: m, + }} + } + return f, nil +} + +// LintSingularStringField returns a problem if the field is not a singular string. +func LintSingularStringField(f protoreflect.FieldDescriptor) []lint.Problem { + return LintSingularField(f, protoreflect.StringKind, "string") +} + +// LintSingularField returns a problem if the field is not singular i.e. it is repeated. +func LintSingularField(f protoreflect.FieldDescriptor, t protoreflect.Kind, want string) []lint.Problem { + if f.Kind() != t || f.IsList() { + return []lint.Problem{{ + Message: fmt.Sprintf("The `%s` field must be a singular %s.", f.Name(), want), + Suggestion: want, + Descriptor: f, + Location: locations.FieldType(f), + }} + } + return nil +} + +// LintSingularBoolField returns a problem if the field is not a singular bool. +func LintSingularBoolField(f protoreflect.FieldDescriptor) []lint.Problem { + return LintSingularField(f, protoreflect.BoolKind, "bool") +} + +// LintFieldMask returns a problem if the field is not a singular google.protobuf.FieldMask. +func LintFieldMask(f protoreflect.FieldDescriptor) []lint.Problem { + const want = "google.protobuf.FieldMask" + if t := f.Message(); t == nil || t.FullName() != want || f.IsList() { + return []lint.Problem{{ + Message: fmt.Sprintf("The `%s` field should be a singular %s.", f.Name(), want), + Suggestion: want, + Descriptor: f, + Location: locations.FieldType(f), + }} + } + return nil +} + +// LintNotOneof returns a problem if the field is a oneof. +func LintNotOneof(f protoreflect.FieldDescriptor) []lint.Problem { + if f.ContainingOneof() != nil && !f.HasOptionalKeyword() { + return []lint.Problem{{ + Message: fmt.Sprintf("The `%s` field should not be a oneof field.", f.Name()), + Descriptor: f, + }} + } + return nil +} + +// LintFieldPresentAndSingularString returns a problem if a message does not have the given singular-string field. +func LintFieldPresentAndSingularString(field string) func(protoreflect.MessageDescriptor) []lint.Problem { + return func(m protoreflect.MessageDescriptor) []lint.Problem { + f, problems := LintFieldPresent(m, field) + if f == nil { + return problems + } + return LintSingularStringField(f) + } +} + +func lintFieldBehavior(f protoreflect.FieldDescriptor, want string) []lint.Problem { + if !GetFieldBehavior(f).Contains(want) { + return []lint.Problem{{ + Message: fmt.Sprintf("The `%s` field should include `(google.api.field_behavior) = %s`.", f.Name(), want), + Descriptor: f, + }} + } + return nil +} + +// LintRequiredField returns a problem if the field's behavior is not REQUIRED. +func LintRequiredField(f protoreflect.FieldDescriptor) []lint.Problem { + return lintFieldBehavior(f, "REQUIRED") +} + +// LintOutputOnlyField returns a problem if the field's behavior is not OUTPUT_ONLY. +func LintOutputOnlyField(f protoreflect.FieldDescriptor) []lint.Problem { + return lintFieldBehavior(f, "OUTPUT_ONLY") +} + +// LintFieldResourceReference returns a problem if the field does not have a resource reference annotation. +func LintFieldResourceReference(f protoreflect.FieldDescriptor) []lint.Problem { + if ref := GetResourceReference(f); ref == nil { + return []lint.Problem{{ + Message: fmt.Sprintf("The `%s` field should include a `google.api.resource_reference` annotation.", f.Name()), + Descriptor: f, + }} + } + return nil +} + +func lintHTTPBody(m protoreflect.MethodDescriptor, want, msg string) []lint.Problem { + for _, httpRule := range GetHTTPRules(m) { + if httpRule.Body != want { + return []lint.Problem{{ + Message: fmt.Sprintf("The `%s` method should %s HTTP body.", m.Name(), msg), + Descriptor: m, + Location: locations.MethodHTTPRule(m), + }} + } + } + return nil +} + +// LintNoHTTPBody returns a problem for each HTTP rule whose body is not "". +func LintNoHTTPBody(m protoreflect.MethodDescriptor) []lint.Problem { + return lintHTTPBody(m, "", "not have an") +} + +// LintWildcardHTTPBody returns a problem for each HTTP rule whose body is not "*". +func LintWildcardHTTPBody(m protoreflect.MethodDescriptor) []lint.Problem { + return lintHTTPBody(m, "*", `use "*" as the`) +} + +// LintHTTPMethod returns a problem for each HTTP rule whose HTTP method is not the given one. +func LintHTTPMethod(verb string) func(protoreflect.MethodDescriptor) []lint.Problem { + return func(m protoreflect.MethodDescriptor) []lint.Problem { + for _, httpRule := range GetHTTPRules(m) { + if httpRule.Method != verb { + return []lint.Problem{{ + Message: fmt.Sprintf("The `%s` method should use the HTTP %s verb.", m.Name(), verb), + Descriptor: m, + Location: locations.MethodHTTPRule(m), + }} + } + } + return nil + } +} + +// LintMethodHasMatchingRequestName returns a problem if the given method's request type does not +// have a name matching the method's, with a "Request" suffix. +func LintMethodHasMatchingRequestName(m protoreflect.MethodDescriptor) []lint.Problem { + if got, want := m.Input().Name(), m.Name()+"Request"; got != want { + return []lint.Problem{{ + Message: fmt.Sprintf("Request message should be named after the RPC, i.e. %q.", want), + Suggestion: string(want), + Descriptor: m, + Location: locations.MethodRequestType(m), + }} + } + return nil +} + +// LintMethodHasMatchingResponseName returns a problem if the given method's response type does not +// have a name matching the method's, with a "Response" suffix. +func LintMethodHasMatchingResponseName(m protoreflect.MethodDescriptor) []lint.Problem { + // GetResponseType handles the LRO case. + rt := GetResponseType(m) + if rt == nil { + return nil + } + if got, want := rt.Name(), m.Name()+"Response"; got != want { + loc := locations.MethodResponseType(m) + suggestion := want + + // If the RPC is an LRO, we need to tweak the finding. + if isLongRunningOperation(m.Output()) { + loc = locations.MethodOperationInfo(m) + // Clear the suggestion b.c we cannot easily pin point the + // response_type field. + suggestion = "" + } + + return []lint.Problem{{ + Message: fmt.Sprintf("Response message should be named after the RPC, i.e. %q.", want), + Suggestion: string(suggestion), + Descriptor: m, + Location: loc, + }} + } + return nil +} + +// LintHTTPURIHasParentVariable returns a problem if any of the given method's HTTP rules do not +// have a parent variable in the URI. +func LintHTTPURIHasParentVariable(m protoreflect.MethodDescriptor) []lint.Problem { + return LintHTTPURIHasVariable(m, "parent") +} + +// LintHTTPURIHasVariable returns a problem if any of the given method's HTTP rules do not +// have the given variable in the URI. +func LintHTTPURIHasVariable(m protoreflect.MethodDescriptor, v string) []lint.Problem { + for _, httpRule := range GetHTTPRules(m) { + if _, ok := httpRule.GetVariables()[v]; !ok { + return []lint.Problem{{ + Message: fmt.Sprintf("HTTP URI should include a `%s` variable.", v), + Descriptor: m, + Location: locations.MethodHTTPRule(m), + }} + } + } + return nil +} + +// LintHTTPURIVariableCount returns a problem if the given method's HTTP rules +// do not contain the given number of variables in the URI. +func LintHTTPURIVariableCount(m protoreflect.MethodDescriptor, n int) []lint.Problem { + varsText := "variables" + if n == 1 { + varsText = "variable" + } + + varsCount := 0 + for _, httpRule := range GetHTTPRules(m) { + varsCount = max(varsCount, len(httpRule.GetVariables())) + } + if varsCount != n { + return []lint.Problem{{ + Message: fmt.Sprintf("HTTP URI should contain %d %s.", n, varsText), + Descriptor: m, + Location: locations.MethodHTTPRule(m), + }} + } + return nil +} + +// LintHTTPURIHasNameVariable returns a problem if any of the given method's HTTP rules do not +// have a name variable in the URI. +func LintHTTPURIHasNameVariable(m protoreflect.MethodDescriptor) []lint.Problem { + return LintHTTPURIHasVariable(m, "name") +} + +// LintPluralMethodName checks that a collection-based method uses the plural form of the +// resource type, or noun if response does not have a resource, being operated on. +// It first checks if the response has a repeated resource field with `plural` defined. +// If not, it attempts to check the plurality of the noun portion of the method name. +func LintPluralMethodName(m protoreflect.MethodDescriptor, verb string) []lint.Problem { + var want string + + // First attempt to base the pluralization on the `plural` defined in + // the `google.api.resource` in the response, if present. + if rt := GetResponseType(m); rt != nil { + rf := rt.Fields() + for i := 0; i < rf.Len(); i++ { + f := rf.Get(i) + if f.Cardinality() != protoreflect.Repeated || f.Kind() != protoreflect.MessageKind { + continue + } + + msg := f.Message() + if !IsResource(msg) { + continue + } + if p := GetResourcePlural(GetResource(msg)); p != "" { + want = strcase.UpperCamelCase(p) + break + } + } + } + + pluralMethodResourceName := strings.TrimPrefix(string(m.Name()), verb) + var notPlural bool + if want != "" { + notPlural = pluralMethodResourceName != want + } else { + notPlural = !pluralizeClient.IsPlural(pluralMethodResourceName) + want = pluralizeClient.Plural(pluralMethodResourceName) + } + + if notPlural { + return []lint.Problem{{ + Message: fmt.Sprintf( + `The resource part in method %q should not be %q, but should be its plural form %q`, + m.Name(), pluralMethodResourceName, want, + ), + Descriptor: m, + Location: locations.DescriptorName(m), + Suggestion: verb + want, + }} + } + + return nil +} + +func max(x, y int) int { + if x > y { + return x + } + return y +} diff --git a/rules/internal/utils/v2/common_lints_test.go b/rules/internal/utils/v2/common_lints_test.go new file mode 100644 index 00000000..84117ff3 --- /dev/null +++ b/rules/internal/utils/v2/common_lints_test.go @@ -0,0 +1,408 @@ +package utils + +import ( + "testing" + + "github.com/aep-dev/api-linter/rules/internal/testutils/v2" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func TestLintSingularStringField(t *testing.T) { + for _, test := range []struct { + testName string + FieldType string + problems testutils.Problems + }{ + {"Valid", `string`, nil}, + {"Invalid", `int32`, testutils.Problems{{Suggestion: "string"}}}, + {"InvalidRepeated", `repeated string`, testutils.Problems{{Suggestion: "string"}}}, + } { + t.Run(test.testName, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + message Message { + {{.FieldType}} foo = 1; + } + `, test) + field := f.Messages().Get(0).Fields().Get(0) + problems := LintSingularStringField(field) + if diff := test.problems.SetDescriptor(field).Diff(problems); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestLintRequiredField(t *testing.T) { + for _, test := range []struct { + testName string + Annotation string + problems testutils.Problems + }{ + {"Valid", `[(google.api.field_behavior) = REQUIRED]`, nil}, + {"Invalid", ``, testutils.Problems{{Message: "REQUIRED"}}}, + } { + t.Run(test.testName, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + import "google/api/field_behavior.proto"; + message Message { + string foo = 1 {{.Annotation}}; + } + `, test) + field := f.Messages().Get(0).Fields().Get(0) + problems := LintRequiredField(field) + if diff := test.problems.SetDescriptor(field).Diff(problems); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestLintFieldResourceReference(t *testing.T) { + for _, test := range []struct { + testName string + Annotation string + problems testutils.Problems + }{ + {"Valid", `[(google.api.resource_reference).type = "bar"]`, nil}, + {"Invalid", ``, testutils.Problems{{Message: "resource_reference"}}}, + } { + t.Run(test.testName, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + import "google/api/resource.proto"; + message Message { + string foo = 1 {{.Annotation}}; + } + `, test) + field := f.Messages().Get(0).Fields().Get(0) + problems := LintFieldResourceReference(field) + if diff := test.problems.SetDescriptor(field).Diff(problems); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestLintNoHTTPBody(t *testing.T) { + for _, test := range []struct { + testName string + Body string + problems testutils.Problems + }{ + {"Valid", ``, nil}, + {"Invalid", `*`, testutils.Problems{{Message: "not have an HTTP body"}}}, + } { + t.Run(test.testName, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + import "google/api/annotations.proto"; + service Library { + rpc GetBook(GetBookRequest) returns (Book) { + option (google.api.http) = { + get: "/v1/{name=publishers/*/books/*}" + body: "{{.Body}}" + }; + } + } + message Book {} + message GetBookRequest {} + `, test) + method := f.Services().Get(0).Methods().Get(0) + problems := LintNoHTTPBody(method) + if diff := test.problems.SetDescriptor(method).Diff(problems); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestLintWildcardHTTPBody(t *testing.T) { + for _, test := range []struct { + testName string + Body string + problems testutils.Problems + }{ + {"Valid", `*`, nil}, + {"Invalid", ``, testutils.Problems{{Message: `use "*" as the HTTP body`}}}, + } { + t.Run(test.testName, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + import "google/api/annotations.proto"; + service Library { + rpc ArchiveBook(ArchiveBookRequest) returns (Book) { + option (google.api.http) = { + post: "/v1/{name=publishers/*/books/*}:archive" + body: "{{.Body}}" + }; + } + } + message Book {} + message ArchiveBookRequest {} + `, test) + method := f.Services().Get(0).Methods().Get(0) + problems := LintWildcardHTTPBody(method) + if diff := test.problems.SetDescriptor(method).Diff(problems); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestLintHTTPMethod(t *testing.T) { + for _, test := range []struct { + testName string + Method string + problems testutils.Problems + }{ + {"Valid", `get`, nil}, + {"Invalid", `delete`, testutils.Problems{{Message: `HTTP GET`}}}, + } { + t.Run(test.testName, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + import "google/api/annotations.proto"; + service Library { + rpc GetBook(GetBookRequest) returns (Book) { + option (google.api.http) = { + {{.Method}}: "/v1/{name=publishers/*/books/*}" + }; + } + } + message Book {} + message GetBookRequest {} + `, test) + method := f.Services().Get(0).Methods().Get(0) + problems := LintHTTPMethod("GET")(method) + if diff := test.problems.SetDescriptor(method).Diff(problems); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestLintMethodHasMatchingRequestName(t *testing.T) { + for _, test := range []struct { + testName string + MessageName string + problems testutils.Problems + }{ + {"Valid", "GetBookRequest", nil}, + {"Invalid", "AcquireBookRequest", testutils.Problems{{Suggestion: "GetBookRequest"}}}, + } { + t.Run(test.testName, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + service Library { + rpc GetBook({{.MessageName}}) returns (Book); + } + message Book {} + message {{.MessageName}} {} + `, test) + method := f.Services().Get(0).Methods().Get(0) + problems := LintMethodHasMatchingRequestName(method) + if diff := test.problems.SetDescriptor(method).Diff(problems); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestLintMethodHasMatchingResponseName(t *testing.T) { + for _, test := range []struct { + testName string + ResponseName string + problems testutils.Problems + }{ + {"Valid", "GetBookResponse", nil}, + {"Invalid", "AcquireBookResponse", testutils.Problems{{Suggestion: "GetBookResponse"}}}, + } { + t.Run(test.testName, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + service Library { + rpc GetBook(GetBookRequest) returns ({{.ResponseName}}); + } + message GetBookRequest {} + message {{.ResponseName}} {} + `, test) + method := f.Services().Get(0).Methods().Get(0) + problems := LintMethodHasMatchingResponseName(method) + if diff := test.problems.SetDescriptor(method).Diff(problems); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestLintMethodHasMatchingResponseNameLRO(t *testing.T) { + for _, test := range []struct { + testName string + MessageName string + problems testutils.Problems + }{ + {"Valid", "GetBookResponse", nil}, + {"Invalid", "AcquireBookResponse", testutils.Problems{{Message: "GetBookResponse"}}}, + } { + t.Run(test.testName, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + import "google/longrunning/operations.proto"; + + service Library { + rpc GetBook(GetBookRequest) returns (google.longrunning.Operation) { + option (google.longrunning.operation_info) = { + response_type: "{{.MessageName}}" + metadata_type: "OperationMetadata" + }; + } + } + message GetBookRequest {} + message {{.MessageName}} {} + message OperationMetadata {} + `, test) + method := f.Services().Get(0).Methods().Get(0) + problems := LintMethodHasMatchingResponseName(method) + if diff := test.problems.SetDescriptor(method).Diff(problems); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestLintSingularField(t *testing.T) { + for _, test := range []struct { + testName string + Label string + problems testutils.Problems + }{ + {"Valid", "", nil}, + {"Invalid", "repeated", testutils.Problems{{Suggestion: "string"}}}, + } { + t.Run(test.testName, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + message Message { + {{.Label}} string foo = 1; + } + `, test) + field := f.Messages().Get(0).Fields().Get(0) + problems := LintSingularField(field, protoreflect.StringKind, "string") + if diff := test.problems.SetDescriptor(field).Diff(problems); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestLintNotOneof(t *testing.T) { + for _, test := range []struct { + testName string + Field string + problems testutils.Problems + }{ + {"Valid", `string foo = 1;`, nil}, + {"ValidProto3Optional", `optional string foo = 1;`, nil}, + {"Invalid", `oneof foo_oneof { string foo = 1; }`, testutils.Problems{{Message: "should not be a oneof"}}}, + } { + t.Run(test.testName, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + message Message { + {{.Field}} + } + `, test) + field := f.Messages().Get(0).Fields().Get(0) + problems := LintNotOneof(field) + if diff := test.problems.SetDescriptor(field).Diff(problems); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestLintPluralMethodName(t *testing.T) { + // Set up the testing permutations. + tests := []struct { + testName string + prefix string + MethodName string + CollectionName string + ResponseItems string + problems testutils.Problems + }{ + { + testName: "ValidBatchGetBooks", + prefix: "BatchGet", + MethodName: "BatchGetBooks", + CollectionName: "books", + ResponseItems: "repeated Book books = 1;", + problems: testutils.Problems{}, + }, + { + testName: "ValidBatchGetMen", + prefix: "BatchGet", + MethodName: "BatchGetMen", + CollectionName: "men", + ResponseItems: "repeated Other men = 1;", + problems: testutils.Problems{}, + }, + { + testName: "ValidBatchGetNames-NonMessageItems", + prefix: "BatchGet", + MethodName: "BatchGetNames", + CollectionName: "names", + ResponseItems: "repeated string names = 1;", + problems: testutils.Problems{}, + }, + { + testName: "InvalidSingularBus", + prefix: "BatchGet", + MethodName: "BatchGetBus", + CollectionName: "buses", + ResponseItems: "repeated Other buses = 1;", + problems: testutils.Problems{{Message: "Buses", Suggestion: "BatchGetBuses"}}, + }, + { + testName: "Invalid-SingularCorpPerson", + prefix: "BatchGet", + MethodName: "BatchGetCorpPerson", + CollectionName: "corpPerson", + ResponseItems: "repeated Other corp_people = 1;", + problems: testutils.Problems{{Message: "CorpPeople", Suggestion: "BatchGetCorpPeople"}}, + }, + } + + // Run each test individually. + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + import "google/api/annotations.proto"; + import "google/api/resource.proto"; + + service Test { + rpc {{.MethodName}}({{.MethodName}}Request) returns ({{.MethodName}}Response) { + option (google.api.http) = { + get: "/v1/{parent=publishers/*}/{{.CollectionName}}:batchGet" + }; + } + } + + message {{.MethodName}}Request {} + + message {{.MethodName}}Response { + {{ .ResponseItems }} + } + + message Book { + option (google.api.resource) = { + type: "library.googleapis.com/Book" + pattern: "publishers/{publisher}/books/{book}" + singular: "book" + plural: "books" + }; + } + + message Other {} + `, test) + + m := file.Services().Get(0).Methods().Get(0) + + problems := LintPluralMethodName(m, test.prefix) + if diff := test.problems.SetDescriptor(m).Diff(problems); diff != "" { + t.Error(diff) + } + }) + } +} diff --git a/rules/internal/utils/v2/common_proto.go b/rules/internal/utils/v2/common_proto.go new file mode 100644 index 00000000..bd1d3750 --- /dev/null +++ b/rules/internal/utils/v2/common_proto.go @@ -0,0 +1,32 @@ +// 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 utils + +import ( + "strings" + + "google.golang.org/protobuf/reflect/protoreflect" +) + +// IsCommonProto returns true if a proto file is considered "common". +func IsCommonProto(f protoreflect.FileDescriptor) bool { + p := string(f.Package()) + for _, prefix := range []string{"google.api", "google.protobuf", "google.rpc", "google.longrunning"} { + if strings.HasPrefix(p, prefix) { + return true + } + } + return false +} diff --git a/rules/internal/utils/v2/common_proto_test.go b/rules/internal/utils/v2/common_proto_test.go new file mode 100644 index 00000000..1a11dfb7 --- /dev/null +++ b/rules/internal/utils/v2/common_proto_test.go @@ -0,0 +1,44 @@ +// 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 utils + +import ( + "testing" + + "github.com/aep-dev/api-linter/rules/internal/testutils/v2" +) + +func TestIsCommonProto(t *testing.T) { + for _, test := range []struct { + Package string + want bool + }{ + {"google.api", true}, + {"google.longrunning", true}, + {"google.protobuf", true}, + {"google.rpc", true}, + {"google.api.experimental", true}, + {"google.cloud.speech.v1", false}, + } { + t.Run(test.Package, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + package {{.Package}}; + `, test) + if got := IsCommonProto(f); got != test.want { + t.Errorf("Got %v, expected %v", got, test.want) + } + }) + } +} diff --git a/rules/internal/utils/v2/declarative_friendly.go b/rules/internal/utils/v2/declarative_friendly.go new file mode 100644 index 00000000..4a60a4a3 --- /dev/null +++ b/rules/internal/utils/v2/declarative_friendly.go @@ -0,0 +1,140 @@ +// 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 utils + +import ( + "strings" + + "bitbucket.org/creachadair/stringset" + "github.com/stoewer/go-strcase" + apb "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// DeclarativeFriendlyResource returns the declarative-friendly resource +// associated with this descriptor. +// +// For messages: +// If the message is annotated with google.api.resource and +// style: DECLARATIVE_FRIENDLY is set, that message is returned. +// If the message is a standard method request message for a resource with +// google.api.resource and style:DECLARATIVE_FRIENDLY set, then the resource +// is returned. +// +// For methods: +// If the output message is a declarative-friendly resource, it is returned. +// If the method begins with "List" and the first repeated field is a +// declarative-friendly resource, the resource is returned. +// If the method begins with "Delete", the return type is Empty, and an +// appropriate resource message is found and is declarative-friendly, that +// resource is returned. +// If the method is a custom method where a matching resource is found (by +// subset checks on the name) and is declarative-friendly, the resource is +// returned. +// +// If there is no declarative-friendly resource, it returns nil. +func DeclarativeFriendlyResource(d protoreflect.Descriptor) protoreflect.MessageDescriptor { + switch m := d.(type) { + case protoreflect.MessageDescriptor: + // Get the google.api.resource annotation and see if it is styled + // declarative-friendly. + if resource := GetResource(m); resource != nil { + for _, style := range resource.GetStyle() { + if style == apb.ResourceDescriptor_DECLARATIVE_FRIENDLY { + return m + } + } + } + + // If this is a standard method request message, find the corresponding + // resource message. The easiest way to do this is to farm it out to the + // corresponding method. + if n := m.Name(); strings.HasSuffix(string(n), "Request") { + if method := FindMethod(m.ParentFile(), strings.TrimSuffix(string(n), "Request")); method != nil { + return DeclarativeFriendlyResource(method) + } + } + case protoreflect.MethodDescriptor: + response := m.Output() + + // If this is a Delete method (AIP-135) with a return value of Empty, + // try to find the resource. + // + // Note: This needs to precede the LRO logic because Delete requests + // may resolve to Empty, in which case FindMessage will return nil and + // short-circuit this logic. + if strings.HasPrefix(string(m.Name()), "Delete") && stringset.New("Empty", "Operation").Contains(string(m.Output().Name())) { + if resource := FindMessage(m.ParentFile(), strings.TrimPrefix(string(m.Name()), "Delete")); resource != nil { + return DeclarativeFriendlyResource(resource) + } + } + + // If the method is an LRO, then get the response type from the + // operation_info annotation. + if IsOperation(response) { + if opInfo := GetOperationInfo(m); opInfo != nil { + response = FindMessage(m.ParentFile(), opInfo.GetResponseType()) + + // Sanity check: We may not have found the message. + // If that is the case, give up and assume the method is not + // declarative-friendly. + if response == nil { + return nil + } + } + } + + // If the return value has a google.api.resource annotation, we can + // assume it is the resource and check it. + if IsResource(response) { + return DeclarativeFriendlyResource(response) + } + + // If the return value is a List response (AIP-132), we should be able + // to find the resource as a field in the response. + if n := response.Name(); strings.HasPrefix(string(n), "List") && strings.HasSuffix(string(n), "Response") { + for i := 0; i < response.Fields().Len(); i++ { + field := response.Fields().Get(i) + if field.IsList() && field.Message() != nil { + return DeclarativeFriendlyResource(field.Message()) + } + } + } + + // At this point, we probably have a custom method. + // Try to identify a resource by whittling away at the method name and + // seeing if there is a match. + snakeName := strings.Split(strcase.SnakeCase(string(m.Name())), "_") + for i := 1; i < len(snakeName); i++ { + name := strcase.UpperCamelCase(strings.Join(snakeName[i:], "_")) + if resource := FindMessage(m.ParentFile(), name); resource != nil { + return DeclarativeFriendlyResource(resource) + } + } + } + return nil +} + +// IsDeclarativeFriendlyMessage returns true if the descriptor is +// declarative-friendly (if DeclarativeFriendlyResource(m) is not nil). +func IsDeclarativeFriendlyMessage(m protoreflect.MessageDescriptor) bool { + return DeclarativeFriendlyResource(m) != nil +} + +// IsDeclarativeFriendlyMethod returns true if the method is for a +// declarative-friendly resource (if DeclarativeFriendlyResource(m) is not nil). +func IsDeclarativeFriendlyMethod(m protoreflect.MethodDescriptor) bool { + return DeclarativeFriendlyResource(m) != nil +} diff --git a/rules/internal/utils/v2/declarative_friendly_test.go b/rules/internal/utils/v2/declarative_friendly_test.go new file mode 100644 index 00000000..cd4a77a6 --- /dev/null +++ b/rules/internal/utils/v2/declarative_friendly_test.go @@ -0,0 +1,197 @@ +// 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 utils + +import ( + "fmt" + "testing" + + "github.com/aep-dev/api-linter/rules/internal/testutils/v2" +) + +func TestDeclarativeFriendlyMessage(t *testing.T) { + // Test the cases where a google.api.resource annotation is present. + for _, test := range []struct { + name string + Style string + want bool + }{ + {"True", "style: DECLARATIVE_FRIENDLY", true}, + {"FalseNoStyle", "", false}, + {"FalseOtherStyle", "style: STYLE_UNSPECIFIED", false}, + } { + t.Run(test.name, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + import "google/api/resource.proto"; + + message Book { + option (google.api.resource) = { + type: "library.googleapis.com/Book" + {{.Style}} + }; + } + + message CreateBookRequest { + Book book = 1; + } + + service Library { + rpc CreateBook(CreateBookRequest) returns (Book); + } + `, test) + for i := 0; i < f.Messages().Len(); i++ { + m := f.Messages().Get(i) + t.Run(string(m.Name()), func(t *testing.T) { + if got := IsDeclarativeFriendlyMessage(m); got != test.want { + t.Errorf("Got %v, expected %v.", got, test.want) + } + }) + } + }) + } + + // Test the case where the google.api.resource annotation is not present. + t.Run("NotResource", func(t *testing.T) { + m := testutils.ParseProto3Tmpl(t, "message Book {}", nil).Messages().Get(0) + if IsDeclarativeFriendlyMessage(m) { + t.Errorf("Got true, expected false.") + } + }) +} + +func TestDeclarativeFriendlyMethod(t *testing.T) { + // We need different templates for different situations. + // + // Note: The Book resource itself is always present and omitted here to + // avoid excess repetition; it is appended to the templates in the body of + // the test. + tmpls := map[string]string{ + // The basic template just returns the resource with no frills. + "basic": ` + service Library { + rpc GetBook(GetBookRequest) returns (Book); + } + + message GetBookRequest {} + `, + + // The LRO template returns the resource, but as the result of an LRO + // that has to be resolved first. + "lro": ` + import "google/longrunning/operations.proto"; + + service Library { + rpc GetBook(GetBookRequest) returns (google.longrunning.Operation) { + option (google.longrunning.operation_info) = { + response_type: "Book" + }; + } + } + + message GetBookRequest {} + `, + + // The List template returns a normal list response. + "list": ` + service Library { + rpc ListBooks(ListBooksRequest) returns (ListBooksResponse); + } + + message ListBooksRequest {} + message ListBooksResponse { + repeated Book books = 1; + } + `, + + // The Delete template returns a normal delete response. + "delete": ` + import "google/protobuf/empty.proto"; + service Library { + rpc DeleteBook(DeleteBookRequest) returns (google.protobuf.Empty); + } + message DeleteBookRequest {} + `, + + // The custom method template is a straightforward custom method + // with no direct reference to Book. + "custom": ` + service Library { + rpc ArchiveBook(ArchiveBookRequest) returns (ArchiveBookResponse); + } + + message ArchiveBookRequest {} + message ArchiveBookResponse {} + `, + } + + for key, tmpl := range tmpls { + t.Run(key, func(t *testing.T) { + for _, test := range []struct { + name string + want bool + }{ + {"true", true}, + {"false", false}, + } { + t.Run(test.name, func(t *testing.T) { + // Set the style of the resource to DECLARATIVE_FRIENDLY if that + // is the expected result. + s := struct{ Style string }{Style: ""} + if test.want == true { + s.Style = "style: DECLARATIVE_FRIENDLY" + } + + // Parse the template and test the method. + f := testutils.ParseProto3Tmpl(t, fmt.Sprintf(` + import "google/api/resource.proto"; + + %s + + message Book { + option (google.api.resource) = { + type: "library.googleapis.com/Book" + {{.Style}} + }; + } + `, tmpl), s) + m := f.Services().Get(0).Methods().Get(0) + if got := IsDeclarativeFriendlyMethod(m); got != test.want { + t.Errorf("Got %v, expected %v.", got, test.want) + } + }) + } + }) + } + + // Test an edge case where the LRO response is not found. + t.Run("lro/not-found", func(t *testing.T) { + f := testutils.ParseProto3String(t, ` + import "google/longrunning/operations.proto"; + service Library { + rpc CreateBook(CreateBookRequest) returns (google.longrunning.Operation) { + option (google.longrunning.operation_info) = { + response_type: "Shrug" + }; + } + } + message CreateBookRequest {} + `) + m := f.Services().Get(0).Methods().Get(0) + want := false + if got := IsDeclarativeFriendlyMethod(m); got != want { + t.Errorf("Got %v, expected %v.", got, want) + } + }) +} diff --git a/rules/internal/utils/v2/extension.go b/rules/internal/utils/v2/extension.go new file mode 100644 index 00000000..d685975d --- /dev/null +++ b/rules/internal/utils/v2/extension.go @@ -0,0 +1,365 @@ +// Copyright 2019 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 utils + +import ( + "strings" + + "bitbucket.org/creachadair/stringset" + lrpb "cloud.google.com/go/longrunning/autogen/longrunningpb" + apb "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// GetFieldBehavior returns a stringset.Set of FieldBehavior annotations for +// the given field. +func GetFieldBehavior(f protoreflect.FieldDescriptor) stringset.Set { + opts := f.Options() + if !opts.ProtoReflect().Has(apb.E_FieldBehavior.TypeDescriptor()) { + return stringset.New() + } + extValue := opts.ProtoReflect().Get(apb.E_FieldBehavior.TypeDescriptor()) + extList := extValue.List() + + answer := stringset.New() + for i := 0; i < extList.Len(); i++ { + fb := apb.FieldBehavior(extList.Get(i).Enum()) + answer.Add(fb.String()) + } + return answer +} + +// GetExtensionGeneric centralizes loading extensions of type message in a +// generic way. Sometimes, direct type assertions are not possible, and fallback +// logic of using proto Marshaling APIs is necessary. This is still being +// explored and refined, but at the moment this is the easiest means of loading +// extensions of type message present on a protoreflect.Descriptor. +// If it the extension is not set or it cannot be parsed out, a nil pointer and +// false are returned. +func GetExtensionGeneric[T proto.Message](o protoreflect.Message, ed protoreflect.FieldDescriptor, c T) (T, bool) { + var zero T + if !o.Has(ed) { + return zero, false + } + + ext := o.Get(ed).Message().Interface() + if v, ok := ext.(T); ok { + return v, ok + } + + d, err := proto.Marshal(ext) + if err != nil { + return zero, false + } + if err := proto.Unmarshal(d, c); err != nil { + return zero, false + } + return c, true +} + +// GetOperationInfo returns the google.longrunning.operation_info annotation. +func GetOperationInfo(m protoreflect.MethodDescriptor) *lrpb.OperationInfo { + if m == nil { + return nil + } + opInfo, ok := &lrpb.OperationInfo{}, false + opInfo, ok = GetExtensionGeneric(m.Options().ProtoReflect(), lrpb.E_OperationInfo.TypeDescriptor(), opInfo) + if !ok { + return nil + } + return opInfo +} + +// GetOperationResponseType returns the message referred to by the +// (google.longrunning.operation_info).response_type annotation. +func GetOperationResponseType(m protoreflect.MethodDescriptor) protoreflect.MessageDescriptor { + if m == nil { + return nil + } + info := GetOperationInfo(m) + if info == nil { + return nil + } + typ := FindMessage(m.ParentFile(), info.GetResponseType()) + + return typ +} + +// GetResponseType returns the OutputType if the response is +// not an LRO, or the ResponseType otherwise. +func GetResponseType(m protoreflect.MethodDescriptor) protoreflect.MessageDescriptor { + if m == nil { + return nil + } + + ot := m.Output() + if !isLongRunningOperation(ot) { + return ot + } + + return GetOperationResponseType(m) +} + +func isLongRunningOperation(m protoreflect.MessageDescriptor) bool { + return m.ParentFile().Package() == "google.longrunning" && m.Name() == "Operation" +} + +// GetMetadataType returns the message referred to by the +// (google.longrunning.operation_info).metadata_type annotation. +func GetMetadataType(m protoreflect.MethodDescriptor) protoreflect.MessageDescriptor { + if m == nil { + return nil + } + info := GetOperationInfo(m) + if info == nil { + return nil + } + typ := FindMessage(m.ParentFile(), info.GetMetadataType()) + + return typ +} + +// GetMethodSignatures returns the `google.api.method_signature` annotations. +func GetMethodSignatures(m protoreflect.MethodDescriptor) [][]string { + opts := m.Options() + if !opts.ProtoReflect().Has(apb.E_MethodSignature.TypeDescriptor()) { + return [][]string{} + } + extValue := opts.ProtoReflect().Get(apb.E_MethodSignature.TypeDescriptor()) + extList := extValue.List() + + answer := [][]string{} + for i := 0; i < extList.Len(); i++ { + sig := extList.Get(i).String() + answer = append(answer, strings.Split(sig, ",")) + } + return answer +} + +// GetResource returns the google.api.resource annotation. +func GetResource(m protoreflect.MessageDescriptor) *apb.ResourceDescriptor { + if m == nil { + return nil + } + res, ok := &apb.ResourceDescriptor{}, false + if res, ok = GetExtensionGeneric(m.Options().ProtoReflect(), apb.E_Resource.TypeDescriptor(), res); !ok { + return nil + } + + return res +} + +// IsResource returns true if the message has a populated google.api.resource +// annotation with a non-empty "type" field. +func IsResource(m protoreflect.MessageDescriptor) bool { + if res := GetResource(m); res != nil { + return res.GetType() != "" + } + return false +} + +// IsSingletonResource returns true if the given message is a singleton +// resource according to its pattern. +func IsSingletonResource(m protoreflect.MessageDescriptor) bool { + for _, pattern := range GetResource(m).GetPattern() { + if IsSingletonResourcePattern(pattern) { + return true + } + } + return false +} + +// IsSingletonResourcePattern returns true if the given message is a singleton +// resource according to its pattern. +func IsSingletonResourcePattern(pattern string) bool { + // If the pattern ends in something other than "}", that indicates that this is a singleton. + // + // For example: + // publishers/{publisher}/books/{book} -- not a singleton, many books + // publishers/*/settings -- a singleton; one settings object per publisher + return !strings.HasSuffix(pattern, "}") +} + +// GetResourceDefinitions returns the google.api.resource_definition annotations +// for a file. +func GetResourceDefinitions(f protoreflect.FileDescriptor) []*apb.ResourceDescriptor { + opts := f.Options() + if !opts.ProtoReflect().Has(apb.E_ResourceDefinition.TypeDescriptor()) { + return nil + } + extValue := opts.ProtoReflect().Get(apb.E_ResourceDefinition.TypeDescriptor()) + extList := extValue.List() + + answer := []*apb.ResourceDescriptor{} + for i := 0; i < extList.Len(); i++ { + msg := extList.Get(i).Message().Interface() + if rd, ok := msg.(*apb.ResourceDescriptor); ok { + answer = append(answer, rd) + } else { + // It may be a dynamic message, so we need to marshal and unmarshal. + b, err := proto.Marshal(msg) + if err != nil { + continue + } + rd := &apb.ResourceDescriptor{} + if err := proto.Unmarshal(b, rd); err != nil { + continue + } + answer = append(answer, rd) + } + } + return answer +} + +// HasResourceReference returns if the field has a google.api.resource_reference annotation. +func HasResourceReference(f protoreflect.FieldDescriptor) bool { + if f == nil { + return false + } + return f.Options().ProtoReflect().Has(apb.E_ResourceReference.TypeDescriptor()) +} + +// GetResourceReference returns the google.api.resource_reference annotation. +func GetResourceReference(f protoreflect.FieldDescriptor) *apb.ResourceReference { + if f == nil { + return nil + } + + ref, ok := &apb.ResourceReference{}, false + if ref, ok = GetExtensionGeneric(f.Options().ProtoReflect(), apb.E_ResourceReference.TypeDescriptor(), ref); !ok { + return nil + } + + return ref +} + +// FindResource returns first resource of type matching the reference param. +// resource Type name being referenced. It looks within a given file and its +// depenedencies, it cannot search within the entire protobuf package. +// This is especially useful for resolving google.api.resource_reference +// annotations. +func FindResource(reference string, file protoreflect.FileDescriptor) *apb.ResourceDescriptor { + m := FindResourceMessage(reference, file) + return GetResource(m) +} + +// FindResourceMessage returns the message containing the first resource of type +// matching the resource Type name being referenced. It looks within a given +// file and its depenedencies, it cannot search within the entire protobuf +// package. This is especially useful for resolving +// google.api.resource_reference annotations to the message that owns a +// resource. +func FindResourceMessage(reference string, file protoreflect.FileDescriptor) protoreflect.MessageDescriptor { + files := []protoreflect.FileDescriptor{file} + for i := 0; i < file.Imports().Len(); i++ { + files = append(files, file.Imports().Get(i).FileDescriptor) + } + + for _, f := range files { + for i := 0; i < f.Messages().Len(); i++ { + m := f.Messages().Get(i) + if r := GetResource(m); r != nil { + if r.GetType() == reference { + return m + } + } + } + } + return nil +} + +// SplitResourceTypeName splits the `Resource.type` field into the service name +// and the resource type name. +func SplitResourceTypeName(typ string) (service string, typeName string, ok bool) { + split := strings.Split(typ, "/") + if len(split) != 2 || split[0] == "" || split[1] == "" { + return + } + + service = split[0] + typeName = split[1] + ok = true + + return +} + +// FindResourceChildren attempts to search for other resources defined in the +// package that are parented by the given resource. +func FindResourceChildren(parent *apb.ResourceDescriptor, file protoreflect.FileDescriptor) []*apb.ResourceDescriptor { + pats := parent.GetPattern() + if len(pats) == 0 { + return nil + } + // Use the first pattern in the resource because: + // 1. Patterns cannot be rearranged, so this is the true first pattern + // 2. The true first pattern is the one most likely to be used as a parent. + first := pats[0] + + var children []*apb.ResourceDescriptor + files := []protoreflect.FileDescriptor{file} + for i := 0; i < file.Imports().Len(); i++ { + files = append(files, file.Imports().Get(i).FileDescriptor) + } + + for _, f := range files { + for i := 0; i < f.Messages().Len(); i++ { + m := f.Messages().Get(i) + if r := GetResource(m); r != nil && r.GetType() != parent.GetType() { + for _, p := range r.GetPattern() { + if strings.HasPrefix(p, first) { + children = append(children, r) + break + } + } + } + } + } + + return children +} + +func HasFieldInfo(fd protoreflect.FieldDescriptor) bool { + return fd != nil && fd.Options().ProtoReflect().Has(apb.E_FieldInfo.TypeDescriptor()) +} + +func GetFieldInfo(fd protoreflect.FieldDescriptor) *apb.FieldInfo { + if !HasFieldInfo(fd) { + return nil + } + + fi, ok := &apb.FieldInfo{}, false + if fi, ok = GetExtensionGeneric(fd.Options().ProtoReflect(), apb.E_FieldInfo.TypeDescriptor(), fi); !ok { + return nil + } + + return fi +} + +func HasFormat(fd protoreflect.FieldDescriptor) bool { + if !HasFieldInfo(fd) { + return false + } + + fi := GetFieldInfo(fd) + return fi.GetFormat() != apb.FieldInfo_FORMAT_UNSPECIFIED +} + +func GetFormat(fd protoreflect.FieldDescriptor) apb.FieldInfo_Format { + if !HasFormat(fd) { + return apb.FieldInfo_FORMAT_UNSPECIFIED + } + return GetFieldInfo(fd).GetFormat() +} diff --git a/rules/internal/utils/v2/extension_test.go b/rules/internal/utils/v2/extension_test.go new file mode 100644 index 00000000..3b535d96 --- /dev/null +++ b/rules/internal/utils/v2/extension_test.go @@ -0,0 +1,755 @@ +// Copyright 2019 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 utils + +import ( + "testing" + + "bitbucket.org/creachadair/stringset" + "github.com/google/go-cmp/cmp" + "github.com/aep-dev/api-linter/rules/internal/testutils/v2" + apb "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func TestGetFieldBehavior(t *testing.T) { + f := testutils.ParseProto3String(t, ` + import "google/api/field_behavior.proto"; + + message Book { + string name = 1 [ + (google.api.field_behavior) = IMMUTABLE, + (google.api.field_behavior) = OUTPUT_ONLY]; + + string title = 2 [(google.api.field_behavior) = REQUIRED]; + + string summary = 3; + } + `) + msg := f.Messages().Get(0) + tests := []struct { + fieldName string + fieldBehaviors stringset.Set + }{ + {"name", stringset.New("IMMUTABLE", "OUTPUT_ONLY")}, + {"title", stringset.New("REQUIRED")}, + {"summary", stringset.New()}, + } + for _, test := range tests { + t.Run(test.fieldName, func(t *testing.T) { + f := msg.Fields().ByName(protoreflect.Name(test.fieldName)) + if diff := cmp.Diff(GetFieldBehavior(f), test.fieldBehaviors); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestGetMethodSignatures(t *testing.T) { + for _, test := range []struct { + name string + want [][]string + Signatures string + }{ + {"Zero", [][]string{}, ""}, + {"One", [][]string{{"name"}}, `option (google.api.method_signature) = "name";`}, + { + "Two", + [][]string{{"name"}, {"name", "read_mask"}}, + `option (google.api.method_signature) = "name"; + option (google.api.method_signature) = "name,read_mask";`, + }, + } { + t.Run(test.name, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + import "google/api/client.proto"; + service Library { + rpc GetBook(GetBookRequest) returns (Book) { + {{.Signatures}} + } + } + message Book {} + message GetBookRequest {} + `, test) + method := f.Services().Get(0).Methods().Get(0) + if diff := cmp.Diff(GetMethodSignatures(method), test.want); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestGetOperationInfo(t *testing.T) { + f := testutils.ParseProto3String(t, ` + import "google/longrunning/operations.proto"; + service Library { + rpc WriteBook(WriteBookRequest) returns (google.longrunning.Operation) { + option (google.longrunning.operation_info) = { + response_type: "WriteBookResponse" + metadata_type: "WriteBookMetadata" + }; + } + } + message WriteBookRequest {} + `) + lro := GetOperationInfo(f.Services().Get(0).Methods().Get(0)) + if got, want := lro.ResponseType, "WriteBookResponse"; got != want { + t.Errorf("Response type - got %q, want %q.", got, want) + } + if got, want := lro.MetadataType, "WriteBookMetadata"; got != want { + t.Errorf("Metadata type - got %q, want %q.", got, want) + } +} + +func TestGetOperationInfoNone(t *testing.T) { + f := testutils.ParseProto3String(t, ` + service Library { + rpc GetBook(GetBookRequest) returns (Book); + } + message GetBookRequest {} + message Book {} + `) + lro := GetOperationInfo(f.Services().Get(0).Methods().Get(0)) + if lro != nil { + t.Errorf("Got %v, expected nil LRO annotation.", lro) + } +} + +func TestGetOperationInfoResponseType(t *testing.T) { + // Set up testing permutations. + tests := []struct { + testName string + ResponseType string + valid bool + }{ + {"Valid", "WriteBookResponse", true}, + {"Invalid", "Foo", false}, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + import "google/longrunning/operations.proto"; + service Library { + rpc WriteBook(WriteBookRequest) returns (google.longrunning.Operation) { + option (google.longrunning.operation_info) = { + response_type: "{{ .ResponseType }}" + metadata_type: "WriteBookMetadata" + }; + } + } + message WriteBookRequest {} + message WriteBookResponse {} + `, test) + + typ := GetOperationResponseType(f.Services().Get(0).Methods().Get(0)) + + if validType := typ != nil; validType != test.valid { + t.Fatalf("Expected valid(%v) response_type message", test.valid) + } + + if !test.valid { + return + } + + if got, want := typ.Name(), protoreflect.Name(test.ResponseType); got != want { + t.Errorf("Response type - got %q, want %q.", got, want) + } + }) + } +} + +func TestGetOperationInfoMetadataType(t *testing.T) { + // Set up testing permutations. + tests := []struct { + testName string + MetadataType string + valid bool + }{ + {"Valid", "WriteBookMetadata", true}, + {"Invalid", "Foo", false}, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + f := testutils.ParseProto3Tmpl(t, ` + import "google/longrunning/operations.proto"; + service Library { + rpc WriteBook(WriteBookRequest) returns (google.longrunning.Operation) { + option (google.longrunning.operation_info) = { + response_type: "WriteBookResponse" + metadata_type: "{{ .MetadataType }}" + }; + } + } + message WriteBookRequest {} + message WriteBookMetadata {} + `, test) + + typ := GetMetadataType(f.Services().Get(0).Methods().Get(0)) + + if validType := typ != nil; validType != test.valid { + t.Fatalf("Expected valid(%v) metadata_type message", test.valid) + } + + if !test.valid { + return + } + + if got, want := typ.Name(), protoreflect.Name(test.MetadataType); got != want { + t.Errorf("Metadata type - got %q, want %q.", got, want) + } + }) + } +} + +func TestGetResource(t *testing.T) { + t.Run("Present", func(t *testing.T) { + f := testutils.ParseProto3String(t, ` + import "google/api/resource.proto"; + message Book { + option (google.api.resource) = { + type: "library.googleapis.com/Book" + pattern: "publishers/{publisher}/books/{book}" + }; + } + `) + resource := GetResource(f.Messages().Get(0)) + if got, want := resource.GetType(), "library.googleapis.com/Book"; got != want { + t.Errorf("Got %q, expected %q.", got, want) + } + if got, want := resource.GetPattern()[0], "publishers/{publisher}/books/{book}"; got != want { + t.Errorf("Got %q, expected %q.", got, want) + } + }) + t.Run("Absent", func(t *testing.T) { + f := testutils.ParseProto3String(t, "message Book {}") + if got := GetResource(f.Messages().Get(0)); got != nil { + t.Errorf(`Got "%v", expected nil.`, got) + } + }) + t.Run("Nil", func(t *testing.T) { + if got := GetResource(nil); got != nil { + t.Errorf(`Got "%v", expected nil.`, got) + } + }) +} + +func TestGetResourceDefinition(t *testing.T) { + t.Run("Zero", func(t *testing.T) { + f := testutils.ParseProto3String(t, ` + import "google/api/resource.proto"; + `) + if got := GetResourceDefinitions(f); got != nil { + t.Errorf("Got %v, expected nil.", got) + } + }) + t.Run("One", func(t *testing.T) { + f := testutils.ParseProto3String(t, ` + import "google/api/resource.proto"; + option (google.api.resource_definition) = { + type: "library.googleapis.com/Book" + }; + `) + defs := GetResourceDefinitions(f) + if got, want := len(defs), 1; got != want { + t.Errorf("Got %d definitions, expected %d.", got, want) + } + if got, want := defs[0].GetType(), "library.googleapis.com/Book"; got != want { + t.Errorf("Got %s for type, expected %s.", got, want) + } + }) + t.Run("Two", func(t *testing.T) { + f := testutils.ParseProto3String(t, ` + import "google/api/resource.proto"; + option (google.api.resource_definition) = { + type: "library.googleapis.com/Book" + }; + option (google.api.resource_definition) = { + type: "library.googleapis.com/Author" + }; + `) + defs := GetResourceDefinitions(f) + if got, want := len(defs), 2; got != want { + t.Errorf("Got %d definitions, expected %d.", got, want) + } + if got, want := defs[0].GetType(), "library.googleapis.com/Book"; got != want { + t.Errorf("Got %s for type, expected %s.", got, want) + } + if got, want := defs[1].GetType(), "library.googleapis.com/Author"; got != want { + t.Errorf("Got %s for type, expected %s.", got, want) + } + }) +} + +func TestGetResourceReference(t *testing.T) { + t.Run("Present", func(t *testing.T) { + f := testutils.ParseProto3String(t, ` + import "google/api/resource.proto"; + message GetBookRequest { + string name = 1 [(google.api.resource_reference) = { + type: "library.googleapis.com/Book" + }]; + } + `) + ref := GetResourceReference(f.Messages().Get(0).Fields().Get(0)) + if got, want := ref.GetType(), "library.googleapis.com/Book"; got != want { + t.Errorf("Got %q, expected %q.", got, want) + } + }) + t.Run("Absent", func(t *testing.T) { + f := testutils.ParseProto3String(t, "message GetBookRequest { string name = 1; }") + if got := GetResourceReference(f.Messages().Get(0).Fields().Get(0)); got != nil { + t.Errorf(`Got "%v", expected nil`, got) + } + }) +} + +func TestFindResource(t *testing.T) { + files := testutils.ParseProtoStrings(t, map[string]string{ + "book.proto": ` + syntax = "proto3"; + package test; + + import "google/api/resource.proto"; + + message Book { + option (google.api.resource) = { + type: "library.googleapis.com/Book" + pattern: "publishers/{publisher}/books/{book}" + }; + + string name = 1; + } + `, + "shelf.proto": ` + syntax = "proto3"; + package test; + + import "book.proto"; + import "google/api/resource.proto"; + + message Shelf { + option (google.api.resource) = { + type: "library.googleapis.com/Shelf" + pattern: "shelves/{shelf}" + }; + + string name = 1; + + repeated Book books = 2; + } + `, + }) + + for _, tst := range []struct { + name, reference string + notFound bool + }{ + {"local_reference", "library.googleapis.com/Shelf", false}, + {"imported_reference", "library.googleapis.com/Book", false}, + {"unresolvable", "foo.googleapis.com/Bar", true}, + } { + t.Run(tst.name, func(t *testing.T) { + got := FindResource(tst.reference, files["shelf.proto"]) + + if tst.notFound && got != nil { + t.Fatalf("Expected to not find the resource, but found %q", got.GetType()) + } + + if !tst.notFound && got == nil { + t.Errorf("Got nil, expected %q", tst.reference) + } else if !tst.notFound && got.GetType() != tst.reference { + t.Errorf("Got %q, expected %q", got.GetType(), tst.reference) + } + }) + } +} + +func TestFindResourceMessage(t *testing.T) { + files := testutils.ParseProtoStrings(t, map[string]string{ + "book.proto": ` + syntax = "proto3"; + package test; + + import "google/api/resource.proto"; + + message Book { + option (google.api.resource) = { + type: "library.googleapis.com/Book" + pattern: "publishers/{publisher}/books/{book}" + }; + + string name = 1; + } + `, + "shelf.proto": ` + syntax = "proto3"; + package test; + + import "book.proto"; + import "google/api/resource.proto"; + + message Shelf { + option (google.api.resource) = { + type: "library.googleapis.com/Shelf" + pattern: "shelves/{shelf}" + }; + + string name = 1; + + repeated Book books = 2; + } + `, + }) + + for _, tst := range []struct { + name, reference, wantMsg string + notFound bool + }{ + {"local_reference", "library.googleapis.com/Shelf", "Shelf", false}, + {"imported_reference", "library.googleapis.com/Book", "Book", false}, + {"unresolvable", "foo.googleapis.com/Bar", "", true}, + } { + t.Run(tst.name, func(t *testing.T) { + got := FindResourceMessage(tst.reference, files["shelf.proto"]) + + if tst.notFound && got != nil { + t.Fatalf("Expected to not find the message, but found %q", got.Name()) + } + + if !tst.notFound && got == nil { + t.Errorf("Got nil, expected %q", tst.wantMsg) + } else if !tst.notFound && string(got.Name()) != tst.wantMsg { + t.Errorf("Got %q, expected %q", got.Name(), tst.wantMsg) + } + }) + } +} + +func TestSplitResourceTypeName(t *testing.T) { + for _, tst := range []struct { + name, input, service, typeName string + ok bool + }{ + {"Valid", "foo.googleapis.com/Foo", "foo.googleapis.com", "Foo", true}, + {"InvalidExtraSlashes", "foo.googleapis.com/Foo/Bar", "", "", false}, + {"InvalidNoService", "/Foo", "", "", false}, + {"InvalidNoTypeName", "foo.googleapis.com/", "", "", false}, + } { + t.Run(tst.name, func(t *testing.T) { + s, typ, ok := SplitResourceTypeName(tst.input) + if ok != tst.ok { + t.Fatalf("Expected %v for ok, but got %v", tst.ok, ok) + } + if diff := cmp.Diff(s, tst.service); diff != "" { + t.Errorf("service: got(-),want(+):\n%s", diff) + } + if diff := cmp.Diff(typ, tst.typeName); diff != "" { + t.Errorf("type name: got(-),want(+):\n%s", diff) + } + }) + } +} + +func TestGetOutputOrLROResponseMessage(t *testing.T) { + for _, test := range []struct { + name string + RPCs string + want string + }{ + {"BookOutputType", ` + rpc CreateBook(CreateBookRequest) returns (Book) {}; + `, "Book"}, + {"BespokeOperationResource", ` + rpc CreateBook(CreateBookRequest) returns (Operation) {}; + `, "Operation"}, + {"LROBookResponse", ` + rpc CreateBook(CreateBookRequest) returns (google.longrunning.Operation) { + option (google.longrunning.operation_info) = { + response_type: "Book" + }; + };`, "Book"}, + {"LROMissingResponse", ` + rpc CreateBook(CreateBookRequest) returns (google.longrunning.Operation) { + }; + `, ""}, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + import "google/api/resource.proto"; + import "google/longrunning/operations.proto"; + import "google/protobuf/field_mask.proto"; + service Foo { + {{.RPCs}} + } + + // This is at the top to make it retrievable + // by the test code. + message Book { + option (google.api.resource) = { + type: "library.googleapis.com/Book" + pattern: "books/{book}" + singular: "book" + plural: "books" + }; + } + + message CreateBookRequest { + // The parent resource where this book will be created. + // Format: publishers/{publisher} + string parent = 1; + + // The book to create. + Book book = 2; + } + + // bespoke operation message (not an LRO) + message Operation { + } + `, test) + method := file.Services().Get(0).Methods().Get(0) + resp := GetResponseType(method) + got := "" + if resp != nil { + got = string(resp.Name()) + } + if got != test.want { + t.Errorf( + "GetOutputOrLROResponseMessage got %q, want %q", + got, test.want, + ) + } + }) + } +} + +func TestFindResourceChildren(t *testing.T) { + publisher := &apb.ResourceDescriptor{ + Type: "library.googleapis.com/Publisher", + Pattern: []string{ + "publishers/{publisher}", + }, + } + shelf := &apb.ResourceDescriptor{ + Type: "library.googleapis.com/Shelf", + Pattern: []string{ + "shelves/{shelf}", + }, + } + book := &apb.ResourceDescriptor{ + Type: "library.googleapis.com/Book", + Pattern: []string{ + "publishers/{publisher}/books/{book}", + }, + } + edition := &apb.ResourceDescriptor{ + Type: "library.googleapis.com/Edition", + Pattern: []string{ + "publishers/{publisher}/books/{book}/editions/{edition}", + }, + } + files := testutils.ParseProtoStrings(t, map[string]string{ + "book.proto": ` + syntax = "proto3"; + package test; + + import "google/api/resource.proto"; + + message Book { + option (google.api.resource) = { + type: "library.googleapis.com/Book" + pattern: "publishers/{publisher}/books/{book}" + }; + string name = 1; + } + + message Edition { + option (google.api.resource) = { + type: "library.googleapis.com/Edition" + pattern: "publishers/{publisher}/books/{book}/editions/{edition}" + }; + + string name = 1; + } + `, + "shelf.proto": ` + syntax = "proto3"; + package test; + + import "book.proto"; + import "google/api/resource.proto"; + + message Shelf { + option (google.api.resource) = { + type: "library.googleapis.com/Shelf" + pattern: "shelves/{shelf}" + }; + + string name = 1; + + repeated Book books = 2; + } + `, + }) + + for _, tst := range []struct { + name string + parent *apb.ResourceDescriptor + want []*apb.ResourceDescriptor + }{ + {"has_child_same_file", book, []*apb.ResourceDescriptor{edition}}, + {"has_child_other_file", publisher, []*apb.ResourceDescriptor{book, edition}}, + {"no_children", shelf, nil}, + } { + t.Run(tst.name, func(t *testing.T) { + got := FindResourceChildren(tst.parent, files["shelf.proto"]) + if diff := cmp.Diff(tst.want, got, cmp.Comparer(proto.Equal)); diff != "" { + t.Errorf("got(-),want(+):\n%s", diff) + } + }) + } +} + +func TestHasFieldInfo(t *testing.T) { + testCases := []struct { + name, FieldInfo string + want bool + }{ + { + name: "HasFieldInfo", + FieldInfo: "[(google.api.field_info).format = UUID4]", + want: true, + }, + { + name: "NoFieldInfo", + want: false, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + import "google/api/field_info.proto"; + + message CreateBookRequest { + string foo = 1 {{.FieldInfo}}; + } + `, tc) + fd := file.Messages().ByName("CreateBookRequest").Fields().ByName("foo") + if got := HasFieldInfo(fd); got != tc.want { + t.Errorf("HasFieldInfo(%+v): expected %v, got %v", fd, tc.want, got) + } + }) + } +} + +func TestGetFieldInfo(t *testing.T) { + testCases := []struct { + name, FieldInfo string + want *apb.FieldInfo + }{ + { + name: "HasFieldInfo", + FieldInfo: "[(google.api.field_info).format = UUID4]", + want: &apb.FieldInfo{Format: apb.FieldInfo_UUID4}, + }, + { + name: "NoFieldInfo", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + import "google/api/field_info.proto"; + + message CreateBookRequest { + string foo = 1 {{.FieldInfo}}; + } + `, tc) + fd := file.Messages().ByName("CreateBookRequest").Fields().ByName("foo") + got := GetFieldInfo(fd) + if diff := cmp.Diff(got, tc.want, cmp.Comparer(proto.Equal)); diff != "" { + t.Errorf("GetFieldInfo(%+v): got(-),want(+):\n%s", fd, diff) + } + }) + } +} + +func TestHasFormat(t *testing.T) { + testCases := []struct { + name, Format string + want bool + }{ + { + name: "HasFormat", + Format: "format: UUID4", + want: true, + }, + { + name: "NoFormat", + want: false, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + import "google/api/field_info.proto"; + + message CreateBookRequest { + string foo = 1 [(google.api.field_info) = { + {{.Format}} + }]; + } + `, tc) + fd := file.Messages().ByName("CreateBookRequest").Fields().ByName("foo") + if got := HasFormat(fd); got != tc.want { + t.Errorf("HasFormat(%+v): expected %v, got %v", fd, tc.want, got) + } + }) + } +} + +func TestGetFormat(t *testing.T) { + testCases := []struct { + name, Format string + want apb.FieldInfo_Format + }{ + { + name: "HasUUID4Format", + Format: "format: UUID4", + want: apb.FieldInfo_UUID4, + }, + { + name: "NoFormat", + want: apb.FieldInfo_FORMAT_UNSPECIFIED, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + import "google/api/field_info.proto"; + + message CreateBookRequest { + string foo = 1 [(google.api.field_info) = { + {{.Format}} + }]; + } + `, tc) + fd := file.Messages().ByName("CreateBookRequest").Fields().ByName("foo") + if got := GetFormat(fd); got != tc.want { + t.Errorf("GetFormat(%+v): expected %v, got %v", fd, tc.want, got) + } + }) + } +} diff --git a/rules/internal/utils/v2/find.go b/rules/internal/utils/v2/find.go new file mode 100644 index 00000000..cea9a6e8 --- /dev/null +++ b/rules/internal/utils/v2/find.go @@ -0,0 +1,169 @@ +// 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 utils + +import ( + "sort" + "strings" + + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" +) + +// FindMessage looks for a message in a file and all imports within the +// same package. +func FindMessage(f protoreflect.FileDescriptor, name string) protoreflect.MessageDescriptor { + // Default to using the current file's package. + pkg := f.Package() + + // FileDescriptor.FindMessage requires fully-qualified message names; + // attempt to infer that. + if !strings.Contains(name, ".") { + if pkg != "" { + name = string(pkg) + "." + name + } + } else if !strings.HasPrefix(name, string(pkg)+".") { + // If value is fully qualified, but from a different package, + // accommodate that. + pkg = protoreflect.FullName(name[:strings.LastIndex(name, ".")]) + } + + files := &protoregistry.Files{} + for _, fd := range GetAllDependencies(f) { + // It is safe to ignore this error. If a file is already registered, + // it will return an error, but that is fine. + _ = files.RegisterFile(fd) + } + + // Attempt to find the message in the file provided. + if d, err := files.FindDescriptorByName(protoreflect.FullName(name)); err == nil { + if m, ok := d.(protoreflect.MessageDescriptor); ok { + // If the message's package is not what we expect, then it is + // the wrong message. + if m.ParentFile().Package() == pkg { + return m + } + } + } + + // Whelp, no luck. Too bad. + return nil +} + +// FindMethod searches a file and all imports within the same package, and +// returns the method with a given name, or nil if none is found. +func FindMethod(f protoreflect.FileDescriptor, name string) protoreflect.MethodDescriptor { + for _, s := range getServices(f) { + if m := s.Methods().ByName(protoreflect.Name(name)); m != nil { + return m + } + } + return nil +} + +// getServices finds all services in a file and all imports within the +// same package. +func getServices(f protoreflect.FileDescriptor) []protoreflect.ServiceDescriptor { + var answer []protoreflect.ServiceDescriptor + for i := 0; i < f.Services().Len(); i++ { + answer = append(answer, f.Services().Get(i)) + } + for i := 0; i < f.Imports().Len(); i++ { + dep := f.Imports().Get(i) + if f.Package() == dep.Package() { + answer = append(answer, getServices(dep.FileDescriptor)...) + } + } + return answer +} + +// GetAllDependencies returns all dependencies. +func GetAllDependencies(file protoreflect.FileDescriptor) map[string]protoreflect.FileDescriptor { + answer := map[string]protoreflect.FileDescriptor{file.Path(): file} + for i := 0; i < file.Imports().Len(); i++ { + f := file.Imports().Get(i).FileDescriptor + if _, found := answer[f.Path()]; !found { + answer[f.Path()] = f + for name, f2 := range GetAllDependencies(f) { + answer[name] = f2 + } + } + } + return answer +} + +type fieldSorter []protoreflect.FieldDescriptor + +// Len is part of sort.Interface. +func (f fieldSorter) Len() int { + return len(f) +} + +// Swap is part of sort.Interface. +func (f fieldSorter) Swap(i, j int) { + f[i], f[j] = f[j], f[i] +} + +// Less is part of sort.Interface. Compare field number. +func (f fieldSorter) Less(i, j int) bool { + return f[i].Number() < f[j].Number() +} + +// GetRepeatedMessageFields returns all fields labeled `repeated` of type +// Message in the given message, sorted in field number order. +func GetRepeatedMessageFields(m protoreflect.MessageDescriptor) []protoreflect.FieldDescriptor { + var fields fieldSorter + + // If an unresolable message is fed into this helper, return empty slice. + if m == nil { + return fields + } + + for i := 0; i < m.Fields().Len(); i++ { + f := m.Fields().Get(i) + if f.IsList() && f.Kind() == protoreflect.MessageKind { + fields = append(fields, f) + } + } + + sort.Sort(fields) + + return fields +} + +// FindFieldDotNotation returns a field descriptor from a given message that +// corresponds to the dot separated path e.g. "book.name". If the path is +// unresolable the method returns nil. This is especially useful for resolving +// path variables in google.api.http and nested fields in +// google.api.method_signature annotations. +func FindFieldDotNotation(msg protoreflect.MessageDescriptor, ref string) protoreflect.FieldDescriptor { + path := strings.Split(ref, ".") + end := len(path) - 1 + for i, seg := range path { + field := msg.Fields().ByName(protoreflect.Name(seg)) + if field == nil { + return nil + } + + if m := field.Message(); m != nil && i != end { + msg = m + continue + } + + return field + } + + return nil +} diff --git a/rules/internal/utils/v2/find_test.go b/rules/internal/utils/v2/find_test.go new file mode 100644 index 00000000..cb156aea --- /dev/null +++ b/rules/internal/utils/v2/find_test.go @@ -0,0 +1,98 @@ +// 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 utils + +import ( + "strings" + "testing" + + "github.com/aep-dev/api-linter/rules/internal/testutils/v2" +) + +func TestFindMessage(t *testing.T) { + files := testutils.ParseProtoStrings(t, map[string]string{ + "a.proto": ` + package test; + message Book {} + `, + "b.proto": ` + package other; + message Scroll {} + `, + "c.proto": ` + package test; + import "a.proto"; + import "b.proto"; + `, + }) + if book := FindMessage(files["c.proto"], "Book"); book == nil { + t.Errorf("Got nil, expected Book message.") + } + if scroll := FindMessage(files["c.proto"], "Scroll"); scroll != nil { + t.Errorf("Got Scroll message, expected nil.") + } + if book := FindMessage(files["c.proto"], "test.Book"); book == nil { + t.Errorf("Got nil, expected Book message from qualified name.") + } + if scroll := FindMessage(files["c.proto"], "other.Scroll"); scroll == nil { + t.Errorf("Got nil message, expected Scroll message from qualified name.") + } +} + +func TestFindFieldDotNotation(t *testing.T) { + file := testutils.ParseProto3String(t, ` + package test; + + message CreateBookRequest { + string parent = 1; + + Book book = 2; + } + + message Book { + string name = 1; + + message PublishingInfo { + string publisher = 1; + int32 edition = 2; + } + + PublishingInfo publishing_info = 2; + } + `) + msg := file.Messages().Get(0) + + for _, tst := range []struct { + name, path string + }{ + {"top_level", "parent"}, + {"nested", "book.name"}, + {"double_nested", "book.publishing_info.publisher"}, + } { + t.Run(tst.name, func(t *testing.T) { + split := strings.Split(tst.path, ".") + want := split[len(split)-1] + + f := FindFieldDotNotation(msg, tst.path) + + if f == nil { + t.Errorf("Got nil, expected %q field", want) + } else if got := f.Name(); string(got) != want { + t.Errorf("Got %q, expected %q", got, want) + } + }) + } + +} diff --git a/rules/internal/utils/v2/http.go b/rules/internal/utils/v2/http.go new file mode 100644 index 00000000..738e8459 --- /dev/null +++ b/rules/internal/utils/v2/http.go @@ -0,0 +1,156 @@ +// Copyright 2019 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 utils + +import ( + "regexp" + "strings" + + apb "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// HasHTTPRules returns true when the given method descriptor is annotated with +// a google.api.http option. +func HasHTTPRules(m protoreflect.MethodDescriptor) bool { + return m.Options().ProtoReflect().Has(apb.E_Http.TypeDescriptor()) +} + +// GetHTTPRules returns a slice of HTTP rules for a given method descriptor. +// +// Note: This returns a slice -- it takes the google.api.http annotation, +// and then flattens the values in `additional_bindings`. +// This allows rule authors to simply range over all of the HTTP rules, +// since the common case is to want to apply the checks to all of them. +func GetHTTPRules(m protoreflect.MethodDescriptor) []*HTTPRule { + rules := []*HTTPRule{} + opts := m.Options() + if !opts.ProtoReflect().Has(apb.E_Http.TypeDescriptor()) { + return rules + } + + // Get the "primary" rule (the direct google.api.http annotation). + ext := opts.ProtoReflect().Get(apb.E_Http.TypeDescriptor()).Message() + if parsedRule := parseRule(ext); parsedRule != nil { + rules = append(rules, parsedRule) + } + + // Add any additional bindings. + additionalBindingsDesc := ext.Descriptor().Fields().ByName("additional_bindings") + if additionalBindingsDesc != nil { + bindings := ext.Get(additionalBindingsDesc).List() + for i := 0; i < bindings.Len(); i++ { + if parsedRule := parseRule(bindings.Get(i).Message()); parsedRule != nil { + rules = append(rules, parsedRule) + } + } + } + + // Done; return the rules. + return rules +} + +func parseRule(rule protoreflect.Message) *HTTPRule { + var method, uri, body, responseBody string + rule.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { + switch fd.Name() { + case "body": + body = v.String() + case "response_body": + responseBody = v.String() + case "get", "put", "post", "delete", "patch": + if v.String() != "" { + method = strings.ToUpper(string(fd.Name())) + uri = v.String() + } + case "custom": + custom := v.Message() + custom.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { + switch fd.Name() { + case "kind": + method = v.String() + case "path": + uri = v.String() + } + return true + }) + } + return true + }) + + if uri != "" { + return &HTTPRule{ + Method: method, + URI: uri, + Body: body, + ResponseBody: responseBody, + } + } + return nil +} + +// HTTPRule defines a parsed, easier-to-query equivalent to `annotations.HttpRule`. +type HTTPRule struct { + // The HTTP method. Guaranteed to be in all caps. + // This is set to "CUSTOM" if the Custom property is set. + Method string + + // The HTTP URI (the value corresponding to the selected HTTP method). + URI string + + // The `body` value forwarded from the generated proto's HttpRule. + Body string + + // The `response_body` value forwarded from the generated proto's HttpRule. + ResponseBody string +} + +// GetVariables returns the variable segments in a URI as a map. +// +// For a given variable, the key is the variable's field path. The value is the +// variable's template, which will match segment(s) of the URL. +// +// For more details on the path template syntax, see +// https://github.com/googleapis/googleapis/blob/6e1a5a066659794f26091674e3668229e7750052/google/api/http.proto#L224. +func (h *HTTPRule) GetVariables() map[string]string { + vars := map[string]string{} + + // Replace the version template variable with "v". + uri := VersionedSegment.ReplaceAllString(h.URI, "v") + for _, match := range plainVar.FindAllStringSubmatch(uri, -1) { + vars[match[1]] = "*" + } + for _, match := range varSegment.FindAllStringSubmatch(uri, -1) { + vars[match[1]] = match[2] + } + return vars +} + +// GetPlainURI returns the URI with variable segment information removed. +func (h *HTTPRule) GetPlainURI() string { + return plainVar.ReplaceAllString( + varSegment.ReplaceAllString( + VersionedSegment.ReplaceAllString(h.URI, "v"), + "$2"), + "*") +} + +var ( + plainVar = regexp.MustCompile(`\{([^}=]+)\}`) + varSegment = regexp.MustCompile(`\{([^}=]+)=([^}]+)\}`) + // VersionedSegment is a regex to extract the API version from + // an HTTP path. + VersionedSegment = regexp.MustCompile(`\{\$api_version\}`) +) diff --git a/rules/internal/utils/v2/http_test.go b/rules/internal/utils/v2/http_test.go new file mode 100644 index 00000000..c9d45982 --- /dev/null +++ b/rules/internal/utils/v2/http_test.go @@ -0,0 +1,182 @@ +// Copyright 2019 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 utils + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/aep-dev/api-linter/rules/internal/testutils/v2" + apb "google.golang.org/genproto/googleapis/api/annotations" +) + +func TestGetHTTPRules(t *testing.T) { + for _, method := range []string{"GET", "POST", "PUT", "PATCH", "DELETE"} { + t.Run(method, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + import "google/api/annotations.proto"; + service Library { + rpc FrobBook(FrobBookRequest) returns (FrobBookResponse) { + option (google.api.http) = { + {{.M}}: "/v1/publishers/*/books/*" + additional_bindings { + {{.M}}: "/v1/books/*" + } + }; + } + } + message FrobBookRequest {} + message FrobBookResponse {} + `, struct{ M string }{M: strings.ToLower(method)}) + + // Get the rules. + resp := GetHTTPRules(file.Services().Get(0).Methods().Get(0)) + + // Establish that we get back both HTTP rules, in order. + if got, want := resp[0].URI, "/v1/publishers/*/books/*"; got != want { + t.Errorf("Rule 1: Got URI %q, expected %q.", got, want) + } + if got, want := resp[1].URI, "/v1/books/*"; got != want { + t.Errorf("Rule 2: Got URI %q, expected %q.", got, want) + } + for _, httpRules := range resp { + if got, want := httpRules.Method, method; got != want { + t.Errorf("Got method %q, expected %q.", got, want) + } + } + }) + } +} + +func TestGetHTTPRulesEmpty(t *testing.T) { + file := testutils.ParseProto3String(t, ` + import "google/api/annotations.proto"; + service Library { + rpc FrobBook(FrobBookRequest) returns (FrobBookResponse); + } + message FrobBookRequest {} + message FrobBookResponse {} + `) + if resp := GetHTTPRules(file.Services().Get(0).Methods().Get(0)); len(resp) > 0 { + t.Errorf("Got %v; expected no rules.", resp) + } +} + +func TestParseRuleEmpty(t *testing.T) { + http := &apb.HttpRule{} + if got := parseRule(http.ProtoReflect()); got != nil { + t.Errorf("Got %v, expected nil.", got) + } +} + +func TestGetHTTPRulesCustom(t *testing.T) { + file := testutils.ParseProto3String(t, ` + import "google/api/annotations.proto"; + service Library { + rpc FrobBook(FrobBookRequest) returns (FrobBookResponse) { + option (google.api.http) = { + custom: { + kind: "HEAD" + path: "/v1/books/*" + } + }; + } + } + message FrobBookRequest {} + message FrobBookResponse {} + `) + rule := GetHTTPRules(file.Services().Get(0).Methods().Get(0))[0] + if got, want := rule.Method, "HEAD"; got != want { + t.Errorf("Got %q; expected %q.", got, want) + } + if got, want := rule.URI, "/v1/books/*"; got != want { + t.Errorf("Got %q; expected %q.", got, want) + } +} + +func TestGetPlainURI(t *testing.T) { + tests := []struct { + name string + uri string + plainURI string + }{ + {"KeyOnly", "/v1/publishers/{pub_id}/books/{book_id}", "/v1/publishers/*/books/*"}, + {"KeyValue", "/v1/{name=publishers/*/books/*}", "/v1/publishers/*/books/*"}, + {"MultiKeyValue", "/v1/{publisher=publishers/*}/{book=books/*}", "/v1/publishers/*/books/*"}, + {"TemplateVariableSegment", "/{$api_version}/publishers/{pub_id}/books/{book_id}", "/v/publishers/*/books/*"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + rule := &HTTPRule{URI: test.uri} + if diff := cmp.Diff(rule.GetPlainURI(), test.plainURI); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestGetVariables(t *testing.T) { + tests := []struct { + name string + uri string + vars map[string]string + }{ + {"KeyOnly", "/v1/publishers/{pub_id}/books/{book_id}", map[string]string{"pub_id": "*", "book_id": "*"}}, + {"KeyValue", "/v1/{name=publishers/*/books/*}", map[string]string{"name": "publishers/*/books/*"}}, + {"MultiKeyValue", "/v1/{publisher=publishers/*}/{book=books/*}", map[string]string{"publisher": "publishers/*", "book": "books/*"}}, + {"IgnoreVersioningVariable", "/{$api_version}/{name=publishers/*/books/*}", map[string]string{"name": "publishers/*/books/*"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + rule := &HTTPRule{URI: test.uri} + if diff := cmp.Diff(rule.GetVariables(), test.vars); diff != "" { + t.Error(diff) + } + }) + } +} + +func TestHasHTTPRules(t *testing.T) { + for _, tst := range []struct { + name string + Annotation string + }{ + {"has_rule", `option (google.api.http) = {get: "/v1/foos"};`}, + {"no_rule", ""}, + } { + t.Run(tst.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + import "google/api/annotations.proto"; + + service Foo { + rpc ListFoos (ListFoosRequest) returns (ListFoosResponse) { + {{ .Annotation }} + } + } + + message ListFoosRequest {} + message ListFoosResponse {} + `, tst) + want := tst.Annotation != "" + + got := HasHTTPRules(file.Services().Get(0).Methods().Get(0)) + + if got != want { + t.Errorf("Got %v, expected %v", got, want) + } + }) + } +} diff --git a/rules/internal/utils/v2/message.go b/rules/internal/utils/v2/message.go new file mode 100644 index 00000000..8fc22b54 --- /dev/null +++ b/rules/internal/utils/v2/message.go @@ -0,0 +1,86 @@ +// 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 utils + +import ( + "regexp" + + "github.com/stoewer/go-strcase" + "google.golang.org/protobuf/reflect/protoreflect" +) + +var ( + getReqMessageRegexp = regexp.MustCompile("^Get[A-Z]+[A-Za-z0-9]*Request$") + listReqMessageRegexp = regexp.MustCompile("^List[A-Z]+[A-Za-z0-9]*Request$") + listRespMessageRegexp = regexp.MustCompile("^List([A-Z]+[A-Za-z0-9]*)Response$") + listRevisionsReqMessageRegexp = regexp.MustCompile(`^List(?:[A-Z]+[A-Za-z0-9]+)RevisionsRequest$`) + listRevisionsRespMessageRegexp = regexp.MustCompile(`^List(?:[A-Z]+[A-Za-z0-9]+)RevisionsResponse$`) + createReqMessageRegexp = regexp.MustCompile("^Create[A-Z]+[A-Za-z0-9]*Request$") + updateReqMessageRegexp = regexp.MustCompile("^Update[A-Z]+[A-Za-z0-9]*Request$") + deleteReqMessageRegexp = regexp.MustCompile("^Delete[A-Z]+[A-Za-z0-9]*Request$") +) + +// Returns true if this is an AIP-131 Get request message, false otherwise. +func IsGetRequestMessage(m protoreflect.MessageDescriptor) bool { + return getReqMessageRegexp.MatchString(string(m.Name())) +} + +// Return true if this is an AIP-132 List request message, false otherwise. +func IsListRequestMessage(m protoreflect.MessageDescriptor) bool { + return listReqMessageRegexp.MatchString(string(m.Name())) && !IsListRevisionsRequestMessage(m) +} + +// Return true if this is an AIP-132 List response message, false otherwise. +func IsListResponseMessage(m protoreflect.MessageDescriptor) bool { + return listRespMessageRegexp.MatchString(string(m.Name())) && !IsListRevisionsResponseMessage(m) +} + +// Returns the name of the resource type from the response message name based on +// Standard List response message naming convention. If the message is not a +// Standard List response message, empty string is returned. +func ListResponseResourceName(m protoreflect.MessageDescriptor) string { + if !IsListResponseMessage(m) { + return "" + } + + return strcase.SnakeCase(listRespMessageRegexp.FindStringSubmatch(string(m.Name()))[1]) +} + +// IsListRevisionsRequestMessage returns true if this is an AIP-162 List +// Revisions request message, false otherwise. +func IsListRevisionsRequestMessage(m protoreflect.MessageDescriptor) bool { + return listRevisionsReqMessageRegexp.MatchString(string(m.Name())) +} + +// IsListRevisionsResponseMessage returns true if this is an AIP-162 List +// Revisions response message, false otherwise. +func IsListRevisionsResponseMessage(m protoreflect.MessageDescriptor) bool { + return listRevisionsRespMessageRegexp.MatchString(string(m.Name())) +} + +// Returns true if this is an AIP-133 Get request message, false otherwise. +func IsCreateRequestMessage(m protoreflect.MessageDescriptor) bool { + return createReqMessageRegexp.MatchString(string(m.Name())) +} + +// Returns true if this is an AIP-134 Update request message, false otherwise. +func IsUpdateRequestMessage(m protoreflect.MessageDescriptor) bool { + return updateReqMessageRegexp.MatchString(string(m.Name())) +} + +// Returns true if this is an AIP-135 Delete request message, false otherwise. +func IsDeleteRequestMessage(m protoreflect.MessageDescriptor) bool { + return deleteReqMessageRegexp.MatchString(string(m.Name())) +} diff --git a/rules/internal/utils/v2/message_test.go b/rules/internal/utils/v2/message_test.go new file mode 100644 index 00000000..b4a8b2a1 --- /dev/null +++ b/rules/internal/utils/v2/message_test.go @@ -0,0 +1,358 @@ +// 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 utils + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/aep-dev/api-linter/rules/internal/testutils/v2" +) + +func TestListResponseResourceName(t *testing.T) { + for _, test := range []struct { + name string + RPC string + want string + }{ + {"ValidBooks", "ListBooks", "books"}, + {"ValidCamelCase", "ListBookShelves", "book_shelves"}, + {"InvalidListRevisions", "ListBookRevisions", ""}, + {"InvalidNotList", "WriteBook", ""}, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + message {{.RPC}}Response {} + `, test) + m := file.Messages().Get(0) + got := ListResponseResourceName(m) + if diff := cmp.Diff(got, test.want); diff != "" { + t.Errorf("got(-),want(+):\n%s", diff) + } + + }) + } +} + +func TestIsListResponseMessage(t *testing.T) { + for _, test := range []struct { + name string + RPC string + want bool + }{ + { + name: "valid list response", + RPC: "ListBooks", + want: true, + }, + { + name: "not list response", + RPC: "ArchiveBook", + want: false, + }, + { + name: "lookalike response", + RPC: "Listen", + want: false, + }, + { + name: "multiword lookalike response", + RPC: "ListenForever", + want: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + message {{.RPC}}Response {} + `, test) + m := file.Messages().Get(0) + if got, want := IsListResponseMessage(m), test.want; got != want { + t.Errorf("got %v, want %v", got, want) + } + }) + } +} + +func TestIsListRequestMessage(t *testing.T) { + for _, test := range []struct { + name string + RPC string + want bool + }{ + { + name: "valid list request", + RPC: "ListBooks", + want: true, + }, + { + name: "not list request", + RPC: "ArchiveBook", + want: false, + }, + { + name: "lookalike request", + RPC: "Listen", + want: false, + }, + { + name: "multiword lookalike request", + RPC: "ListenForever", + want: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + message {{.RPC}}Request {} + `, test) + m := file.Messages().Get(0) + if got, want := IsListRequestMessage(m), test.want; got != want { + t.Errorf("got %v, want %v", got, want) + } + }) + } +} + +func TestIsListRevisionsResponseMessage(t *testing.T) { + for _, test := range []struct { + name string + RPC string + want bool + }{ + { + name: "valid list revisions response", + RPC: "ListBook", + want: true, + }, + { + name: "not list revisions response", + RPC: "ArchiveBook", + want: false, + }, + { + name: "lookalike response", + RPC: "Listen", + want: false, + }, + { + name: "multiword lookalike response", + RPC: "ListenForever", + want: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + message {{.RPC}}RevisionsResponse {} + `, test) + m := file.Messages().Get(0) + if got, want := IsListRevisionsResponseMessage(m), test.want; got != want { + t.Errorf("got %v, want %v", got, want) + } + }) + } +} + +func TestIsListRevisionsRequestMessage(t *testing.T) { + for _, test := range []struct { + name string + RPC string + want bool + }{ + { + name: "valid list revisions request", + RPC: "ListBook", + want: true, + }, + { + name: "not list revisions request", + RPC: "ArchiveBook", + want: false, + }, + { + name: "lookalike request", + RPC: "Listen", + want: false, + }, + { + name: "multiword lookalike request", + RPC: "ListenForever", + want: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + message {{.RPC}}RevisionsRequest {} + `, test) + m := file.Messages().Get(0) + if got, want := IsListRevisionsRequestMessage(m), test.want; got != want { + t.Errorf("got %v, want %v", got, want) + } + }) + } +} + +func TestIsGetRequestMessage(t *testing.T) { + for _, test := range []struct { + name string + RPC string + want bool + }{ + { + name: "valid get request", + RPC: "GetBook", + want: true, + }, + { + name: "not get request", + RPC: "ArchiveBook", + want: false, + }, + { + name: "lookalike request", + RPC: "Getup", + want: false, + }, + { + name: "multiword lookalike request", + RPC: "GetupFinder", + want: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + message {{.RPC}}Request {} + `, test) + m := file.Messages().Get(0) + if got, want := IsGetRequestMessage(m), test.want; got != want { + t.Errorf("got %v, want %v", got, want) + } + }) + } +} + +func TestIsCreateRequestMessage(t *testing.T) { + for _, test := range []struct { + name string + RPC string + want bool + }{ + { + name: "valid create request", + RPC: "CreateBook", + want: true, + }, + { + name: "not create request", + RPC: "ArchiveBook", + want: false, + }, + { + name: "lookalike request", + RPC: "Created", + want: false, + }, + { + name: "multiword lookalike request", + RPC: "CreatedBook", + want: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + message {{.RPC}}Request {} + `, test) + m := file.Messages().Get(0) + if got, want := IsCreateRequestMessage(m), test.want; got != want { + t.Errorf("got %v, want %v", got, want) + } + }) + } +} + +func TestIsUpdateRequestMessage(t *testing.T) { + for _, test := range []struct { + name string + RPC string + want bool + }{ + { + name: "valid update request", + RPC: "UpdateBook", + want: true, + }, + { + name: "not update request", + RPC: "ArchiveBook", + want: false, + }, + { + name: "lookalike request", + RPC: "Updater", + want: false, + }, + { + name: "multiword lookalike request", + RPC: "UpdaterAndCreator", + want: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + message {{.RPC}}Request {} + `, test) + m := file.Messages().Get(0) + if got, want := IsUpdateRequestMessage(m), test.want; got != want { + t.Errorf("got %v, want %v", got, want) + } + }) + } +} + +func TestIsDeleteRequestMessage(t *testing.T) { + for _, test := range []struct { + name string + RPC string + want bool + }{ + { + name: "valid delete request", + RPC: "DeleteBook", + want: true, + }, + { + name: "not delete request", + RPC: "ArchiveBook", + want: false, + }, + { + name: "lookalike request", + RPC: "Deleter", + want: false, + }, + { + name: "multiword lookalike request", + RPC: "DeleterAndPurge", + want: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + message {{.RPC}}Request {} + `, test) + m := file.Messages().Get(0) + if got, want := IsDeleteRequestMessage(m), test.want; got != want { + t.Errorf("got %v, want %v", got, want) + } + }) + } +} diff --git a/rules/internal/utils/v2/method.go b/rules/internal/utils/v2/method.go new file mode 100644 index 00000000..40520e1c --- /dev/null +++ b/rules/internal/utils/v2/method.go @@ -0,0 +1,190 @@ +// 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 utils + +import ( + "regexp" + + "google.golang.org/protobuf/reflect/protoreflect" +) + +var ( + createMethodRegexp = regexp.MustCompile("^Create(?:[A-Z]|$)") + getMethodRegexp = regexp.MustCompile("^Get(?:[A-Z]|$)") + listMethodRegexp = regexp.MustCompile("^List(?:[A-Z]|$)") + updateMethodRegexp = regexp.MustCompile("^Update(?:[A-Z]|$)") + deleteMethodRegexp = regexp.MustCompile("^Delete(?:[A-Z]|$)") + standardMethodRegexp = regexp.MustCompile("^(Batch(Get|Create|Update|Delete))|(Get|Create|Update|Delete|List)(?:[A-Z]|$)") + + // AIP-162 Resource revision methods + listRevisionsMethodRegexp = regexp.MustCompile(`^List(?:[A-Za-z0-9]+)Revisions$`) + legacyListRevisionsURINameRegexp = regexp.MustCompile(`:listRevisions$`) + commitRevisionMethodRegexp = regexp.MustCompile(`^Commit([A-Za-z0-9]+)$`) + deleteRevisionMethodRegexp = regexp.MustCompile(`^Delete([A-Za-z0-9]+)Revision$`) + rollbackRevisionMethodRegexp = regexp.MustCompile(`^Rollback([A-Za-z0-9]+)$`) + tagRevisionMethodRegexp = regexp.MustCompile(`^Tag([A-Za-z0-9]+)Revision$`) +) + +// IsCreateMethod returns true if this is a AIP-133 Create method. +func IsCreateMethod(m protoreflect.MethodDescriptor) bool { + return createMethodRegexp.MatchString(string(m.Name())) +} + +// IsCreateMethodWithResolvedReturnType returns true if this is a AIP-133 Create method with +// a non-nil response type. This method should be used for filtering in linter +// rules which access the response type of the method, to avoid crashing due +// to dereferencing a nil pointer to the response. +func IsCreateMethodWithResolvedReturnType(m protoreflect.MethodDescriptor) bool { + if !IsCreateMethod(m) { + return false + } + + return GetResponseType(m) != nil +} + +// IsGetMethod returns true if this is a AIP-131 Get method. +func IsGetMethod(m protoreflect.MethodDescriptor) bool { + methodName := string(m.Name()) + if methodName == "GetIamPolicy" { + return false + } + return getMethodRegexp.MatchString(methodName) +} + +// IsListMethod return true if this is an AIP-132 List method +func IsListMethod(m protoreflect.MethodDescriptor) bool { + return listMethodRegexp.MatchString(string(m.Name())) && !IsLegacyListRevisionsMethod(m) +} + +// IsLegacyListRevisions identifies such a method by having the appropriate +// method name, having a `name` field instead of parent, and a HTTP suffix of +// `listRevisions`. +func IsLegacyListRevisionsMethod(m protoreflect.MethodDescriptor) bool { + // Must be named like List{Resource}Revisions. + if !listRevisionsMethodRegexp.MatchString(string(m.Name())) { + return false + } + + // Must have a `name` field instead of `parent`. + if m.Input().Fields().ByName("name") == nil { + return false + } + + // Must have the `:listRevisions` HTTP URI suffix. + if !HasHTTPRules(m) { + // If it doesn't have HTTP bindings, we shouldn't proceed to the next + // check, but a List{Resource}Revisions method with a `name` field is + // probably enough to be sure in the absence of HTTP bindings. + return true + } + + // Just check the first bidning as they should all have the same suffix. + h := GetHTTPRules(m)[0].GetPlainURI() + return legacyListRevisionsURINameRegexp.MatchString(h) +} + +// IsUpdateMethod returns true if this is a AIP-134 Update method +func IsUpdateMethod(m protoreflect.MethodDescriptor) bool { + methodName := string(m.Name()) + return updateMethodRegexp.MatchString(methodName) +} + +// Returns true if this is a AIP-135 Delete method, false otherwise. +func IsDeleteMethod(m protoreflect.MethodDescriptor) bool { + return deleteMethodRegexp.MatchString(string(m.Name())) && !deleteRevisionMethodRegexp.MatchString(string(m.Name())) +} + +// GetListResourceMessage returns the resource for a list method, +// nil otherwise. +func GetListResourceMessage(m protoreflect.MethodDescriptor) protoreflect.MessageDescriptor { + repeated := GetRepeatedMessageFields(m.Output()) + if len(repeated) > 0 { + return repeated[0].Message() + } + return nil +} + +// IsStreaming returns if the method is either client or server streaming. +func IsStreaming(m protoreflect.MethodDescriptor) bool { + return m.IsStreamingClient() || m.IsStreamingServer() +} + +// IsStandardMethod returns true if this is a AIP-130 Standard Method +func IsStandardMethod(m protoreflect.MethodDescriptor) bool { + return standardMethodRegexp.MatchString(string(m.Name())) +} + +// IsCustomMethod returns true if this is a AIP-136 Custom Method +func IsCustomMethod(m protoreflect.MethodDescriptor) bool { + return !IsStandardMethod(m) && !isRevisionMethod(m) +} + +// isRevisionMethod returns true if the given method is any of the documented +// Revision methods. At the moment, this is only relevant for excluding revision +// methods from other method type checks. +func isRevisionMethod(m protoreflect.MethodDescriptor) bool { + return IsDeleteRevisionMethod(m) || + IsTagRevisionMethod(m) || + IsCommitRevisionMethod(m) || + IsRollbackRevisionMethod(m) +} + +// IsDeleteRevisionMethod returns true if this is an AIP-162 Delete Revision +// method, false otherwise. +func IsDeleteRevisionMethod(m protoreflect.MethodDescriptor) bool { + return deleteRevisionMethodRegexp.MatchString(string(m.Name())) +} + +// IsTagRevisionMethod returns true if this is an AIP-162 Tag Revision method, +// false otherwise. +func IsTagRevisionMethod(m protoreflect.MethodDescriptor) bool { + return tagRevisionMethodRegexp.MatchString(string(m.Name())) +} + +// IsCommitRevisionMethod returns true if this is an AIP-162 Commit method, +// false otherwise. +func IsCommitRevisionMethod(m protoreflect.MethodDescriptor) bool { + return commitRevisionMethodRegexp.MatchString(string(m.Name())) +} + +// IsRollbackRevisionMethod returns true if this is an AIP-162 Rollback method, +// false otherwise. +func IsRollbackRevisionMethod(m protoreflect.MethodDescriptor) bool { + return rollbackRevisionMethodRegexp.MatchString(string(m.Name())) +} + +// ExtractRevisionResource uses the appropriate revision method regular +// expression to capture and extract the resource noun in the method name. +// If the given method is not one of the standard revision RPCs, it returns +// empty string and false. +func ExtractRevisionResource(m protoreflect.MethodDescriptor) (string, bool) { + if !isRevisionMethod(m) { + return "", false + } + + n := string(m.Name()) + + if IsCommitRevisionMethod(m) { + return commitRevisionMethodRegexp.FindStringSubmatch(n)[1], true + } else if IsTagRevisionMethod(m) { + return tagRevisionMethodRegexp.FindStringSubmatch(n)[1], true + } else if IsRollbackRevisionMethod(m) { + return rollbackRevisionMethodRegexp.FindStringSubmatch(n)[1], true + } else if IsDeleteRevisionMethod(m) { + return deleteRevisionMethodRegexp.FindStringSubmatch(n)[1], true + } + + return "", false +} diff --git a/rules/internal/utils/v2/method_test.go b/rules/internal/utils/v2/method_test.go new file mode 100644 index 00000000..5113ce6f --- /dev/null +++ b/rules/internal/utils/v2/method_test.go @@ -0,0 +1,500 @@ +// 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 utils + +import ( + "testing" + + "github.com/aep-dev/api-linter/rules/internal/testutils/v2" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func TestIsCreateMethod(t *testing.T) { + for _, test := range []struct { + name string + RPCs string + want bool + }{ + {"ValidBook", ` + rpc CreateBook(CreateBookRequest) returns (Book) {}; + `, true}, + {"InvalidNonCreate", ` + rpc GenerateBook(CreateBookRequest) returns (Book) {}; + `, false}, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + import "google/api/resource.proto"; + import "google/protobuf/field_mask.proto"; + service Foo { + {{.RPCs}} + } + + // This is at the top to make it retrievable + // by the test code. + message Book { + option (google.api.resource) = { + type: "library.googleapis.com/Book" + pattern: "books/{book}" + singular: "book" + plural: "books" + }; + } + + message CreateBookRequest { + // The parent resource where this book will be created. + // Format: publishers/{publisher} + string parent = 1; + + // The book to create. + Book book = 2; + } + `, test) + method := file.Services().Get(0).Methods().Get(0) + got := IsCreateMethod(method) + if got != test.want { + t.Errorf("IsCreateMethod got %v, want %v", got, test.want) + } + }) + } +} + +func TestIsUpdateMethod(t *testing.T) { + for _, test := range []struct { + name string + RPCs string + want bool + }{ + {"ValidBook", ` + rpc UpdateBook(UpdateBookRequest) returns (Book) {}; + `, true}, + {"InvalidNonUpdate", ` + rpc UpsertBook(UpdateBookRequest) returns (Book) {}; + `, false}, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + import "google/api/resource.proto"; + import "google/protobuf/field_mask.proto"; + service Foo { + {{.RPCs}} + } + + // This is at the top to make it retrievable + // by the test code. + message Book { + option (google.api.resource) = { + type: "library.googleapis.com/Book" + pattern: "books/{book}" + singular: "book" + plural: "books" + }; + } + + message UpdateBookRequest { + Book book = 1; + google.protobuf.FieldMask update_mask = 2; + } + `, test) + method := file.Services().Get(0).Methods().Get(0) + got := IsUpdateMethod(method) + if got != test.want { + t.Errorf("IsUpdateMethod got %v, want %v", got, test.want) + } + }) + } +} + +func TestIsListMethod(t *testing.T) { + for _, test := range []struct { + name string + RPCs string + want bool + }{ + {"ValidList", ` + rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {}; + `, true}, + {"ValidListRevisionsMethod", ` + rpc ListBookRevisions(ListBookRevisionsRequest) returns (ListBookRevisionsResponse) {}; + `, true}, + {"InvalidNonList", ` + rpc EnumerateBooks(ListBooksRequest) returns (ListBooksResponse) {}; + `, false}, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + import "google/api/resource.proto"; + import "google/protobuf/field_mask.proto"; + service Foo { + {{.RPCs}} + } + + // This is at the top to make it retrievable + // by the test code. + message Book { + option (google.api.resource) = { + type: "library.googleapis.com/Book" + pattern: "books/{book}" + singular: "book" + plural: "books" + }; + } + + // This is at the top to make it retrievable + // by the test code. + message BookRevision { + option (google.api.resource) = { + type: "library.googleapis.com/BookRevision" + pattern: "books/{book}/revisions/{revision}" + singular: "bookRevision" + plural: "bookRevisions" + }; + } + + message ListBooksRequest { + string parent = 1; + int32 page_size = 2; + string page_token = 3; + } + + message ListBooksResponse { + repeated Book books = 1; + string next_page_token = 2; + } + + message ListBookRevisionsRequest { + string parent = 1; + int32 page_size = 2; + string page_token = 3; + } + + message ListBookRevisionsResponse { + repeated BookRevision book_revisions = 1; + string next_page_token = 2; + } + `, test) + method := file.Services().Get(0).Methods().Get(0) + got := IsListMethod(method) + if got != test.want { + t.Errorf("IsListMethod got %v, want %v", got, test.want) + } + }) + } +} + +func TestIsLegacyListRevisionsMethod(t *testing.T) { + for _, test := range []struct { + name string + RPCs string + want bool + }{ + {"ValidLegacyListRevisionsMethod", ` + rpc ListBookRevisions(ListBookRevisionsRequest) returns (ListBookRevisionsResponse) { + option (google.api.http) = { + get: "/v1/{name=books/*}:listRevisions" + }; + }; + `, true}, + {"ValidLegacyListRevisionsMethodWithoutHTTP", ` + rpc ListBookRevisions(ListBookRevisionsRequest) returns (ListBookRevisionsResponse) {}; + `, true}, + {"InvalidLegacyListRevisionsMethod", ` + rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {}; + `, false}, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + import "google/api/annotations.proto"; + import "google/api/resource.proto"; + import "google/protobuf/field_mask.proto"; + service Foo { + {{.RPCs}} + } + + // This is at the top to make it retrievable + // by the test code. + message Book { + option (google.api.resource) = { + type: "library.googleapis.com/Book" + pattern: "books/{book}" + singular: "book" + plural: "books" + }; + } + + message ListBooksRequest { + string parent = 1; + int32 page_size = 2; + string page_token = 3; + } + + message ListBooksResponse { + repeated Book books = 1; + string next_page_token = 2; + } + + message ListBookRevisionsRequest { + string name = 1; + int32 page_size = 2; + string page_token = 3; + } + + message ListBookRevisionsResponse { + repeated Book books = 1; + string next_page_token = 2; + } + `, test) + method := file.Services().Get(0).Methods().Get(0) + got := IsLegacyListRevisionsMethod(method) + if got != test.want { + t.Errorf("IsLegacyListRevisionsMethod got %v, want %v", got, test.want) + } + }) + } +} + +func TestGetListResourceMessage(t *testing.T) { + for _, test := range []struct { + name string + RPCs string + want string + }{ + {"ValidBooks", ` + rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {}; + `, "Book"}, + {"InvalidNotListMethod", ` + rpc GetBook(ListBooksRequest) returns (Book) {}; + `, ""}, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + import "google/api/resource.proto"; + import "google/protobuf/field_mask.proto"; + service Foo { + {{.RPCs}} + } + + // This is at the top to make it retrievable + // by the test code. + message Book { + option (google.api.resource) = { + type: "library.googleapis.com/Book" + pattern: "books/{book}" + singular: "book" + plural: "books" + }; + } + + message ListBooksRequest { + string parent = 1; + int32 page_size = 2; + string page_token = 3; + } + + message ListBooksResponse { + repeated Book books = 1; + string next_page_token = 2; + } + `, test) + method := file.Services().Get(0).Methods().Get(0) + message := GetListResourceMessage(method) + got := "" + if message != nil { + got = string(message.Name()) + } + if got != test.want { + t.Errorf("GetListResourceMessage got %q, want %q", got, test.want) + } + }) + } +} + +func TestIsStandardMethod(t *testing.T) { + for _, test := range []struct { + name string + RPCs string + wantIsStandard bool + }{ + {"ValidCreate", ` + rpc CreateBook(Book) returns (Book) {}; + `, true}, + {"ValidUpdate", ` + rpc UpdateBook(Book) returns (Book) {}; + `, true}, + {"ValidGet", ` + rpc GetBook(Book) returns (Book) {}; + `, true}, + {"ValidDelete", ` + rpc DeleteBook(Book) returns (Book) {}; + `, true}, + {"ValidList", ` + rpc ListBooks(Book) returns (Book) {}; + `, true}, + {"ValidBatchCreate", ` + rpc BatchCreateBooks(Book) returns (Book) {}; + `, true}, + {"ValidBatchUpdate", ` + rpc BatchUpdateBooks(Book) returns (Book) {}; + `, true}, + {"ValidBatchGet", ` + rpc BatchGetBooks(Book) returns (Book) {}; + `, true}, + {"ValidBatchDelete", ` + rpc BatchDeleteBooks(Book) returns (Book) {}; + `, true}, + {"InvalidArchive", ` + rpc ArchiveBook(Book) returns (Book) {}; + `, false}, + {"InvalidSort", ` + rpc SortBooks(Book) returns (Book) {}; + `, false}, + {"InvalidTranslate", ` + rpc TranslateText(Book) returns (Book) {}; + `, false}, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + service Foo { + {{.RPCs}} + } + + // This is the request and response, which is irrelevant for + // asserting if the rpc is a standard method or not. We just + // check the naming of the rpc against a regex + message Book {} + `, test) + method := file.Services().Get(0).Methods().Get(0) + gotIsStandard := IsStandardMethod(method) + gotIsCustom := IsCustomMethod(method) + if gotIsStandard != test.wantIsStandard { + t.Errorf("IsStandardMethod got %v, want %v", gotIsStandard, test.wantIsStandard) + } + if gotIsCustom != !test.wantIsStandard { + t.Errorf("IsCustomMethod got %v, want %v", gotIsCustom, !test.wantIsStandard) + } + }) + } +} + +func TestIsRevisionMethod(t *testing.T) { + for _, test := range []struct { + name string + MethodName string + want bool + is func(m protoreflect.MethodDescriptor) bool + }{ + { + "IsRollbackRevisionMethod", + "RollbackBook", + true, + IsRollbackRevisionMethod, + }, + { + "IsCommitRevisionMethod", + "CommitBook", + true, + IsCommitRevisionMethod, + }, + { + "IsTagRevisionMethod", + "TagBookRevision", + true, + IsTagRevisionMethod, + }, + { + "IsDeleteRevisionMethod", + "DeleteBookRevision", + true, + IsDeleteRevisionMethod, + }, + { + "NotRevisionMethod", + "GetBook", + false, + isRevisionMethod, + }, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + service Foo { + rpc {{.MethodName}}(Book) returns (Book); + } + + // This is the request and response, which is irrelevant for + // asserting if the rpc is a standard method or not. We just + // check the naming of the rpc against a regex + message Book {} + `, test) + method := file.Services().Get(0).Methods().Get(0) + got := test.is(method) + + if got != test.want { + t.Errorf("got %v want %v", got, test.want) + } + }) + } +} + +func TestExtractRevisionResource(t *testing.T) { + for _, test := range []struct { + name string + MethodName string + want string + }{ + { + "RollbackRevisionMethod", + "RollbackBook", + "Book", + }, + { + "CommitRevisionMethod", + "CommitBook", + "Book", + }, + { + "TagRevisionMethod", + "TagBookRevision", + "Book", + }, + { + "DeleteRevisionMethod", + "DeleteBookRevision", + "Book", + }, + { + "NotRevisionMethod", + "GetBook", + "", + }, + } { + t.Run(test.name, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + service Foo { + rpc {{.MethodName}}(Book) returns (Book); + } + + // This is the request and response, which is irrelevant for + // asserting if the rpc is a standard method or not. We just + // check the naming of the rpc against a regex + message Book {} + `, test) + method := file.Services().Get(0).Methods().Get(0) + got, _ := ExtractRevisionResource(method) + + if got != test.want { + t.Errorf("got %q want %q", got, test.want) + } + }) + } +} diff --git a/rules/internal/utils/v2/resource.go b/rules/internal/utils/v2/resource.go new file mode 100644 index 00000000..47510f71 --- /dev/null +++ b/rules/internal/utils/v2/resource.go @@ -0,0 +1,110 @@ +// 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 utils + +import ( + "strings" + + apb "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// GetResourceSingular returns the resource singular. The +// empty string is returned if the singular cannot be found. +// Since the singular is not always annotated, it extracts +// it from multiple different locations including: +// 1. the singular annotation +// 2. the type definition +func GetResourceSingular(r *apb.ResourceDescriptor) string { + if r == nil { + return "" + } + if r.Singular != "" { + return r.Singular + } + if r.Type != "" { + _, tn, ok := SplitResourceTypeName(r.Type) + if ok { + return tn + } + } + return "" +} + +// GetResourcePlural is a convenience method for getting the `plural` field of a +// resource. +func GetResourcePlural(r *apb.ResourceDescriptor) string { + if r == nil { + return "" + } + + return r.Plural +} + +// GetResourceNameField is a convenience method for getting the name of the +// field that represents the resource's name. This is either set by the +// `name_field` attribute, or defaults to "name". +func GetResourceNameField(r *apb.ResourceDescriptor) string { + if r == nil { + return "" + } + if n := r.GetNameField(); n != "" { + return n + } + return "name" +} + +// IsResourceRevision determines if the given message represents a resource +// revision as described in AIP-162. +func IsResourceRevision(m protoreflect.MessageDescriptor) bool { + return IsResource(m) && strings.HasSuffix(string(m.Name()), "Revision") +} + +// IsRevisionRelationship determines if the "revision" resource is actually +// a revision of the "parent" resource. +func IsRevisionRelationship(parent, revision *apb.ResourceDescriptor) bool { + _, pType, ok := SplitResourceTypeName(parent.GetType()) + if !ok { + return false + } + _, rType, ok := SplitResourceTypeName(revision.GetType()) + if !ok { + return false + } + + if !strings.HasSuffix(rType, "Revision") { + return false + } + rType = strings.TrimSuffix(rType, "Revision") + return pType == rType +} + +// HasParent determines if the given resource has a parent resource or not +// based on the pattern(s) it defines having multiple resource ID segments +// or not. Incomplete or nil input returns false. +func HasParent(resource *apb.ResourceDescriptor) bool { + if resource == nil || len(resource.GetPattern()) == 0 { + return false + } + + for _, pattern := range resource.GetPattern() { + // multiple ID variable segments indicates presence of parent + if strings.Count(pattern, "{") > 1 { + return true + } + } + + return false +} diff --git a/rules/internal/utils/v2/resource_test.go b/rules/internal/utils/v2/resource_test.go new file mode 100644 index 00000000..303e4a73 --- /dev/null +++ b/rules/internal/utils/v2/resource_test.go @@ -0,0 +1,222 @@ +// 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 utils + +import ( + "testing" + + "github.com/aep-dev/api-linter/rules/internal/testutils/v2" + apb "google.golang.org/genproto/googleapis/api/annotations" +) + +func TestGetResourceSingular(t *testing.T) { + for _, test := range []struct { + name string + resource *apb.ResourceDescriptor + want string + }{ + { + name: "SingularSpecified", + resource: &apb.ResourceDescriptor{ + Singular: "bookShelf", + }, + want: "bookShelf", + }, + { + name: "SingularAndTypeSpecified", + resource: &apb.ResourceDescriptor{ + Singular: "bookShelf", + // NOTE: this is not a correct resource annotation. + // it must match singular. + Type: "library.googleapis.com/book", + }, + want: "bookShelf", + }, + { + name: "TypeSpecified", + resource: &apb.ResourceDescriptor{ + Type: "library.googleapis.com/bookShelf", + }, + want: "bookShelf", + }, + { + name: "NothingSpecified", + resource: &apb.ResourceDescriptor{}, + want: "", + }, + { + name: "Nil", + resource: nil, + want: "", + }, + } { + t.Run(test.name, func(t *testing.T) { + got := GetResourceSingular(test.resource) + if got != test.want { + t.Errorf("GetResourceSingular: expected %v, got %v", test.want, got) + } + }) + } +} + +func TestGetResourcePlural(t *testing.T) { + for _, test := range []struct { + name string + resource *apb.ResourceDescriptor + want string + }{ + { + name: "PluralSpecified", + resource: &apb.ResourceDescriptor{ + Plural: "bookShelves", + }, + want: "bookShelves", + }, + { + name: "NothingSpecified", + resource: &apb.ResourceDescriptor{}, + want: "", + }, + { + name: "Nil", + resource: nil, + want: "", + }, + } { + t.Run(test.name, func(t *testing.T) { + got := GetResourcePlural(test.resource) + if got != test.want { + t.Errorf("GetResourcePlural: expected %v, got %v", test.want, got) + } + }) + } +} + +func TestIsResourceRevision(t *testing.T) { + for _, test := range []struct { + name, Message, Resource string + want bool + }{ + { + name: "valid_revision", + Message: "BookRevision", + Resource: `option (google.api.resource) = {type: "library.googleapis.com/BookRevision"};`, + want: true, + }, + { + name: "not_revision_no_resource", + Message: "BookRevision", + want: false, + }, + { + name: "not_revision_bad_name", + Message: "Book", + Resource: `option (google.api.resource) = {type: "library.googleapis.com/Book"};`, + want: false, + }, + } { + f := testutils.ParseProto3Tmpl(t, ` + import "google/api/resource.proto"; + message {{.Message}} { + {{.Resource}} + string name = 1; + } + `, test) + m := f.Messages().Get(0) + if got := IsResourceRevision(m); got != test.want { + t.Errorf("IsResourceRevision(%+v): got %v, want %v", m, got, test.want) + } + } +} + +func TestIsRevisionRelationship(t *testing.T) { + for _, test := range []struct { + name string + typeA, typeB string + want bool + }{ + { + name: "revision_relationship", + typeA: "library.googleapis.com/Book", + typeB: "library.googleapis.com/BookRevision", + want: true, + }, + { + name: "non_revision_relationship", + typeA: "library.googleapis.com/Book", + typeB: "library.googleapis.com/Library", + want: false, + }, + { + name: "invalid_type_a", + typeA: "library.googleapis.com", + typeB: "library.googleapis.com/Library", + want: false, + }, + { + name: "invalid_type_b", + typeA: "library.googleapis.com/Book", + typeB: "library.googleapis.com", + want: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + a := &apb.ResourceDescriptor{Type: test.typeA} + b := &apb.ResourceDescriptor{Type: test.typeB} + if got := IsRevisionRelationship(a, b); got != test.want { + t.Errorf("IsRevisionRelationship(%s, %s): got %v, want %v", test.typeA, test.typeB, got, test.want) + } + }) + } +} + +func TestHasParent(t *testing.T) { + for _, test := range []struct { + name string + pattern string + want bool + }{ + { + name: "child resource", + pattern: "foos/{foo}/bars/{bar}", + want: true, + }, + { + name: "top level resource", + pattern: "foos/{foo}", + want: false, + }, + { + name: "top level singleton", + pattern: "foo", + want: false, + }, + { + name: "empty", + pattern: "", + want: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + var in *apb.ResourceDescriptor + if test.pattern != "" { + in = &apb.ResourceDescriptor{Pattern: []string{test.pattern}} + } + if got := HasParent(in); got != test.want { + t.Errorf("HasParent(%s): got %v, want %v", test.pattern, got, test.want) + } + }) + } +} diff --git a/rules/internal/utils/v2/string_pluralize.go b/rules/internal/utils/v2/string_pluralize.go new file mode 100644 index 00000000..b416086f --- /dev/null +++ b/rules/internal/utils/v2/string_pluralize.go @@ -0,0 +1,34 @@ +// Copyright 2019 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 utils + +import ( + "github.com/gertd/go-pluralize" +) + +var pluralizeClient = pluralize.NewClient() + +// ToPlural converts a string to its plural form. +func ToPlural(s string) string { + // Need to convert name to singular first to support none standard case such as persons, cactuses. + // persons -> person -> people + + return pluralizeClient.Plural(pluralizeClient.Singular(s)) +} + +// ToSingular converts a string to its singular form. +func ToSingular(s string) string { + return pluralizeClient.Singular(s) +} diff --git a/rules/internal/utils/v2/string_pluralize_test.go b/rules/internal/utils/v2/string_pluralize_test.go new file mode 100644 index 00000000..8cc3887d --- /dev/null +++ b/rules/internal/utils/v2/string_pluralize_test.go @@ -0,0 +1,43 @@ +// Copyright 2019 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 utils + +import ( + "testing" +) + +func TestPluralize(t *testing.T) { + tests := []struct { + name string + word string + pluralizedWord string + }{ + {"PluralizeSingularWord", "person", "people"}, + {"PluralizePluralWord", "people", "people"}, + {"PluralizeNonstandardPluralWord", "persons", "people"}, + {"PluralizeNoPluralFormWord", "moose", "moose"}, + {"PluralizePluralLatinWord", "cacti", "cacti"}, + {"PluralizeNonstandardPluralLatinWord", "cactuses", "cacti"}, + {"PluralizePluralCamelCaseWord", "student_profiles", "student_profiles"}, + {"PluralizeSingularCamelCaseWord", "student_profile", "student_profiles"}, + } + for _, test := range tests { + t.Run(test.word, func(t *testing.T) { + if got := ToPlural(test.word); got != test.pluralizedWord { + t.Errorf("Plural(%s) got %s, but want %s", test.word, got, test.pluralizedWord) + } + }) + } +} diff --git a/rules/internal/utils/v2/type_name.go b/rules/internal/utils/v2/type_name.go new file mode 100644 index 00000000..a2c30e16 --- /dev/null +++ b/rules/internal/utils/v2/type_name.go @@ -0,0 +1,58 @@ +// Copyright 2019 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 utils + +import ( + "fmt" + "strings" + + "google.golang.org/protobuf/reflect/protoreflect" +) + +// GetTypeName returns the name of the type of the field, as a string, +// regardless of primitive, message, etc. +func GetTypeName(f protoreflect.FieldDescriptor) string { + if f.IsMap() { + return fmt.Sprintf("map<%s, %s>", GetTypeName(f.MapKey()), GetTypeName(f.MapValue())) + } + if m := f.Message(); m != nil { + return string(m.FullName()) + } + if e := f.Enum(); e != nil { + return string(e.FullName()) + } + return f.Kind().String() +} + +// IsOperation returns if the message is a longrunning Operation or not. +func IsOperation(m protoreflect.MessageDescriptor) bool { + return m.FullName() == "google.longrunning.Operation" +} + +// GetResourceMessageName returns the resource message type name from method +func GetResourceMessageName(m protoreflect.MethodDescriptor, expectedVerb string) string { + if !strings.HasPrefix(string(m.Name()), expectedVerb) { + return "" + } + + // Usually the response message will be the resource message, and its name will + // be part of method name (make a double check here to avoid the issue when + // method or output naming doesn't follow the right principles) + // Ignore this rule if the return type is an LRO + if strings.Contains(string(m.Name()[len(expectedVerb):]), string(m.Output().Name())) && !IsOperation(m.Output()) { + return string(m.Output().Name()) + } + return string(m.Name()[len(expectedVerb):]) +} diff --git a/rules/internal/utils/v2/type_name_test.go b/rules/internal/utils/v2/type_name_test.go new file mode 100644 index 00000000..ce96bea6 --- /dev/null +++ b/rules/internal/utils/v2/type_name_test.go @@ -0,0 +1,41 @@ +// Copyright 2019 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 utils + +import ( + "testing" + + "github.com/aep-dev/api-linter/rules/internal/testutils/v2" +) + +func TestGetTypeName(t *testing.T) { + for _, ty := range []string{"int32", "int64", "string", "bytes", "google.protobuf.Timestamp", "Format"} { + t.Run(ty, func(t *testing.T) { + file := testutils.ParseProto3Tmpl(t, ` + import "google/protobuf/timestamp.proto"; + message Book { + {{.Type}} field = 1; + } + enum Format { + FORMAT_UNSPECIFIED = 0; + } + `, struct{ Type string }{ty}) + field := file.Messages().Get(0).Fields().Get(0) + if got, want := GetTypeName(field), ty; got != want { + t.Errorf("Got %q, expected %q.", got, want) + } + }) + } +} diff --git a/rules/v2/rules.go b/rules/v2/rules.go new file mode 100644 index 00000000..6d7c7d23 --- /dev/null +++ b/rules/v2/rules.go @@ -0,0 +1,24 @@ +// Copyright 2026 The AEP Authors +// +// 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 rules + +import ( + "github.com/aep-dev/api-linter/lint/v2" +) + +// Add adds all rules to the registry. +func Add(r lint.RuleRegistry) error { + return nil +}