Skip to content

Commit

Permalink
Merge pull request #80 from megaport/feat/json-logging
Browse files Browse the repository at this point in the history
feat: add option to log response body
  • Loading branch information
MegaportPhilipBrowne authored Oct 8, 2024
2 parents 3178fa7 + 30135cf commit 3b8f10d
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 5 deletions.
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))

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

0 comments on commit 3b8f10d

Please sign in to comment.