Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Improve CLI with test suite and builder pattern #928

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions cli/blocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ import (
"github.com/spf13/cobra"
)

var blocksCmd = &cobra.Command{
Use: "blocks",
Short: "Interact with the database's blockstore",
}
func MakeBlocksCommand() *cobra.Command {
var cmd = &cobra.Command{
Use: "blocks",
Short: "Interact with the database's blockstore",
}

func init() {
clientCmd.AddCommand(blocksCmd)
return cmd
}
92 changes: 46 additions & 46 deletions cli/blocks_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,63 +18,63 @@ import (
"github.com/spf13/cobra"

httpapi "github.com/sourcenetwork/defradb/api/http"
"github.com/sourcenetwork/defradb/config"
)

var getCmd = &cobra.Command{
Use: "get [CID]",
Short: "Get a block by its CID from the blockstore.",
RunE: func(cmd *cobra.Command, args []string) (err error) {
if len(args) != 1 {
return NewErrMissingArg("CID")
}
cid := args[0]

endpoint, err := httpapi.JoinPaths(cfg.API.AddressToURL(), httpapi.BlocksPath, cid)
if err != nil {
return NewErrFailedToJoinEndpoint(err)
}
func MakeBlocksGetCommand(cfg *config.Config) *cobra.Command {
var cmd = &cobra.Command{
Use: "get [CID]",
Short: "Get a block by its CID from the blockstore.",
RunE: func(cmd *cobra.Command, args []string) (err error) {
if len(args) != 1 {
return NewErrMissingArg("CID")
}
cid := args[0]

res, err := http.Get(endpoint.String())
if err != nil {
return NewErrFailedToSendRequest(err)
}
endpoint, err := httpapi.JoinPaths(cfg.API.AddressToURL(), httpapi.BlocksPath, cid)
if err != nil {
return NewErrFailedToJoinEndpoint(err)
}

defer func() {
if e := res.Body.Close(); e != nil {
err = NewErrFailedToReadResponseBody(err)
res, err := http.Get(endpoint.String())
if err != nil {
return NewErrFailedToSendRequest(err)
}
}()

response, err := io.ReadAll(res.Body)
if err != nil {
return NewErrFailedToReadResponseBody(err)
}
defer func() {
if e := res.Body.Close(); e != nil {
err = NewErrFailedToReadResponseBody(err)
}
}()

stdout, err := os.Stdout.Stat()
if err != nil {
return NewErrFailedToStatStdOut(err)
}
if isFileInfoPipe(stdout) {
cmd.Println(string(response))
} else {
graphlErr, err := hasGraphQLErrors(response)
response, err := io.ReadAll(res.Body)
if err != nil {
return NewErrFailedToHandleGQLErrors(err)
return NewErrFailedToReadResponseBody(err)
}
indentedResult, err := indentJSON(response)

stdout, err := os.Stdout.Stat()
if err != nil {
return NewErrFailedToPrettyPrintResponse(err)
return NewErrFailedToStatStdOut(err)
}
if graphlErr {
log.FeedbackError(cmd.Context(), indentedResult)
if isFileInfoPipe(stdout) {
cmd.Println(string(response))
} else {
log.FeedbackInfo(cmd.Context(), indentedResult)
graphlErr, err := hasGraphQLErrors(response)
if err != nil {
return NewErrFailedToHandleGQLErrors(err)
}
indentedResult, err := indentJSON(response)
if err != nil {
return NewErrFailedToPrettyPrintResponse(err)
}
if graphlErr {
log.FeedbackError(cmd.Context(), indentedResult)
} else {
log.FeedbackInfo(cmd.Context(), indentedResult)
}
}
}
return nil
},
}

func init() {
blocksCmd.AddCommand(getCmd)
return nil
},
}
return cmd
}
83 changes: 72 additions & 11 deletions cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ import (
"os"
"strings"

"github.com/spf13/cobra"

"github.com/sourcenetwork/defradb/config"
"github.com/sourcenetwork/defradb/errors"
"github.com/sourcenetwork/defradb/logging"
)

var log = logging.MustNewLogger("defra.cli")

const badgerDatastoreName = "badger"

// Errors with how the command is invoked by user
Expand All @@ -42,30 +46,87 @@ var usageErrors = []string{
errTooManyArgs,
}

var log = logging.MustNewLogger("defra.cli")
type DefraCommand struct {
RootCmd *cobra.Command
Cfg *config.Config
}

// NewDefraCommand returns the root command instanciated with its tree of subcommands.
func NewDefraCommand(cfg *config.Config) DefraCommand {
rootCmd := MakeRootCommand(cfg)
rpcCmd := MakeRPCCommand(cfg)
blocksCmd := MakeBlocksCommand()
schemaCmd := MakeSchemaCommand()
clientCmd := MakeClientCommand()
rpcReplicatorCmd := MakeReplicatorCommand()
p2pCollectionCmd := MakeP2PCollectionCommand()
p2pCollectionCmd.AddCommand(
MakeP2PCollectionAddCommand(cfg),
MakeP2PCollectionRemoveCommand(cfg),
MakeP2PCollectionGetallCommand(cfg),
)
rpcReplicatorCmd.AddCommand(
MakeReplicatorGetallCommand(cfg),
MakeReplicatorSetCommand(cfg),
MakeReplicatorDeleteCommand(cfg),
)
rpcCmd.AddCommand(
rpcReplicatorCmd,
p2pCollectionCmd,
)
blocksCmd.AddCommand(
MakeBlocksGetCommand(cfg),
)
schemaCmd.AddCommand(
MakeSchemaAddCommand(cfg),
MakeSchemaPatchCommand(cfg),
)
clientCmd.AddCommand(
MakeDumpCommand(cfg),
MakePingCommand(cfg),
MakeRequestCommand(cfg),
MakePeerIDCommand(cfg),
schemaCmd,
rpcCmd,
blocksCmd,
)
rootCmd.AddCommand(
clientCmd,
MakeStartCommand(cfg),
MakeServerDumpCmd(cfg),
MakeVersionCommand(),
MakeInitCommand(cfg),
)

var cfg = config.DefaultConfig()
var RootCmd = rootCmd
return DefraCommand{rootCmd, cfg}
}

func Execute() {
ctx := context.Background()
func (defraCmd *DefraCommand) Execute(ctx context.Context) error {
// Silence cobra's default output to control usage and error display.
rootCmd.SilenceUsage = true
rootCmd.SilenceErrors = true
rootCmd.SetOut(os.Stdout)
cmd, err := rootCmd.ExecuteContextC(ctx)
defraCmd.RootCmd.SilenceUsage = true
defraCmd.RootCmd.SilenceErrors = true
defraCmd.RootCmd.SetOut(os.Stdout)
cmd, err := defraCmd.RootCmd.ExecuteContextC(ctx)
if err != nil {
// Intentional cancellation.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil
}
// User error.
for _, cobraError := range usageErrors {
if strings.HasPrefix(err.Error(), cobraError) {
log.FeedbackErrorE(ctx, "Usage error", err)
if usageErr := cmd.Usage(); usageErr != nil {
log.FeedbackFatalE(ctx, "error displaying usage help", usageErr)
}
os.Exit(1)
return err
}
}
log.FeedbackFatalE(ctx, "Execution error", err)
// Internal error.
log.FeedbackErrorE(ctx, "Execution error", err)
return err
}
return nil
}

func isFileInfoPipe(fi os.FileInfo) bool {
Expand Down
84 changes: 84 additions & 0 deletions cli/cli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright 2022 Democratized Data Foundation
//
// Use of this software is governed by the Business Source License
// included in the file licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0, included in the file
// licenses/APL.txt.

package cli

import (
"fmt"
"math/rand"
"net"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"

"github.com/sourcenetwork/defradb/config"
"github.com/sourcenetwork/defradb/errors"
)

// Verify that the top-level commands are registered, and if particular ones have subcommands.
func TestNewDefraCommand(t *testing.T) {
expectedCommandNames := []string{
"client",
"init",
"server-dump",
"start",
"version",
}
actualCommandNames := []string{}
r := NewDefraCommand(config.DefaultConfig())
for _, c := range r.RootCmd.Commands() {
actualCommandNames = append(actualCommandNames, c.Name())
}
for _, expectedCommandName := range expectedCommandNames {
assert.Contains(t, actualCommandNames, expectedCommandName)
}
for _, c := range r.RootCmd.Commands() {
if c.Name() == "client" {
assert.NotEmpty(t, c.Commands())
}
}
}

func TestAllHaveUsage(t *testing.T) {
cfg := config.DefaultConfig()
defra := NewDefraCommand(cfg)
walkCommandTree(t, defra.RootCmd, func(c *cobra.Command) {
assert.NotEmpty(t, c.Use)
})
}

func walkCommandTree(t *testing.T, cmd *cobra.Command, f func(*cobra.Command)) {
f(cmd)
for _, c := range cmd.Commands() {
walkCommandTree(t, c, f)
}
}

// findFreePortInRange returns a free port in the range [minPort, maxPort].
// The range of ports that are unfrequently used is [49152, 65535].
func findFreePortInRange(minPort, maxPort int) (int, error) {
if minPort < 1 || maxPort > 65535 || minPort > maxPort {
return 0, errors.New("invalid port range")
}

const maxAttempts = 100
for i := 0; i < maxAttempts; i++ {
port := rand.Intn(maxPort-minPort+1) + minPort
addr := fmt.Sprintf("127.0.0.1:%d", port)
listener, err := net.Listen("tcp", addr)
if err == nil {
_ = listener.Close()
return port, nil
}
}

return 0, errors.New("unable to find a free port")
}
14 changes: 7 additions & 7 deletions cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ import (
"github.com/spf13/cobra"
)

var clientCmd = &cobra.Command{
Use: "client",
Short: "Interact with a running DefraDB node as a client",
Long: `Interact with a running DefraDB node as a client.
func MakeClientCommand() *cobra.Command {
var cmd = &cobra.Command{
Use: "client",
Short: "Interact with a running DefraDB node as a client",
Long: `Interact with a running DefraDB node as a client.
Execute queries, add schema types, and run debug routines.`,
}
}

func init() {
rootCmd.AddCommand(clientCmd)
return cmd
}
Loading