From 1bf3429006bf379991826cc16a60559399f2e965 Mon Sep 17 00:00:00 2001 From: Naveen <172697+naveensrinivasan@users.noreply.github.com> Date: Sun, 10 Nov 2024 13:21:33 -0600 Subject: [PATCH] Refactor cmd for globalsearch (#113) * Refactor cmd for globalsearch Signed-off-by: naveensrinivasan <172697+naveensrinivasan@users.noreply.github.com> * Cleaned up go mod Signed-off-by: naveensrinivasan <172697+naveensrinivasan@users.noreply.github.com> * Included the additional information Signed-off-by: naveensrinivasan <172697+naveensrinivasan@users.noreply.github.com> * Clean up Go mod tidy Signed-off-by: naveensrinivasan <172697+naveensrinivasan@users.noreply.github.com> * Fixed the go mod tidy Signed-off-by: naveensrinivasan <172697+naveensrinivasan@users.noreply.github.com> --------- Signed-off-by: naveensrinivasan <172697+naveensrinivasan@users.noreply.github.com> --- cmd/query/globsearch/globsearch.go | 160 ++++++++------ cmd/query/globsearch/globsearch_test.go | 278 ++++++++++++++++++++++++ cmd/query/query.go | 2 +- go.mod | 1 + go.sum | 2 + pkg/storages/e2e_test.go | 6 +- 6 files changed, 380 insertions(+), 69 deletions(-) create mode 100644 cmd/query/globsearch/globsearch_test.go diff --git a/cmd/query/globsearch/globsearch.go b/cmd/query/globsearch/globsearch.go index 7498151..15740d4 100644 --- a/cmd/query/globsearch/globsearch.go +++ b/cmd/query/globsearch/globsearch.go @@ -1,127 +1,157 @@ package globsearch import ( + "encoding/json" "fmt" + "io" "net/http" - "os" "strconv" "connectrpc.com/connect" "github.com/bit-bom/minefield/cmd/helpers" apiv1 "github.com/bit-bom/minefield/gen/api/v1" "github.com/bit-bom/minefield/gen/api/v1/apiv1connect" - "github.com/bit-bom/minefield/pkg/graph" "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" ) type options struct { - storage graph.Storage - maxOutput int - showInfo bool // New field to control the display of the Info column - saveQuery string + maxOutput int + addr string + output string + showAdditionalInfo bool + graphServiceClient apiv1connect.GraphServiceClient } +// AddFlags adds command-line flags to the provided cobra command. func (o *options) AddFlags(cmd *cobra.Command) { cmd.Flags().IntVar(&o.maxOutput, "max-output", 10, "maximum number of results to display") - cmd.Flags().BoolVar(&o.showInfo, "show-info", true, "display the info column") - cmd.Flags().StringVar(&o.saveQuery, "save-query", "", "save the query to a specific file") + cmd.Flags().StringVar(&o.addr, "addr", "http://localhost:8089", "address of the minefield server") + cmd.Flags().StringVar(&o.output, "output", "table", "output format (table or json)") + cmd.Flags().BoolVar(&o.showAdditionalInfo, "show-additional-info", false, "show additional info") } +// Run executes the globsearch command with the provided arguments. func (o *options) Run(cmd *cobra.Command, args []string) error { pattern := args[0] - httpClient := &http.Client{} - addr := os.Getenv("BITBOMDEV_ADDR") - if addr == "" { - addr = "http://localhost:8089" + if pattern == "" { + return fmt.Errorf("pattern is required") } - client := apiv1connect.NewGraphServiceClient(httpClient, addr) - // Create a new context - ctx := cmd.Context() - - // Create a new QueryRequest - req := connect.NewRequest(&apiv1.GetNodesByGlobRequest{ - Pattern: pattern, - }) + // Initialize client if not injected (for testing) + if o.graphServiceClient == nil { + o.graphServiceClient = apiv1connect.NewGraphServiceClient( + http.DefaultClient, + o.addr, + ) + } - // Make the Query request - res, err := client.GetNodesByGlob(ctx, req) + // Query nodes matching pattern + res, err := o.graphServiceClient.GetNodesByGlob( + cmd.Context(), + connect.NewRequest(&apiv1.GetNodesByGlobRequest{Pattern: pattern}), + ) if err != nil { - return fmt.Errorf("query failed: %v", err) + return fmt.Errorf("query failed: %w", err) } if len(res.Msg.Nodes) == 0 { - fmt.Println("No nodes found matching pattern:", pattern) - return nil - } - - // Initialize the table - table := tablewriter.NewWriter(os.Stdout) - table.SetAutoWrapText(false) - table.SetRowLine(true) - - // Dynamically set the header based on the showInfo flag - headers := []string{"Name", "Type", "ID"} - if o.showInfo { - headers = append(headers, "Info") + return fmt.Errorf("no nodes found matching pattern: %s", pattern) } - table.SetHeader(headers) - count := 0 - var f *os.File - if o.saveQuery != "" { - f, err = os.Create(o.saveQuery) + // Format and display results + switch o.output { + case "json": + jsonOutput, err := FormatNodeJSON(res.Msg.Nodes) if err != nil { - return err + return fmt.Errorf("failed to format nodes as JSON: %w", err) } - defer f.Close() + cmd.Println(string(jsonOutput)) + return nil + case "table": + return formatTable(cmd.OutOrStdout(), res.Msg.Nodes, o.maxOutput, o.showAdditionalInfo) + default: + return fmt.Errorf("unknown output format: %s", o.output) } - - for _, node := range res.Msg.Nodes { - if count >= o.maxOutput { +} + +// formatTable formats the nodes into a table and writes it to the provided writer. +func formatTable(w io.Writer, nodes []*apiv1.Node, maxOutput int, showAdditionalInfo bool) error { + table := tablewriter.NewWriter(w) + table.SetHeader([]string{"Name", "Type", "ID"}) + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(true) + if showAdditionalInfo { + table.SetHeader([]string{"Name", "Type", "ID", "Info"}) + } + for i, node := range nodes { + if i >= maxOutput { break } - - // Build the common row data row := []string{ node.Name, node.Type, - strconv.Itoa(int(node.Id)), + strconv.FormatUint(uint64(node.Id), 10), } - - // If showInfo is true, compute the additionalInfo and append it - if o.showInfo { + if showAdditionalInfo { additionalInfo := helpers.ComputeAdditionalInfo(node) row = append(row, additionalInfo) } - - // Append the row to the table table.Append(row) - - if o.saveQuery != "" { - f.WriteString(node.Name + "\n") - } - count++ } table.Render() - return nil } -func New(storage graph.Storage) *cobra.Command { - o := &options{ - storage: storage, - } +// New returns a new cobra command for globsearch. +func New() *cobra.Command { + o := &options{} cmd := &cobra.Command{ Use: "globsearch [pattern]", Short: "Search for nodes by glob pattern", + Long: "Search for nodes in the graph using a glob pattern", Args: cobra.ExactArgs(1), RunE: o.Run, DisableAutoGenTag: true, } o.AddFlags(cmd) - return cmd } + +type nodeOutput struct { + Name string `json:"name"` + Type string `json:"type"` + ID string `json:"id"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// FormatNodeJSON formats the nodes as JSON. +func FormatNodeJSON(nodes []*apiv1.Node) ([]byte, error) { + if nodes == nil { + return nil, fmt.Errorf("nodes cannot be nil") + } + + if len(nodes) == 0 { + return nil, fmt.Errorf("no nodes found") + } + + outputs := make([]nodeOutput, 0, len(nodes)) + for _, node := range nodes { + var metadata map[string]interface{} + if len(node.Metadata) > 0 { + if err := json.Unmarshal(node.Metadata, &metadata); err != nil { + return nil, fmt.Errorf("failed to unmarshal metadata for node %s: %w", node.Name, err) + } + } + + outputs = append(outputs, nodeOutput{ + Name: node.Name, + Type: node.Type, + ID: strconv.FormatUint(uint64(node.Id), 10), + Metadata: metadata, + }) + } + + return json.MarshalIndent(outputs, "", " ") +} diff --git a/cmd/query/globsearch/globsearch_test.go b/cmd/query/globsearch/globsearch_test.go new file mode 100644 index 0000000..7b42f6a --- /dev/null +++ b/cmd/query/globsearch/globsearch_test.go @@ -0,0 +1,278 @@ +package globsearch + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "connectrpc.com/connect" + apiv1 "github.com/bit-bom/minefield/gen/api/v1" + "github.com/spf13/cobra" + "github.com/zeebo/assert" +) + +func TestFormatTable(t *testing.T) { + tests := []struct { + name string + nodes []*apiv1.Node + maxOutput int + want []string // Strings that should appear in the output + }{ + { + name: "basic output", + nodes: []*apiv1.Node{ + {Name: "node1", Type: "type1", Id: 1}, + {Name: "node2", Type: "type2", Id: 2}, + }, + maxOutput: 10, + want: []string{ + "node1", "type1", "1", + "node2", "type2", "2", + }, + }, + { + name: "respects maxOutput", + nodes: []*apiv1.Node{ + {Name: "node1", Type: "type1", Id: 1}, + {Name: "node2", Type: "type2", Id: 2}, + {Name: "node3", Type: "type3", Id: 3}, + }, + maxOutput: 2, + want: []string{ + "node1", "type1", "1", + "node2", "type2", "2", + }, + }, + { + name: "empty nodes", + nodes: []*apiv1.Node{}, + maxOutput: 10, + want: []string{"NAME", "TYPE", "ID"}, // Should only contain headers + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + err := formatTable(buf, tt.nodes, tt.maxOutput, false) + if err != nil { + t.Errorf("formatTable() error = %v", err) + return + } + + got := buf.String() + for _, want := range tt.want { + if !strings.Contains(got, want) { + t.Errorf("formatTable() output doesn't contain %q\nGot:\n%s", want, got) + } + } + }) + } +} + +func TestNew_Flags(t *testing.T) { + // Start test server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Mock response if needed + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + tests := []struct { + name string + args []string + checkFn func(*cobra.Command) error + }{ + { + name: "default flags", + args: []string{"pattern"}, + checkFn: func(cmd *cobra.Command) error { + maxOutput, _ := cmd.Flags().GetInt("max-output") + if maxOutput != 10 { + return fmt.Errorf("max-output = %v, want %v", maxOutput, 10) + } + + addr, _ := cmd.Flags().GetString("addr") + if addr != "http://localhost:8089" { + return fmt.Errorf("addr = %v, want %v", addr, "http://localhost:8089") + } + + output, _ := cmd.Flags().GetString("output") + if output != "table" { + return fmt.Errorf("output = %v, want %v", output, "table") + } + + return nil + }, + }, + { + name: "custom flags", + args: []string{ + "pattern", + "--max-output=20", + fmt.Sprintf("--addr=%s", ts.URL), + "--output=json", + }, + checkFn: func(cmd *cobra.Command) error { + maxOutput, _ := cmd.Flags().GetInt("max-output") + if maxOutput != 20 { + return fmt.Errorf("max-output = %v, want %v", maxOutput, 20) + } + + addr, _ := cmd.Flags().GetString("addr") + if addr != ts.URL { + return fmt.Errorf("addr = %v, want %v", addr, ts.URL) + } + + output, _ := cmd.Flags().GetString("output") + if output != "json" { + return fmt.Errorf("output = %v, want %v", output, "json") + } + + return nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := New() + cmd.SetArgs(tt.args) + + // Parse flags + if err := cmd.ParseFlags(tt.args); err != nil { + t.Errorf("cmd.ParseFlags() error = %v", err) + return + } + + // Check flag values + if err := tt.checkFn(cmd); err != nil { + t.Error(err) + } + }) + } +} + +type mockGraphServiceClient struct { + GetNodesByGlobFunc func(ctx context.Context, req *connect.Request[apiv1.GetNodesByGlobRequest]) (*connect.Response[apiv1.GetNodesByGlobResponse], error) + GetNodeFunc func(ctx context.Context, req *connect.Request[apiv1.GetNodeRequest]) (*connect.Response[apiv1.GetNodeResponse], error) + GetNodeByNameFunc func(ctx context.Context, req *connect.Request[apiv1.GetNodeByNameRequest]) (*connect.Response[apiv1.GetNodeByNameResponse], error) +} + +func (m *mockGraphServiceClient) GetNodesByGlob(ctx context.Context, req *connect.Request[apiv1.GetNodesByGlobRequest]) (*connect.Response[apiv1.GetNodesByGlobResponse], error) { + return m.GetNodesByGlobFunc(ctx, req) +} + +func (m *mockGraphServiceClient) GetNode(ctx context.Context, req *connect.Request[apiv1.GetNodeRequest]) (*connect.Response[apiv1.GetNodeResponse], error) { + return m.GetNodeFunc(ctx, req) +} + +func (m *mockGraphServiceClient) GetNodeByName(ctx context.Context, req *connect.Request[apiv1.GetNodeByNameRequest]) (*connect.Response[apiv1.GetNodeByNameResponse], error) { + return m.GetNodeByNameFunc(ctx, req) +} + +func TestRun(t *testing.T) { + tests := []struct { + name string + pattern string + output string + maxOutput int + mockResponse *apiv1.GetNodesByGlobResponse + mockError error + expectError bool + expectedErrorString string + }{ + { + name: "valid response with nodes", + pattern: "node*", + output: "json", + maxOutput: 10, + mockResponse: &apiv1.GetNodesByGlobResponse{ + Nodes: []*apiv1.Node{ + {Name: "node1", Type: "type1", Id: 1}, + {Name: "node2", Type: "type2", Id: 2}, + }, + }, + }, + { + name: "metadata", + pattern: "node*", + output: "json", + maxOutput: 10, + mockResponse: &apiv1.GetNodesByGlobResponse{ + Nodes: []*apiv1.Node{ + {Name: "node1", Type: "type1", Id: 1, Metadata: []byte(`{"field": "value1"}`)}, + }, + }, + }, + { + name: "no nodes found", + pattern: "unknown*", + output: "table", + maxOutput: 10, + mockResponse: &apiv1.GetNodesByGlobResponse{Nodes: []*apiv1.Node{}}, + expectError: true, + expectedErrorString: "no nodes found matching pattern: unknown*", + }, + { + name: "client error", + pattern: "error*", + output: "json", + maxOutput: 10, + mockError: errors.New("client error"), + expectError: true, + expectedErrorString: "query failed: client error", + }, + { + name: "unknown output format", + pattern: "node*", + output: "unknown", + maxOutput: 10, + mockResponse: &apiv1.GetNodesByGlobResponse{Nodes: []*apiv1.Node{{Name: "node1", Type: "type1", Id: 1}}}, + expectError: true, + expectedErrorString: "unknown output format: unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mockGraphServiceClient{ + GetNodesByGlobFunc: func(ctx context.Context, req *connect.Request[apiv1.GetNodesByGlobRequest]) (*connect.Response[apiv1.GetNodesByGlobResponse], error) { + if tt.mockError != nil { + return nil, tt.mockError + } + return connect.NewResponse(tt.mockResponse), nil + }, + } + + o := &options{ + addr: "http://localhost:8089", + output: tt.output, + maxOutput: tt.maxOutput, + graphServiceClient: mockClient, + } + + // Create a cobra command and context + cmd := &cobra.Command{} + cmd.SetOut(io.Discard) // Discard output during testing + cmd.SetContext(context.Background()) + + args := []string{tt.pattern} + + err := o.Run(cmd, args) + + if tt.expectError { + assert.Error(t, err) + assert.Equal(t, tt.expectedErrorString, err.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/cmd/query/query.go b/cmd/query/query.go index a77fc62..5d8b60e 100644 --- a/cmd/query/query.go +++ b/cmd/query/query.go @@ -16,7 +16,7 @@ func New(storage graph.Storage) *cobra.Command { } cmd.AddCommand(custom.New(storage)) - cmd.AddCommand(globsearch.New(storage)) cmd.AddCommand(getMetadata.New(storage)) + cmd.AddCommand(globsearch.New()) return cmd } diff --git a/go.mod b/go.mod index 7990c30..8c2f3e4 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/protobom/protobom v0.5.0 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 + github.com/zeebo/assert v1.3.1 go.uber.org/fx v1.23.0 golang.org/x/net v0.30.0 google.golang.org/protobuf v1.35.1 diff --git a/go.sum b/go.sum index f1b0e5a..1cf461d 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A= +github.com/zeebo/assert v1.3.1/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= go.uber.org/dig v1.18.0 h1:imUL1UiY0Mg4bqbFfsRQO5G4CGRBec/ZujWTvSVp3pw= go.uber.org/dig v1.18.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/fx v1.23.0 h1:lIr/gYWQGfTwGcSXWXu4vP5Ws6iqnNEIY+F/aFzCKTg= diff --git a/pkg/storages/e2e_test.go b/pkg/storages/e2e_test.go index e9bebc5..047274b 100644 --- a/pkg/storages/e2e_test.go +++ b/pkg/storages/e2e_test.go @@ -12,9 +12,9 @@ import ( ) func TestParseAndExecute_E2E(t *testing.T) { - //if _, ok := os.LookupEnv("e2e"); !ok { - // t.Skip("E2E tests are not enabled") - //} + if _, ok := os.LookupEnv("e2e"); !ok { + t.Skip("E2E tests are not enabled") + } redisStorage := setupTestRedis() sbomPath := filepath.Join("..", "..", "testdata", "sboms")