From 34340cef98bb6d6b39761a6d17389a449738c4d8 Mon Sep 17 00:00:00 2001 From: Jye Cusch Date: Fri, 12 Sep 2025 13:50:18 +1000 Subject: [PATCH] fix(cli): add debug logging flag Adds a debug flag to enable verbose logging of API requests and responses. This feature helps troubleshoot issues by providing detailed information about the communication between the CLI and the backend server. The debug flag is added as a persistent flag to the root command, allowing it to be used with any subcommand. The API client and the WorkOS client are updated to log requests and responses when the debug flag is enabled. --- cli/cmd/root.go | 7 ++- cli/internal/api/api.go | 48 ++++++++++++++++- cli/internal/config/config.go | 9 +++- cli/internal/workos/http/client.go | 85 ++++++++++++++++++++++++------ cli/internal/workos/workos.go | 2 +- 5 files changed, 130 insertions(+), 21 deletions(-) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 3f395fe1..0711c602 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -22,12 +22,17 @@ func NewRootCmd(injector do.Injector) *cobra.Command { return err } + if debugFlag, _ := cmd.Flags().GetBool("debug"); debugFlag { + conf.SetDebug(true) + } + do.ProvideValue(injector, conf) return nil }, } - // Add commands that use the CLI struct methods + rootCmd.PersistentFlags().Bool("debug", false, "Enable debug mode with verbose API request/response logging") + rootCmd.AddCommand(NewLoginCmd(injector)) rootCmd.AddCommand(NewLogoutCmd(injector)) rootCmd.AddCommand(NewAccessTokenCmd(injector)) diff --git a/cli/internal/api/api.go b/cli/internal/api/api.go index b68d39e8..11d643c8 100644 --- a/cli/internal/api/api.go +++ b/cli/internal/api/api.go @@ -5,7 +5,9 @@ import ( "fmt" "io" "net/http" + "net/http/httputil" "net/url" + "os" "github.com/nitrictech/suga/cli/internal/config" "github.com/nitrictech/suga/cli/internal/utils" @@ -21,6 +23,7 @@ type TokenProvider interface { type SugaApiClient struct { tokenProvider TokenProvider apiUrl *url.URL + debugEnabled bool } func NewSugaApiClient(injector do.Injector) (*SugaApiClient, error) { @@ -39,9 +42,34 @@ func NewSugaApiClient(injector do.Injector) (*SugaApiClient, error) { return &SugaApiClient{ apiUrl: apiUrl, tokenProvider: tokenProvider, + debugEnabled: config.Debug, }, nil } +// logRequest logs the HTTP request details if debug mode is enabled +func (c *SugaApiClient) logRequest(req *http.Request) { + if !c.debugEnabled { + return + } + + fmt.Fprintf(os.Stderr, "[DEBUG] API Request:\n") + if dump, err := httputil.DumpRequest(req, true); err == nil { + fmt.Fprintf(os.Stderr, "%s\n\n", dump) + } +} + +// logResponse logs the HTTP response details if debug mode is enabled +func (c *SugaApiClient) logResponse(resp *http.Response) { + if !c.debugEnabled { + return + } + + fmt.Fprintf(os.Stderr, "[DEBUG] API Response:\n") + if dump, err := httputil.DumpResponse(resp, true); err == nil { + fmt.Fprintf(os.Stderr, "%s\n\n", dump) + } +} + // doRequestWithRetry executes an HTTP request and retries once with a refreshed token on 401/403. // Reuses req.Context() and req.GetBody (when available) to rebuild the body. func (c *SugaApiClient) doRequestWithRetry(req *http.Request, requiresAuth bool) (*http.Response, error) { @@ -58,12 +86,18 @@ func (c *SugaApiClient) doRequestWithRetry(req *http.Request, requiresAuth bool) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) } + // Log the request + c.logRequest(req) + // Execute the request resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } + // Log the response + c.logResponse(resp) + // If we got a 401 or 403 and auth is required, try refreshing the token if requiresAuth && (resp.StatusCode == 401 || resp.StatusCode == 403) { resp.Body.Close() // Close the first response body @@ -100,12 +134,22 @@ func (c *SugaApiClient) doRequestWithRetry(req *http.Request, requiresAuth bool) // Copy headers retryReq.Header = req.Header.Clone() - + // Update authorization header with new token retryReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + // Log the retry request + c.logRequest(retryReq) + // Retry the request - return http.DefaultClient.Do(retryReq) + retryResp, err := http.DefaultClient.Do(retryReq) + if err != nil { + return nil, err + } + + // Log the retry response + c.logResponse(retryResp) + return retryResp, nil } return resp, nil diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 7527f340..6f2d6768 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -14,8 +14,9 @@ import ( ) type Config struct { - v *viper.Viper `mapstructure:"-"` - Url string `mapstructure:"url" desc:"The base URL of the Suga server (e.g., https://app.addsuga.com)"` + v *viper.Viper `mapstructure:"-"` + Url string `mapstructure:"url" desc:"The base URL of the Suga server (e.g., https://app.addsuga.com)"` + Debug bool `mapstructure:"debug" desc:"Enable debug mode with verbose logging"` } func (c *Config) FileUsed() string { @@ -93,6 +94,10 @@ func (c *Config) SetSugaServerUrl(newUrl string) error { return nil } +func (c *Config) SetDebug(debug bool) { + c.Debug = debug +} + func (c *Config) Save(global bool) error { var configMap map[string]interface{} err := mapstructure.Decode(c, &configMap) diff --git a/cli/internal/workos/http/client.go b/cli/internal/workos/http/client.go index 9968f02a..02e24df7 100644 --- a/cli/internal/workos/http/client.go +++ b/cli/internal/workos/http/client.go @@ -11,9 +11,12 @@ import ( "io" "math/big" "net/http" + "net/http/httputil" "net/url" + "os" "path" + "github.com/nitrictech/suga/cli/internal/config" "github.com/nitrictech/suga/cli/internal/utils" ) @@ -96,35 +99,61 @@ type GetAuthorizationUrlOptions struct { // HttpClient represents the WorkOS HTTP client type HttpClient struct { - baseURL string - clientID string - client *http.Client + baseURL string + clientID string + client *http.Client + debugEnabled bool } // NewHttpClient creates a new WorkOS HTTP client -func NewHttpClient(clientID string, options ...ClientOption) *HttpClient { - config := &clientConfig{ +func NewHttpClient(clientID string, cfg *config.Config, options ...ClientOption) *HttpClient { + clientConfig := &clientConfig{ hostname: DEFAULT_HOSTNAME, scheme: "https", } for _, option := range options { - option(config) + option(clientConfig) } baseURL := &url.URL{ - Scheme: config.scheme, - Host: config.hostname, + Scheme: clientConfig.scheme, + Host: clientConfig.hostname, } - if config.port != 0 { - baseURL.Host = fmt.Sprintf("%s:%d", config.hostname, config.port) + if clientConfig.port != 0 { + baseURL.Host = fmt.Sprintf("%s:%d", clientConfig.hostname, clientConfig.port) } return &HttpClient{ - baseURL: baseURL.String(), - clientID: clientID, - client: &http.Client{}, + baseURL: baseURL.String(), + clientID: clientID, + client: &http.Client{}, + debugEnabled: cfg.Debug, + } +} + +// logRequest logs the HTTP request details if debug mode is enabled +func (h *HttpClient) logRequest(req *http.Request) { + if !h.debugEnabled { + return + } + + fmt.Fprintf(os.Stderr, "[DEBUG] WorkOS Request:\n") + if dump, err := httputil.DumpRequest(req, true); err == nil { + fmt.Fprintf(os.Stderr, "%s\n\n", dump) + } +} + +// logResponse logs the HTTP response details if debug mode is enabled +func (h *HttpClient) logResponse(resp *http.Response) { + if !h.debugEnabled { + return + } + + fmt.Fprintf(os.Stderr, "[DEBUG] WorkOS Response:\n") + if dump, err := httputil.DumpResponse(resp, true); err == nil { + fmt.Fprintf(os.Stderr, "%s\n\n", dump) } } @@ -274,7 +303,17 @@ func (h *HttpClient) post(path string, body map[string]interface{}) (*http.Respo req.Header.Set("Content-Type", "application/json") req.Header.Set("x-amz-content-sha256", utils.CalculateSHA256(jsonBody)) - return h.client.Do(req) + // Log the request + h.logRequest(req) + + resp, err := h.client.Do(req) + if err != nil { + return nil, err + } + + // Log the response + h.logResponse(resp) + return resp, nil } func (h *HttpClient) get(path string) (*http.Response, error) { @@ -292,7 +331,17 @@ func (h *HttpClient) get(path string) (*http.Response, error) { req.Header.Set("Accept", "application/json, text/plain, */*") - return h.client.Do(req) + // Log the request + h.logRequest(req) + + resp, err := h.client.Do(req) + if err != nil { + return nil, err + } + + // Log the response + h.logResponse(resp) + return resp, nil } type AuthenticatedWithRefreshTokenOptions struct { @@ -478,12 +527,18 @@ func (c *HttpClient) PollDeviceTokenWithContext(ctx context.Context, deviceCode req.Header.Set("Content-Type", "application/json") req.Header.Set("x-amz-content-sha256", utils.CalculateSHA256(jsonBody)) + // Log the request + c.logRequest(req) + response, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to make device token request: %w", err) } defer response.Body.Close() + // Log the response + c.logResponse(response) + body, err := io.ReadAll(response.Body) if err != nil { return nil, fmt.Errorf("failed to read device token response: %w", err) diff --git a/cli/internal/workos/workos.go b/cli/internal/workos/workos.go index 552288d5..a1a78aea 100644 --- a/cli/internal/workos/workos.go +++ b/cli/internal/workos/workos.go @@ -52,7 +52,7 @@ func NewWorkOSAuth(inj do.Injector) (*WorkOSAuth, error) { opts = append(opts, http.WithPort(pn)) } } - httpClient := http.NewHttpClient("", opts...) + httpClient := http.NewHttpClient("", config, opts...) tokenStore := do.MustInvokeAs[TokenStore](inj)