diff --git a/e2e/testscripts/logs/logs.txtar b/e2e/testscripts/logs/logs.txtar new file mode 100644 index 00000000..9bee4579 --- /dev/null +++ b/e2e/testscripts/logs/logs.txtar @@ -0,0 +1,8 @@ +# Get log entries +exec algolia logs list +! stderr . +stdout -count=5 url + +# Wrong log type should return error +! exec algolia logs list --type foo +stderr 'invalid argument' diff --git a/pkg/cmd/logs/list/list.go b/pkg/cmd/logs/list/list.go new file mode 100644 index 00000000..30fb7aeb --- /dev/null +++ b/pkg/cmd/logs/list/list.go @@ -0,0 +1,117 @@ +package list + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/algoliasearch-client-go/v4/algolia/search" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" +) + +type LogOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + SearchClient func() (*search.APIClient, error) + + PrintFlags *cmdutil.PrintFlags + + Entries int32 + Start int32 + LogType string + IndexName *string +} + +// NewListCmd returns a new command for retrieving logs +func NewListCmd(f *cmdutil.Factory, runF func(*LogOptions) error) *cobra.Command { + opts := &LogOptions{ + IO: f.IOStreams, + Config: f.Config, + SearchClient: f.SearchClient, + PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), + } + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"l"}, + Short: "List log entries", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return runLogsCmd(opts) + }, + Annotations: map[string]string{ + "acls": "logs", + }, + Example: heredoc.Doc(` + # Show the latest 5 Search API log entries + $ algolia logs + + # Show the log entries 11 to 20 + $ algolia logs --entries 10 --start 11 + + # Only show log entries with errors + $ algolia logs --type error + `), + } + + opts.PrintFlags.AddFlags(cmd) + + cmd.Flags().Int32VarP(&opts.Entries, "entries", "e", 5, "How many log entries to show") + cmd.Flags(). + Int32VarP(&opts.Start, "start", "s", 1, "Number of the first log entry to retrieve (starts with 1)") + cmdutil.StringEnumFlag( + cmd, + &opts.LogType, + "type", + "t", + "all", + []string{"all", "build", "query", "error"}, + "Type of log entries", + ) + + cmdutil.NilStringFlag(cmd, &opts.IndexName, "index", "i", "Filter logs by index name") + + return cmd +} + +func runLogsCmd(opts *LogOptions) error { + client, err := opts.SearchClient() + if err != nil { + return err + } + + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + realLogType, err := search.NewLogTypeFromValue(opts.LogType) + if err != nil { + return fmt.Errorf("invalid log type %s: %v", opts.LogType, err) + } + + request := client.NewApiGetLogsRequest(). + // Offset is 0 based + WithOffset(opts.Start - 1). + WithLength(opts.Entries). + WithType(*realLogType) + + if opts.IndexName != nil { + request = request.WithIndexName(*opts.IndexName) + } + + opts.IO.StartProgressIndicatorWithLabel("Retrieving logs") + res, err := client.GetLogs(request) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/logs/list/list_test.go b/pkg/cmd/logs/list/list_test.go new file mode 100644 index 00000000..5d0b7c84 --- /dev/null +++ b/pkg/cmd/logs/list/list_test.go @@ -0,0 +1,66 @@ +package list + +import ( + "testing" + + "github.com/algolia/cli/pkg/cmdutil" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewLogsCmd(t *testing.T) { + testIndexName := "foo" + tests := []struct { + name string + cli string + wantsErr bool + wantsOpts LogOptions + }{ + { + name: "with default options", + cli: "", + wantsErr: false, + wantsOpts: LogOptions{ + Entries: 5, + Start: 1, + LogType: "all", + IndexName: nil, + }, + }, + { + name: "with 69 entries, starting at 420, type query, filtered by index foo", + cli: "--entries 69 --start 420 --type query --index foo", + wantsErr: false, + wantsOpts: LogOptions{ + Entries: 69, + Start: 420, + LogType: "query", + IndexName: &testIndexName, + }, + }, + } + + for _, tt := range tests { + f := &cmdutil.Factory{} + var opts *LogOptions + cmd := NewListCmd(f, func(o *LogOptions) error { + opts = o + return nil + }) + args, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(args) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.wantsOpts.Entries, opts.Entries) + assert.Equal(t, tt.wantsOpts.Start, opts.Start) + assert.Equal(t, tt.wantsOpts.LogType, opts.LogType) + assert.Equal(t, tt.wantsOpts.IndexName, opts.IndexName) + } +} diff --git a/pkg/cmd/logs/logs.go b/pkg/cmd/logs/logs.go new file mode 100644 index 00000000..cefab3fd --- /dev/null +++ b/pkg/cmd/logs/logs.go @@ -0,0 +1,19 @@ +package logs + +import ( + "github.com/spf13/cobra" + + "github.com/algolia/cli/pkg/cmd/logs/list" + "github.com/algolia/cli/pkg/cmdutil" +) + +// NewLogsCmd returns a new command for retrieving logs +func NewLogsCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "logs", + Short: "Retrieve your Algolia Search API logs", + } + + cmd.AddCommand(list.NewListCmd(f, nil)) + return cmd +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index c0b66ee2..f6938339 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -27,6 +27,7 @@ import ( "github.com/algolia/cli/pkg/cmd/events" "github.com/algolia/cli/pkg/cmd/factory" "github.com/algolia/cli/pkg/cmd/indices" + "github.com/algolia/cli/pkg/cmd/logs" "github.com/algolia/cli/pkg/cmd/objects" "github.com/algolia/cli/pkg/cmd/open" "github.com/algolia/cli/pkg/cmd/profile" @@ -99,16 +100,17 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(open.NewOpenCmd(f)) // API related commands - cmd.AddCommand(search.NewSearchCmd(f)) + cmd.AddCommand(apikeys.NewAPIKeysCmd(f)) + cmd.AddCommand(crawler.NewCrawlersCmd(f)) + cmd.AddCommand(dictionary.NewDictionaryCmd(f)) + cmd.AddCommand(events.NewEventsCmd(f)) cmd.AddCommand(indices.NewIndicesCmd(f)) + cmd.AddCommand(logs.NewLogsCmd(f)) cmd.AddCommand(objects.NewObjectsCmd(f)) - cmd.AddCommand(apikeys.NewAPIKeysCmd(f)) - cmd.AddCommand(settings.NewSettingsCmd(f)) cmd.AddCommand(rules.NewRulesCmd(f)) + cmd.AddCommand(search.NewSearchCmd(f)) + cmd.AddCommand(settings.NewSettingsCmd(f)) cmd.AddCommand(synonyms.NewSynonymsCmd(f)) - cmd.AddCommand(dictionary.NewDictionaryCmd(f)) - cmd.AddCommand(events.NewEventsCmd(f)) - cmd.AddCommand(crawler.NewCrawlersCmd(f)) return cmd } diff --git a/pkg/cmdutil/flags.go b/pkg/cmdutil/flags.go new file mode 100644 index 00000000..77a5ea21 --- /dev/null +++ b/pkg/cmdutil/flags.go @@ -0,0 +1,102 @@ +package cmdutil + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Pretty much the whole code in this file is copied from the GitHub CLI + +// NilStringFlag defines a new flag with a string pointer receiver. +// This helps distinguishing `--flag ""` from not setting the flag at all. +func NilStringFlag( + cmd *cobra.Command, + p **string, + name string, + shorthand string, + usage string, +) *pflag.Flag { + return cmd.Flags().VarPF(newStringValue(p), name, shorthand, usage) +} + +// StringEnumFlag defines a new string flag restricted to allowed options +func StringEnumFlag( + cmd *cobra.Command, + p *string, + name, shorthand, defaultValue string, + options []string, + usage string, +) *pflag.Flag { + *p = defaultValue + val := &enumValue{string: p, options: options} + f := cmd.Flags(). + VarPF(val, name, shorthand, fmt.Sprintf("%s: %s", usage, formatValuesForUsageDocs(options))) + _ = cmd.RegisterFlagCompletionFunc( + name, + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return options, cobra.ShellCompDirectiveNoFileComp + }, + ) + return f +} + +type enumValue struct { + string *string + options []string +} + +func (e *enumValue) Set(value string) error { + if !isIncluded(value, e.options) { + return fmt.Errorf("valid values are %s", formatValuesForUsageDocs(e.options)) + } + *e.string = value + return nil +} + +func (e *enumValue) String() string { + return *e.string +} + +func (e *enumValue) Type() string { + return "string" +} + +func isIncluded(value string, opts []string) bool { + for _, opt := range opts { + if strings.EqualFold(opt, value) { + return true + } + } + return false +} + +func formatValuesForUsageDocs(values []string) string { + return fmt.Sprintf("{%s}", strings.Join(values, "|")) +} + +type stringValue struct { + string **string +} + +func (s *stringValue) Set(value string) error { + *s.string = &value + return nil +} + +func (s *stringValue) String() string { + if s.string == nil || *s.string == nil { + return "" + } + return **s.string +} + +func (s *stringValue) Type() string { + return "string" +} + +func newStringValue(p **string) *stringValue { + return &stringValue{p} +}