From d0b3bf7db2d248f2953c7faabf5e9a77b2a8df92 Mon Sep 17 00:00:00 2001
From: Kai Welke <kai.welke@algolia.com>
Date: Wed, 12 Feb 2025 11:07:42 +0100
Subject: [PATCH] feat(logs): add new command

New command: algolia logs list for the /1/logs endpoint
---
 e2e/testscripts/logs/logs.txtar |   8 +++
 pkg/cmd/logs/list/list.go       | 117 ++++++++++++++++++++++++++++++++
 pkg/cmd/logs/list/list_test.go  |  66 ++++++++++++++++++
 pkg/cmd/logs/logs.go            |  19 ++++++
 pkg/cmd/root/root.go            |  14 ++--
 pkg/cmdutil/flags.go            | 102 ++++++++++++++++++++++++++++
 6 files changed, 320 insertions(+), 6 deletions(-)
 create mode 100644 e2e/testscripts/logs/logs.txtar
 create mode 100644 pkg/cmd/logs/list/list.go
 create mode 100644 pkg/cmd/logs/list/list_test.go
 create mode 100644 pkg/cmd/logs/logs.go
 create mode 100644 pkg/cmdutil/flags.go

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}
+}