Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add option to log response body #80

Merged
merged 16 commits into from
Oct 8, 2024
Merged
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
72 changes: 67 additions & 5 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ type Client struct {
accessToken string // Access Token for client
tokenExpiry time.Time // Token Expiration

LogResponseBody bool // Log Response Body of HTTP Requests

// Optional function called after every successful request made to the API
onRequestCompleted RequestCompletionCallback

Expand All @@ -93,6 +95,41 @@ type AccessTokenResponse struct {
Error string `json:"error"`
}

// Custom Handler with Log Filtering
type LevelFilterHandler struct {
level slog.Level
handler slog.Handler
}

func NewLevelFilterHandler(level slog.Level, handler slog.Handler) *LevelFilterHandler {
return &LevelFilterHandler{level: level, handler: handler}
}

func (h *LevelFilterHandler) Handle(ctx context.Context, r slog.Record) error {
if r.Level >= h.level {
return h.handler.Handle(ctx, r)
}
return nil
}

func (h *LevelFilterHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &LevelFilterHandler{
level: h.level,
handler: h.handler.WithAttrs(attrs),
}
}

func (h *LevelFilterHandler) WithGroup(name string) slog.Handler {
return &LevelFilterHandler{
level: h.level,
handler: h.handler.WithGroup(name),
}
}

func (h *LevelFilterHandler) Enabled(ctx context.Context, level slog.Level) bool {
return level >= h.level
}

// RequestCompletionCallback defines the type of the request callback function
type RequestCompletionCallback func(*http.Request, *http.Response)

Expand Down Expand Up @@ -211,6 +248,14 @@ func WithEnvironment(e Environment) ClientOpt {
}
}

// WithLogResponseBody is a client option for setting the log response body flag
func WithLogResponseBody() ClientOpt {
return func(c *Client) error {
c.LogResponseBody = true
return nil
}
}

// NewRequest creates an API request. A relative URL can be provided in urlStr, which will be resolved to the
// BaseURL of the Client. Relative URLS should always be specified without a preceding slash. If specified, the
// value pointed to by body is JSON encoded and included in as the request body.
Expand Down Expand Up @@ -279,14 +324,31 @@ func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*htt
}
reqTime := time.Since(reqStart)

c.Logger.DebugContext(ctx, "completed API request",
slog.Duration("duration", reqTime),
respBody := resp.Body

attrs := []slog.Attr{slog.Duration("duration", reqTime),
slog.Int("status_code", resp.StatusCode),
slog.String("path", req.URL.EscapedPath()),
slog.String("api_host", c.BaseURL.Host),
slog.String("method", req.Method),
slog.String("trace_id", resp.Header.Get(headerTraceId)),
)
slog.String("trace_id", resp.Header.Get(headerTraceId))}

if c.LogResponseBody {
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}

// Base64 encode the response body
encodedBody := base64.StdEncoding.EncodeToString(b)

// Create new reader for the later code
respBody = io.NopCloser(bytes.NewReader(b))
mega-alex marked this conversation as resolved.
Show resolved Hide resolved

attrs = append(attrs, slog.String("response_body_base_64", encodedBody))
}

c.Logger.DebugContext(ctx, "completed api request", slog.Any("api_request", attrs))

err = CheckResponse(resp)
if err != nil {
Expand All @@ -295,7 +357,7 @@ func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*htt

if resp.StatusCode != http.StatusNoContent && v != nil {
if w, ok := v.(io.Writer); ok {
_, err = io.Copy(w, resp.Body)
_, err = io.Copy(w, respBody)
if err != nil {
return nil, err
}
Expand Down
38 changes: 38 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package megaport

import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/http/httputil"
Expand Down Expand Up @@ -138,6 +141,41 @@ func (suite *ClientTestSuite) TestNewRequest_withCustomUserAgent() {
}
}

// TestNewRequest_withResponseLogging tests if the NewRequest function returns a request with response logging.
func (suite *ClientTestSuite) TestNewRequest_withResponseLogging() {
// for debugging - capture logs
logCapture := &bytes.Buffer{}
levelFilterHandler := NewLevelFilterHandler(slog.LevelDebug, slog.NewJSONHandler(io.Writer(logCapture), nil))

c, err := New(nil, WithLogResponseBody(), WithLogHandler(levelFilterHandler))
if err != nil {
suite.FailNowf("unexpected error", "New() unexpected error: %v", err.Error())
}
suite.client = c
url, _ := url.Parse(suite.server.URL)
suite.client.BaseURL = url

suite.mux.HandleFunc("/a", func(w http.ResponseWriter, r *http.Request) {
if m := http.MethodGet; m != r.Method {
suite.FailNowf("Incorrect request method", "Request method = %v, expected %v", r.Method, m)
}
fmt.Fprint(w, `{"A":"a"}`)
})

req, _ := suite.client.NewRequest(ctx, http.MethodGet, "/a", nil)
_, err = suite.client.Do(ctx, req, nil)
if err != nil {
suite.FailNowf("Unexpected error: Do()", "Unexpected error: Do(): %v", err.Error())
}

// Check the log output for the expected base64 encoded response body
expectedBase64 := "eyJBIjoiYSJ9" // base64 encoded {"A":"a"}
logOutput := logCapture.String()
if !strings.Contains(logOutput, expectedBase64) {
suite.FailNowf("Log output does not contain expected base64", "Log output: %s", logOutput)
}
}

// TestNewRequest_withCustomHeaders tests if the NewRequest function returns a request with custom headers.
func (suite *ClientTestSuite) TestNewRequest_withCustomHeaders() {
expectedIdentity := "identity"
Expand Down