From 9a8ac723d06e7214f6514247580231e8f9b65c41 Mon Sep 17 00:00:00 2001 From: Lauren De bruyn Date: Fri, 10 Apr 2026 11:14:33 +0200 Subject: [PATCH 1/3] fix: add scheme support, centralize BaseURL, fix response body leak Add --scheme flag to auth login so users can connect over HTTP for local dev. Centralize host+scheme defaulting in Profile.BaseURL() and use it everywhere (API client, auth status, login confirmation). Fix response body leak in UpdateMetricMonitoring by decoding the POST response. Remove redundant UpdateMetricMonitoringRequest struct in favor of MetricMonitoringSettings. Co-Authored-By: Claude Opus 4.6 (1M context) --- go/cmd/auth.go | 16 +++++++--------- go/cmd/monitor.go | 2 +- go/internal/api/client.go | 6 +----- go/internal/api/datasets.go | 3 +-- go/internal/api/monitors.go | 22 +++++++++++----------- go/internal/config/credentials.go | 14 ++++++++++++++ 6 files changed, 35 insertions(+), 28 deletions(-) diff --git a/go/cmd/auth.go b/go/cmd/auth.go index 761098a..6806c55 100644 --- a/go/cmd/auth.go +++ b/go/cmd/auth.go @@ -24,8 +24,9 @@ var authLoginCmd = &cobra.Command{ host, _ := cmd.Flags().GetString("host") apiKeyID, _ := cmd.Flags().GetString("api-key-id") apiKeySecret, _ := cmd.Flags().GetString("api-key-secret") + scheme, _ := cmd.Flags().GetString("scheme") - anyFlagSet := cmd.Flags().Changed("host") || cmd.Flags().Changed("api-key-id") || cmd.Flags().Changed("api-key-secret") + anyFlagSet := cmd.Flags().Changed("host") || cmd.Flags().Changed("api-key-id") || cmd.Flags().Changed("api-key-secret") || cmd.Flags().Changed("scheme") if anyFlagSet { // Non-interactive: flags were explicitly provided @@ -119,10 +120,9 @@ var authLoginCmd = &cobra.Command{ host = "cloud.soda.io" } - fmt.Println(output.Dim.Render(" Testing connection to " + host + "...")) - // Test connection before saving - testProfile := config.Profile{Host: host, APIKeyID: apiKeyID, APIKeySecret: apiKeySecret} + testProfile := config.Profile{Host: host, APIKeyID: apiKeyID, APIKeySecret: apiKeySecret, Scheme: scheme} + fmt.Println(output.Dim.Render(" Testing connection to " + testProfile.BaseURL() + "...")) if err := api.New(testProfile).Ping(); err != nil { return err } @@ -183,13 +183,10 @@ var authStatusCmd = &cobra.Command{ return output.Errorf(2, "could not read credentials: %v", err) } p, ok := creds[profileName] - host := p.Host - if host == "" { - host = "cloud.soda.io" - } + baseURL := p.BaseURL() fmt.Printf(" %-20s %s\n", output.Bold.Render("Profile"), profileName) - fmt.Printf(" %-20s %s\n", output.Bold.Render("Host"), host) + fmt.Printf(" %-20s %s\n", output.Bold.Render("Host"), baseURL) if !ok || p.APIKeyID == "" { fmt.Printf(" %-20s %s\n", output.Bold.Render("Connection"), output.Dim.Render("not configured — run `sodacli auth login`")) @@ -221,6 +218,7 @@ func init() { authLoginCmd.Flags().String("host", "", "Soda Cloud host (default: cloud.soda.io)") authLoginCmd.Flags().String("api-key-id", "", "Soda Cloud API key ID") authLoginCmd.Flags().String("api-key-secret", "", "Soda Cloud API key secret") + authLoginCmd.Flags().String("scheme", "", `URL scheme: "http" or "https" (default: "https")`) authCmd.AddCommand(authLoginCmd, authLogoutCmd, authStatusCmd, authSwitchCmd) } diff --git a/go/cmd/monitor.go b/go/cmd/monitor.go index 9c3642d..03779af 100644 --- a/go/cmd/monitor.go +++ b/go/cmd/monitor.go @@ -239,7 +239,7 @@ var monitorConfigCmd = &cobra.Command{ timezone, _ := cmd.Flags().GetString("timezone") - req := api.UpdateMetricMonitoringRequest{} + req := api.MetricMonitoringSettings{} if enable { t := true req.Enabled = &t diff --git a/go/internal/api/client.go b/go/internal/api/client.go index 517e959..b3b6b0d 100644 --- a/go/internal/api/client.go +++ b/go/internal/api/client.go @@ -21,12 +21,8 @@ type Client struct { } func New(p config.Profile) *Client { - host := p.Host - if host == "" { - host = "cloud.soda.io" - } return &Client{ - baseURL: "https://" + host, + baseURL: p.BaseURL(), apiKeyID: p.APIKeyID, apiKeySecret: p.APIKeySecret, http: &http.Client{Timeout: 30 * time.Second}, diff --git a/go/internal/api/datasets.go b/go/internal/api/datasets.go index 793e1a0..c46aa6d 100644 --- a/go/internal/api/datasets.go +++ b/go/internal/api/datasets.go @@ -190,8 +190,7 @@ type TimePartitionRequest struct { // ── Metric monitoring (via dataset update) ──────────────────────────────────── // MetricMonitoringSettings is the shape of the `metricMonitoring` field inside -// POST /api/v1/datasets/{id}. It is separate from UpdateMetricMonitoringRequest -// which targets the (unavailable) /metricMonitoring sub-resource. +// POST /api/v1/datasets/{id}. type MetricMonitoringSettings struct { Enabled *bool `json:"enabled,omitempty"` ScanSchedule *ScanSchedule `json:"scanSchedule,omitempty"` diff --git a/go/internal/api/monitors.go b/go/internal/api/monitors.go index 38ee719..365a9b5 100644 --- a/go/internal/api/monitors.go +++ b/go/internal/api/monitors.go @@ -61,12 +61,6 @@ type MetricMonitoringConfig struct { CustomSqlMetricMonitors []CustomSqlMonitor `json:"customSqlMetricMonitors"` } -type UpdateMetricMonitoringRequest struct { - Enabled *bool `json:"enabled,omitempty"` - ScanSchedule *ScanSchedule `json:"scanSchedule,omitempty"` - DatasetMetricMonitorsConfiguration []DatasetMetricMonitorCfg `json:"datasetMetricMonitorsConfiguration,omitempty"` -} - func (c *Client) GetMetricMonitoring(datasetID string) (*MetricMonitoringConfig, error) { resp, err := c.get("/api/v1/datasets/"+datasetID+"/metricMonitoring", nil) if err != nil { @@ -79,16 +73,22 @@ func (c *Client) GetMetricMonitoring(datasetID string) (*MetricMonitoringConfig, return &result, nil } -func (c *Client) UpdateMetricMonitoring(datasetID string, req UpdateMetricMonitoringRequest) (*MetricMonitoringConfig, error) { - resp, err := c.post("/api/v1/datasets/"+datasetID+"/metricMonitoring", req) +func (c *Client) UpdateMetricMonitoring(datasetID string, req MetricMonitoringSettings) (*MetricMonitoringConfig, error) { + // Use the dataset update endpoint (POST /api/v1/datasets/{id}) with the + // metricMonitoring field — the dedicated /metricMonitoring sub-resource + // is not available on all deployments. + updateReq := UpdateDatasetRequest{MetricMonitoring: &req} + resp, err := c.post("/api/v1/datasets/"+datasetID, updateReq) if err != nil { return nil, err } - var result MetricMonitoringConfig - if err := decode(resp, &result); err != nil { + // Drain and close body — the POST returns a Dataset, not MetricMonitoringConfig. + var discard Dataset + if err := decode(resp, &discard); err != nil { return nil, err } - return &result, nil + // Re-fetch the monitoring config in the expected shape. + return c.GetMetricMonitoring(datasetID) } // EnableDefaultMonitoring enables all dataset-level metric monitors for a dataset. diff --git a/go/internal/config/credentials.go b/go/internal/config/credentials.go index a895477..c97e105 100644 --- a/go/internal/config/credentials.go +++ b/go/internal/config/credentials.go @@ -12,6 +12,20 @@ type Profile struct { Host string `yaml:"host"` APIKeyID string `yaml:"api_key_id"` APIKeySecret string `yaml:"api_key_secret"` + Scheme string `yaml:"scheme,omitempty"` // "http" or "https" (default: "https") +} + +// BaseURL returns the full base URL for the profile (e.g. "https://cloud.soda.io"). +func (p Profile) BaseURL() string { + host := p.Host + if host == "" { + host = "cloud.soda.io" + } + scheme := p.Scheme + if scheme == "" { + scheme = "https" + } + return scheme + "://" + host } type Credentials map[string]Profile From d95df54f5e7fa8f64778e435b68910f2d48fc9b4 Mon Sep 17 00:00:00 2001 From: Michael Van de Steene Date: Fri, 17 Apr 2026 10:48:10 +0200 Subject: [PATCH 2/3] fix: replace --scheme flag with scheme-in-host convention Instead of a separate --scheme parameter, users can now specify http:// or https:// as part of the --host value. Defaults updated to https://cloud.soda.io to make the convention visible. Co-Authored-By: Claude Opus 4.6 (1M context) --- go/cmd/auth.go | 16 +++++++--------- go/internal/config/credentials.go | 13 +++++++------ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/go/cmd/auth.go b/go/cmd/auth.go index 6806c55..1aa2a10 100644 --- a/go/cmd/auth.go +++ b/go/cmd/auth.go @@ -24,14 +24,13 @@ var authLoginCmd = &cobra.Command{ host, _ := cmd.Flags().GetString("host") apiKeyID, _ := cmd.Flags().GetString("api-key-id") apiKeySecret, _ := cmd.Flags().GetString("api-key-secret") - scheme, _ := cmd.Flags().GetString("scheme") - anyFlagSet := cmd.Flags().Changed("host") || cmd.Flags().Changed("api-key-id") || cmd.Flags().Changed("api-key-secret") || cmd.Flags().Changed("scheme") + anyFlagSet := cmd.Flags().Changed("host") || cmd.Flags().Changed("api-key-id") || cmd.Flags().Changed("api-key-secret") if anyFlagSet { // Non-interactive: flags were explicitly provided if host == "" { - host = "cloud.soda.io" + host = "https://cloud.soda.io" } if apiKeyID == "" || apiKeySecret == "" { @@ -95,13 +94,13 @@ var authLoginCmd = &cobra.Command{ } if host == "" { - host = "cloud.soda.io" + host = "https://cloud.soda.io" } form := huh.NewForm(huh.NewGroup( huh.NewInput(). Title("Soda Cloud host"). - Description("EU: cloud.soda.io · US: cloud.us.soda.io"). + Description("EU: https://cloud.soda.io · US: https://cloud.us.soda.io"). Value(&host), huh.NewInput(). Title("API key ID"). @@ -117,11 +116,11 @@ var authLoginCmd = &cobra.Command{ } if host == "" { - host = "cloud.soda.io" + host = "https://cloud.soda.io" } // Test connection before saving - testProfile := config.Profile{Host: host, APIKeyID: apiKeyID, APIKeySecret: apiKeySecret, Scheme: scheme} + testProfile := config.Profile{Host: host, APIKeyID: apiKeyID, APIKeySecret: apiKeySecret} fmt.Println(output.Dim.Render(" Testing connection to " + testProfile.BaseURL() + "...")) if err := api.New(testProfile).Ping(); err != nil { return err @@ -215,10 +214,9 @@ var authSwitchCmd = &cobra.Command{ } func init() { - authLoginCmd.Flags().String("host", "", "Soda Cloud host (default: cloud.soda.io)") + authLoginCmd.Flags().String("host", "", "Soda Cloud host (default: https://cloud.soda.io)") authLoginCmd.Flags().String("api-key-id", "", "Soda Cloud API key ID") authLoginCmd.Flags().String("api-key-secret", "", "Soda Cloud API key secret") - authLoginCmd.Flags().String("scheme", "", `URL scheme: "http" or "https" (default: "https")`) authCmd.AddCommand(authLoginCmd, authLogoutCmd, authStatusCmd, authSwitchCmd) } diff --git a/go/internal/config/credentials.go b/go/internal/config/credentials.go index c97e105..886cee5 100644 --- a/go/internal/config/credentials.go +++ b/go/internal/config/credentials.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "gopkg.in/yaml.v3" ) @@ -12,20 +13,20 @@ type Profile struct { Host string `yaml:"host"` APIKeyID string `yaml:"api_key_id"` APIKeySecret string `yaml:"api_key_secret"` - Scheme string `yaml:"scheme,omitempty"` // "http" or "https" (default: "https") } // BaseURL returns the full base URL for the profile (e.g. "https://cloud.soda.io"). +// If Host already contains a scheme (http:// or https://), it is used as-is. +// Otherwise, https:// is prepended. func (p Profile) BaseURL() string { host := p.Host if host == "" { - host = "cloud.soda.io" + host = "https://cloud.soda.io" } - scheme := p.Scheme - if scheme == "" { - scheme = "https" + if strings.HasPrefix(host, "http://") || strings.HasPrefix(host, "https://") { + return strings.TrimRight(host, "/") } - return scheme + "://" + host + return "https://" + strings.TrimRight(host, "/") } type Credentials map[string]Profile From e15a635fe2659c2758f06cdb56654a611a5535f5 Mon Sep 17 00:00:00 2001 From: Tyler Adkins Date: Sat, 18 Apr 2026 14:54:23 -0400 Subject: [PATCH 3/3] fix: monitor config --enable must populate default monitor types Soda Cloud's PublicApiUpdateDatasetRequest validator now rejects requests with metricMonitoring.enabled=true unless datasetMetricMonitorsConfiguration is also provided: body.metricMonitoring.datasetMetricMonitorsConfiguration: Scan schedule and dataset metric monitors configuration must be provided if metric monitoring is enabled `dataset onboard --monitoring` already sends the list (via EnableDatasetDefaults), but `monitor config --enable --schedule ...` was only sending Enabled + ScanSchedule. Populate the same default monitor types (rowCount, freshness, schema, rowsInserted, totalRowCountChange, timeliness) so both code paths stay in sync. - Export defaultDatasetMonitorTypes as DefaultDatasetMonitorTypes for reuse across cmd/ and internal/api/. - Populate req.DatasetMetricMonitorsConfiguration when --enable is set. Co-Authored-By: Claude Opus 4.7 (1M context) --- go/cmd/monitor.go | 8 ++++++++ go/internal/api/monitors.go | 8 ++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/go/cmd/monitor.go b/go/cmd/monitor.go index 03779af..a3760e4 100644 --- a/go/cmd/monitor.go +++ b/go/cmd/monitor.go @@ -243,6 +243,14 @@ var monitorConfigCmd = &cobra.Command{ if enable { t := true req.Enabled = &t + monitors := make([]api.DatasetMetricMonitorCfg, len(api.DefaultDatasetMonitorTypes)) + for i, mt := range api.DefaultDatasetMonitorTypes { + monitors[i] = api.DatasetMetricMonitorCfg{ + MetricType: mt, + Configuration: api.DatasetMonitorConfig{IsEnabled: true}, + } + } + req.DatasetMetricMonitorsConfiguration = monitors } else if disable { f := false req.Enabled = &f diff --git a/go/internal/api/monitors.go b/go/internal/api/monitors.go index 365a9b5..17ab084 100644 --- a/go/internal/api/monitors.go +++ b/go/internal/api/monitors.go @@ -93,8 +93,8 @@ func (c *Client) UpdateMetricMonitoring(datasetID string, req MetricMonitoringSe // EnableDefaultMonitoring enables all dataset-level metric monitors for a dataset. // It uses POST /api/v1/datasets/{id} (the generic dataset update endpoint) because -// defaultDatasetMonitorTypes are the known API metricType values for dataset-level monitors. -var defaultDatasetMonitorTypes = []string{ +// DefaultDatasetMonitorTypes are the known API metricType values for dataset-level monitors. +var DefaultDatasetMonitorTypes = []string{ "rowCount", "freshness", "schema", "rowsInserted", "totalRowCountChange", "timeliness", } @@ -119,8 +119,8 @@ func (c *Client) EnableDatasetDefaults(datasetID string, monitoring, profiling b if monitoring { t := true - monitors := make([]DatasetMetricMonitorCfg, len(defaultDatasetMonitorTypes)) - for i, mt := range defaultDatasetMonitorTypes { + monitors := make([]DatasetMetricMonitorCfg, len(DefaultDatasetMonitorTypes)) + for i, mt := range DefaultDatasetMonitorTypes { monitors[i] = DatasetMetricMonitorCfg{ MetricType: mt, Configuration: DatasetMonitorConfig{IsEnabled: true},