Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
package client

import (
"bytes"
"context"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"math/rand"
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
163 changes: 163 additions & 0 deletions internal/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions internal/tag/tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand All @@ -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()
}

Expand All @@ -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()
}

Expand All @@ -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)
}

Expand Down
Loading