From 8fe79cc3888eedb2cbfd34396cb04d93722369e3 Mon Sep 17 00:00:00 2001 From: nobe4 Date: Wed, 22 Nov 2023 23:17:35 +0100 Subject: [PATCH 1/5] feat(command): add api command This adds a more generic way to interact with the slack API, it is a one-stop method that _could_ support any request to the slack API. This means the existing client.get/client.post could be rewritten to use this method. Adding an API handler goes very much in the `gh api` way as well. Sometimes the helpers aren't enough and proxying to the API is the only real way to make something. This will need further consideration and documentation writing to explain the different cases and gotchas of using the API directly. --- cmd/gh-slack/cmd/api.go | 95 ++++++++++++++++++++++++++++++++++ cmd/gh-slack/cmd/root.go | 1 + internal/slackclient/client.go | 62 ++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 cmd/gh-slack/cmd/api.go diff --git a/cmd/gh-slack/cmd/api.go b/cmd/gh-slack/cmd/api.go new file mode 100644 index 0000000..a9814ea --- /dev/null +++ b/cmd/gh-slack/cmd/api.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "fmt" + "io" + "log" + "strings" + + "github.com/cli/go-gh/pkg/config" + "github.com/rneatherway/gh-slack/internal/slackclient" + "github.com/spf13/cobra" +) + +var apiCmd = &cobra.Command{ + Use: "api verb path", + Short: "Send an API call to slack", + Long: "Send an API call to slack", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Read() + if err != nil { + return err + } + + team, err := getFlagOrElseConfig(cfg, cmd.Flags(), "team") + if err != nil { + return err + } + + if len(args) != 2 { + return fmt.Errorf("Expected 2 arguments: verb and path, see help") + } + + verb := strings.ToUpper(args[0]) + path := args[1] + + fields, err := cmd.Flags().GetStringSlice("field") + if err != nil { + return err + } + + mappedFields, err := mapFields(fields) + if err != nil { + return err + } + + logger := log.New(io.Discard, "", log.LstdFlags) + if verbose { + logger = log.Default() + } + + client, err := slackclient.New(team, logger) + if err != nil { + return err + } + + response, err := client.API(verb, path, mappedFields, body) + if err != nil { + return err + } + + fmt.Println(string(response)) + return nil + }, + Example: ` gh-slack api get conversations.list -f types=public_channel,private_channel + gh-slack api post chat.postMessage -b '{"channel":"123","blocks":[...]}`, +} + +var fields []string +var body string + +func init() { + apiCmd.Flags().StringSliceVarP(&fields, "field", "f", nil, "Fields to pass to the api call") + apiCmd.Flags().StringVarP(&body, "body", "b", "{}", "Body to send as JSON") + apiCmd.Flags().StringP("team", "t", "", "Slack team name (required here or in config)") + apiCmd.SetHelpTemplate(apiCmdUsage) + apiCmd.SetUsageTemplate(apiCmdUsage) +} + +func mapFields(fields []string) (map[string]string, error) { + mappedFields := map[string]string{} + + for _, field := range fields { + parts := strings.SplitN(field, "=", 2) + + if len(parts) != 2 || parts[1] == "" { + return nil, fmt.Errorf("field '%s' is missing a value", field) + } + + mappedFields[parts[0]] = parts[1] + } + + return mappedFields, nil +} + +const apiCmdUsage string = `TODO` diff --git a/cmd/gh-slack/cmd/root.go b/cmd/gh-slack/cmd/root.go index 81cbc85..ffecfb2 100644 --- a/cmd/gh-slack/cmd/root.go +++ b/cmd/gh-slack/cmd/root.go @@ -41,6 +41,7 @@ var verbose bool = false func init() { rootCmd.AddCommand(readCmd) rootCmd.AddCommand(sendCmd) + rootCmd.AddCommand(apiCmd) rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Show verbose debug information") rootCmd.SetHelpTemplate(rootCmdUsageTemplate) rootCmd.SetUsageTemplate(rootCmdUsageTemplate) diff --git a/internal/slackclient/client.go b/internal/slackclient/client.go index 6e679db..33f6759 100644 --- a/internal/slackclient/client.go +++ b/internal/slackclient/client.go @@ -184,6 +184,68 @@ func (c *SlackClient) UsernameForMessage(message Message) (string, error) { return "ghost", nil } +func (c *SlackClient) API(verb, path string, params map[string]string, body string) ([]byte, error) { + u, err := url.Parse(fmt.Sprintf("https://%s.slack.com/api/", c.team)) + if err != nil { + return nil, err + } + u.Path += path + q := u.Query() + for p := range params { + q.Add(p, params[p]) + } + u.RawQuery = q.Encode() + + messageBytes := []byte(body) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal message: %w", err) + } + + reqBody := bytes.NewReader(messageBytes) + + resBody := []byte{} + + for { + req, err := http.NewRequest(verb, u.String(), reqBody) + if err != nil { + return nil, err + } + // FIXME: this doesn't seem to break non-POST/non-data requests, but migth + // be polluting the headers. + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.auth.Token)) + for key := range c.auth.Cookies { + req.AddCookie(&http.Cookie{Name: key, Value: c.auth.Cookies[key]}) + } + + resp, err := httpclient.Client.Do(req) + if err != nil { + return nil, err + } + + resBody, err = io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode == 429 { + s, err := strconv.Atoi(resp.Header["Retry-After"][0]) + if err != nil { + return nil, err + } + d := time.Duration(s) + c.log.Printf("rate limited, waiting %ds", d) + time.Sleep(d * time.Second) + } else if resp.StatusCode >= 300 { + return nil, fmt.Errorf("status code %d, headers: %q, body: %q", resp.StatusCode, resp.Header, body) + } else { + break + } + } + + return resBody, nil +} + func (c *SlackClient) get(path string, params map[string]string) ([]byte, error) { u, err := url.Parse(fmt.Sprintf("https://%s.slack.com/api/", c.team)) if err != nil { From cdd805d20ffc6037381929a8af88df3e60b90151 Mon Sep 17 00:00:00 2001 From: nobe4 Date: Thu, 23 Nov 2023 18:32:23 +0100 Subject: [PATCH 2/5] fix(api): use proper flag method to keep values together --- cmd/gh-slack/cmd/api.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/gh-slack/cmd/api.go b/cmd/gh-slack/cmd/api.go index a9814ea..b5ea957 100644 --- a/cmd/gh-slack/cmd/api.go +++ b/cmd/gh-slack/cmd/api.go @@ -33,7 +33,7 @@ var apiCmd = &cobra.Command{ verb := strings.ToUpper(args[0]) path := args[1] - fields, err := cmd.Flags().GetStringSlice("field") + fields, err := cmd.Flags().GetStringArray("field") if err != nil { return err } @@ -69,7 +69,7 @@ var fields []string var body string func init() { - apiCmd.Flags().StringSliceVarP(&fields, "field", "f", nil, "Fields to pass to the api call") + apiCmd.Flags().StringArrayVarP(&fields, "field", "f", nil, "Fields to pass to the api call") apiCmd.Flags().StringVarP(&body, "body", "b", "{}", "Body to send as JSON") apiCmd.Flags().StringP("team", "t", "", "Slack team name (required here or in config)") apiCmd.SetHelpTemplate(apiCmdUsage) From 81e82dccd092b6f6dfe72a5407a6fb8217f723f2 Mon Sep 17 00:00:00 2001 From: nobe4 Date: Mon, 4 Dec 2023 18:58:35 +0100 Subject: [PATCH 3/5] refactor(api): make post/get use API This removes a lot of duplicated code. --- internal/slackclient/client.go | 104 ++------------------------------- 1 file changed, 4 insertions(+), 100 deletions(-) diff --git a/internal/slackclient/client.go b/internal/slackclient/client.go index 33f6759..a0f40cf 100644 --- a/internal/slackclient/client.go +++ b/internal/slackclient/client.go @@ -210,8 +210,8 @@ func (c *SlackClient) API(verb, path string, params map[string]string, body stri if err != nil { return nil, err } - // FIXME: this doesn't seem to break non-POST/non-data requests, but migth - // be polluting the headers. + // FIXME: this doesn't seem to break non-POST/non-data requests, but migth + // be polluting the headers. req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.auth.Token)) for key := range c.auth.Cookies { @@ -247,112 +247,16 @@ func (c *SlackClient) API(verb, path string, params map[string]string, body stri } func (c *SlackClient) get(path string, params map[string]string) ([]byte, error) { - u, err := url.Parse(fmt.Sprintf("https://%s.slack.com/api/", c.team)) - if err != nil { - return nil, err - } - u.Path += path - q := u.Query() - q.Add("token", c.auth.Token) - for p := range params { - q.Add(p, params[p]) - } - u.RawQuery = q.Encode() - - var body []byte - for { - req, err := http.NewRequest("GET", u.String(), nil) - if err != nil { - return nil, err - } - for key := range c.auth.Cookies { - req.AddCookie(&http.Cookie{Name: key, Value: c.auth.Cookies[key]}) - } - - resp, err := httpclient.Client.Do(req) - if err != nil { - return nil, err - } - - body, err = io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if resp.StatusCode == 429 { - s, err := strconv.Atoi(resp.Header["Retry-After"][0]) - if err != nil { - return nil, err - } - d := time.Duration(s) - c.log.Printf("rate limited, waiting %ds", d) - time.Sleep(d * time.Second) - } else if resp.StatusCode >= 300 { - return nil, fmt.Errorf("status code %d, headers: %q, body: %q", resp.StatusCode, resp.Header, body) - } else { - break - } - } - - return body, nil + return c.API("GET", path, params, "{}") } func (c *SlackClient) post(path string, params map[string]string, msg *SendMessage) ([]byte, error) { - u, err := url.Parse(fmt.Sprintf("https://%s.slack.com/api/", c.team)) - if err != nil { - return nil, err - } - u.Path += path - q := u.Query() - for p := range params { - q.Add(p, params[p]) - } - u.RawQuery = q.Encode() - - var body []byte messageBytes, err := json.Marshal(msg) if err != nil { return nil, fmt.Errorf("failed to unmarshal message: %w", err) } - reqBody := bytes.NewReader(messageBytes) - - for { - req, err := http.NewRequest(http.MethodPost, u.String(), reqBody) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json; charset=utf-8") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.auth.Token)) - for key := range c.auth.Cookies { - req.AddCookie(&http.Cookie{Name: key, Value: c.auth.Cookies[key]}) - } - - resp, err := httpclient.Client.Do(req) - if err != nil { - return nil, err - } - - body, err = io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if resp.StatusCode == 429 { - s, err := strconv.Atoi(resp.Header["Retry-After"][0]) - if err != nil { - return nil, err - } - d := time.Duration(s) - c.log.Printf("rate limited, waiting %ds", d) - time.Sleep(d * time.Second) - } else if resp.StatusCode >= 300 { - return nil, fmt.Errorf("status code %d, headers: %q, body: %q", resp.StatusCode, resp.Header, body) - } else { - break - } - } - return body, nil + return c.API("POST", path, params, string(messageBytes)) } func (c *SlackClient) ChannelInfo(id string) (*Channel, error) { From e701ee71020d378df35eff854ee508e1c144f2f9 Mon Sep 17 00:00:00 2001 From: nobe4 Date: Wed, 6 Dec 2023 18:18:10 +0100 Subject: [PATCH 4/5] docs(api): add example and fix help --- cmd/gh-slack/cmd/api.go | 30 +++++++++++++++++++++++++++++- cmd/gh-slack/cmd/root.go | 1 + 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/cmd/gh-slack/cmd/api.go b/cmd/gh-slack/cmd/api.go index b5ea957..caa7830 100644 --- a/cmd/gh-slack/cmd/api.go +++ b/cmd/gh-slack/cmd/api.go @@ -92,4 +92,32 @@ func mapFields(fields []string) (map[string]string, error) { return mappedFields, nil } -const apiCmdUsage string = `TODO` +const apiCmdUsage string = `Usage:{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}}{{end}}{{if gt (len .Aliases) 0}} +Aliases: + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +Examples: +{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} + +Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} + +{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} + +Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +` diff --git a/cmd/gh-slack/cmd/root.go b/cmd/gh-slack/cmd/root.go index ffecfb2..b417f36 100644 --- a/cmd/gh-slack/cmd/root.go +++ b/cmd/gh-slack/cmd/root.go @@ -24,6 +24,7 @@ var rootCmd = &cobra.Command{ gh-slack read gh-slack read -i gh-slack send -m -c -t + gh-slack api post chat.postMessage -b '{"channel":"123","blocks":[...]} ` + sendConfigExample, } From 84cff6eabf4794cbfdafd312f3cd324bf8ecd473 Mon Sep 17 00:00:00 2001 From: nobe4 Date: Wed, 6 Dec 2023 19:51:37 +0100 Subject: [PATCH 5/5] feat(api): make verb optional Add verb switching and documentation showing how to use the API without a verb. --- cmd/gh-slack/cmd/api.go | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/cmd/gh-slack/cmd/api.go b/cmd/gh-slack/cmd/api.go index caa7830..37453b3 100644 --- a/cmd/gh-slack/cmd/api.go +++ b/cmd/gh-slack/cmd/api.go @@ -12,7 +12,7 @@ import ( ) var apiCmd = &cobra.Command{ - Use: "api verb path", + Use: "api [verb] path", Short: "Send an API call to slack", Long: "Send an API call to slack", RunE: func(cmd *cobra.Command, args []string) error { @@ -26,13 +26,6 @@ var apiCmd = &cobra.Command{ return err } - if len(args) != 2 { - return fmt.Errorf("Expected 2 arguments: verb and path, see help") - } - - verb := strings.ToUpper(args[0]) - path := args[1] - fields, err := cmd.Flags().GetStringArray("field") if err != nil { return err @@ -53,6 +46,21 @@ var apiCmd = &cobra.Command{ return err } + var verb, path string + if len(args) == 2 { + verb = strings.ToUpper(args[0]) + path = args[1] + } else if len(args) == 1 { + path = args[0] + if body == "" { + verb = "GET" + } else { + verb = "POST" + } + } else { + return fmt.Errorf("Expected 1 or 2 arguments: verb and/or path, see help") + } + response, err := client.API(verb, path, mappedFields, body) if err != nil { return err @@ -70,7 +78,7 @@ var body string func init() { apiCmd.Flags().StringArrayVarP(&fields, "field", "f", nil, "Fields to pass to the api call") - apiCmd.Flags().StringVarP(&body, "body", "b", "{}", "Body to send as JSON") + apiCmd.Flags().StringVarP(&body, "body", "b", "", "Body to send as JSON") apiCmd.Flags().StringP("team", "t", "", "Slack team name (required here or in config)") apiCmd.SetHelpTemplate(apiCmdUsage) apiCmd.SetUsageTemplate(apiCmdUsage) @@ -99,7 +107,12 @@ Aliases: {{.NameAndAliases}}{{end}}{{if .HasExample}} Examples: -{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} +{{.Example}} + +The verb is optional: +- If no body is sent, GET will be used. +- If a body is sent, POST will be used. +{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}