Skip to content

Add HTTP Request and Response to debug logs #271

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

Merged
merged 9 commits into from
May 3, 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
199 changes: 198 additions & 1 deletion internal/pkg/print/debug.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
package print

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"slices"
"sort"
"strings"

"github.com/stackitcloud/stackit-sdk-go/core/config"
)

var defaultHTTPHeaders = []string{"Accept", "Content-Type", "Content-Length", "User-Agent", "Date", "Referrer-Policy"}

// BuildDebugStrFromInputModel converts an input model to a user-friendly string representation.
// This function converts the input model to a map, removes empty values, and generates a string representation of the map.
// The purpose of this function is to provide a more readable output than the default JSON representation.
Expand All @@ -31,6 +39,9 @@ func BuildDebugStrFromInputModel(model any) (string, error) {
// The string representation is in the format: [key1: value1, key2: value2, ...]
// The keys are ordered alphabetically to make the output deterministic.
func BuildDebugStrFromMap(inputMap map[string]any) string {
if inputMap == nil {
return "[]"
}
// Sort the keys to make the output deterministic
keys := make([]string, 0, len(inputMap))
for key := range inputMap {
Expand All @@ -44,7 +55,25 @@ func BuildDebugStrFromMap(inputMap map[string]any) string {
if isEmpty(value) {
continue
}
keyValues = append(keyValues, fmt.Sprintf("%s: %v", key, value))

valueStr := fmt.Sprintf("%v", value)

switch value := value.(type) {
case map[string]any:
valueStr = BuildDebugStrFromMap(value)
case []any:
sliceStr := make([]string, len(value))
for i, item := range value {
if itemMap, ok := item.(map[string]any); ok {
sliceStr[i] = BuildDebugStrFromMap(itemMap)
} else {
sliceStr[i] = fmt.Sprintf("%v", item)
}
}
valueStr = BuildDebugStrFromSlice(sliceStr)
}

keyValues = append(keyValues, fmt.Sprintf("%s: %v", key, valueStr))
}

result := strings.Join(keyValues, ", ")
Expand All @@ -57,6 +86,174 @@ func BuildDebugStrFromSlice(inputSlice []string) string {
return fmt.Sprintf("[%s]", sliceStr)
}

// buildHeaderMap converts a map to a user-friendly string representation.
// This function also filters the headers based on the includeHeaders parameter.
// If includeHeaders is empty, the default header filters are used.
func buildHeaderMap(headers http.Header, includeHeaders []string) map[string]any {
headersMap := make(map[string]any)
for key, values := range headers {
headersMap[key] = strings.Join(values, ", ")
}

headersToInclude := defaultHTTPHeaders
if len(includeHeaders) != 0 {
headersToInclude = includeHeaders
}
for key := range headersMap {
if !slices.Contains(headersToInclude, key) {
delete(headersMap, key)
}
}

return headersMap
}

// drainBody reads all of b to memory and then returns two equivalent
// ReadClosers yielding the same bytes.
//
// It returns an error if the initial slurp of all bytes fails. It does not attempt
// to make the returned ReadClosers have identical error-matching behavior.
// Taken directly from the httputil package
// https://cs.opensource.google/go/go/+/refs/tags/go1.22.2:src/net/http/httputil/dump.go;drc=1d45a7ef560a76318ed59dfdb178cecd58caf948;l=25
func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) {
if b == nil || b == http.NoBody {
// No copying needed. Preserve the magic sentinel meaning of NoBody.
return http.NoBody, http.NoBody, nil
}
var buf bytes.Buffer
if _, err = buf.ReadFrom(b); err != nil {
return nil, b, err
}
if err := b.Close(); err != nil {
return nil, b, err
}
return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil
}

// BuildDebugStrFromHTTPRequest converts an HTTP request to a user-friendly string representation.
// This function also receives a list of headers to include in the output, if empty, the default headers are used.
// The return value is a list of strings that should be printed separately.
func BuildDebugStrFromHTTPRequest(req *http.Request, includeHeaders []string) ([]string, error) {
if req == nil {
return nil, fmt.Errorf("request is nil")
}
if req.URL == nil || req.Proto == "" || req.Method == "" {
return nil, fmt.Errorf("request is invalid")
}

status := fmt.Sprintf("request to %s: %s %s", req.URL, req.Method, req.Proto)

headersMap := buildHeaderMap(req.Header, includeHeaders)
headers := fmt.Sprintf("request headers: %v", BuildDebugStrFromMap(headersMap))

var save io.ReadCloser
var err error

save, req.Body, err = drainBody(req.Body)
if err != nil {
return []string{status, headers}, fmt.Errorf("drain response body: %w", err)
}
bodyBytes, err := io.ReadAll(req.Body)
if err != nil {
return []string{status, headers}, fmt.Errorf("read response body: %w", err)
}
req.Body = save
var bodyMap map[string]any
if len(bodyBytes) != 0 {
if err := json.Unmarshal(bodyBytes, &bodyMap); err != nil {
return nil, fmt.Errorf("unmarshal response body: %w", err)
}
}
if len(bodyMap) == 0 {
return []string{status, headers}, nil
}
body := fmt.Sprintf("request body: %s", BuildDebugStrFromMap(bodyMap))

return []string{status, headers, body}, nil
}

// BuildDebugStrFromHTTPResponse converts an HTTP response to a user-friendly string representation.
// This function also receives a list of headers to include in the output, if empty, the default headers are used.
// The return value is a list of strings that should be printed separately.
func BuildDebugStrFromHTTPResponse(resp *http.Response, includeHeaders []string) ([]string, error) {
if resp == nil {
return nil, fmt.Errorf("response is nil")
}

if resp.Request == nil || resp.Proto == "" || resp.Status == "" {
return nil, fmt.Errorf("response is invalid")
}

status := fmt.Sprintf("response from %s: %s %s", resp.Request.URL, resp.Proto, resp.Status)

headersMap := buildHeaderMap(resp.Header, includeHeaders)
headers := fmt.Sprintf("response headers: %v", BuildDebugStrFromMap(headersMap))

var save io.ReadCloser
var err error

save, resp.Body, err = drainBody(resp.Body)
if err != nil {
return []string{status, headers}, fmt.Errorf("drain response body: %w", err)
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return []string{status, headers}, fmt.Errorf("read response body: %w", err)
}
resp.Body = save
var bodyMap map[string]any
if len(bodyBytes) != 0 {
if err := json.Unmarshal(bodyBytes, &bodyMap); err != nil {
return nil, fmt.Errorf("unmarshal response body: %w", err)
}
}
if len(bodyMap) == 0 {
return []string{status, headers}, nil
}
body := fmt.Sprintf("response body: %s", BuildDebugStrFromMap(bodyMap))

return []string{status, headers, body}, nil
}

// RequestResponseCapturer is a middleware that captures the request and response of an HTTP request.
// Receives a printer and a list of headers to include in the output
// If the list of headers is empty, the default headers are used.
// The printer is used to print the captured data.
func RequestResponseCapturer(p *Printer, includeHeaders []string) config.Middleware {
return func(rt http.RoundTripper) http.RoundTripper {
return &roundTripperWithCapture{rt, p, includeHeaders}
}
}

type roundTripperWithCapture struct {
transport http.RoundTripper
p *Printer
debugHttpHeaders []string
}

func (rt roundTripperWithCapture) RoundTrip(req *http.Request) (*http.Response, error) {
reqStr, err := BuildDebugStrFromHTTPRequest(req, rt.debugHttpHeaders)
if err != nil {
rt.p.Debug(ErrorLevel, "printing request to debug logs: %v", err)
}
for _, line := range reqStr {
rt.p.Debug(DebugLevel, line)
}
resp, err := rt.transport.RoundTrip(req)
defer func() {
if err == nil {
respStrSlice, tempErr := BuildDebugStrFromHTTPResponse(resp, rt.debugHttpHeaders)
if tempErr != nil {
rt.p.Debug(ErrorLevel, "printing HTTP response to debug logs: %v", tempErr)
}
for _, line := range respStrSlice {
rt.p.Debug(DebugLevel, line)
}
}
}()
return resp, err
}

// isEmpty checks if a value is empty (nil, empty string, zero value for other types)
func isEmpty(value interface{}) bool {
if value == nil {
Expand Down
Loading