diff --git a/go.mod b/go.mod index 5868e38..5d1eccc 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/google/go-querystring v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/hookdeck/hookdeck-go-sdk v0.0.37 // indirect + github.com/hookdeck/hookdeck-go-sdk v0.4.1 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kr/pty v1.1.8 // indirect diff --git a/go.sum b/go.sum index a33bb0e..95f2ee9 100644 --- a/go.sum +++ b/go.sum @@ -128,6 +128,8 @@ github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDG github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= github.com/hookdeck/hookdeck-go-sdk v0.0.37 h1:Y+QnwsWuJ6KMkpY2qJZDeGzcKc4GkzBrRaEnIb8zimc= github.com/hookdeck/hookdeck-go-sdk v0.0.37/go.mod h1:kfFn3/WEGcxuPkaaf8lAq9L+3nYg45GwGy4utH/Tnmg= +github.com/hookdeck/hookdeck-go-sdk v0.4.1 h1:r/rZJeBuDq31amTIB1LDHkA5lTAG2jAmZGqhgHRYKy8= +github.com/hookdeck/hookdeck-go-sdk v0.4.1/go.mod h1:kfFn3/WEGcxuPkaaf8lAq9L+3nYg45GwGy4utH/Tnmg= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= diff --git a/pkg/cmd/completion.go b/pkg/cmd/completion.go index a99842d..cc6408f 100644 --- a/pkg/cmd/completion.go +++ b/pkg/cmd/completion.go @@ -110,7 +110,7 @@ func selectShell(shell string) error { } return err default: - return fmt.Errorf("Could not automatically detect your shell. Please run the command with the `--shell` flag for either bash or zsh") + return fmt.Errorf("could not automatically detect your shell. Please run the command with the `--shell` flag for either bash or zsh") } } diff --git a/pkg/cmd/listen.go b/pkg/cmd/listen.go index d770d42..7513636 100644 --- a/pkg/cmd/listen.go +++ b/pkg/cmd/listen.go @@ -17,6 +17,7 @@ package cmd import ( "errors" + "fmt" "net/url" "strconv" "strings" @@ -26,9 +27,9 @@ import ( ) type listenCmd struct { - cmd *cobra.Command - wsBaseURL string - noWSS bool + cmd *cobra.Command + noWSS bool + cliPath string } func newListenCmd() *listenCmd { @@ -37,9 +38,15 @@ func newListenCmd() *listenCmd { lc.cmd = &cobra.Command{ Use: "listen", Short: "Forward events for a source to your local server", + Long: `Forward events for a source to your local server. + +This command will create a new Hookdeck Source if it doesn't exist. + +By default the Hookdeck Destination will be named "CLI", and the +Destination CLI path will be "/". To set the CLI path, use the "--cli-path" flag.`, Args: func(cmd *cobra.Command, args []string) error { if len(args) < 1 { - return errors.New("Requires a port or forwarding URL to forward the events to") + return errors.New("requires a port or forwarding URL to forward the events to") } _, err_port := strconv.ParseInt(args[0], 10, 64) @@ -53,21 +60,21 @@ func newListenCmd() *listenCmd { } if err_port != nil && err_url != nil { - return errors.New("Argument is not a valid port or forwading URL") + return errors.New("argument is not a valid port or forwading URL") } if err_port != nil { if parsed_url.Host == "" { - return errors.New("Forwarding URL must contain a host.") + return errors.New("forwarding URL must contain a host") } if parsed_url.RawQuery != "" { - return errors.New("Forwarding URL cannot contain query params.") + return errors.New("forwarding URL cannot contain query params") } } if len(args) > 3 { - return errors.New("Invalid extra argument provided") + return errors.New("invalid extra argument provided") } return nil @@ -75,22 +82,50 @@ func newListenCmd() *listenCmd { RunE: lc.runListenCmd, } lc.cmd.Flags().BoolVar(&lc.noWSS, "no-wss", false, "Force unencrypted ws:// protocol instead of wss://") + lc.cmd.Flags().MarkHidden("no-wss") + lc.cmd.Flags().StringVar(&lc.cliPath, "cli-path", "", "Sets the server path of that locally running web server the events will be forwarded to") + + usage := lc.cmd.UsageTemplate() + + usage = strings.Replace( + usage, + "{{.UseLine}}", + `hookdeck listen [port or forwarding URL] [source] [connection] [flags] + +Arguments: + + - [port or forwarding URL]: Required. The port or forwarding URL to forward the events to e.g., "3000" or "http://localhost:3000" + - [source]: Required. The name of source to forward the events from e.g., "shopify", "stripe" + - [connection]: Optional. The name of the connection linking the Source and the Destination + `, 1) + + usage += fmt.Sprintf(` + +Examples: + + Forward events from a Hookdeck Source named "shopify" to a local server running on port %[1]d: + + hookdeck listen %[1]d shopify + + Forward events to a local server running on "http://myapp.test": + + hookdeck listen %[1]d http://myapp.test + + Forward events to the path "/webhooks" on local server running on port %[1]d: + + hookdeck listen %[1]d --cli-path /webhooks + `, 3000) - lc.cmd.SetUsageTemplate( - strings.Replace( - lc.cmd.UsageTemplate(), - "{{.UseLine}}", - "hookdeck listen [port or forwarding URL] [source] [connection] [flags]", 1), - ) + lc.cmd.SetUsageTemplate(usage) return lc } // listenCmd represents the listen command func (lc *listenCmd) runListenCmd(cmd *cobra.Command, args []string) error { - var sourceAlias, connectionQuery string + var sourceQuery, connectionQuery string if len(args) > 1 { - sourceAlias = args[1] + sourceQuery = args[1] } if len(args) > 2 { connectionQuery = args[2] @@ -112,7 +147,8 @@ func (lc *listenCmd) runListenCmd(cmd *cobra.Command, args []string) error { url.Scheme = "http" } - return listen.Listen(url, sourceAlias, connectionQuery, listen.Flags{ - NoWSS: lc.noWSS, + return listen.Listen(url, sourceQuery, connectionQuery, listen.Flags{ + NoWSS: lc.noWSS, + CliPath: lc.cliPath, }, &Config) } diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 167e0f1..6534bbc 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -28,8 +28,6 @@ import ( "github.com/spf13/cobra" ) -var cfgFile string - var Config config.Config var rootCmd = &cobra.Command{ diff --git a/pkg/cmd/whoami.go b/pkg/cmd/whoami.go index 1c667f7..64678a4 100644 --- a/pkg/cmd/whoami.go +++ b/pkg/cmd/whoami.go @@ -11,8 +11,7 @@ import ( ) type whoamiCmd struct { - cmd *cobra.Command - interactive bool + cmd *cobra.Command } func newWhoamiCmd() *whoamiCmd { diff --git a/pkg/hookdeck/session.go b/pkg/hookdeck/session.go index 8e6901f..31acda2 100644 --- a/pkg/hookdeck/session.go +++ b/pkg/hookdeck/session.go @@ -13,7 +13,6 @@ type Session struct { } type CreateSessionInput struct { - SourceId string `json:"source_id"` ConnectionIds []string `json:"webhook_ids"` } @@ -29,7 +28,7 @@ func (c *Client) CreateSession(input CreateSessionInput) (Session, error) { if res.StatusCode != http.StatusOK { defer res.Body.Close() body, _ := ioutil.ReadAll(res.Body) - return Session{}, fmt.Errorf("Unexpected http status code: %d %s", res.StatusCode, string(body)) + return Session{}, fmt.Errorf("unexpected http status code: %d %s", res.StatusCode, string(body)) } session := Session{} postprocessJsonResponse(res, &session) diff --git a/pkg/listen/connection.go b/pkg/listen/connection.go index ba9f02e..9952417 100644 --- a/pkg/listen/connection.go +++ b/pkg/listen/connection.go @@ -2,93 +2,114 @@ package listen import ( "context" - "errors" "fmt" "strings" - "github.com/AlecAivazis/survey/v2" "github.com/gosimple/slug" hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" hookdeckclient "github.com/hookdeck/hookdeck-go-sdk/client" + log "github.com/sirupsen/logrus" ) -func getConnections(client *hookdeckclient.Client, source *hookdecksdk.Source, connectionQuery string) ([]*hookdecksdk.Connection, error) { - // TODO: Filter connections using connectionQuery - var connections []*hookdecksdk.Connection - connectionList, err := client.Connection.List(context.Background(), &hookdecksdk.ConnectionListRequest{ - SourceId: &source.Id, +func getConnections(client *hookdeckclient.Client, sources []*hookdecksdk.Source, connectionFilterString string, isMultiSource bool, cliPath string) ([]*hookdecksdk.Connection, error) { + sourceIDs := []*string{} + + for _, source := range sources { + sourceIDs = append(sourceIDs, &source.Id) + } + + connectionQuery, err := client.Connection.List(context.Background(), &hookdecksdk.ConnectionListRequest{ + SourceId: sourceIDs, }) if err != nil { - return nil, err + return []*hookdecksdk.Connection{}, err } - connections = connectionList.Models - var filteredConnections []*hookdecksdk.Connection + connections, err := filterConnections(connectionQuery.Models, connectionFilterString) + if err != nil { + return []*hookdecksdk.Connection{}, err + } + + connections, err = ensureConnections(client, connections, sources, isMultiSource, connectionFilterString, cliPath) + if err != nil { + return []*hookdecksdk.Connection{}, err + } + + return connections, nil +} + +// 1. Filter to only include CLI destination +// 2. Apply connectionFilterString +func filterConnections(connections []*hookdecksdk.Connection, connectionFilterString string) ([]*hookdecksdk.Connection, error) { + // 1. Filter to only include CLI destination + var cliDestinationConnections []*hookdecksdk.Connection for _, connection := range connections { if connection.Destination.CliPath != nil && *connection.Destination.CliPath != "" { - filteredConnections = append(filteredConnections, connection) + cliDestinationConnections = append(cliDestinationConnections, connection) } } - connections = filteredConnections - if connectionQuery != "" { - is_path, err := isPath(connectionQuery) - if err != nil { - return connections, err - } - var filteredConnections []*hookdecksdk.Connection - for _, connection := range connections { - if (is_path && connection.Destination.CliPath != nil && strings.Contains(*connection.Destination.CliPath, connectionQuery)) || (connection.Name != nil && *connection.Name == connectionQuery) { - filteredConnections = append(filteredConnections, connection) - } - } - connections = filteredConnections + if connectionFilterString == "" { + return cliDestinationConnections, nil } - if len(connections) == 0 { - answers := struct { - Label string `survey:"label"` - Path string `survey:"path"` - }{} - var qs = []*survey.Question{ - { - Name: "path", - Prompt: &survey.Input{Message: "What path should the events be forwarded to (ie: /webhooks)?"}, - Validate: func(val interface{}) error { - str, ok := val.(string) - is_path, err := isPath(str) - if !ok || !is_path || err != nil { - return errors.New("invalid path") - } - return nil - }, - }, - { - Name: "label", - Prompt: &survey.Input{Message: "What's your connection label (ie: My API)?"}, - Validate: survey.Required, - }, + // 2. Apply connectionFilterString + isPath, err := isPath(connectionFilterString) + if err != nil { + return connections, err + } + var filteredConnections []*hookdecksdk.Connection + for _, connection := range cliDestinationConnections { + if (isPath && connection.Destination.CliPath != nil && strings.Contains(*connection.Destination.CliPath, connectionFilterString)) || (connection.Name != nil && *connection.Name == connectionFilterString) { + filteredConnections = append(filteredConnections, connection) } + } - err := survey.Ask(qs, &answers) - if err != nil { - fmt.Println(err.Error()) - return connections, err - } - alias := slug.Make(answers.Label) - connection, err := client.Connection.Create(context.Background(), &hookdecksdk.ConnectionCreateRequest{ - Name: hookdecksdk.OptionalOrNull(&alias), - SourceId: hookdecksdk.OptionalOrNull(&source.Id), - Destination: hookdecksdk.OptionalOrNull(&hookdecksdk.ConnectionCreateRequestDestination{ - Name: alias, - CliPath: &answers.Path, - }), - }) - if err != nil { - return connections, err - } - connections = append(connections, connection) + return filteredConnections, nil +} + +// When users want to listen to a single source but there is no connection for that source, +// we can help user set up a new connection for it. +func ensureConnections(client *hookdeckclient.Client, connections []*hookdecksdk.Connection, sources []*hookdecksdk.Source, isMultiSource bool, connectionFilterString string, cliPath string) ([]*hookdecksdk.Connection, error) { + if len(connections) > 0 || isMultiSource { + log.Debug(fmt.Sprintf("Connection exists for Source \"%s\", Connection \"%s\", and CLI path \"%s\"", sources[0].Name, connectionFilterString, cliPath)) + + return connections, nil + } + + log.Debug(fmt.Sprintf("No connection found. Creating a connection for Source \"%s\", Connection \"%s\", and CLI path \"%s\"", sources[0].Name, connectionFilterString, cliPath)) + + connectionDetails := struct { + Label string `survey:"label"` + Path string `survey:"path"` + }{} + + if len(connectionFilterString) == 0 { + connectionDetails.Label = "cli" + } else { + connectionDetails.Label = connectionFilterString + } + + if len(cliPath) == 0 { + connectionDetails.Path = "/" + } else { + connectionDetails.Path = cliPath + } + + alias := slug.Make(connectionDetails.Label) + + connection, err := client.Connection.Create(context.Background(), &hookdecksdk.ConnectionCreateRequest{ + Name: hookdecksdk.OptionalOrNull(&alias), + SourceId: hookdecksdk.OptionalOrNull(&sources[0].Id), + Destination: hookdecksdk.OptionalOrNull(&hookdecksdk.ConnectionCreateRequestDestination{ + Name: alias, + CliPath: &connectionDetails.Path, + }), + }) + if err != nil { + return connections, err } + connections = append(connections, connection) return connections, nil } diff --git a/pkg/listen/listen.go b/pkg/listen/listen.go index 65c41f8..edde953 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -17,77 +17,113 @@ package listen import ( "context" + "errors" "fmt" "net/url" "regexp" + "strings" - "github.com/hookdeck/hookdeck-cli/pkg/ansi" "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/login" "github.com/hookdeck/hookdeck-cli/pkg/proxy" + hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" log "github.com/sirupsen/logrus" ) type Flags struct { - NoWSS bool + NoWSS bool + CliPath string } // listenCmd represents the listen command -func Listen(URL *url.URL, source_alias string, connectionQuery string, flags Flags, config *config.Config) error { +func Listen(URL *url.URL, sourceQuery string, connectionFilterString string, flags Flags, config *config.Config) error { var err error - var guest_url string + var guestURL string + + sourceAliases, err := parseSourceQuery(sourceQuery) + if err != nil { + return err + } + + isMultiSource := len(sourceAliases) > 1 || (len(sourceAliases) == 1 && sourceAliases[0] == "*") + + if flags.CliPath != "" { + if isMultiSource { + return errors.New("Can only set a CLI path when listening to a single source") + } + + flagIsPath, err := isPath(flags.CliPath) + if err != nil { + return err + } + if !flagIsPath { + return errors.New("The CLI path must be in a valid format") + } + } if config.Profile.APIKey == "" { - guest_url, err = login.GuestLogin(config) - if guest_url == "" { + guestURL, err = login.GuestLogin(config) + if guestURL == "" { return err } } sdkClient := config.GetClient() - source, err := getSource(sdkClient, source_alias) + // Prepare data + + sources, err := getSources(sdkClient, sourceAliases) if err != nil { return err } - connections, err := getConnections(sdkClient, source, connectionQuery) + connections, err := getConnections(sdkClient, sources, connectionFilterString, isMultiSource, flags.CliPath) if err != nil { return err } - fmt.Println() - fmt.Println(ansi.Bold("Dashboard")) - if guest_url != "" { - fmt.Println("👤 Console URL: " + guest_url) - fmt.Println("Sign up in the Console to make your webhook URL permanent.") - fmt.Println() - } else { - var url = config.DashboardBaseURL - if config.Profile.TeamID != "" { - url += "?team_id=" + config.Profile.TeamID - } - if config.Profile.TeamMode == "console" { - url = config.ConsoleBaseURL + "?source_id=" + source.Id - } - fmt.Println("👉 Inspect and replay events: " + url) - fmt.Println() + if len(flags.CliPath) != 0 && len(connections) > 1 { + return errors.New(fmt.Errorf(`Multiple CLI destinations found. Cannot set the CLI path on multiple destinations. +Specify a single destination to update the CLI path. For example, pass a connection name: + + hookdeck listen %s %s %s --cli-path %s`, URL.String(), sources[0].Name, "connection-name", flags.CliPath).Error()) } - fmt.Println(ansi.Bold(source.Name + " Source")) - fmt.Println("🔌 Event URL: " + source.Url) - fmt.Println() + // If the "cli-path" flag has been passed and the destination has a current cli path value but it's different, update destination path + if len(flags.CliPath) != 0 && + len(connections) == 1 && + *connections[0].Destination.CliPath != "" && + *connections[0].Destination.CliPath != flags.CliPath { - fmt.Println(ansi.Bold("Connections")) - for _, connection := range connections { - var connectionName string - if connection.Name != nil { - connectionName = *connection.Name - } else { - connectionName = connection.Destination.Name + updateMsg := fmt.Sprintf("Updating destination CLI path from \"%s\" to \"%s\"", *connections[0].Destination.CliPath, flags.CliPath) + log.Debug(updateMsg) + + path := flags.CliPath + _, err := sdkClient.Destination.Update(context.Background(), connections[0].Destination.Id, &hookdecksdk.DestinationUpdateRequest{ + CliPath: hookdecksdk.Optional(path), + }) + + if err != nil { + return err } - fmt.Println(connectionName + " forwarding to " + *connection.Destination.CliPath) + + connections[0].Destination.CliPath = &path } + + sources = getRelevantSources(sources, connections) + + if err := validateData(sources, connections); err != nil { + return err + } + + // Start proxy + printListenMessage(config, isMultiSource) + fmt.Println() + printDashboardInformation(config, guestURL) + fmt.Println() + printSources(config, sources) + fmt.Println() + printConnections(config, connections) fmt.Println() p := proxy.New(&proxy.Config{ @@ -103,7 +139,7 @@ func Listen(URL *url.URL, source_alias string, connectionQuery string, flags Fla URL: URL, Log: log.StandardLogger(), Insecure: config.Insecure, - }, source, connections) + }, connections) err = p.Run(context.Background()) if err != nil { @@ -113,7 +149,57 @@ func Listen(URL *url.URL, source_alias string, connectionQuery string, flags Fla return nil } +func parseSourceQuery(sourceQuery string) ([]string, error) { + var sourceAliases []string + if sourceQuery == "" { + sourceAliases = []string{} + } else if strings.Contains(sourceQuery, ",") { + sourceAliases = strings.Split(sourceQuery, ",") + } else if strings.Contains(sourceQuery, " ") { + sourceAliases = strings.Split(sourceQuery, " ") + } else { + sourceAliases = append(sourceAliases, sourceQuery) + } + + for i := range sourceAliases { + sourceAliases[i] = strings.TrimSpace(sourceAliases[i]) + } + + // TODO: remove once we can support better limit + if len(sourceAliases) > 10 { + return []string{}, errors.New("max 10 sources supported") + } + + return sourceAliases, nil +} + func isPath(value string) (bool, error) { is_path, err := regexp.MatchString(`^(\/)+([/a-zA-Z0-9-_%\.\-\_\~\!\$\&\'\(\)\*\+\,\;\=\:\@]*)$`, value) return is_path, err } + +func validateData(sources []*hookdecksdk.Source, connections []*hookdecksdk.Connection) error { + if len(connections) == 0 { + return errors.New("no connections provided") + } + + return nil +} + +func getRelevantSources(sources []*hookdecksdk.Source, connections []*hookdecksdk.Connection) []*hookdecksdk.Source { + relevantSourceId := map[string]bool{} + + for _, connection := range connections { + relevantSourceId[connection.Source.Id] = true + } + + relevantSources := []*hookdecksdk.Source{} + + for _, source := range sources { + if relevantSourceId[source.Id] { + relevantSources = append(relevantSources, source) + } + } + + return relevantSources +} diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go new file mode 100644 index 0000000..7708624 --- /dev/null +++ b/pkg/listen/printer.go @@ -0,0 +1,51 @@ +package listen + +import ( + "fmt" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/config" + hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" +) + +func printListenMessage(config *config.Config, isMultiSource bool) { + if !isMultiSource { + return + } + + fmt.Println() + fmt.Println("Listening for events on Sources that have Connections with CLI Destinations") +} + +func printDashboardInformation(config *config.Config, guestURL string) { + fmt.Println(ansi.Bold("Dashboard")) + if guestURL != "" { + fmt.Println("👤 Console URL: " + guestURL) + fmt.Println("Sign up in the Console to make your webhook URL permanent.") + fmt.Println() + } else { + var url = config.DashboardBaseURL + if config.Profile.TeamID != "" { + url += "?team_id=" + config.Profile.TeamID + } + if config.Profile.TeamMode == "console" { + url = config.ConsoleBaseURL + } + fmt.Println("👉 Inspect and replay events: " + url) + } +} + +func printSources(config *config.Config, sources []*hookdecksdk.Source) { + fmt.Println(ansi.Bold("Sources")) + + for _, source := range sources { + fmt.Printf("🔌 %s URL: %s\n", source.Name, source.Url) + } +} + +func printConnections(config *config.Config, connections []*hookdecksdk.Connection) { + fmt.Println(ansi.Bold("Connections")) + for _, connection := range connections { + fmt.Println(*connection.FullName + " forwarding to " + *connection.Destination.CliPath) + } +} diff --git a/pkg/listen/source.go b/pkg/listen/source.go index c5039df..cd600ad 100644 --- a/pkg/listen/source.go +++ b/pkg/listen/source.go @@ -2,6 +2,7 @@ package listen import ( "context" + "errors" "fmt" "github.com/AlecAivazis/survey/v2" @@ -10,79 +11,186 @@ import ( hookdeckclient "github.com/hookdeck/hookdeck-go-sdk/client" ) -func getSource(sdkClient *hookdeckclient.Client, source_alias string) (*hookdecksdk.Source, error) { - var source *hookdecksdk.Source - if source_alias != "" { - sources, _ := sdkClient.Source.List(context.Background(), &hookdecksdk.SourceListRequest{ - Name: &source_alias, - }) - if *sources.Count > 0 { - source = sources.Models[0] +// There are 4 cases: +// +// 1. search all sources (query string == '*') +// 2. search multiple sources +// 3. search 1 source +// 4. no specific source +// +// For case 1 & 2, we'll simply query the sources data and return. +// If no source is found, we'll show an error message and exit. +// +// For case 3, we'll search for the 1 source. +// If that source is not found, we'll create it and move forward. +// +// For case 4, we'll get available sources and ask the user which ones +// they'd like to use. They will also have an option to create a new source. + +func getSources(sdkClient *hookdeckclient.Client, sourceQuery []string) ([]*hookdecksdk.Source, error) { + limit := 255 // Hookdeck API limit + + // case 1 + if len(sourceQuery) == 1 && sourceQuery[0] == "*" { + sources, err := sdkClient.Source.List(context.Background(), &hookdecksdk.SourceListRequest{}) + if err != nil { + return []*hookdecksdk.Source{}, err + } + if sources == nil || *sources.Count == 0 { + return []*hookdecksdk.Source{}, errors.New("unable to find any matching sources") + } + return validateSources(sources.Models) + + // case 2 + } else if len(sourceQuery) > 1 { + searchedSources, err := listMultipleSources(sdkClient, sourceQuery) + if err != nil { + return []*hookdecksdk.Source{}, err + } + return validateSources(searchedSources) + + // case 3 + } else if len(sourceQuery) == 1 { + searchedSources, err := listMultipleSources(sdkClient, sourceQuery) + if err != nil { + return []*hookdecksdk.Source{}, err + } + if len(searchedSources) > 0 { + return validateSources(searchedSources) } - if source == nil { - // TODO: Prompt here? - source, _ = sdkClient.Source.Create(context.Background(), &hookdecksdk.SourceCreateRequest{ - Name: slug.Make(source_alias), - }) + + // Create source with provided name + source, err := createSource(sdkClient, &sourceQuery[0]) + if err != nil { + return []*hookdecksdk.Source{}, err } + + return validateSources([]*hookdecksdk.Source{source}) + + // case 4 } else { - sources, _ := sdkClient.Source.List(context.Background(), &hookdecksdk.SourceListRequest{}) - if *sources.Count > 0 { - var sources_alias []string - for _, temp_source := range sources.Models { - sources_alias = append(sources_alias, temp_source.Name) - } + sources := []*hookdecksdk.Source{} - answers := struct { - SourceAlias string `survey:"source"` - }{} - - var qs = []*survey.Question{ - { - Name: "source", - Prompt: &survey.Select{ - Message: "Select a source", - Options: append(sources_alias, "Create new source"), - }, - }, - } + availableSources, err := sdkClient.Source.List(context.Background(), &hookdecksdk.SourceListRequest{ + Limit: &limit, + }) + + if err != nil { + return []*hookdecksdk.Source{}, err + } - err := survey.Ask(qs, &answers) + if *availableSources.Count > 0 { + selectedSources, err := selectSources(availableSources.Models) if err != nil { - fmt.Println(err.Error()) - return source, err + return []*hookdecksdk.Source{}, err } + sources = append(sources, selectedSources...) + } - if answers.SourceAlias != "Create new source" { - for _, temp_source := range sources.Models { - if temp_source.Name == answers.SourceAlias { - source = temp_source - } - } + if len(sources) == 0 { + source, err := createSource(sdkClient, nil) + if err != nil { + return []*hookdecksdk.Source{}, err } + sources = append(sources, source) } - if source == nil { - answers := struct { - Label string `survey:"label"` // or you can tag fields to match a specific name - }{} - var qs = []*survey.Question{ - { - Name: "label", - Prompt: &survey.Input{Message: "What should be your new source label?"}, - Validate: survey.Required, - }, - } + return validateSources(sources) + } +} - err := survey.Ask(qs, &answers) - if err != nil { - return source, err +func listMultipleSources(sdkClient *hookdeckclient.Client, sourceQuery []string) ([]*hookdecksdk.Source, error) { + sources := []*hookdecksdk.Source{} + + for _, sourceName := range sourceQuery { + sourceQuery, err := sdkClient.Source.List(context.Background(), &hookdecksdk.SourceListRequest{ + Name: &sourceName, + }) + if err != nil { + return []*hookdecksdk.Source{}, err + } + if len(sourceQuery.Models) > 0 { + sources = append(sources, sourceQuery.Models[0]) + } + } + + return sources, nil +} + +func selectSources(availableSources []*hookdecksdk.Source) ([]*hookdecksdk.Source, error) { + sources := []*hookdecksdk.Source{} + + var sourceAliases []string + for _, temp_source := range availableSources { + sourceAliases = append(sourceAliases, temp_source.Name) + } + + answers := struct { + SourceAlias string `survey:"source"` + }{} + + var qs = []*survey.Question{ + { + Name: "source", + Prompt: &survey.Select{ + Message: "Select a source", + Options: append(sourceAliases, "Create new source"), + }, + }, + } + + err := survey.Ask(qs, &answers) + if err != nil { + fmt.Println(err.Error()) + return []*hookdecksdk.Source{}, err + } + + if answers.SourceAlias != "Create new source" { + for _, currentSource := range availableSources { + if currentSource.Name == answers.SourceAlias { + sources = append(sources, currentSource) } + } + } + + return sources, nil +} + +func createSource(sdkClient *hookdeckclient.Client, name *string) (*hookdecksdk.Source, error) { + var sourceName string + + if name != nil { + sourceName = *name + } else { + answers := struct { + Label string `survey:"label"` // or you can tag fields to match a specific name + }{} + var qs = []*survey.Question{ + { + Name: "label", + Prompt: &survey.Input{Message: "What should be your new source label?"}, + Validate: survey.Required, + }, + } - source, _ = sdkClient.Source.Create(context.Background(), &hookdecksdk.SourceCreateRequest{ - Name: slug.Make(answers.Label), - }) + err := survey.Ask(qs, &answers) + if err != nil { + return nil, err } + sourceName = answers.Label } - return source, nil + + source, err := sdkClient.Source.Create(context.Background(), &hookdecksdk.SourceCreateRequest{ + Name: slug.Make(sourceName), + }) + + return source, err +} + +func validateSources(sources []*hookdecksdk.Source) ([]*hookdecksdk.Source, error) { + if len(sources) == 0 { + return []*hookdecksdk.Source{}, errors.New("unable to find any matching sources") + } + + return sources, nil } diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 708325a..b07a1fd 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -56,12 +56,10 @@ type Config struct { // webhook events, forwards them to the local endpoint and sends the response // back to Hookdeck. type Proxy struct { - cfg *Config - source *hookdecksdk.Source - connections []*hookdecksdk.Connection - connections_paths map[string]string - webSocketClient *websocket.Client - connectionTimer *time.Timer + cfg *Config + connections []*hookdecksdk.Connection + webSocketClient *websocket.Client + connectionTimer *time.Timer } func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { @@ -214,14 +212,15 @@ func (p *Proxy) createSession(ctx context.Context) (hookdeck.Session, error) { TeamID: p.cfg.TeamID, } - var connection_ids []string + var connectionIDs []string for _, connection := range p.connections { - connection_ids = append(connection_ids, connection.Id) + connectionIDs = append(connectionIDs, connection.Id) } for i := 0; i <= 5; i++ { - session, err = client.CreateSession(hookdeck.CreateSessionInput{SourceId: p.source.Id, - ConnectionIds: connection_ids}) + session, err = client.CreateSession(hookdeck.CreateSessionInput{ + ConnectionIds: connectionIDs, + }) if err == nil { return session, nil @@ -343,8 +342,6 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h return } - // body := truncate(string(buf), 5000, true) - if p.webSocketClient != nil { p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ AttemptResponse: &websocket.AttemptResponse{ @@ -364,73 +361,16 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h // // New creates a new Proxy -func New(cfg *Config, source *hookdecksdk.Source, connections []*hookdecksdk.Connection) *Proxy { +func New(cfg *Config, connections []*hookdecksdk.Connection) *Proxy { if cfg.Log == nil { cfg.Log = &log.Logger{Out: ioutil.Discard} } - connections_paths := make(map[string]string) - - for _, connection := range connections { - connections_paths[connection.Id] = *connection.Destination.CliPath - } - p := &Proxy{ - cfg: cfg, - connections: connections, - connections_paths: connections_paths, - source: source, - connectionTimer: time.NewTimer(0), // Defaults to no delay + cfg: cfg, + connections: connections, + connectionTimer: time.NewTimer(0), // Defaults to no delay } return p } - -// -// Private constants -// - -const ( - maxBodySize = 5000 - maxNumHeaders = 20 - maxHeaderKeySize = 50 - maxHeaderValueSize = 200 -) - -// -// Private functions -// - -// truncate will truncate str to be less than or equal to maxByteLength bytes. -// It will respect UTF8 and truncate the string at a code point boundary. -// If ellipsis is true, we'll append "..." to the truncated string if the string -// was in fact truncated, and if there's enough room. Note that the -// full string returned will always be <= maxByteLength bytes long, even with ellipsis. -func truncate(str string, maxByteLength int, ellipsis bool) string { - if len(str) <= maxByteLength { - return str - } - - bytes := []byte(str) - - if ellipsis && maxByteLength > 3 { - maxByteLength -= 3 - } else { - ellipsis = false - } - - for maxByteLength > 0 && maxByteLength < len(bytes) && isUTF8ContinuationByte(bytes[maxByteLength]) { - maxByteLength-- - } - - result := string(bytes[0:maxByteLength]) - if ellipsis { - result += "..." - } - - return result -} - -func isUTF8ContinuationByte(b byte) bool { - return (b & 0xC0) == 0x80 -}