diff --git a/.changes/next-release/bugfix-client-4qrbt6n2.json b/.changes/next-release/bugfix-client-4qrbt6n2.json new file mode 100644 index 0000000..4e2a7b1 --- /dev/null +++ b/.changes/next-release/bugfix-client-4qrbt6n2.json @@ -0,0 +1,5 @@ +{ + "type": "bugfix", + "category": "client", + "description": "Surface nested API error bodies ({\"error\":{\"message\":...}}) instead of a bare HTTP status, and make the --debug flag log request/response" +} diff --git a/go/internal/client/client.go b/go/internal/client/client.go index 8c6433e..8e553e6 100644 --- a/go/internal/client/client.go +++ b/go/internal/client/client.go @@ -8,6 +8,8 @@ import ( "io" "net/http" "net/url" + "os" + "strings" "time" "github.com/vngcloud/greennode-cli/internal/auth" @@ -191,6 +193,14 @@ func (c *GreenodeClient) requestRaw(method, path string, params map[string]strin req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") + if c.debug { + fmt.Fprintf(os.Stderr, "[debug] %s %s\n", method, fullURL) + if body != nil { + jsonBody, _ := json.Marshal(body) + fmt.Fprintf(os.Stderr, "[debug] request body: %s\n", string(jsonBody)) + } + } + resp, err := c.httpClient.Do(req) if err != nil { lastErr = err @@ -205,6 +215,10 @@ func (c *GreenodeClient) requestRaw(method, path string, params map[string]strin respBody, _ := io.ReadAll(resp.Body) resp.Body.Close() + if c.debug { + fmt.Fprintf(os.Stderr, "[debug] response %d: %s\n", resp.StatusCode, string(respBody)) + } + // 401 — refresh token and retry once if resp.StatusCode == http.StatusUnauthorized { token, err = c.tokenManager.RefreshToken() @@ -266,6 +280,12 @@ func formatError(statusCode int, body []byte) string { } } } + // Fallback: the error payload didn't use a known string field (e.g. the + // VKS API returns {"error": {...}} as a nested object). Surface the raw + // JSON body so the server's message isn't silently dropped. + if detail == "" && len(body) > 0 { + detail = strings.TrimSpace(string(body)) + } } else { detail = string(body) } diff --git a/go/internal/client/client_test.go b/go/internal/client/client_test.go index 0df21a0..7b65225 100644 --- a/go/internal/client/client_test.go +++ b/go/internal/client/client_test.go @@ -4,6 +4,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -39,3 +40,21 @@ func TestPatchSendsPatchMethodAndBody(t *testing.T) { t.Errorf("body = %q, want enableAutoHealing payload", gotBody) } } + +func TestFormatErrorSurfacesNestedErrorObject(t *testing.T) { + // VKS returns errors as {"error": {"message": ...}} — a nested object, not a + // string. The detail must still reach the user instead of being dropped. + body := []byte(`{"error":{"message":"KubeConfig can only be requested when the cluster is ACTIVE."}}`) + got := formatError(http.StatusBadRequest, body) + if !strings.Contains(got, "cluster is ACTIVE") { + t.Errorf("formatError = %q, want it to contain the nested error message", got) + } +} + +func TestFormatErrorUsesPlainStringMessage(t *testing.T) { + body := []byte(`{"message":"boom"}`) + got := formatError(http.StatusBadRequest, body) + if !strings.Contains(got, "boom") { + t.Errorf("formatError = %q, want it to contain %q", got, "boom") + } +}