Skip to content

Commit 485c714

Browse files
authored
Add HTTP Request and Response to debug logs (#271)
* initial implementation * remove unused body close * add comment explaing the body closure * add testing * remove reduntant comments * address PR comments, refactor reading the body * fix linting * simplify implementation * extend other clients with debug middleware
1 parent 05a040f commit 485c714

File tree

17 files changed

+607
-1
lines changed

17 files changed

+607
-1
lines changed

internal/pkg/print/debug.go

Lines changed: 198 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
package print
22

33
import (
4+
"bytes"
45
"encoding/json"
56
"fmt"
7+
"io"
8+
"net/http"
9+
"slices"
610
"sort"
711
"strings"
12+
13+
"github.com/stackitcloud/stackit-sdk-go/core/config"
814
)
915

16+
var defaultHTTPHeaders = []string{"Accept", "Content-Type", "Content-Length", "User-Agent", "Date", "Referrer-Policy"}
17+
1018
// BuildDebugStrFromInputModel converts an input model to a user-friendly string representation.
1119
// This function converts the input model to a map, removes empty values, and generates a string representation of the map.
1220
// The purpose of this function is to provide a more readable output than the default JSON representation.
@@ -31,6 +39,9 @@ func BuildDebugStrFromInputModel(model any) (string, error) {
3139
// The string representation is in the format: [key1: value1, key2: value2, ...]
3240
// The keys are ordered alphabetically to make the output deterministic.
3341
func BuildDebugStrFromMap(inputMap map[string]any) string {
42+
if inputMap == nil {
43+
return "[]"
44+
}
3445
// Sort the keys to make the output deterministic
3546
keys := make([]string, 0, len(inputMap))
3647
for key := range inputMap {
@@ -44,7 +55,25 @@ func BuildDebugStrFromMap(inputMap map[string]any) string {
4455
if isEmpty(value) {
4556
continue
4657
}
47-
keyValues = append(keyValues, fmt.Sprintf("%s: %v", key, value))
58+
59+
valueStr := fmt.Sprintf("%v", value)
60+
61+
switch value := value.(type) {
62+
case map[string]any:
63+
valueStr = BuildDebugStrFromMap(value)
64+
case []any:
65+
sliceStr := make([]string, len(value))
66+
for i, item := range value {
67+
if itemMap, ok := item.(map[string]any); ok {
68+
sliceStr[i] = BuildDebugStrFromMap(itemMap)
69+
} else {
70+
sliceStr[i] = fmt.Sprintf("%v", item)
71+
}
72+
}
73+
valueStr = BuildDebugStrFromSlice(sliceStr)
74+
}
75+
76+
keyValues = append(keyValues, fmt.Sprintf("%s: %v", key, valueStr))
4877
}
4978

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

89+
// buildHeaderMap converts a map to a user-friendly string representation.
90+
// This function also filters the headers based on the includeHeaders parameter.
91+
// If includeHeaders is empty, the default header filters are used.
92+
func buildHeaderMap(headers http.Header, includeHeaders []string) map[string]any {
93+
headersMap := make(map[string]any)
94+
for key, values := range headers {
95+
headersMap[key] = strings.Join(values, ", ")
96+
}
97+
98+
headersToInclude := defaultHTTPHeaders
99+
if len(includeHeaders) != 0 {
100+
headersToInclude = includeHeaders
101+
}
102+
for key := range headersMap {
103+
if !slices.Contains(headersToInclude, key) {
104+
delete(headersMap, key)
105+
}
106+
}
107+
108+
return headersMap
109+
}
110+
111+
// drainBody reads all of b to memory and then returns two equivalent
112+
// ReadClosers yielding the same bytes.
113+
//
114+
// It returns an error if the initial slurp of all bytes fails. It does not attempt
115+
// to make the returned ReadClosers have identical error-matching behavior.
116+
// Taken directly from the httputil package
117+
// https://cs.opensource.google/go/go/+/refs/tags/go1.22.2:src/net/http/httputil/dump.go;drc=1d45a7ef560a76318ed59dfdb178cecd58caf948;l=25
118+
func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) {
119+
if b == nil || b == http.NoBody {
120+
// No copying needed. Preserve the magic sentinel meaning of NoBody.
121+
return http.NoBody, http.NoBody, nil
122+
}
123+
var buf bytes.Buffer
124+
if _, err = buf.ReadFrom(b); err != nil {
125+
return nil, b, err
126+
}
127+
if err := b.Close(); err != nil {
128+
return nil, b, err
129+
}
130+
return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil
131+
}
132+
133+
// BuildDebugStrFromHTTPRequest converts an HTTP request to a user-friendly string representation.
134+
// This function also receives a list of headers to include in the output, if empty, the default headers are used.
135+
// The return value is a list of strings that should be printed separately.
136+
func BuildDebugStrFromHTTPRequest(req *http.Request, includeHeaders []string) ([]string, error) {
137+
if req == nil {
138+
return nil, fmt.Errorf("request is nil")
139+
}
140+
if req.URL == nil || req.Proto == "" || req.Method == "" {
141+
return nil, fmt.Errorf("request is invalid")
142+
}
143+
144+
status := fmt.Sprintf("request to %s: %s %s", req.URL, req.Method, req.Proto)
145+
146+
headersMap := buildHeaderMap(req.Header, includeHeaders)
147+
headers := fmt.Sprintf("request headers: %v", BuildDebugStrFromMap(headersMap))
148+
149+
var save io.ReadCloser
150+
var err error
151+
152+
save, req.Body, err = drainBody(req.Body)
153+
if err != nil {
154+
return []string{status, headers}, fmt.Errorf("drain response body: %w", err)
155+
}
156+
bodyBytes, err := io.ReadAll(req.Body)
157+
if err != nil {
158+
return []string{status, headers}, fmt.Errorf("read response body: %w", err)
159+
}
160+
req.Body = save
161+
var bodyMap map[string]any
162+
if len(bodyBytes) != 0 {
163+
if err := json.Unmarshal(bodyBytes, &bodyMap); err != nil {
164+
return nil, fmt.Errorf("unmarshal response body: %w", err)
165+
}
166+
}
167+
if len(bodyMap) == 0 {
168+
return []string{status, headers}, nil
169+
}
170+
body := fmt.Sprintf("request body: %s", BuildDebugStrFromMap(bodyMap))
171+
172+
return []string{status, headers, body}, nil
173+
}
174+
175+
// BuildDebugStrFromHTTPResponse converts an HTTP response to a user-friendly string representation.
176+
// This function also receives a list of headers to include in the output, if empty, the default headers are used.
177+
// The return value is a list of strings that should be printed separately.
178+
func BuildDebugStrFromHTTPResponse(resp *http.Response, includeHeaders []string) ([]string, error) {
179+
if resp == nil {
180+
return nil, fmt.Errorf("response is nil")
181+
}
182+
183+
if resp.Request == nil || resp.Proto == "" || resp.Status == "" {
184+
return nil, fmt.Errorf("response is invalid")
185+
}
186+
187+
status := fmt.Sprintf("response from %s: %s %s", resp.Request.URL, resp.Proto, resp.Status)
188+
189+
headersMap := buildHeaderMap(resp.Header, includeHeaders)
190+
headers := fmt.Sprintf("response headers: %v", BuildDebugStrFromMap(headersMap))
191+
192+
var save io.ReadCloser
193+
var err error
194+
195+
save, resp.Body, err = drainBody(resp.Body)
196+
if err != nil {
197+
return []string{status, headers}, fmt.Errorf("drain response body: %w", err)
198+
}
199+
bodyBytes, err := io.ReadAll(resp.Body)
200+
if err != nil {
201+
return []string{status, headers}, fmt.Errorf("read response body: %w", err)
202+
}
203+
resp.Body = save
204+
var bodyMap map[string]any
205+
if len(bodyBytes) != 0 {
206+
if err := json.Unmarshal(bodyBytes, &bodyMap); err != nil {
207+
return nil, fmt.Errorf("unmarshal response body: %w", err)
208+
}
209+
}
210+
if len(bodyMap) == 0 {
211+
return []string{status, headers}, nil
212+
}
213+
body := fmt.Sprintf("response body: %s", BuildDebugStrFromMap(bodyMap))
214+
215+
return []string{status, headers, body}, nil
216+
}
217+
218+
// RequestResponseCapturer is a middleware that captures the request and response of an HTTP request.
219+
// Receives a printer and a list of headers to include in the output
220+
// If the list of headers is empty, the default headers are used.
221+
// The printer is used to print the captured data.
222+
func RequestResponseCapturer(p *Printer, includeHeaders []string) config.Middleware {
223+
return func(rt http.RoundTripper) http.RoundTripper {
224+
return &roundTripperWithCapture{rt, p, includeHeaders}
225+
}
226+
}
227+
228+
type roundTripperWithCapture struct {
229+
transport http.RoundTripper
230+
p *Printer
231+
debugHttpHeaders []string
232+
}
233+
234+
func (rt roundTripperWithCapture) RoundTrip(req *http.Request) (*http.Response, error) {
235+
reqStr, err := BuildDebugStrFromHTTPRequest(req, rt.debugHttpHeaders)
236+
if err != nil {
237+
rt.p.Debug(ErrorLevel, "printing request to debug logs: %v", err)
238+
}
239+
for _, line := range reqStr {
240+
rt.p.Debug(DebugLevel, line)
241+
}
242+
resp, err := rt.transport.RoundTrip(req)
243+
defer func() {
244+
if err == nil {
245+
respStrSlice, tempErr := BuildDebugStrFromHTTPResponse(resp, rt.debugHttpHeaders)
246+
if tempErr != nil {
247+
rt.p.Debug(ErrorLevel, "printing HTTP response to debug logs: %v", tempErr)
248+
}
249+
for _, line := range respStrSlice {
250+
rt.p.Debug(DebugLevel, line)
251+
}
252+
}
253+
}()
254+
return resp, err
255+
}
256+
60257
// isEmpty checks if a value is empty (nil, empty string, zero value for other types)
61258
func isEmpty(value interface{}) bool {
62259
if value == nil {

0 commit comments

Comments
 (0)