diff --git a/app/app.go b/app/app.go index 2e09e493..0899f0db 100644 --- a/app/app.go +++ b/app/app.go @@ -41,6 +41,7 @@ func (a *App) Run(args []string) int { case "cli", "repl": // Sub commands for new-style interface. // If an arg named "cli" or "repl" is passed, it is regarded as a sub-command of new-style. a.cmd.registerNewCommands() + a.cmd.RunE = nil case "-h", "--help": // If the help flags is passed, call registerNewCommands for display sub-command helps. a.cmd.registerNewCommands() diff --git a/app/cli_commands.go b/app/cli_commands.go index 3d358b0c..4d8df9aa 100644 --- a/app/cli_commands.go +++ b/app/cli_commands.go @@ -1,6 +1,8 @@ package app import ( + "strings" + "github.com/ktr0731/evans/cui" "github.com/ktr0731/evans/mode" "github.com/pkg/errors" @@ -12,12 +14,55 @@ func newCLICallCommand(flags *flags, ui cui.UI) *cobra.Command { Use: "call [options ...] ", Aliases: []string{"c"}, Short: "call a RPC", + Long: `call invokes a RPC based on the passed method name.`, RunE: runFunc(flags, func(cmd *cobra.Command, cfg *mergedConfig) error { args := cmd.Flags().Args() if len(args) == 0 { return errors.New("method is required") } - if err := mode.RunAsCLIMode(cfg.Config, args[0], cfg.file, ui); err != nil { + invoker, err := mode.NewCallCLIInvoker(ui, args[0], cfg.file, cfg.Config.Request.Header) + if err != nil { + return err + } + if err := mode.RunAsCLIMode(cfg.Config, invoker); err != nil { + return errors.Wrap(err, "failed to run CLI mode") + } + return nil + }), + SilenceErrors: true, + SilenceUsage: true, + } + + bindCLICallFlags(cmd.Flags(), flags, ui.Writer()) + + cmd.SetHelpFunc(usageFunc(ui.Writer())) + return cmd +} + +func newCLIListCommand(flags *flags, ui cui.UI) *cobra.Command { + var ( + out string + ) + cmd := &cobra.Command{ + Use: "list [options ...] [fully-qualified service/method name]", + Aliases: []string{"ls", "show"}, + Short: "list services or methods", + Long: `list provides listing feature against to gRPC services or methods belong to a service. +If a fully-qualified service name (in the form of .), +list lists method names belong to the service. If not, list lists all services.`, + Example: strings.Join([]string{ + " $ evans -r cli list # list all services", + " $ evans -r cli list -o json # list all services with JSON format", + ` $ evans -r cli list api.Service # list all methods belong to service "api.Service"`, + }, "\n"), + RunE: runFunc(flags, func(cmd *cobra.Command, cfg *mergedConfig) error { + var dsn string + args := cmd.Flags().Args() + if len(args) > 0 { + dsn = args[0] + } + invoker := mode.NewListCLIInvoker(ui, dsn, out) + if err := mode.RunAsCLIMode(cfg.Config, invoker); err != nil { return errors.Wrap(err, "failed to run CLI mode") } return nil @@ -26,7 +71,9 @@ func newCLICallCommand(flags *flags, ui cui.UI) *cobra.Command { SilenceUsage: true, } - bindCLIFlags(cmd.LocalFlags(), flags, ui.Writer()) + f := cmd.Flags() + initFlagSet(f, ui.Writer()) + f.StringVarP(&out, "output", "o", "name", `output format. one of "json" or "name".`) cmd.SetHelpFunc(usageFunc(ui.Writer())) return cmd diff --git a/app/commands.go b/app/commands.go index 64af81a9..20a9b6e7 100644 --- a/app/commands.go +++ b/app/commands.go @@ -98,47 +98,26 @@ func runFunc( func newOldCommand(flags *flags, ui cui.UI) *command { cmd := &cobra.Command{ Use: "evans [global options ...] ", - RunE: runFunc(flags, func(cmd *cobra.Command, cfg *mergedConfig) error { + RunE: runFunc(flags, func(cmd *cobra.Command, cfg *mergedConfig) (err error) { if cfg.REPL.ColoredOutput { ui = cui.NewColored(ui) } - defer ui.Warn("evans: deprecated usage, please use sub-commands. see `evans -h` for more details.") + defer func() { + if err == nil { + ui.Warn("evans: deprecated usage, please use sub-commands. see `evans -h` for more details.") + } + }() isCLIMode := (cfg.cli || mode.IsCLIMode(cfg.file)) if cfg.repl || !isCLIMode { - cache, err := cache.Get() - if err != nil { - return errors.Wrap(err, "failed to get the cache content") - } - - baseCtx, cancel := context.WithCancel(context.Background()) - defer cancel() - eg, ctx := errgroup.WithContext(baseCtx) - // Run update checker asynchronously. - eg.Go(func() error { - return checkUpdate(ctx, cfg.Config, cache) - }) - - if cfg.Config.Meta.AutoUpdate { - eg.Go(func() error { - return processUpdate(ctx, cfg.Config, ui.Writer(), cache, prompt.New()) - }) - } else if err := processUpdate(ctx, cfg.Config, ui.Writer(), cache, prompt.New()); err != nil { - return errors.Wrap(err, "failed to update Evans") - } - - if err := mode.RunAsREPLMode(cfg.Config, ui, cache); err != nil { - return errors.Wrap(err, "failed to run REPL mode") - } - - // Always call cancel func because it is hope to abort update checking if REPL mode is finished - // before update checking. If update checking is finished before REPL mode, cancel do nothing. - cancel() - if err := eg.Wait(); err != nil { - return errors.Wrap(err, "failed to check application update") - } - } else if err := mode.RunAsCLIMode(cfg.Config, cfg.call, cfg.file, ui); err != nil { + return runREPLCommand(cfg, ui) + } + invoker, err := mode.NewCallCLIInvoker(ui, cfg.call, cfg.file, cfg.Config.Request.Header) + if err != nil { + return err + } + if err := mode.RunAsCLIMode(cfg.Config, invoker); err != nil { return errors.Wrap(err, "failed to run CLI mode") } @@ -217,7 +196,11 @@ func newCLICommand(flags *flags, ui cui.UI) *cobra.Command { } call = args[0] } - if err := mode.RunAsCLIMode(cfg.Config, call, cfg.file, ui); err != nil { + invoker, err := mode.NewCallCLIInvoker(ui, call, cfg.file, cfg.Config.Request.Header) + if err != nil { + return err + } + if err := mode.RunAsCLIMode(cfg.Config, invoker); err != nil { return errors.Wrap(err, "failed to run CLI mode") } return nil @@ -225,12 +208,12 @@ func newCLICommand(flags *flags, ui cui.UI) *cobra.Command { SilenceErrors: true, SilenceUsage: true, } - f := cmd.LocalFlags() + f := cmd.Flags() initFlagSet(f, ui.Writer()) - f.BoolVarP(&flags.meta.help, "help", "h", false, "display help text and exit") cmd.SetHelpFunc(usageFunc(ui.Writer())) cmd.AddCommand( newCLICallCommand(flags, ui), + newCLIListCommand(flags, ui), ) return cmd } @@ -240,48 +223,52 @@ func newREPLCommand(flags *flags, ui cui.UI) *cobra.Command { Use: "repl [options ...]", Short: "REPL mode", RunE: runFunc(flags, func(_ *cobra.Command, cfg *mergedConfig) error { - cache, err := cache.Get() - if err != nil { - return errors.Wrap(err, "failed to get the cache content") - } - - baseCtx, cancel := context.WithCancel(context.Background()) - defer cancel() - eg, ctx := errgroup.WithContext(baseCtx) - // Run update checker asynchronously. - eg.Go(func() error { - return checkUpdate(ctx, cfg.Config, cache) - }) - - if cfg.Config.Meta.AutoUpdate { - eg.Go(func() error { - return processUpdate(ctx, cfg.Config, ui.Writer(), cache, prompt.New()) - }) - } else if err := processUpdate(ctx, cfg.Config, ui.Writer(), cache, prompt.New()); err != nil { - return errors.Wrap(err, "failed to update Evans") - } - - if err := mode.RunAsREPLMode(cfg.Config, ui, cache); err != nil { - return errors.Wrap(err, "failed to run REPL mode") - } - - // Always call cancel func because it is hope to abort update checking if REPL mode is finished - // before update checking. If update checking is finished before REPL mode, cancel do nothing. - cancel() - if err := eg.Wait(); err != nil { - return errors.Wrap(err, "failed to check application update") - } - return nil + return runREPLCommand(cfg, ui) }), SilenceErrors: true, SilenceUsage: true, } - bindREPLFlags(cmd.LocalFlags(), flags, ui.Writer()) + bindREPLFlags(cmd.Flags(), flags, ui.Writer()) cmd.SetHelpFunc(usageFunc(ui.Writer())) return cmd } -func bindCLIFlags(f *pflag.FlagSet, flags *flags, w io.Writer) { +func runREPLCommand(cfg *mergedConfig, ui cui.UI) error { + cache, err := cache.Get() + if err != nil { + return errors.Wrap(err, "failed to get the cache content") + } + + baseCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + eg, ctx := errgroup.WithContext(baseCtx) + // Run update checker asynchronously. + eg.Go(func() error { + return checkUpdate(ctx, cfg.Config, cache) + }) + + if cfg.Config.Meta.AutoUpdate { + eg.Go(func() error { + return processUpdate(ctx, cfg.Config, ui.Writer(), cache, prompt.New()) + }) + } else if err := processUpdate(ctx, cfg.Config, ui.Writer(), cache, prompt.New()); err != nil { + return errors.Wrap(err, "failed to update Evans") + } + + if err := mode.RunAsREPLMode(cfg.Config, ui, cache); err != nil { + return errors.Wrap(err, "failed to run REPL mode") + } + + // Always call cancel func because it is hope to abort update checking if REPL mode is finished + // before update checking. If update checking is finished before REPL mode, cancel do nothing. + cancel() + if err := eg.Wait(); err != nil { + return errors.Wrap(err, "failed to check application update") + } + return nil +} + +func bindCLICallFlags(f *pflag.FlagSet, flags *flags, w io.Writer) { initFlagSet(f, w) f.StringVarP(&flags.cli.file, "file", "f", "", "a script file that will be executed by (used only CLI mode)") f.BoolVarP(&flags.meta.help, "help", "h", false, "display help text and exit") @@ -303,10 +290,14 @@ func printOptions(w io.Writer, f *pflag.FlagSet) { logger.Printf("failed to write string: %s", err) } tw := tabwriter.NewWriter(w, 0, 8, 8, ' ', tabwriter.TabIndent) + var hasHelp bool f.VisitAll(func(f *pflag.Flag) { if f.Hidden { return } + if f.Name == "help" { + hasHelp = true + } cmd := "--" + f.Name if f.Shorthand != "" { cmd += ", -" + f.Shorthand @@ -321,6 +312,12 @@ func printOptions(w io.Writer, f *pflag.FlagSet) { } fmt.Fprintf(tw, " %s\t%s\n", cmd, usage) }) + // Always show --help text. + if !hasHelp { + cmd := "--help, -h" + usage := `display help text and exit (default "false")` + fmt.Fprintf(tw, " %s\t%s\n", cmd, usage) + } tw.Flush() } @@ -339,9 +336,21 @@ func usageFunc(out io.Writer) func(*cobra.Command, []string) { } printVersion(out) + fmt.Fprint(out, "\n") var buf bytes.Buffer printOptions(&buf, cmd.LocalFlags()) - fmt.Fprintf(out, usageFormat, strings.Join(shortUsages, " "), buf.String()) + fmt.Fprintf(out, "Usage: %s\n\n", strings.Join(shortUsages, " ")) + if cmd.Long != "" { + fmt.Fprint(out, cmd.Long) + fmt.Fprint(out, "\n\n") + } + if cmd.Example != "" { + fmt.Fprint(out, "Examples:\n") + fmt.Fprint(out, cmd.Example) + fmt.Fprint(out, "\n\n") + } + fmt.Fprint(out, buf.String()) + fmt.Fprint(out, "\n") if len(cmd.Commands()) > 0 { fmt.Fprintf(out, "Available Commands:\n") @@ -351,7 +360,8 @@ func usageFunc(out io.Writer) func(*cobra.Command, []string) { if c.Name() == "help" { continue } - fmt.Fprintf(w, " %s\t%s\n", c.Name(), c.Short) + cmdAndAliases := append([]string{c.Name()}, c.Aliases...) + fmt.Fprintf(w, " %s\t%s\n", strings.Join(cmdAndAliases, ", "), c.Short) } w.Flush() fmt.Fprintf(out, "\n") diff --git a/app/flag.go b/app/flag.go index 88639eaa..c33881f2 100644 --- a/app/flag.go +++ b/app/flag.go @@ -10,14 +10,6 @@ import ( "github.com/pkg/errors" ) -var ( - usageFormat = ` -Usage: %s - -%s -` -) - // flags defines available command line flags. type flags struct { mode struct { diff --git a/cache/cache.go b/cache/cache.go index bc3d9f1d..b55f5dab 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -60,7 +60,7 @@ var decodeTOML = func(r io.Reader, i interface{}) error { } // Get returns loaded cache contents. -func Get() (*Cache, error) { +var Get = func() (*Cache, error) { // Use variable for mocking from means_dev.go. p := resolvePath() if _, err := os.Stat(p); os.IsNotExist(err) { diff --git a/e2e/cli_test.go b/e2e/cli_test.go index 36e337d5..b4cd2095 100644 --- a/e2e/cli_test.go +++ b/e2e/cli_test.go @@ -13,6 +13,7 @@ import ( "github.com/ktr0731/evans/cui" "github.com/ktr0731/evans/meta" "github.com/ktr0731/evans/mode" + "github.com/ktr0731/evans/usecase" ) func TestE2E_CLI(t *testing.T) { @@ -49,6 +50,8 @@ func TestE2E_CLI(t *testing.T) { // The output we expected. It is ignored if expectedCode isn't 0. expectedOut string + // assertWithGolden asserts the output with the golden file. + assertWithGolden bool // The exit code we expected. expectedCode int @@ -59,14 +62,14 @@ func TestE2E_CLI(t *testing.T) { unflatten bool }{ "print usage text to the Writer (common flag)": { - commonFlags: "--help", - expectedOut: expectedCLIUsageOut, - unflatten: true, + commonFlags: "--help", + assertWithGolden: true, + unflatten: true, }, "print usage text to the Writer": { - args: "--help", - expectedOut: expectedCLIUsageOut, - unflatten: true, + args: "--help", + assertWithGolden: true, + unflatten: true, }, "print version text to the Writer (common flag)": { commonFlags: "--version", @@ -91,8 +94,14 @@ func TestE2E_CLI(t *testing.T) { expectedCode: 1, }, - // CLI mode + // call command + "print call command usage": { + commonFlags: "", + cmd: "call", + args: "-h", + assertWithGolden: true, + }, "cannot launch CLI mode because proto files didn't be passed": { commonFlags: "--package api --service Example", cmd: "call", @@ -147,6 +156,12 @@ func TestE2E_CLI(t *testing.T) { args: "--file testdata/unary_call.in Unary", expectedOut: `{ "message": "hello, oumae" }`, }, + "call fully-qualified unary RPC with an input file by CLI mode": { + commonFlags: "--proto testdata/test.proto", + cmd: "call", + args: "--file testdata/unary_call.in api.Example.Unary", + expectedOut: `{ "message": "hello, oumae" }`, + }, "call unary RPC with --call flag (backward-compatibility)": { commonFlags: "--package api --service Example --proto testdata/test.proto", cmd: "", @@ -229,7 +244,7 @@ func TestE2E_CLI(t *testing.T) { }, }, - // CLI mode with reflection + // call command with reflection "cannot launch CLI mode with reflection because method is missing": { commonFlags: "--reflection --package api --service Example --proto testdata/test.proto", @@ -253,7 +268,7 @@ func TestE2E_CLI(t *testing.T) { expectedOut: `{ "message": "hello, oumae" }`, }, - // CLI mode with TLS + // call command with TLS "cannot launch CLI mode with TLS because the server didn't enable TLS": { commonFlags: "--tls --service Example --proto testdata/test.proto", @@ -335,7 +350,7 @@ func TestE2E_CLI(t *testing.T) { expectedOut: `{ "message": "hello, oumae" }`, }, - // CLI mode with gRPC-Web + // call command with gRPC-Web "cannot send a request to gRPC-Web server because the server didn't enable gRPC-Web": { commonFlags: "--web --package api --service Example --proto testdata/test.proto", @@ -392,10 +407,86 @@ func TestE2E_CLI(t *testing.T) { } }, }, + + // list command + "print list command usage": { + commonFlags: "", + cmd: "list", + args: "-h", + assertWithGolden: true, + }, + "list services without args": { + commonFlags: "--proto testdata/test.proto", + cmd: "list", + args: "", + expectedOut: `api.Example`, + }, + "list services without args and two protos": { + commonFlags: "--proto testdata/test.proto,testdata/empty_package.proto", + cmd: "list", + args: "", + expectedOut: `EmptyPackageService api.Example`, + }, + "list services with name format": { + commonFlags: "--proto testdata/test.proto", + cmd: "list", + args: "-o name", + expectedOut: `api.Example`, + }, + "list services with JSON format": { + commonFlags: "--proto testdata/test.proto", + cmd: "list", + args: "-o json", + expectedOut: `{ "services": [ { "name": "api.Example" } ] }`, + }, + "list methods with name format": { + commonFlags: "--proto testdata/test.proto", + cmd: "list", + args: "-o name api.Example", + expectedOut: `api.Example.BidiStreaming api.Example.ClientStreaming api.Example.ServerStreaming api.Example.Unary api.Example.UnaryBytes api.Example.UnaryEnum api.Example.UnaryHeader api.Example.UnaryMap api.Example.UnaryMapMessage api.Example.UnaryMessage api.Example.UnaryOneof api.Example.UnaryRepeated api.Example.UnaryRepeatedEnum api.Example.UnaryRepeatedMessage api.Example.UnarySelf`, + }, + "list methods with JSON format": { + commonFlags: "--proto testdata/test.proto", + cmd: "list", + args: "-o json api.Example", + expectedOut: `{ "methods": [ { "name": "BidiStreaming", "fully_qualified_name": "api.Example.BidiStreaming", "request_type": "api.SimpleRequest", "response_type": "api.SimpleResponse" }, { "name": "ClientStreaming", "fully_qualified_name": "api.Example.ClientStreaming", "request_type": "api.SimpleRequest", "response_type": "api.SimpleResponse" }, { "name": "ServerStreaming", "fully_qualified_name": "api.Example.ServerStreaming", "request_type": "api.SimpleRequest", "response_type": "api.SimpleResponse" }, { "name": "Unary", "fully_qualified_name": "api.Example.Unary", "request_type": "api.SimpleRequest", "response_type": "api.SimpleResponse" }, { "name": "UnaryBytes", "fully_qualified_name": "api.Example.UnaryBytes", "request_type": "api.UnaryBytesRequest", "response_type": "api.SimpleResponse" }, { "name": "UnaryEnum", "fully_qualified_name": "api.Example.UnaryEnum", "request_type": "api.UnaryEnumRequest", "response_type": "api.SimpleResponse" }, { "name": "UnaryHeader", "fully_qualified_name": "api.Example.UnaryHeader", "request_type": "api.UnaryHeaderRequest", "response_type": "api.SimpleResponse" }, { "name": "UnaryMap", "fully_qualified_name": "api.Example.UnaryMap", "request_type": "api.UnaryMapRequest", "response_type": "api.SimpleResponse" }, { "name": "UnaryMapMessage", "fully_qualified_name": "api.Example.UnaryMapMessage", "request_type": "api.UnaryMapMessageRequest", "response_type": "api.SimpleResponse" }, { "name": "UnaryMessage", "fully_qualified_name": "api.Example.UnaryMessage", "request_type": "api.UnaryMessageRequest", "response_type": "api.SimpleResponse" }, { "name": "UnaryOneof", "fully_qualified_name": "api.Example.UnaryOneof", "request_type": "api.UnaryOneofRequest", "response_type": "api.SimpleResponse" }, { "name": "UnaryRepeated", "fully_qualified_name": "api.Example.UnaryRepeated", "request_type": "api.UnaryRepeatedRequest", "response_type": "api.SimpleResponse" }, { "name": "UnaryRepeatedEnum", "fully_qualified_name": "api.Example.UnaryRepeatedEnum", "request_type": "api.UnaryRepeatedEnumRequest", "response_type": "api.SimpleResponse" }, { "name": "UnaryRepeatedMessage", "fully_qualified_name": "api.Example.UnaryRepeatedMessage", "request_type": "api.UnaryRepeatedMessageRequest", "response_type": "api.SimpleResponse" }, { "name": "UnarySelf", "fully_qualified_name": "api.Example.UnarySelf", "request_type": "api.UnarySelfRequest", "response_type": "api.SimpleResponse" } ] }`, + }, + "list methods that have empty name": { + commonFlags: "--proto testdata/empty_package.proto", + cmd: "list", + args: "EmptyPackageService", + expectedOut: `EmptyPackageService.Unary`, + }, + "list a method with name format": { + commonFlags: "--proto testdata/test.proto,testdata/empty_package.proto", + cmd: "list", + args: "-o name api.Example.Unary", + expectedOut: `api.Example.Unary`, + }, + "list a method with JSON format": { + commonFlags: "--proto testdata/test.proto", + cmd: "list", + args: "-o json api.Example.Unary", + expectedOut: `{ "name": "Unary", "fully_qualified_name": "api.Example.Unary", "request_type": "api.SimpleRequest", "response_type": "api.SimpleResponse" }`, + }, + "cannot list because of invalid package name": { + commonFlags: "--proto testdata/test.proto", + cmd: "list", + args: "Foo", + expectedCode: 1, + }, + "cannot list because of invalid service name": { + commonFlags: "--proto testdata/test.proto", + cmd: "list", + args: "api.Foo", + expectedCode: 1, + }, } for name, c := range cases { c := c t.Run(name, func(t *testing.T) { + defer usecase.Clear() + stopServer, port := startServer(t, c.tls, c.reflection, c.web, c.registerEmptyPackageService) defer stopServer() @@ -440,6 +531,9 @@ func TestE2E_CLI(t *testing.T) { if c.expectedOut != "" && actual != c.expectedOut { t.Errorf("unexpected output:\n%s", cmp.Diff(c.expectedOut, actual)) } + if c.assertWithGolden { + compareWithGolden(t, outBuf.String()) + } if eoutBuf.String() != "" { t.Errorf("expected code is 0, but got an error message: '%s'", eoutBuf.String()) } @@ -447,15 +541,3 @@ func TestE2E_CLI(t *testing.T) { }) } } - -var expectedCLIUsageOut = fmt.Sprintf(`evans %s - -Usage: evans [global options ...] cli - -Options: - --help, -h display help text and exit (default "false") - -Available Commands: - call call a RPC - -`, meta.Version) diff --git a/e2e/testdata/fixtures/teste2e_cli-print_call_command_usage.golden b/e2e/testdata/fixtures/teste2e_cli-print_call_command_usage.golden new file mode 100644 index 00000000..f74cc318 --- /dev/null +++ b/e2e/testdata/fixtures/teste2e_cli-print_call_command_usage.golden @@ -0,0 +1,9 @@ +evans 0.8.5 + +Usage: evans [global options ...] cli call [options ...] + +call invokes a RPC based on the passed method name. + +Options: + --help, -h display help text and exit (default "false") + diff --git a/e2e/testdata/fixtures/teste2e_cli-print_list_command_usage.golden b/e2e/testdata/fixtures/teste2e_cli-print_list_command_usage.golden new file mode 100644 index 00000000..63aedac2 --- /dev/null +++ b/e2e/testdata/fixtures/teste2e_cli-print_list_command_usage.golden @@ -0,0 +1,17 @@ +evans 0.8.5 + +Usage: evans [global options ...] cli list [options ...] [fully-qualified service/method name] + +list provides listing feature against to gRPC services or methods belong to a service. +If a fully-qualified service name (in the form of .), +list lists method names belong to the service. If not, list lists all services. + +Examples: + $ evans -r cli list # list all services + $ evans -r cli list -o json # list all services with JSON format + $ evans -r cli list api.Service # list all methods belong to service "api.Service" + +Options: + --output, -o string output format. one of "json" or "name". (default "name") + --help, -h display help text and exit (default "false") + diff --git a/e2e/testdata/fixtures/teste2e_cli-print_usage_text_to_the_writer.golden b/e2e/testdata/fixtures/teste2e_cli-print_usage_text_to_the_writer.golden new file mode 100644 index 00000000..92bab2bd --- /dev/null +++ b/e2e/testdata/fixtures/teste2e_cli-print_usage_text_to_the_writer.golden @@ -0,0 +1,11 @@ +evans 0.8.5 + +Usage: evans [global options ...] cli + +Options: + --help, -h display help text and exit (default "false") + +Available Commands: + call, c call a RPC + list, ls, show list services or methods + diff --git a/e2e/testdata/fixtures/teste2e_cli-print_usage_text_to_the_writer_(common_flag).golden b/e2e/testdata/fixtures/teste2e_cli-print_usage_text_to_the_writer_(common_flag).golden new file mode 100644 index 00000000..92bab2bd --- /dev/null +++ b/e2e/testdata/fixtures/teste2e_cli-print_usage_text_to_the_writer_(common_flag).golden @@ -0,0 +1,11 @@ +evans 0.8.5 + +Usage: evans [global options ...] cli + +Options: + --help, -h display help text and exit (default "false") + +Available Commands: + call, c call a RPC + list, ls, show list services or methods + diff --git a/e2e/testdata/fixtures/teste2e_oldrepl-show_rpc.golden b/e2e/testdata/fixtures/teste2e_oldrepl-show_rpc.golden index 9bed7a76..b45f9979 100644 --- a/e2e/testdata/fixtures/teste2e_oldrepl-show_rpc.golden +++ b/e2e/testdata/fixtures/teste2e_oldrepl-show_rpc.golden @@ -1,20 +1,20 @@ -+----------------------+-----------------------------+----------------+ -| RPC | REQUEST TYPE | RESPONSE TYPE | -+----------------------+-----------------------------+----------------+ -| BidiStreaming | SimpleRequest | SimpleResponse | -| ClientStreaming | SimpleRequest | SimpleResponse | -| ServerStreaming | SimpleRequest | SimpleResponse | -| Unary | SimpleRequest | SimpleResponse | -| UnaryBytes | UnaryBytesRequest | SimpleResponse | -| UnaryEnum | UnaryEnumRequest | SimpleResponse | -| UnaryHeader | UnaryHeaderRequest | SimpleResponse | -| UnaryMap | UnaryMapRequest | SimpleResponse | -| UnaryMapMessage | UnaryMapMessageRequest | SimpleResponse | -| UnaryMessage | UnaryMessageRequest | SimpleResponse | -| UnaryOneof | UnaryOneofRequest | SimpleResponse | -| UnaryRepeated | UnaryRepeatedRequest | SimpleResponse | -| UnaryRepeatedEnum | UnaryRepeatedEnumRequest | SimpleResponse | -| UnaryRepeatedMessage | UnaryRepeatedMessageRequest | SimpleResponse | -| UnarySelf | UnarySelfRequest | SimpleResponse | -+----------------------+-----------------------------+----------------+ ++----------------------+----------------------------------+---------------------------------+--------------------+ +| NAME | FULLY-QUALIFIED NAME | REQUEST TYPE | RESPONSE TYPE | ++----------------------+----------------------------------+---------------------------------+--------------------+ +| BidiStreaming | api.Example.BidiStreaming | api.SimpleRequest | api.SimpleResponse | +| ClientStreaming | api.Example.ClientStreaming | api.SimpleRequest | api.SimpleResponse | +| ServerStreaming | api.Example.ServerStreaming | api.SimpleRequest | api.SimpleResponse | +| Unary | api.Example.Unary | api.SimpleRequest | api.SimpleResponse | +| UnaryBytes | api.Example.UnaryBytes | api.UnaryBytesRequest | api.SimpleResponse | +| UnaryEnum | api.Example.UnaryEnum | api.UnaryEnumRequest | api.SimpleResponse | +| UnaryHeader | api.Example.UnaryHeader | api.UnaryHeaderRequest | api.SimpleResponse | +| UnaryMap | api.Example.UnaryMap | api.UnaryMapRequest | api.SimpleResponse | +| UnaryMapMessage | api.Example.UnaryMapMessage | api.UnaryMapMessageRequest | api.SimpleResponse | +| UnaryMessage | api.Example.UnaryMessage | api.UnaryMessageRequest | api.SimpleResponse | +| UnaryOneof | api.Example.UnaryOneof | api.UnaryOneofRequest | api.SimpleResponse | +| UnaryRepeated | api.Example.UnaryRepeated | api.UnaryRepeatedRequest | api.SimpleResponse | +| UnaryRepeatedEnum | api.Example.UnaryRepeatedEnum | api.UnaryRepeatedEnumRequest | api.SimpleResponse | +| UnaryRepeatedMessage | api.Example.UnaryRepeatedMessage | api.UnaryRepeatedMessageRequest | api.SimpleResponse | +| UnarySelf | api.Example.UnarySelf | api.UnarySelfRequest | api.SimpleResponse | ++----------------------+----------------------------------+---------------------------------+--------------------+ diff --git a/e2e/testdata/fixtures/teste2e_repl-show_rpc.golden b/e2e/testdata/fixtures/teste2e_repl-show_rpc.golden index 9bed7a76..b45f9979 100644 --- a/e2e/testdata/fixtures/teste2e_repl-show_rpc.golden +++ b/e2e/testdata/fixtures/teste2e_repl-show_rpc.golden @@ -1,20 +1,20 @@ -+----------------------+-----------------------------+----------------+ -| RPC | REQUEST TYPE | RESPONSE TYPE | -+----------------------+-----------------------------+----------------+ -| BidiStreaming | SimpleRequest | SimpleResponse | -| ClientStreaming | SimpleRequest | SimpleResponse | -| ServerStreaming | SimpleRequest | SimpleResponse | -| Unary | SimpleRequest | SimpleResponse | -| UnaryBytes | UnaryBytesRequest | SimpleResponse | -| UnaryEnum | UnaryEnumRequest | SimpleResponse | -| UnaryHeader | UnaryHeaderRequest | SimpleResponse | -| UnaryMap | UnaryMapRequest | SimpleResponse | -| UnaryMapMessage | UnaryMapMessageRequest | SimpleResponse | -| UnaryMessage | UnaryMessageRequest | SimpleResponse | -| UnaryOneof | UnaryOneofRequest | SimpleResponse | -| UnaryRepeated | UnaryRepeatedRequest | SimpleResponse | -| UnaryRepeatedEnum | UnaryRepeatedEnumRequest | SimpleResponse | -| UnaryRepeatedMessage | UnaryRepeatedMessageRequest | SimpleResponse | -| UnarySelf | UnarySelfRequest | SimpleResponse | -+----------------------+-----------------------------+----------------+ ++----------------------+----------------------------------+---------------------------------+--------------------+ +| NAME | FULLY-QUALIFIED NAME | REQUEST TYPE | RESPONSE TYPE | ++----------------------+----------------------------------+---------------------------------+--------------------+ +| BidiStreaming | api.Example.BidiStreaming | api.SimpleRequest | api.SimpleResponse | +| ClientStreaming | api.Example.ClientStreaming | api.SimpleRequest | api.SimpleResponse | +| ServerStreaming | api.Example.ServerStreaming | api.SimpleRequest | api.SimpleResponse | +| Unary | api.Example.Unary | api.SimpleRequest | api.SimpleResponse | +| UnaryBytes | api.Example.UnaryBytes | api.UnaryBytesRequest | api.SimpleResponse | +| UnaryEnum | api.Example.UnaryEnum | api.UnaryEnumRequest | api.SimpleResponse | +| UnaryHeader | api.Example.UnaryHeader | api.UnaryHeaderRequest | api.SimpleResponse | +| UnaryMap | api.Example.UnaryMap | api.UnaryMapRequest | api.SimpleResponse | +| UnaryMapMessage | api.Example.UnaryMapMessage | api.UnaryMapMessageRequest | api.SimpleResponse | +| UnaryMessage | api.Example.UnaryMessage | api.UnaryMessageRequest | api.SimpleResponse | +| UnaryOneof | api.Example.UnaryOneof | api.UnaryOneofRequest | api.SimpleResponse | +| UnaryRepeated | api.Example.UnaryRepeated | api.UnaryRepeatedRequest | api.SimpleResponse | +| UnaryRepeatedEnum | api.Example.UnaryRepeatedEnum | api.UnaryRepeatedEnumRequest | api.SimpleResponse | +| UnaryRepeatedMessage | api.Example.UnaryRepeatedMessage | api.UnaryRepeatedMessageRequest | api.SimpleResponse | +| UnarySelf | api.Example.UnarySelf | api.UnarySelfRequest | api.SimpleResponse | ++----------------------+----------------------------------+---------------------------------+--------------------+ diff --git a/idl/idl.go b/idl/idl.go index 0563b64e..e5b55d3e 100644 --- a/idl/idl.go +++ b/idl/idl.go @@ -59,14 +59,13 @@ type Spec interface { TypeDescriptor(msgName string) (interface{}, error) } -// FullyQualifiedMessageName returns the fully qualified name joined with '.'. -// pkgName is an optional value, but msgName is not. -func FullyQualifiedMessageName(pkgName, msgName string) (string, error) { - if msgName == "" { - return "", errors.New("msgName should not be empty") +// FullyQualifiedMethodName returns the fully-qualified method joined with '.'. +func FullyQualifiedMethodName(fqsn, methodName string) (string, error) { + if fqsn == "" { + return "", errors.New("fqsn should not be empty") } - if pkgName == "" { - return msgName, nil + if methodName == "" { + return "", errors.New("methodName should not be empty") } - return strings.Join([]string{pkgName, msgName}, "."), nil + return strings.Join([]string{fqsn, methodName}, "."), nil } diff --git a/idl/proto/proto.go b/idl/proto/proto.go index cddeff1a..9a9c6607 100644 --- a/idl/proto/proto.go +++ b/idl/proto/proto.go @@ -191,3 +191,12 @@ func FullyQualifiedMessageName(pkg, msg string) string { } return strings.Join(s, ".") } + +// ParseFullyQualifiedServiceName returns the package and service name from a fully-qualified service name. +func ParseFullyQualifiedServiceName(fqsn string) (string, string) { + i := strings.LastIndex(fqsn, ".") + if i == -1 { + return "", fqsn + } + return fqsn[:i], fqsn[i+1:] +} diff --git a/mode/cli.go b/mode/cli.go index b53c9b7d..e1541181 100644 --- a/mode/cli.go +++ b/mode/cli.go @@ -9,7 +9,11 @@ import ( "github.com/ktr0731/evans/config" "github.com/ktr0731/evans/cui" "github.com/ktr0731/evans/fill" + "github.com/ktr0731/evans/idl" + "github.com/ktr0731/evans/idl/proto" + "github.com/ktr0731/evans/present" "github.com/ktr0731/evans/present/json" + "github.com/ktr0731/evans/present/name" "github.com/ktr0731/evans/usecase" "github.com/ktr0731/go-multierror" "github.com/mattn/go-isatty" @@ -19,25 +23,123 @@ import ( // DefaultCLIReader is the reader that is read for inputting request values. It is exported for E2E testing. var DefaultCLIReader io.Reader = os.Stdin -// RunAsCLIMode starts Evans as CLI mode. -func RunAsCLIMode(cfg *config.Config, endpoint, file string, ui cui.UI) error { - if endpoint == "" { - return errors.New("method is required") +// CLIInvoker represents an invokable function for CLI mode. +type CLIInvoker func(context.Context) error + +// NewCallCLIInvoker returns an CLIInvoker implementation for calling RPCs. +// If filePath is empty, the invoker tries to read input from stdin. +func NewCallCLIInvoker(ui cui.UI, methodName, filePath string, headers config.Header) (CLIInvoker, error) { + if methodName == "" { + return nil, errors.New("method is required") } - in := DefaultCLIReader - if file != "" { - f, err := os.Open(file) + return func(ctx context.Context) error { + in := DefaultCLIReader + if filePath != "" { + f, err := os.Open(filePath) + if err != nil { + return errors.Wrap(err, "failed to open the script file") + } + defer f.Close() + in = f + } + filler := fill.NewSilentFiller(in) + usecase.InjectPartially(usecase.Dependencies{Filler: filler}) + + for k, v := range headers { + for _, vv := range v { + usecase.AddHeader(k, vv) + } + } + + // Try to parse methodName as a fully-qualified method name. + // If it is valid, use its fully-qualified service. + fqsn, mtd, err := usecase.ParseFullyQualifiedMethodName(methodName) + if err == nil { + pkg, svc := proto.ParseFullyQualifiedServiceName(fqsn) + if err := usecase.UsePackage(pkg); err != nil { + return errors.Wrapf(err, "failed to use package '%s'", pkg) + } + if err := usecase.UseService(svc); err != nil { + return errors.Wrapf(err, "failed to use service '%s'", svc) + } + methodName = mtd + } + + err = usecase.CallRPC(ctx, ui.Writer(), methodName) if err != nil { - return errors.Wrap(err, "failed to open the script file") + return errors.Wrapf(err, "failed to call RPC '%s'", methodName) } - defer f.Close() - in = f - } - filler := fill.NewSilentFiller(in) - // TODO: parse package and service from call. + return nil + }, nil +} + +func NewListCLIInvoker(ui cui.UI, fqn, format string) CLIInvoker { + const ( + fname = "name" + fjson = "json" + ) + return func(context.Context) error { + var presenter present.Presenter + switch format { + case fname: + presenter = name.NewPresenter() + case fjson: + presenter = json.NewPresenter(" ") + default: + presenter = name.NewPresenter() + } + usecase.InjectPartially(usecase.Dependencies{ResourcePresenter: presenter}) + + commonErr := errors.Errorf("unknown fully-qualified service name or method name '%s'", fqn) + out, err := func() (string, error) { + if fqn == "" { + svc, err := usecase.FormatServices() + if err != nil { + return "", errors.Wrap(err, "failed to list services") + } + return svc, nil + } + + if isFullyQualifiedMethodName(fqn) { + // A fully-qualified method name is passed. + // Return as it is (same behavior as grpc_cli). + rpc, err := usecase.FormatMethod(fqn) + if err != nil { + return "", errors.Wrap(err, "failed to format RPC") + } + return rpc, nil + } + + // Parse as a fully-qualified service name. - // Common dependencies. + pkg, svc := proto.ParseFullyQualifiedServiceName(fqn) + if err := usecase.UsePackage(pkg); err != nil { + return "", commonErr // Return commonErr because UsePackage will be deprecated. + } + + if err := usecase.UseService(svc); err != nil && errors.Is(err, idl.ErrUnknownServiceName) { + return "", commonErr + } else if err != nil { + return "", errors.Wrapf(err, "failed to use service '%s'", svc) + } + + rpcs, err := usecase.FormatMethods() + if err != nil { + return "", errors.Wrap(err, "failed to format RPCs") + } + return rpcs, nil + }() + if err != nil { + return err + } + ui.Output(out) + return nil + } +} + +// RunAsCLIMode starts Evans as CLI mode. +func RunAsCLIMode(cfg *config.Config, invoker CLIInvoker) error { var injectResult error gRPCClient, err := newGRPCClient(cfg) if err != nil { @@ -59,12 +161,13 @@ func RunAsCLIMode(cfg *config.Config, endpoint, file string, ui cui.UI) error { return injectResult } - usecase.Inject( - spec, - filler, - gRPCClient, - json.NewPresenter(), - json.NewPresenter(), + usecase.InjectPartially( + usecase.Dependencies{ + Spec: spec, + GRPCClient: gRPCClient, + ResponsePresenter: json.NewPresenter(" "), + ResourcePresenter: json.NewPresenter(" "), + }, ) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -73,20 +176,6 @@ func RunAsCLIMode(cfg *config.Config, endpoint, file string, ui cui.UI) error { return err } - invoker := func(ctx context.Context) error { - for k, v := range cfg.Request.Header { - for _, vv := range v { - usecase.AddHeader(k, vv) - } - } - - err := usecase.CallRPC(ctx, ui.Writer(), endpoint) - if err != nil { - return errors.Wrapf(err, "failed to call RPC '%s'", endpoint) - } - return nil - } - return invoker(ctx) } @@ -94,3 +183,8 @@ func RunAsCLIMode(cfg *config.Config, endpoint, file string, ui cui.UI) error { func IsCLIMode(file string) bool { return file != "" || (!isatty.IsTerminal(os.Stdin.Fd()) && !isatty.IsCygwinTerminal(os.Stdin.Fd())) } + +func isFullyQualifiedMethodName(s string) bool { + _, _, err := usecase.ParseFullyQualifiedMethodName(s) + return err == nil +} diff --git a/mode/common.go b/mode/common.go index 05662ec4..89f05c63 100644 --- a/mode/common.go +++ b/mode/common.go @@ -74,8 +74,8 @@ func setDefault(cfg *config.Config) error { } } return false - } - if !hasEmptyPackage() { + }() + if !hasEmptyPackage { return nil } } @@ -87,11 +87,16 @@ func setDefault(cfg *config.Config) error { // If the spec has only one service, mark it as the default service. if cfg.Default.Service == "" { - svcNames := usecase.ListServices() + svcNames := usecase.ListServicesOld() if len(svcNames) != 1 { return nil } + cfg.Default.Service = svcNames[0] + i := strings.LastIndex(cfg.Default.Service, ".") + if i != -1 { + cfg.Default.Service = cfg.Default.Service[i+1:] + } } if err := usecase.UseService(cfg.Default.Service); err != nil { return errors.Wrapf(err, "failed to set '%s' as the default service", cfg.Default.Service) diff --git a/mode/repl.go b/mode/repl.go index f8a3e328..f47b6d0f 100644 --- a/mode/repl.go +++ b/mode/repl.go @@ -29,11 +29,13 @@ func RunAsREPLMode(cfg *config.Config, ui cui.UI, cache *cache.Cache) error { } usecase.Inject( - spec, - proto.NewInteractiveFiller(prompt.New(), cfg.REPL.InputPromptFormat), - gRPCClient, - json.NewPresenter(), - table.NewPresenter(), + usecase.Dependencies{ + Spec: spec, + Filler: proto.NewInteractiveFiller(prompt.New(), cfg.REPL.InputPromptFormat), + GRPCClient: gRPCClient, + ResponsePresenter: json.NewPresenter(" "), + ResourcePresenter: table.NewPresenter(), + }, ) ctx, cancel := context.WithCancel(context.Background()) diff --git a/present/json/json.go b/present/json/json.go index d31b23d7..cbad126f 100644 --- a/present/json/json.go +++ b/present/json/json.go @@ -8,17 +8,21 @@ import ( ) // Presenter is a presenter that formats v into JSON string. -type Presenter struct{} +type Presenter struct { + indent string +} -// Format formats v into JSON string. If indent is not empty, Format indents the output. -func (p *Presenter) Format(v interface{}, indent string) (string, error) { - b, err := gojson.MarshalIndent(v, "", indent) +// Format formats v into JSON string. +func (p *Presenter) Format(v interface{}) (string, error) { + b, err := gojson.MarshalIndent(v, "", p.indent) if err != nil { return "", errors.Wrap(err, "failed to format v into JSON string") } return string(b), nil } -func NewPresenter() *Presenter { - return &Presenter{} +// NewPresenter instantiates a JSON presenter. +// If indent is not empty, Format indents the output. +func NewPresenter(indent string) *Presenter { + return &Presenter{indent: indent} } diff --git a/present/name/name.go b/present/name/name.go new file mode 100644 index 00000000..2841a862 --- /dev/null +++ b/present/name/name.go @@ -0,0 +1,75 @@ +package name + +import ( + "errors" + "fmt" + "reflect" + "strings" +) + +// Presenter is a presenter that formats v into the list of names. +type Presenter struct{} + +func indirect(rv reflect.Value) reflect.Value { + if rv.Type().Kind() != reflect.Ptr { + return rv + } + return indirect(reflect.Indirect(rv)) +} + +// Format formats v into the list of names. v should be a struct type. +// Format tries to find "name" tag from the struct fields and format the first appeared field. +// The struct type is only allowed to have struct, slice or primitive type fields. +func (p *Presenter) Format(v interface{}) (string, error) { + return formatFromStruct(reflect.ValueOf(v)) +} + +func formatFromStruct(rv reflect.Value) (string, error) { + rv = indirect(rv) + rt := rv.Type() + if rt.Kind() != reflect.Struct { + return "", errors.New("v should be a struct type") + } + + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + v := f.Tag.Get("name") + if v == "" { + if f.Type.Kind() == reflect.Struct { + s, err := formatFromStruct(rv.Field(i)) + if err == nil { + return s, nil + } + } + continue + } + + if f.Type.Kind() == reflect.Slice { + slice := rv.Field(i) + rows := make([]string, slice.Len()) + for i := 0; i < slice.Len(); i++ { + v := slice.Index(i) + if v.Type().Kind() != reflect.Struct { + return "", errors.New("v should have a slice of a struct") + } + if v.NumField() == 0 { + return "", errors.New("struct should have at least 1 field") + } + for j := 0; j < v.NumField(); j++ { + if v.Type().Field(j).Tag.Get("name") == "" { + continue + } + rows[i] = fmt.Sprint(v.Field(j)) + break + } + } + return strings.Join(rows, "\n"), nil + } + return fmt.Sprint(rv.Field(i)), nil + } + return "", errors.New("invalid type") +} + +func NewPresenter() *Presenter { + return &Presenter{} +} diff --git a/present/name/name_test.go b/present/name/name_test.go new file mode 100644 index 00000000..17e4aa78 --- /dev/null +++ b/present/name/name_test.go @@ -0,0 +1,74 @@ +package name_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/ktr0731/evans/present/name" +) + +func TestPresenter(t *testing.T) { + cases := map[string]struct { + v interface{} + expected string + hasErr bool + }{ + "not a struct": { + v: 100, + hasErr: true, + }, + "doesn't have a slice": { + v: struct{}{}, + hasErr: true, + }, + "doesn't have a slice of a struct": { + v: struct{ V []int }{[]int{1}}, + hasErr: true, + }, + "the slice type has no fields": { + v: struct { + V []struct{} + }{ + V: []struct{}{struct{}{}}, + }, + hasErr: true, + }, + "normal": { + v: struct { + V []struct { + int `name:"target"` + } `name:"target"` + }{ + V: []struct { + int `name:"target"` + }{ + struct { + int `name:"target"` + }{100}, + struct { + int `name:"target"` + }{200}, + }, + }, + expected: "100\n200", + }, + } + for tname, c := range cases { + t.Run(tname, func(t *testing.T) { + p := name.NewPresenter() + actual, err := p.Format(c.v) + if c.hasErr { + if err == nil { + t.Errorf("should return an error, but got nil") + } + return + } + if err != nil { + t.Errorf("should not return an error, but got '%s'", err) + } + if diff := cmp.Diff(c.expected, actual); diff != "" { + t.Errorf("(-want, +got)\n%s", diff) + } + }) + } +} diff --git a/present/present.go b/present/present.go index 2a67e6f6..56c3451f 100644 --- a/present/present.go +++ b/present/present.go @@ -7,5 +7,5 @@ type Presenter interface { // Note that v is a type that belongs to the selected IDL. // // For example, v is a proto.Message if IDL is Protocol Buffers. - Format(v interface{}, indent string) (string, error) + Format(v interface{}) (string, error) } diff --git a/present/table/table.go b/present/table/table.go index fae9b56e..b0bd61bc 100644 --- a/present/table/table.go +++ b/present/table/table.go @@ -30,8 +30,7 @@ func indirectType(rt reflect.Type) reflect.Type { // Format formats v with table layout. v should be a struct type that has a slice field. Format extract the slice field // and display them. See test cases for example. -// TODO: remove indent. -func (p *Presenter) Format(v interface{}, indent string) (string, error) { +func (p *Presenter) Format(v interface{}) (string, error) { rv := indirect(reflect.ValueOf(v)) rt := rv.Type() if rt.Kind() != reflect.Struct { diff --git a/present/table/table_test.go b/present/table/table_test.go index 03dd7183..75d008c0 100644 --- a/present/table/table_test.go +++ b/present/table/table_test.go @@ -43,7 +43,7 @@ func TestPresenter(t *testing.T) { }, } p := NewPresenter() - actual, err := p.Format(&vi, "") + actual, err := p.Format(&vi) fmt.Println(actual) if err != nil { t.Fatal(err) diff --git a/repl/commands.go b/repl/commands.go index a0d91d9f..8c32c5a1 100644 --- a/repl/commands.go +++ b/repl/commands.go @@ -113,11 +113,11 @@ func (c *showCommand) Run(w io.Writer, args []string) error { case "p", "package", "packages": f = usecase.FormatPackages case "s", "svc", "service", "services": - f = usecase.FormatServices + f = usecase.FormatServicesOld case "m", "msg", "message", "messages": f = usecase.FormatMessages case "a", "r", "rpc", "api": - f = usecase.FormatRPCs + f = usecase.FormatMethods case "h", "header", "headers": f = usecase.FormatHeaders default: diff --git a/repl/completer.go b/repl/completer.go index 041dd116..30baf39e 100644 --- a/repl/completer.go +++ b/repl/completer.go @@ -59,7 +59,7 @@ func (c *completer) Complete(d prompt.Document) (s []*prompt.Suggest) { return nil } - for _, svc := range usecase.ListServices() { + for _, svc := range usecase.ListServicesOld() { s = append(s, prompt.NewSuggestion(svc, "")) } @@ -81,7 +81,7 @@ func (c *completer) Complete(d prompt.Document) (s []*prompt.Suggest) { } encountered := make(map[string]interface{}) - for _, svc := range usecase.ListServices() { + for _, svc := range usecase.ListServicesOld() { rpcs, err := usecase.ListRPCs(svc) if err != nil { panic(fmt.Sprintf("ListRPCs must not return an error, but got '%s'", err)) diff --git a/repl/completer_test.go b/repl/completer_test.go index e7570adb..76d8d153 100644 --- a/repl/completer_test.go +++ b/repl/completer_test.go @@ -27,7 +27,15 @@ func TestCompleter(t *testing.T) { if err != nil { t.Fatalf("LoadFiles must not return an error, but got '%s'", err) } - usecase.Inject(spec, nil, nil, nil, nil) + usecase.Inject( + usecase.Dependencies{ + Spec: spec, + Filler: nil, + GRPCClient: nil, + ResponsePresenter: nil, + ResourcePresenter: nil, + }, + ) err = usecase.UsePackage("api") if err != nil { t.Fatalf("UsePackage must not return an error, but got '%s'", err) diff --git a/repl/repl_test.go b/repl/repl_test.go index 2bed22e2..8af5bcd1 100644 --- a/repl/repl_test.go +++ b/repl/repl_test.go @@ -117,7 +117,15 @@ func TestREPL_makePrefix(t *testing.T) { }, } t.Run(name, func(t *testing.T) { - usecase.Inject(dummySpec, nil, nil, nil, nil) + usecase.Inject( + usecase.Dependencies{ + Spec: dummySpec, + Filler: nil, + GRPCClient: nil, + ResponsePresenter: nil, + ResourcePresenter: nil, + }, + ) r, err := New(dummyCfg, prompt.New(), nil, c.pkgName, c.svcName) if c.hasErr { diff --git a/usecase/call_rpc.go b/usecase/call_rpc.go index cc3b95ec..7535670b 100644 --- a/usecase/call_rpc.go +++ b/usecase/call_rpc.go @@ -51,7 +51,7 @@ func (m *dependencyManager) CallRPC(ctx context.Context, w io.Writer, rpcName st return res, nil } flushResponse := func(res interface{}) error { - out, err := m.responsePresenter.Format(res, " ") + out, err := m.responsePresenter.Format(res) if err != nil { return err } diff --git a/usecase/format_headers.go b/usecase/format_headers.go index 875e1a62..5fc40dde 100644 --- a/usecase/format_headers.go +++ b/usecase/format_headers.go @@ -27,7 +27,7 @@ func (m *dependencyManager) FormatHeaders() (string, error) { sort.Slice(s.Headers, func(i, j int) bool { return s.Headers[i].Key < s.Headers[j].Key }) - out, err := m.resourcePresenter.Format(s, " ") + out, err := m.resourcePresenter.Format(s) if err != nil { return "", errors.Wrap(err, "failed to format header names by presenter") } diff --git a/usecase/format_messages.go b/usecase/format_messages.go index 37b6f243..1240ca89 100644 --- a/usecase/format_messages.go +++ b/usecase/format_messages.go @@ -11,7 +11,7 @@ func FormatMessages() (string, error) { return dm.FormatMessages() } func (m *dependencyManager) FormatMessages() (string, error) { - svcs := m.ListServices() + svcs := m.ListServicesOld() type message struct { Message string `json:"message"` } @@ -38,7 +38,7 @@ func (m *dependencyManager) FormatMessages() (string, error) { sort.Slice(v.Messages, func(i, j int) bool { return v.Messages[i].Message < v.Messages[j].Message }) - out, err := m.resourcePresenter.Format(v, " ") + out, err := m.resourcePresenter.Format(v) if err != nil { return "", errors.Wrap(err, "failed to format message names by presenter") } diff --git a/usecase/format_method.go b/usecase/format_method.go new file mode 100644 index 00000000..13632133 --- /dev/null +++ b/usecase/format_method.go @@ -0,0 +1,31 @@ +package usecase + +import ( + "github.com/pkg/errors" +) + +// FormatMethod formats a method. +func FormatMethod(fqmn string) (string, error) { + return dm.FormatMethod(fqmn) +} +func (m *dependencyManager) FormatMethod(fqmn string) (string, error) { + fqsn, _, err := ParseFullyQualifiedMethodName(fqmn) + if err != nil { + return "", err + } + v, err := m.methodsToFormatStructs(fqsn) + if err != nil { + return "", err + } + for _, method := range v.Methods { + if fqmn != method.FullyQualifiedName { + continue + } + out, err := m.resourcePresenter.Format(method) + if err != nil { + return "", errors.Wrap(err, "failed to format method names by presenter") + } + return out, nil + } + return "", errors.New("method is not found") +} diff --git a/usecase/format_methods.go b/usecase/format_methods.go new file mode 100644 index 00000000..241f6894 --- /dev/null +++ b/usecase/format_methods.go @@ -0,0 +1,56 @@ +package usecase + +import ( + "sort" + + "github.com/ktr0731/evans/idl/proto" + "github.com/pkg/errors" +) + +// FormatMethods formats all method names. +func FormatMethods() (string, error) { + return dm.FormatMethods() +} +func (m *dependencyManager) FormatMethods() (string, error) { + fqsn := proto.FullyQualifiedServiceName(m.state.selectedPackage, m.state.selectedService) + v, err := m.methodsToFormatStructs(fqsn) + if err != nil { + return "", err + } + out, err := m.resourcePresenter.Format(v) + if err != nil { + return "", errors.Wrap(err, "failed to format Method names by presenter") + } + return out, nil +} + +func (m *dependencyManager) methodsToFormatStructs(fqsn string) (v struct { + Methods []struct { + Name string `json:"name" table:"name"` + FullyQualifiedName string `json:"fully_qualified_name" name:"target" table:"fully-qualified name"` + RequestType string `json:"request_type" table:"request type"` + ResponseType string `json:"response_type" table:"response type"` + } `json:"methods" name:"target"` +}, _ error) { + methods, err := m.listRPCs(fqsn) + if err != nil { + return v, errors.Wrapf(err, "failed to list methods associated with '%s'", fqsn) + } + for _, m := range methods { + v.Methods = append(v.Methods, struct { + Name string `json:"name" table:"name"` + FullyQualifiedName string `json:"fully_qualified_name" name:"target" table:"fully-qualified name"` + RequestType string `json:"request_type" table:"request type"` + ResponseType string `json:"response_type" table:"response type"` + }{ + Name: m.Name, + FullyQualifiedName: m.FullyQualifiedName, + RequestType: m.RequestType.FullyQualifiedName, + ResponseType: m.ResponseType.FullyQualifiedName, + }) + } + sort.Slice(v.Methods, func(i, j int) bool { + return v.Methods[i].FullyQualifiedName < v.Methods[j].FullyQualifiedName + }) + return v, nil +} diff --git a/usecase/format_packages.go b/usecase/format_packages.go index 535b4173..5556d312 100644 --- a/usecase/format_packages.go +++ b/usecase/format_packages.go @@ -24,7 +24,7 @@ func (m *dependencyManager) FormatPackages() (string, error) { sort.Slice(v.Packages, func(i, j int) bool { return v.Packages[i].Package < v.Packages[j].Package }) - out, err := m.resourcePresenter.Format(v, " ") + out, err := m.resourcePresenter.Format(v) if err != nil { return "", errors.Wrap(err, "failed to format package names by presenter") } diff --git a/usecase/format_rpcs.go b/usecase/format_rpcs.go deleted file mode 100644 index 89a8a77c..00000000 --- a/usecase/format_rpcs.go +++ /dev/null @@ -1,44 +0,0 @@ -package usecase - -import ( - "sort" - - "github.com/pkg/errors" -) - -// FormatRPCs formats all package names. -func FormatRPCs() (string, error) { - return dm.FormatRPCs() -} -func (m *dependencyManager) FormatRPCs() (string, error) { - svcs := m.ListServices() - type rpc struct { - RPC string `json:"rpc"` - RequestType string `json:"request type" table:"request type"` - ResponseType string `json:"response type" table:"response type"` - } - var v struct { - RPCs []rpc `json:"rpcs"` - } - for _, svcName := range svcs { - rpcs, err := m.ListRPCs(svcName) - if err != nil { - return "", errors.Wrapf(err, "failed to list RPCs associated with '%s'", svcName) - } - for _, r := range rpcs { - v.RPCs = append(v.RPCs, rpc{ - r.Name, - r.RequestType.Name, - r.ResponseType.Name, - }) - } - } - sort.Slice(v.RPCs, func(i, j int) bool { - return v.RPCs[i].RPC < v.RPCs[j].RPC - }) - out, err := m.resourcePresenter.Format(v, " ") - if err != nil { - return "", errors.Wrap(err, "failed to format RPC names by presenter") - } - return out, nil -} diff --git a/usecase/format_services.go b/usecase/format_services.go index e6695b16..7c40998f 100644 --- a/usecase/format_services.go +++ b/usecase/format_services.go @@ -6,39 +6,25 @@ import ( "github.com/pkg/errors" ) -// FormatServices formats all package names. +// FormatServices formats all service names the spec loaded. func FormatServices() (string, error) { return dm.FormatServices() } func (m *dependencyManager) FormatServices() (string, error) { - svcs := m.ListServices() - type service struct { - Service string `json:"service"` - RPC string `json:"rpc"` - RequestType string `json:"request type" table:"request type"` - ResponseType string `json:"response type" table:"response type"` + fqsns := m.ListServices() + type svc struct { + Name string `json:"name" name:"target"` } var v struct { - Services []service `json:"services"` + Services []svc `json:"services" name:"target"` } - for _, svcName := range svcs { - rpcs, err := m.ListRPCs(svcName) - if err != nil { - return "", errors.Wrapf(err, "failed to list RPCs associated with '%s'", svcName) - } - for _, rpc := range rpcs { - v.Services = append(v.Services, service{ - svcName, - rpc.Name, - rpc.RequestType.Name, - rpc.ResponseType.Name, - }) - } + for _, fqsn := range fqsns { + v.Services = append(v.Services, svc{fqsn}) } sort.Slice(v.Services, func(i, j int) bool { - return v.Services[i].Service < v.Services[j].Service + return v.Services[i].Name < v.Services[j].Name }) - out, err := m.resourcePresenter.Format(v, " ") + out, err := m.resourcePresenter.Format(v) if err != nil { return "", errors.Wrap(err, "failed to format service names by presenter") } diff --git a/usecase/format_services_old.go b/usecase/format_services_old.go new file mode 100644 index 00000000..ea56cd36 --- /dev/null +++ b/usecase/format_services_old.go @@ -0,0 +1,47 @@ +package usecase + +import ( + "sort" + + "github.com/pkg/errors" +) + +// FormatServicesOld formats all package names. +// Deprecated: dropped in the next major release. +func FormatServicesOld() (string, error) { + return dm.FormatServicesOld() +} +func (m *dependencyManager) FormatServicesOld() (string, error) { + svcs := m.ListServicesOld() + type service struct { + Service string `json:"service"` + RPC string `json:"rpc"` + RequestType string `json:"request type" table:"request type"` + ResponseType string `json:"response type" table:"response type"` + } + var v struct { + Services []service `json:"services"` + } + for _, svc := range svcs { + rpcs, err := m.ListRPCs(svc) + if err != nil { + return "", errors.Wrapf(err, "failed to list RPCs associated with '%s'", svc) + } + for _, rpc := range rpcs { + v.Services = append(v.Services, service{ + svc, + rpc.Name, + rpc.RequestType.Name, + rpc.ResponseType.Name, + }) + } + } + sort.Slice(v.Services, func(i, j int) bool { + return v.Services[i].Service < v.Services[j].Service + }) + out, err := m.resourcePresenter.Format(v) + if err != nil { + return "", errors.Wrap(err, "failed to format service names by presenter") + } + return out, nil +} diff --git a/usecase/header_test.go b/usecase/header_test.go index 82652874..64d5aaa5 100644 --- a/usecase/header_test.go +++ b/usecase/header_test.go @@ -13,7 +13,7 @@ func TestHeader(t *testing.T) { if err != nil { t.Fatalf("grpc.NewClient must not return an error, but got '%s'", err) } - Inject(nil, nil, client, nil, nil) + Inject(Dependencies{nil, nil, client, nil, nil}) AddHeader("kumiko", "oumae") diff --git a/usecase/list_packages.go b/usecase/list_packages.go index e7421b1d..46976270 100644 --- a/usecase/list_packages.go +++ b/usecase/list_packages.go @@ -2,7 +2,8 @@ package usecase import ( "sort" - "strings" + + "github.com/ktr0731/evans/idl/proto" ) // ListPackages lists all package names. @@ -13,11 +14,8 @@ func (m *dependencyManager) ListPackages() []string { svcNames := m.spec.ServiceNames() encountered := make(map[string]interface{}) toPackageName := func(svcName string) string { - i := strings.LastIndex(svcName, ".") - if i == -1 { - return "" - } - return svcName[:i] + pkg, _ := proto.ParseFullyQualifiedServiceName(svcName) + return pkg } for _, svc := range svcNames { encountered[toPackageName(svc)] = nil diff --git a/usecase/list_rpcs.go b/usecase/list_rpcs.go index 3f8b631d..e3d0330e 100644 --- a/usecase/list_rpcs.go +++ b/usecase/list_rpcs.go @@ -16,11 +16,11 @@ func (m *dependencyManager) ListRPCs(svcName string) ([]*grpc.RPC, error) { if svcName == "" { svcName = m.state.selectedService } - return m.listRPCs(m.state.selectedPackage, svcName) + fqsn := proto.FullyQualifiedServiceName(m.state.selectedPackage, svcName) + return m.listRPCs(fqsn) } -func (m *dependencyManager) listRPCs(pkgName, svcName string) ([]*grpc.RPC, error) { - fqsn := proto.FullyQualifiedServiceName(pkgName, svcName) +func (m *dependencyManager) listRPCs(fqsn string) ([]*grpc.RPC, error) { rpcs, err := m.spec.RPCs(fqsn) if err != nil { return nil, errors.Wrap(err, "failed to list RPCs") diff --git a/usecase/list_services.go b/usecase/list_services.go index b320e6e3..34c8d3e3 100644 --- a/usecase/list_services.go +++ b/usecase/list_services.go @@ -1,27 +1,13 @@ package usecase -import "strings" - +// ListServices returns the loaded fully-qualified service names. func ListServices() []string { return dm.ListServices() } func (m *dependencyManager) ListServices() []string { - return m.listServices(m.state.selectedPackage) + return m.listServices() } -func (m *dependencyManager) listServices(pkgName string) []string { - svcs := make([]string, 0, len(m.spec.ServiceNames())) - for _, fqsn := range m.spec.ServiceNames() { - i := strings.LastIndex(fqsn, ".") - var pkg, svc string - if i == -1 { - svc = fqsn - } else { - pkg, svc = fqsn[:i], fqsn[i+1:] - } - if pkg == pkgName { - svcs = append(svcs, svc) - } - } - return svcs +func (m *dependencyManager) listServices() []string { + return m.spec.ServiceNames() } diff --git a/usecase/list_services_old.go b/usecase/list_services_old.go new file mode 100644 index 00000000..1d736435 --- /dev/null +++ b/usecase/list_services_old.go @@ -0,0 +1,26 @@ +package usecase + +import ( + "github.com/ktr0731/evans/idl/proto" +) + +// ListServicesOld returns the services belong to the selected package. +// The returned service names are NOT fully-qualified. +func ListServicesOld() []string { + return dm.ListServicesOld() +} +func (m *dependencyManager) ListServicesOld() []string { + return m.listServicesOld(m.state.selectedPackage) +} + +func (m *dependencyManager) listServicesOld(pkgName string) []string { + var svcs []string + svcNames := m.spec.ServiceNames() + for i := range svcNames { + pkg, svc := proto.ParseFullyQualifiedServiceName(svcNames[i]) + if pkg == pkgName { + svcs = append(svcs, svc) + } + } + return svcs +} diff --git a/usecase/parse_method.go b/usecase/parse_method.go new file mode 100644 index 00000000..de0553d6 --- /dev/null +++ b/usecase/parse_method.go @@ -0,0 +1,25 @@ +package usecase + +import ( + "errors" + "strings" +) + +// ParseFullyQualifiedMethodName parses the passed fully-qualified method as fully-qualified service name and method name. +// ParseFullyQualifiedMethodName may return these errors: +// +// - An error described in idl.Spec.RPC method returns. +// - An error if fqmn is not a valid fully-qualified method name form. +// +func ParseFullyQualifiedMethodName(fqmn string) (fqsn, method string, err error) { + return dm.ParseFullyQualifiedMethodName(fqmn) +} +func (m *dependencyManager) ParseFullyQualifiedMethodName(fqmn string) (string, string, error) { + i := strings.LastIndex(fqmn, ".") + if i == -1 { + return "", "", errors.New("invalid fully-qualified method name") + } + svc, mtd := fqmn[:i], fqmn[i+1:] + _, err := m.spec.RPC(svc, mtd) + return svc, mtd, err +} diff --git a/usecase/use_package.go b/usecase/use_package.go index 0deaa5cf..cb6c8211 100644 --- a/usecase/use_package.go +++ b/usecase/use_package.go @@ -6,7 +6,6 @@ import "github.com/ktr0731/evans/idl" // UsePackage may return these errors: // // - idl.ErrUnknownPackageName: pkgName is not in loaded packages. -// - Other errors. // func UsePackage(pkgName string) error { return dm.UsePackage(pkgName) diff --git a/usecase/use_service.go b/usecase/use_service.go index be3466dd..920b25cd 100644 --- a/usecase/use_service.go +++ b/usecase/use_service.go @@ -1,9 +1,8 @@ package usecase import ( - "strings" - "github.com/ktr0731/evans/idl" + "github.com/ktr0731/evans/idl/proto" "github.com/pkg/errors" ) @@ -12,7 +11,6 @@ import ( // // - ErrPackageUnselected: REPL never call UsePackage. // - ErrUnknownServiceName: svcName is not in loaded services. -// - Other errors. // func UseService(svcName string) error { return dm.UseService(svcName) @@ -23,13 +21,7 @@ func (m *dependencyManager) UseService(svcName string) error { } var hasPackage bool for _, fqsn := range m.spec.ServiceNames() { - i := strings.LastIndex(fqsn, ".") - var pkg, svc string - if i == -1 { - svc = fqsn - } else { - pkg, svc = fqsn[:i], fqsn[i+1:] - } + pkg, svc := proto.ParseFullyQualifiedServiceName(fqsn) if m.state.selectedPackage == pkg { hasPackage = true if svcName == svc { diff --git a/usecase/usecase.go b/usecase/usecase.go index 2e4cf2e4..79ddc0ea 100644 --- a/usecase/usecase.go +++ b/usecase/usecase.go @@ -30,36 +30,55 @@ type state struct { selectedService string } +type Dependencies struct { + Spec idl.Spec + Filler fill.Filler + GRPCClient grpc.Client + ResponsePresenter present.Presenter + ResourcePresenter present.Presenter +} + // Inject corresponds an implementation to an interface type. Inject clears the previous states if it exists. -func Inject( - spec idl.Spec, - filler fill.Filler, - gRPCClient grpc.Client, - responsePresenter present.Presenter, - resourcePresenter present.Presenter, -) { - dm.Inject(spec, filler, gRPCClient, responsePresenter, resourcePresenter) +func Inject(deps Dependencies) { + dm.Inject(deps) } -func (m *dependencyManager) Inject( - spec idl.Spec, - filler fill.Filler, - gRPCClient grpc.Client, - responsePresenter present.Presenter, - resourcePresenter present.Presenter, -) { +func (m *dependencyManager) Inject(d Dependencies) { dm = &dependencyManager{ - spec: spec, - filler: filler, - gRPCClient: gRPCClient, - responsePresenter: responsePresenter, - resourcePresenter: resourcePresenter, + spec: d.Spec, + filler: d.Filler, + gRPCClient: d.GRPCClient, + responsePresenter: d.ResponsePresenter, + resourcePresenter: d.ResourcePresenter, state: defaultState, } } +// InjectPartially is almost same as the Inject, but injects only non-nil dependencies. +func InjectPartially(deps Dependencies) { + dm.InjectPartially(deps) +} + +func (m *dependencyManager) InjectPartially(d Dependencies) { + if d.Spec != nil { + m.spec = d.Spec + } + if d.Filler != nil { + m.filler = d.Filler + } + if d.GRPCClient != nil { + m.gRPCClient = d.GRPCClient + } + if d.ResponsePresenter != nil { + m.responsePresenter = d.ResponsePresenter + } + if d.ResourcePresenter != nil { + m.resourcePresenter = d.ResourcePresenter + } +} + // Clear clears all dependencies and states. Usually, it is used for unit testing. func Clear() { - dm.Inject(nil, nil, nil, nil, nil) + dm.Inject(Dependencies{}) }