diff --git a/internal/client/client.go b/internal/client/client.go index 0b98e054..f1fefc4d 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -2,12 +2,14 @@ package client import ( + "bytes" "context" "crypto/x509" "encoding/base64" "encoding/json" "errors" "fmt" + "io" "log" "math" "math/rand" @@ -407,6 +409,23 @@ func DebugTransport(base http.RoundTripper, debug bool) http.RoundTripper { } return RoundTripFunc(func(req *http.Request) (*http.Response, error) { + // Save body before RoundTrip consumes it. + var bodyBytes []byte + + if req.Body != nil { + var err error + + bodyBytes, err = io.ReadAll(req.Body) + + _ = req.Body.Close() + + if err != nil { + return nil, fmt.Errorf("debug transport: failed to read request body: %w", err) + } + + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + // Note: We cannot dump the request here because inner transports // (UserAgent, Auth0Client, etc.) haven't modified it yet. // DumpRequestOut creates a wire representation, but transports @@ -415,6 +434,10 @@ func DebugTransport(base http.RoundTripper, debug bool) http.RoundTripper { // Call the base transport which will trigger all inner transports res, err := base.RoundTrip(req) + // Restore body for dumping. + if bodyBytes != nil { + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } // Now dump the request after transports have modified it // We do this before checking error so we can see what was attempted dumpRequest(req) diff --git a/internal/client/client_test.go b/internal/client/client_test.go index dd76667d..fbacb50c 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -1051,6 +1051,169 @@ func TestDebugTransport(t *testing.T) { assert.Contains(t, logOutput, "GET") assert.NotContains(t, logOutput, "<") }) + + t.Run("POST request body is preserved in debug output", func(t *testing.T) { + var buf bytes.Buffer + + log.SetOutput(&buf) + + defer log.SetOutput(os.Stderr) + + base := RoundTripFunc(func(req *http.Request) (*http.Response, error) { + // Consume the body like a real transport would. + if req.Body != nil { + _, _ = io.ReadAll(req.Body) + } + + return &http.Response{ + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(`{"id":"123"}`)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }) + + transport := DebugTransport(base, true) + body := `{"name":"test-client","app_type":"spa"}` + req, _ := http.NewRequest("POST", "http://example.com/api/v2/clients", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := transport.RoundTrip(req) + + assert.NoError(t, err) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + logOutput := buf.String() + assert.Contains(t, logOutput, "POST /api/v2/clients") + assert.Contains(t, logOutput, body, "POST request body should appear in debug output") + assert.Contains(t, logOutput, `{"id":"123"}`) + }) + + t.Run("PATCH request body is preserved in debug output", func(t *testing.T) { + var buf bytes.Buffer + + log.SetOutput(&buf) + + defer log.SetOutput(os.Stderr) + + base := RoundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Body != nil { + _, _ = io.ReadAll(req.Body) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"updated":true}`)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }) + + transport := DebugTransport(base, true) + body := `{"name":"updated-client"}` + req, _ := http.NewRequest("PATCH", "http://example.com/api/v2/clients/123", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := transport.RoundTrip(req) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + logOutput := buf.String() + assert.Contains(t, logOutput, "PATCH /api/v2/clients/123") + assert.Contains(t, logOutput, body, "PATCH request body should appear in debug output") + }) + + t.Run("PUT request body is preserved in debug output", func(t *testing.T) { + var buf bytes.Buffer + + log.SetOutput(&buf) + + defer log.SetOutput(os.Stderr) + + base := RoundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Body != nil { + _, _ = io.ReadAll(req.Body) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"replaced":true}`)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }) + + transport := DebugTransport(base, true) + body := `{"name":"replaced-client","app_type":"regular_web"}` + req, _ := http.NewRequest("PUT", "http://example.com/api/v2/clients/456", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := transport.RoundTrip(req) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + logOutput := buf.String() + assert.Contains(t, logOutput, "PUT /api/v2/clients/456") + assert.Contains(t, logOutput, body, "PUT request body should appear in debug output") + }) + + t.Run("Request body is preserved in debug output even on error", func(t *testing.T) { + var buf bytes.Buffer + + log.SetOutput(&buf) + + defer log.SetOutput(os.Stderr) + + expectedErr := fmt.Errorf("connection refused") + base := RoundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Body != nil { + _, _ = io.ReadAll(req.Body) + } + + return nil, expectedErr + }) + + transport := DebugTransport(base, true) + body := `{"name":"test"}` + req, _ := http.NewRequest("POST", "http://example.com/api/v2/clients", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := transport.RoundTrip(req) + + assert.Error(t, err) + assert.Nil(t, resp) + + logOutput := buf.String() + assert.Contains(t, logOutput, "POST /api/v2/clients") + assert.Contains(t, logOutput, body, "Request body should appear in debug output even when transport returns error") + }) + + t.Run("GET request with no body still works", func(t *testing.T) { + var buf bytes.Buffer + + log.SetOutput(&buf) + + defer log.SetOutput(os.Stderr) + + base := RoundTripFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"clients":[]}`)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }) + + transport := DebugTransport(base, true) + req, _ := http.NewRequest("GET", "http://example.com/api/v2/clients", nil) + + resp, err := transport.RoundTrip(req) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + logOutput := buf.String() + assert.Contains(t, logOutput, "GET /api/v2/clients") + assert.Contains(t, logOutput, `{"clients":[]}`) + }) } func TestWithDebug(t *testing.T) { diff --git a/internal/tag/tag.go b/internal/tag/tag.go index d2aba3cd..8eca0cde 100644 --- a/internal/tag/tag.go +++ b/internal/tag/tag.go @@ -8,7 +8,7 @@ import ( // Scopes is used to get the list of scopes. func Scopes(v interface{}) (scopes []string) { val := reflect.ValueOf(v) - if val.Kind() == reflect.Ptr { + if val.Kind() == reflect.Pointer { val = val.Elem() } @@ -18,7 +18,7 @@ func Scopes(v interface{}) (scopes []string) { if scope, ok := typ.Field(i).Tag.Lookup("scope"); ok { if scope != "" { field := val.Field(i) - if field.Kind() == reflect.Ptr { + if field.Kind() == reflect.Pointer { field = field.Elem() } @@ -35,7 +35,7 @@ func Scopes(v interface{}) (scopes []string) { // SetScopes is used for setting the scopes. func SetScopes(v interface{}, enable bool, scopes ...string) { val := reflect.ValueOf(v) - if val.Kind() == reflect.Ptr { + if val.Kind() == reflect.Pointer { val = val.Elem() } @@ -57,7 +57,7 @@ func SetScopes(v interface{}, enable bool, scopes ...string) { field := val.Field(i) v := reflect.ValueOf(enable) - if field.Kind() == reflect.Ptr { + if field.Kind() == reflect.Pointer { v = reflect.ValueOf(&enable) }