From 38d6b8925f2a1fdcbea72c7d11e5eeefab665650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Thu, 2 May 2024 14:12:33 +0100 Subject: [PATCH 1/9] initial implementation --- internal/pkg/print/debug.go | 148 +++++++++++++++++++ internal/pkg/services/argus/client/client.go | 6 + 2 files changed, 154 insertions(+) diff --git a/internal/pkg/print/debug.go b/internal/pkg/print/debug.go index 36686ed4f..2f709b32c 100644 --- a/internal/pkg/print/debug.go +++ b/internal/pkg/print/debug.go @@ -3,10 +3,17 @@ package print import ( "encoding/json" "fmt" + "io" + "net/http" + "slices" "sort" "strings" + + "github.com/stackitcloud/stackit-sdk-go/core/config" ) +var defaultDebugIncludeHeaders = []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. @@ -44,6 +51,21 @@ func BuildDebugStrFromMap(inputMap map[string]any) string { if isEmpty(value) { continue } + // If the value is a map, convert it to a string representation + if valueMap, ok := value.(map[string]any); ok { + value = BuildDebugStrFromMap(valueMap) + } + + // If the value is a slice, convert it to a string representation + if valueSlice, ok := value.([]any); ok { + sliceStr := make([]string, len(valueSlice)) + for i, item := range valueSlice { + if itemMap, ok := item.(map[string]any); ok { + sliceStr[i] = BuildDebugStrFromMap(itemMap) + } + } + value = BuildDebugStrFromSlice(sliceStr) + } keyValues = append(keyValues, fmt.Sprintf("%s: %v", key, value)) } @@ -57,6 +79,132 @@ 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, ", ") + } + + var headersToInclude []string + + if len(includeHeaders) == 0 { + headersToInclude = defaultDebugIncludeHeaders + } else { + headersToInclude = includeHeaders + } + + for key := range headersMap { + if slices.Contains(headersToInclude, key) { + continue + } + delete(headersMap, key) + } + + return headersMap +} + +// 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) { + 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)) + + if req.Body == nil { + return []string{status, headers}, nil + } + // defer req.Body.Close() + body, err := io.ReadAll(req.Body) + if err != nil { + return []string{status, headers}, fmt.Errorf("read request body: %w", err) + } + var bodyMap map[string]any + if len(body) != 0 { + if err := json.Unmarshal(body, &bodyMap); err != nil { + return []string{status, headers}, fmt.Errorf("unmarshal request body: %w", err) + } + } + // restore body + req.Body = io.NopCloser(strings.NewReader(string(body))) + payload := fmt.Sprintf("response body: %v", BuildDebugStrFromMap(bodyMap)) + + return []string{status, headers, payload}, 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) { + 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)) + + if resp.Body == nil { + return []string{status, headers}, nil + } + // defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return []string{status, headers}, fmt.Errorf("read response body: %w", err) + } + var bodyMap map[string]any + if len(body) != 0 { + if err := json.Unmarshal(body, &bodyMap); err != nil { + return []string{status, headers}, fmt.Errorf("unmarshal response body: %w", err) + } + } + // restore body + resp.Body = io.NopCloser(strings.NewReader(string(body))) + payload := fmt.Sprintf("response body: %v", BuildDebugStrFromMap(bodyMap)) + + return []string{status, headers, payload}, 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 + debugHeaders []string +} + +func (rt roundTripperWithCapture) RoundTrip(req *http.Request) (*http.Response, error) { + reqStr, err := BuildDebugStrFromHTTPRequest(req, rt.debugHeaders) + if err != nil { + rt.p.Debug(ErrorLevel, "build request debug string: %v", err) + } + for _, line := range reqStr { + rt.p.Debug(DebugLevel, line) + } + resp, err := rt.transport.RoundTrip(req) + defer func() { + if err == nil { + respStrSlice, err := BuildDebugStrFromHTTPResponse(resp, rt.debugHeaders) + if err != nil { + rt.p.Debug(ErrorLevel, "build response debug string: %v", err) + } + 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 { diff --git a/internal/pkg/services/argus/client/client.go b/internal/pkg/services/argus/client/client.go index cf852d45f..1d7b49361 100644 --- a/internal/pkg/services/argus/client/client.go +++ b/internal/pkg/services/argus/client/client.go @@ -29,6 +29,12 @@ func ConfigureClient(p *print.Printer) (*argus.APIClient, error) { cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, []string{})), + ) + } + apiClient, err = argus.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err) From a17af6759bf19ddaee60af3f5a645fe1cce701e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Thu, 2 May 2024 14:48:46 +0100 Subject: [PATCH 2/9] remove unused body close --- internal/pkg/print/debug.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/pkg/print/debug.go b/internal/pkg/print/debug.go index 2f709b32c..6a1432abb 100644 --- a/internal/pkg/print/debug.go +++ b/internal/pkg/print/debug.go @@ -118,7 +118,6 @@ func BuildDebugStrFromHTTPRequest(req *http.Request, includeHeaders []string) ([ if req.Body == nil { return []string{status, headers}, nil } - // defer req.Body.Close() body, err := io.ReadAll(req.Body) if err != nil { return []string{status, headers}, fmt.Errorf("read request body: %w", err) @@ -148,7 +147,6 @@ func BuildDebugStrFromHTTPResponse(resp *http.Response, includeHeaders []string) if resp.Body == nil { return []string{status, headers}, nil } - // defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return []string{status, headers}, fmt.Errorf("read response body: %w", err) From 4b7737c2894ba912957c57979ca1d4ac73362c4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Thu, 2 May 2024 14:51:48 +0100 Subject: [PATCH 3/9] add comment explaing the body closure --- internal/pkg/print/debug.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/pkg/print/debug.go b/internal/pkg/print/debug.go index 6a1432abb..d9fd43115 100644 --- a/internal/pkg/print/debug.go +++ b/internal/pkg/print/debug.go @@ -128,7 +128,9 @@ func BuildDebugStrFromHTTPRequest(req *http.Request, includeHeaders []string) ([ return []string{status, headers}, fmt.Errorf("unmarshal request body: %w", err) } } + // restore body + // no need to close the body because the sdk will do it req.Body = io.NopCloser(strings.NewReader(string(body))) payload := fmt.Sprintf("response body: %v", BuildDebugStrFromMap(bodyMap)) @@ -157,7 +159,9 @@ func BuildDebugStrFromHTTPResponse(resp *http.Response, includeHeaders []string) return []string{status, headers}, fmt.Errorf("unmarshal response body: %w", err) } } + // restore body + // no need to close the body because the sdk will do it resp.Body = io.NopCloser(strings.NewReader(string(body))) payload := fmt.Sprintf("response body: %v", BuildDebugStrFromMap(bodyMap)) From f1ed39956bbcaf972c9171f116e5799c9a066fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Thu, 2 May 2024 15:56:12 +0100 Subject: [PATCH 4/9] add testing --- internal/pkg/print/debug.go | 23 ++- internal/pkg/print/debug_test.go | 318 +++++++++++++++++++++++++++++++ 2 files changed, 338 insertions(+), 3 deletions(-) diff --git a/internal/pkg/print/debug.go b/internal/pkg/print/debug.go index d9fd43115..493c9e051 100644 --- a/internal/pkg/print/debug.go +++ b/internal/pkg/print/debug.go @@ -62,6 +62,8 @@ func BuildDebugStrFromMap(inputMap map[string]any) string { for i, item := range valueSlice { if itemMap, ok := item.(map[string]any); ok { sliceStr[i] = BuildDebugStrFromMap(itemMap) + } else { + sliceStr[i] = fmt.Sprintf("%v", item) } } value = BuildDebugStrFromSlice(sliceStr) @@ -110,6 +112,13 @@ func buildHeaderMap(headers http.Header, includeHeaders []string) map[string]any // 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) @@ -132,7 +141,7 @@ func BuildDebugStrFromHTTPRequest(req *http.Request, includeHeaders []string) ([ // restore body // no need to close the body because the sdk will do it req.Body = io.NopCloser(strings.NewReader(string(body))) - payload := fmt.Sprintf("response body: %v", BuildDebugStrFromMap(bodyMap)) + payload := fmt.Sprintf("request body: %v", BuildDebugStrFromMap(bodyMap)) return []string{status, headers, payload}, nil } @@ -141,6 +150,14 @@ func BuildDebugStrFromHTTPRequest(req *http.Request, includeHeaders []string) ([ // 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) @@ -187,7 +204,7 @@ type roundTripperWithCapture struct { func (rt roundTripperWithCapture) RoundTrip(req *http.Request) (*http.Response, error) { reqStr, err := BuildDebugStrFromHTTPRequest(req, rt.debugHeaders) if err != nil { - rt.p.Debug(ErrorLevel, "build request debug string: %v", err) + rt.p.Debug(ErrorLevel, "printing request to debug logs: %v", err) } for _, line := range reqStr { rt.p.Debug(DebugLevel, line) @@ -197,7 +214,7 @@ func (rt roundTripperWithCapture) RoundTrip(req *http.Request) (*http.Response, if err == nil { respStrSlice, err := BuildDebugStrFromHTTPResponse(resp, rt.debugHeaders) if err != nil { - rt.p.Debug(ErrorLevel, "build response debug string: %v", err) + rt.p.Debug(ErrorLevel, "printing response to debug logs: %v", err) } for _, line := range respStrSlice { rt.p.Debug(DebugLevel, line) diff --git a/internal/pkg/print/debug_test.go b/internal/pkg/print/debug_test.go index 61ad30450..fc81925ad 100644 --- a/internal/pkg/print/debug_test.go +++ b/internal/pkg/print/debug_test.go @@ -1,8 +1,14 @@ package print import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" "testing" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" ) @@ -51,6 +57,52 @@ func fixtureInputModel(mods ...func(model *testInputModel)) *testInputModel { return model } +func fixtureHTTPRequest(mods ...func(req *http.Request)) *http.Request { + testBody, err := json.Marshal(map[string]string{"key": "value"}) + if err != nil { + return nil + } + request, err := http.NewRequest("GET", "http://example.com", bytes.NewReader(testBody)) + if err != nil { + return nil + } + + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Length", "15") + + for _, mod := range mods { + mod(request) + } + + return request +} + +func fixtureHTTPResponse(mods ...func(resp *http.Response)) *http.Response { + testBody, err := json.Marshal(map[string]string{"key": "value"}) + if err != nil { + return nil + } + response := &http.Response{ + Body: io.NopCloser(bytes.NewReader(testBody)), + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + Status: "200 OK", + Request: &http.Request{Method: "GET", URL: &url.URL{Host: "example.com", Scheme: "http"}}, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"application/json"}, + "Content-Length": []string{"15"}, + }, + } + + for _, mod := range mods { + mod(response) + } + + return response +} + func TestBuildDebugStrFromInputModel(t *testing.T) { tests := []struct { description string @@ -129,6 +181,48 @@ func TestBuildDebugStrFromMap(t *testing.T) { }, expected: "[key1: value1, key2: value2, key3: 123, key4: false]", }, + { + description: "nested map", + inputMap: map[string]any{ + "key1": "value1", + "key2": map[string]any{ + "nestedKey1": "nestedValue1", + "nestedKey2": "nestedValue2", + }, + }, + expected: "[key1: value1, key2: [nestedKey1: nestedValue1, nestedKey2: nestedValue2]]", + }, + { + description: "nested slice of string", + inputMap: map[string]any{ + "key1": "value1", + "key2": []any{"value1", "value2"}, + }, + expected: "[key1: value1, key2: [value1, value2]]", + }, + { + description: "nested slice of int", + inputMap: map[string]any{ + "key1": "value1", + "key2": []any{1, 2}, + }, + expected: "[key1: value1, key2: [1, 2]]", + }, + { + description: "nested slice of map", + inputMap: map[string]any{ + "key1": "value1", + "key2": []any{ + map[string]any{ + "nestedKey1": "nestedValue1", + }, + map[string]any{ + "nestedKey2": "nestedValue2", + }, + }, + }, + expected: "[key1: value1, key2: [[nestedKey1: nestedValue1], [nestedKey2: nestedValue2]]]", + }, { description: "empty values", inputMap: map[string]any{ @@ -184,6 +278,230 @@ func TestBuildDebugStrFromSlice(t *testing.T) { } } +func TestBuildHeaderMap(t *testing.T) { + tests := []struct { + description string + inputHeader http.Header + inputIncludeHeaders []string + expected map[string]any + }{ + { + description: "base", + inputHeader: http.Header{ + "key1": []string{"value1"}, + "key2": []string{"value2"}, + "key3": []string{"value3"}, + }, + inputIncludeHeaders: []string{"key1", "key2"}, + expected: map[string]any{ + "key1": "value1", + "key2": "value2", + }, + }, + { + description: "no include headers", + inputHeader: http.Header{ + "Accept": []string{"value1"}, + "key2": []string{"value2"}, + "Date": []string{"value3"}, + }, + inputIncludeHeaders: []string{}, + expected: map[string]any{ + "Accept": "value1", + "Date": "value3", + }, + }, + { + description: "empty header", + inputHeader: http.Header{}, + inputIncludeHeaders: []string{}, + expected: map[string]any{}, + }, + { + description: "empty header, some include headers", + inputHeader: http.Header{}, + inputIncludeHeaders: []string{ + "key1", + "key2", + }, + expected: map[string]any{}, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + actual := buildHeaderMap(tt.inputHeader, tt.inputIncludeHeaders) + diff := cmp.Diff(actual, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildDebugStrFromHTTPRequest(t *testing.T) { + tests := []struct { + description string + inputReq *http.Request + inputIncludeHeaders []string + expected []string + isValid bool + }{ + { + description: "base", + inputReq: fixtureHTTPRequest(), + expected: []string{ + "request to http://example.com: GET HTTP/1.1", + "request headers: [Accept: application/json, Content-Length: 15, Content-Type: application/json]", + "request body: [key: value]", + }, + isValid: true, + }, + { + description: "include headers", + inputReq: fixtureHTTPRequest(), + inputIncludeHeaders: []string{"Content-Type", "Accept"}, + expected: []string{ + "request to http://example.com: GET HTTP/1.1", + "request headers: [Accept: application/json, Content-Type: application/json]", + "request body: [key: value]", + }, + isValid: true, + }, + { + description: "empty request", + inputReq: &http.Request{}, + isValid: false, + }, + { + description: "nil request", + inputReq: nil, + isValid: false, + }, + { + description: "empty headers", + inputReq: fixtureHTTPRequest(func(req *http.Request) { + req.Header = http.Header{} + }), + expected: []string{ + "request to http://example.com: GET HTTP/1.1", + "request headers: []", + "request body: [key: value]", + }, + isValid: true, + }, + { + description: "empty body", + inputReq: fixtureHTTPRequest(func(req *http.Request) { + req.Body = nil + }), + expected: []string{ + "request to http://example.com: GET HTTP/1.1", + "request headers: [Accept: application/json, Content-Length: 15, Content-Type: application/json]", + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + actual, err := BuildDebugStrFromHTTPRequest(tt.inputReq, tt.inputIncludeHeaders) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("unexpected error: %v", err) + } + if !tt.isValid { + t.Fatalf("expected error, got nil") + } + diff := cmp.Diff(actual, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildDebugStrFromHTTPResponse(t *testing.T) { + tests := []struct { + description string + inputResp *http.Response + inputIncludeHeaders []string + expected []string + isValid bool + }{ + { + description: "base", + inputResp: fixtureHTTPResponse(), // nolint:bodyclose // false positive, body is closed in the test + expected: []string{ + "response from http://example.com: HTTP/1.1 200 OK", + "response headers: [Accept: application/json, Content-Length: 15, Content-Type: application/json]", + "response body: [key: value]", + }, + isValid: true, + }, + { + description: "empty response", + inputResp: &http.Response{}, + isValid: false, + }, + { + description: "nil response", + inputResp: nil, + isValid: false, + }, + { + description: "empty headers", + inputResp: fixtureHTTPResponse(func(resp *http.Response) { // nolint:bodyclose // false positive, body is closed in the test + resp.Header = http.Header{} + }), + expected: []string{ + "response from http://example.com: HTTP/1.1 200 OK", + "response headers: []", + "response body: [key: value]", + }, + isValid: true, + }, + { + description: "empty body", + inputResp: fixtureHTTPResponse(func(resp *http.Response) { // nolint:bodyclose // false positive, body is closed in the test + resp.Body = nil + }), + expected: []string{ + "response from http://example.com: HTTP/1.1 200 OK", + "response headers: [Accept: application/json, Content-Length: 15, Content-Type: application/json]", + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + var err error + if tt.inputResp != nil && tt.inputResp.Body != nil { + defer func() { + err = tt.inputResp.Body.Close() + }() + } + actual, err := BuildDebugStrFromHTTPResponse(tt.inputResp, tt.inputIncludeHeaders) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("unexpected error: %v", err) + } + if !tt.isValid { + t.Fatalf("expected error, got nil") + } + diff := cmp.Diff(actual, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + func TestIsEmpty(t *testing.T) { tests := []struct { description string From aad8d2f2d619d7e855cb94ab32e1e5a4a3c45bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Thu, 2 May 2024 15:58:55 +0100 Subject: [PATCH 5/9] remove reduntant comments --- internal/pkg/print/debug.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/pkg/print/debug.go b/internal/pkg/print/debug.go index 493c9e051..781d8853c 100644 --- a/internal/pkg/print/debug.go +++ b/internal/pkg/print/debug.go @@ -51,7 +51,7 @@ func BuildDebugStrFromMap(inputMap map[string]any) string { if isEmpty(value) { continue } - // If the value is a map, convert it to a string representation + if valueMap, ok := value.(map[string]any); ok { value = BuildDebugStrFromMap(valueMap) } @@ -139,7 +139,6 @@ func BuildDebugStrFromHTTPRequest(req *http.Request, includeHeaders []string) ([ } // restore body - // no need to close the body because the sdk will do it req.Body = io.NopCloser(strings.NewReader(string(body))) payload := fmt.Sprintf("request body: %v", BuildDebugStrFromMap(bodyMap)) @@ -178,7 +177,6 @@ func BuildDebugStrFromHTTPResponse(resp *http.Response, includeHeaders []string) } // restore body - // no need to close the body because the sdk will do it resp.Body = io.NopCloser(strings.NewReader(string(body))) payload := fmt.Sprintf("response body: %v", BuildDebugStrFromMap(bodyMap)) From c4fe537adb5f25a3001be12e93160888147b3ee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Fri, 3 May 2024 11:06:53 +0100 Subject: [PATCH 6/9] address PR comments, refactor reading the body --- internal/pkg/print/debug.go | 196 ++++++++++++++----- internal/pkg/print/debug_test.go | 145 +++++++++++++- internal/pkg/services/argus/client/client.go | 2 +- 3 files changed, 289 insertions(+), 54 deletions(-) diff --git a/internal/pkg/print/debug.go b/internal/pkg/print/debug.go index 781d8853c..e74d03ac9 100644 --- a/internal/pkg/print/debug.go +++ b/internal/pkg/print/debug.go @@ -1,10 +1,12 @@ package print import ( + "bytes" "encoding/json" "fmt" "io" "net/http" + "net/http/httputil" "slices" "sort" "strings" @@ -12,7 +14,7 @@ import ( "github.com/stackitcloud/stackit-sdk-go/core/config" ) -var defaultDebugIncludeHeaders = []string{"Accept", "Content-Type", "Content-Length", "User-Agent", "Date", "Referrer-Policy"} +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. @@ -38,6 +40,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 { @@ -52,23 +57,24 @@ func BuildDebugStrFromMap(inputMap map[string]any) string { continue } - if valueMap, ok := value.(map[string]any); ok { - value = BuildDebugStrFromMap(valueMap) - } + valueStr := fmt.Sprintf("%v", value) - // If the value is a slice, convert it to a string representation - if valueSlice, ok := value.([]any); ok { - sliceStr := make([]string, len(valueSlice)) - for i, item := range valueSlice { + 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) } } - value = BuildDebugStrFromSlice(sliceStr) + valueStr = BuildDebugStrFromSlice(sliceStr) } - keyValues = append(keyValues, fmt.Sprintf("%s: %v", key, value)) + + keyValues = append(keyValues, fmt.Sprintf("%s: %v", key, valueStr)) } result := strings.Join(keyValues, ", ") @@ -90,19 +96,15 @@ func buildHeaderMap(headers http.Header, includeHeaders []string) map[string]any headersMap[key] = strings.Join(values, ", ") } - var headersToInclude []string - - if len(includeHeaders) == 0 { - headersToInclude = defaultDebugIncludeHeaders - } else { + headersToInclude := defaultHTTPHeaders + if len(includeHeaders) != 0 { headersToInclude = includeHeaders } for key := range headersMap { - if slices.Contains(headersToInclude, key) { - continue + if !slices.Contains(headersToInclude, key) { + delete(headersMap, key) } - delete(headersMap, key) } return headersMap @@ -124,25 +126,38 @@ func BuildDebugStrFromHTTPRequest(req *http.Request, includeHeaders []string) ([ headersMap := buildHeaderMap(req.Header, includeHeaders) headers := fmt.Sprintf("request headers: %v", BuildDebugStrFromMap(headersMap)) - if req.Body == nil { - return []string{status, headers}, nil - } - body, err := io.ReadAll(req.Body) + bodyMap, err := dumpReqBody(req) if err != nil { return []string{status, headers}, fmt.Errorf("read request body: %w", err) } - var bodyMap map[string]any - if len(body) != 0 { - if err := json.Unmarshal(body, &bodyMap); err != nil { - return []string{status, headers}, fmt.Errorf("unmarshal request body: %w", err) - } + if bodyMap == nil { + return []string{status, headers}, nil } + body := fmt.Sprintf("request body: %s", BuildDebugStrFromMap(bodyMap)) - // restore body - req.Body = io.NopCloser(strings.NewReader(string(body))) - payload := fmt.Sprintf("request body: %v", BuildDebugStrFromMap(bodyMap)) + return []string{status, headers, body}, nil +} - return []string{status, headers, payload}, nil +// 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 direclty 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 } // BuildDebugStrFromHTTPResponse converts an HTTP response to a user-friendly string representation. @@ -162,25 +177,110 @@ func BuildDebugStrFromHTTPResponse(resp *http.Response, includeHeaders []string) headersMap := buildHeaderMap(resp.Header, includeHeaders) headers := fmt.Sprintf("response headers: %v", BuildDebugStrFromMap(headersMap)) - if resp.Body == nil { + bodyMap, err := dumpRespBody(resp) + if err != nil { + return []string{status, headers}, fmt.Errorf("read response body: %w", err) + } + if bodyMap == nil { return []string{status, headers}, nil } - body, err := io.ReadAll(resp.Body) + body := fmt.Sprintf("response body: %s", BuildDebugStrFromMap(bodyMap)) + + return []string{status, headers, body}, nil +} + +// dumpRespBody reads the response body and returns a string representation of it. +// Based on code from httputil package +// https://pkg.go.dev/net/http/httputil#DumpResponse +func dumpRespBody(resp *http.Response) (map[string]any, error) { + if resp == nil { + return nil, fmt.Errorf("response is nil") + } + if resp.Body == nil || resp.ContentLength == 0 { + return nil, nil + } + var err error + var buf bytes.Buffer + var save io.ReadCloser + + savecl := resp.ContentLength + + save, resp.Body, err = drainBody(resp.Body) if err != nil { - return []string{status, headers}, fmt.Errorf("read response body: %w", err) + return nil, err + } + if _, err = buf.ReadFrom(resp.Body); err != nil { + return nil, fmt.Errorf("read response body: %w", err) } + if err = resp.Body.Close(); err != nil { + return nil, fmt.Errorf("close response body: %w", err) + } + + resp.Body = save + resp.ContentLength = savecl + var bodyMap map[string]any - if len(body) != 0 { - if err := json.Unmarshal(body, &bodyMap); err != nil { - return []string{status, headers}, fmt.Errorf("unmarshal response body: %w", err) + if len(buf.Bytes()) != 0 { + if err := json.Unmarshal(buf.Bytes(), &bodyMap); err != nil { + return nil, fmt.Errorf("unmarshal response body: %w", err) + } + } + + return bodyMap, nil +} + +// dumpReqBody reads the request body and returns a string representation of it. +// Based on code from httputil package +// https://pkg.go.dev/net/http/httputil#DumpRequest +func dumpReqBody(req *http.Request) (map[string]any, error) { + if req == nil { + return nil, fmt.Errorf("request is nil") + } + if req.Body == nil { + return nil, nil + } + var err error + var b bytes.Buffer + var save io.ReadCloser + + save, req.Body, err = drainBody(req.Body) + if err != nil { + return nil, err + } + + var dest io.Writer = &b + chunked := len(req.TransferEncoding) > 0 && req.TransferEncoding[0] == "chunked" + if chunked { + dest = httputil.NewChunkedWriter(&b) + } + _, err = io.Copy(dest, req.Body) + if chunked { + if closer, ok := dest.(io.Closer); ok { + err = closer.Close() + if err != nil { + return nil, fmt.Errorf("close chunked writer: %w", err) + } + } + _, err = b.WriteString("\r\n") + if err != nil { + return nil, fmt.Errorf("write chunked trailer: %w", err) } } - // restore body - resp.Body = io.NopCloser(strings.NewReader(string(body))) - payload := fmt.Sprintf("response body: %v", BuildDebugStrFromMap(bodyMap)) + req.Body = save + if err != nil { + return nil, err + } - return []string{status, headers, payload}, nil + // marshall body to map + var bodyMap map[string]any + if len(b.Bytes()) != 0 { + if err := json.Unmarshal(b.Bytes(), &bodyMap); err != nil { + return nil, fmt.Errorf("unmarshal request body: %w", err) + } + } + + return bodyMap, nil } // RequestResponseCapturer is a middleware that captures the request and response of an HTTP request. @@ -194,13 +294,13 @@ func RequestResponseCapturer(p *Printer, includeHeaders []string) config.Middlew } type roundTripperWithCapture struct { - transport http.RoundTripper - p *Printer - debugHeaders []string + transport http.RoundTripper + p *Printer + debugHttpHeaders []string } func (rt roundTripperWithCapture) RoundTrip(req *http.Request) (*http.Response, error) { - reqStr, err := BuildDebugStrFromHTTPRequest(req, rt.debugHeaders) + reqStr, err := BuildDebugStrFromHTTPRequest(req, rt.debugHttpHeaders) if err != nil { rt.p.Debug(ErrorLevel, "printing request to debug logs: %v", err) } @@ -210,9 +310,9 @@ func (rt roundTripperWithCapture) RoundTrip(req *http.Request) (*http.Response, resp, err := rt.transport.RoundTrip(req) defer func() { if err == nil { - respStrSlice, err := BuildDebugStrFromHTTPResponse(resp, rt.debugHeaders) - if err != nil { - rt.p.Debug(ErrorLevel, "printing response to debug logs: %v", err) + 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) diff --git a/internal/pkg/print/debug_test.go b/internal/pkg/print/debug_test.go index fc81925ad..bfbe9beb1 100644 --- a/internal/pkg/print/debug_test.go +++ b/internal/pkg/print/debug_test.go @@ -84,11 +84,12 @@ func fixtureHTTPResponse(mods ...func(resp *http.Response)) *http.Response { return nil } response := &http.Response{ - Body: io.NopCloser(bytes.NewReader(testBody)), - StatusCode: http.StatusOK, - Proto: "HTTP/1.1", - Status: "200 OK", - Request: &http.Request{Method: "GET", URL: &url.URL{Host: "example.com", Scheme: "http"}}, + Body: io.NopCloser(bytes.NewReader(testBody)), + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + Status: "200 OK", + ContentLength: int64(len(testBody)), + Request: &http.Request{Method: "GET", URL: &url.URL{Host: "example.com", Scheme: "http"}}, Header: http.Header{ "Content-Type": []string{"application/json"}, "Accept": []string{"application/json"}, @@ -589,3 +590,137 @@ func TestIsEmpty(t *testing.T) { }) } } + +func TestDumpRespBody(t *testing.T) { + tests := []struct { + description string + resp *http.Response + expected map[string]any + isValid bool + }{ + { + description: "base", + resp: fixtureHTTPResponse(), // nolint:bodyclose // false positive, body is closed in the test + expected: map[string]any{ + "key": "value", + }, + isValid: true, + }, + { + description: "empty response", + resp: &http.Response{}, + isValid: true, + expected: nil, + }, + { + description: "nil response", + resp: nil, + isValid: false, + }, + { + description: "empty body", + resp: fixtureHTTPResponse(func(resp *http.Response) { // nolint:bodyclose // false positive, body is closed in the test + resp.Body = nil + }), + isValid: true, + }, + { + description: "invalid body", + resp: fixtureHTTPResponse(func(resp *http.Response) { // nolint:bodyclose // false positive, body is closed in the test + resp.Body = io.NopCloser(bytes.NewReader([]byte("invalid"))) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + if tt.resp != nil && tt.resp.Body != nil { + defer func() { + _ = tt.resp.Body.Close() + }() + } + actual, err := dumpRespBody(tt.resp) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("unexpected error: %v", err) + } + if !tt.isValid { + t.Fatalf("expected error, got nil") + } + diff := cmp.Diff(actual, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestDumpReqBody(t *testing.T){ + tests := []struct { + description string + req *http.Request + expected map[string]any + isValid bool + }{ + { + description: "base", + req: fixtureHTTPRequest(), + expected: map[string]any{ + "key": "value", + }, + isValid: true, + }, + { + description: "empty request", + req: &http.Request{}, + isValid: true, + expected: nil, + }, + { + description: "nil request", + req: nil, + isValid: false, + }, + { + description: "empty body", + req: fixtureHTTPRequest(func(req *http.Request) { + req.Body = nil + }), + isValid: true, + }, + { + description: "invalid body", + req: fixtureHTTPRequest(func(req *http.Request) { + req.Body = io.NopCloser(bytes.NewReader([]byte("invalid"))) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + if tt.req != nil && tt.req.Body != nil { + defer func() { + _ = tt.req.Body.Close() + }() + } + actual, err := dumpReqBody(tt.req) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("unexpected error: %v", err) + } + if !tt.isValid { + t.Fatalf("expected error, got nil") + } + diff := cmp.Diff(actual, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} \ No newline at end of file diff --git a/internal/pkg/services/argus/client/client.go b/internal/pkg/services/argus/client/client.go index 1d7b49361..c2f3a11da 100644 --- a/internal/pkg/services/argus/client/client.go +++ b/internal/pkg/services/argus/client/client.go @@ -31,7 +31,7 @@ func ConfigureClient(p *print.Printer) (*argus.APIClient, error) { if p.IsVerbosityDebug() { cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, []string{})), + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), ) } From e4fb29a9047e31f05d77660598c243777b5b12a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Fri, 3 May 2024 11:07:57 +0100 Subject: [PATCH 7/9] fix linting --- internal/pkg/print/debug.go | 2 +- internal/pkg/print/debug_test.go | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/pkg/print/debug.go b/internal/pkg/print/debug.go index e74d03ac9..37aadd865 100644 --- a/internal/pkg/print/debug.go +++ b/internal/pkg/print/debug.go @@ -143,7 +143,7 @@ func BuildDebugStrFromHTTPRequest(req *http.Request, includeHeaders []string) ([ // // 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 direclty from the httputil package +// 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 { diff --git a/internal/pkg/print/debug_test.go b/internal/pkg/print/debug_test.go index bfbe9beb1..9ee206863 100644 --- a/internal/pkg/print/debug_test.go +++ b/internal/pkg/print/debug_test.go @@ -658,16 +658,16 @@ func TestDumpRespBody(t *testing.T) { } } -func TestDumpReqBody(t *testing.T){ +func TestDumpReqBody(t *testing.T) { tests := []struct { description string - req *http.Request + req *http.Request expected map[string]any isValid bool }{ { description: "base", - req: fixtureHTTPRequest(), + req: fixtureHTTPRequest(), expected: map[string]any{ "key": "value", }, @@ -675,13 +675,13 @@ func TestDumpReqBody(t *testing.T){ }, { description: "empty request", - req: &http.Request{}, + req: &http.Request{}, isValid: true, expected: nil, }, { description: "nil request", - req: nil, + req: nil, isValid: false, }, { @@ -723,4 +723,4 @@ func TestDumpReqBody(t *testing.T){ } }) } -} \ No newline at end of file +} From 91091222a8514f781c88382545638fbb5b4aaf6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Fri, 3 May 2024 15:30:33 +0100 Subject: [PATCH 8/9] simplify implementation --- internal/pkg/print/debug.go | 166 +++++++++---------------------- internal/pkg/print/debug_test.go | 134 ------------------------- 2 files changed, 49 insertions(+), 251 deletions(-) diff --git a/internal/pkg/print/debug.go b/internal/pkg/print/debug.go index 37aadd865..59352682e 100644 --- a/internal/pkg/print/debug.go +++ b/internal/pkg/print/debug.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "net/http/httputil" "slices" "sort" "strings" @@ -100,7 +99,6 @@ func buildHeaderMap(headers http.Header, includeHeaders []string) map[string]any if len(includeHeaders) != 0 { headersToInclude = includeHeaders } - for key := range headersMap { if !slices.Contains(headersToInclude, key) { delete(headersMap, key) @@ -110,34 +108,6 @@ func buildHeaderMap(headers http.Header, includeHeaders []string) map[string]any return headersMap } -// 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)) - - bodyMap, err := dumpReqBody(req) - if err != nil { - return []string{status, headers}, fmt.Errorf("read request body: %w", err) - } - if bodyMap == nil { - return []string{status, headers}, nil - } - body := fmt.Sprintf("request body: %s", BuildDebugStrFromMap(bodyMap)) - - return []string{status, headers, body}, nil -} - // drainBody reads all of b to memory and then returns two equivalent // ReadClosers yielding the same bytes. // @@ -160,127 +130,89 @@ func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) { return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil } -// BuildDebugStrFromHTTPResponse converts an HTTP response to a user-friendly string representation. +// 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 BuildDebugStrFromHTTPResponse(resp *http.Response, includeHeaders []string) ([]string, error) { - if resp == nil { - return nil, fmt.Errorf("response is nil") +func BuildDebugStrFromHTTPRequest(req *http.Request, includeHeaders []string) ([]string, error) { + if req == nil { + return nil, fmt.Errorf("request is nil") } - - if resp.Request == nil || resp.Proto == "" || resp.Status == "" { - return nil, fmt.Errorf("response is invalid") + if req.URL == nil || req.Proto == "" || req.Method == "" { + return nil, fmt.Errorf("request is invalid") } - status := fmt.Sprintf("response from %s: %s %s", resp.Request.URL, resp.Proto, resp.Status) + status := fmt.Sprintf("request to %s: %s %s", req.URL, req.Method, req.Proto) - headersMap := buildHeaderMap(resp.Header, includeHeaders) - headers := fmt.Sprintf("response headers: %v", BuildDebugStrFromMap(headersMap)) + headersMap := buildHeaderMap(req.Header, includeHeaders) + headers := fmt.Sprintf("request headers: %v", BuildDebugStrFromMap(headersMap)) + + var save io.ReadCloser + var err error - bodyMap, err := dumpRespBody(resp) + 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) } - if bodyMap == nil { + 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("response body: %s", BuildDebugStrFromMap(bodyMap)) + body := fmt.Sprintf("request body: %s", BuildDebugStrFromMap(bodyMap)) return []string{status, headers, body}, nil } -// dumpRespBody reads the response body and returns a string representation of it. -// Based on code from httputil package -// https://pkg.go.dev/net/http/httputil#DumpResponse -func dumpRespBody(resp *http.Response) (map[string]any, error) { +// 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.Body == nil || resp.ContentLength == 0 { - return nil, nil - } - var err error - var buf bytes.Buffer - var save io.ReadCloser - - savecl := resp.ContentLength - save, resp.Body, err = drainBody(resp.Body) - if err != nil { - return nil, err - } - if _, err = buf.ReadFrom(resp.Body); err != nil { - return nil, fmt.Errorf("read response body: %w", err) - } - if err = resp.Body.Close(); err != nil { - return nil, fmt.Errorf("close response body: %w", err) + if resp.Request == nil || resp.Proto == "" || resp.Status == "" { + return nil, fmt.Errorf("response is invalid") } - resp.Body = save - resp.ContentLength = savecl - - var bodyMap map[string]any - if len(buf.Bytes()) != 0 { - if err := json.Unmarshal(buf.Bytes(), &bodyMap); err != nil { - return nil, fmt.Errorf("unmarshal response body: %w", err) - } - } + status := fmt.Sprintf("response from %s: %s %s", resp.Request.URL, resp.Proto, resp.Status) - return bodyMap, nil -} + headersMap := buildHeaderMap(resp.Header, includeHeaders) + headers := fmt.Sprintf("response headers: %v", BuildDebugStrFromMap(headersMap)) -// dumpReqBody reads the request body and returns a string representation of it. -// Based on code from httputil package -// https://pkg.go.dev/net/http/httputil#DumpRequest -func dumpReqBody(req *http.Request) (map[string]any, error) { - if req == nil { - return nil, fmt.Errorf("request is nil") - } - if req.Body == nil { - return nil, nil - } - var err error - var b bytes.Buffer var save io.ReadCloser + var err error - save, req.Body, err = drainBody(req.Body) + save, resp.Body, err = drainBody(resp.Body) if err != nil { - return nil, err - } - - var dest io.Writer = &b - chunked := len(req.TransferEncoding) > 0 && req.TransferEncoding[0] == "chunked" - if chunked { - dest = httputil.NewChunkedWriter(&b) - } - _, err = io.Copy(dest, req.Body) - if chunked { - if closer, ok := dest.(io.Closer); ok { - err = closer.Close() - if err != nil { - return nil, fmt.Errorf("close chunked writer: %w", err) - } - } - _, err = b.WriteString("\r\n") - if err != nil { - return nil, fmt.Errorf("write chunked trailer: %w", err) - } + return []string{status, headers}, fmt.Errorf("drain response body: %w", err) } - - req.Body = save + bodyBytes, err := io.ReadAll(resp.Body) if err != nil { - return nil, err + return []string{status, headers}, fmt.Errorf("read response body: %w", err) } - - // marshall body to map + resp.Body = save var bodyMap map[string]any - if len(b.Bytes()) != 0 { - if err := json.Unmarshal(b.Bytes(), &bodyMap); err != nil { - return nil, fmt.Errorf("unmarshal request body: %w", err) + 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 bodyMap, nil + return []string{status, headers, body}, nil } // RequestResponseCapturer is a middleware that captures the request and response of an HTTP request. diff --git a/internal/pkg/print/debug_test.go b/internal/pkg/print/debug_test.go index 9ee206863..35c3dfb6a 100644 --- a/internal/pkg/print/debug_test.go +++ b/internal/pkg/print/debug_test.go @@ -590,137 +590,3 @@ func TestIsEmpty(t *testing.T) { }) } } - -func TestDumpRespBody(t *testing.T) { - tests := []struct { - description string - resp *http.Response - expected map[string]any - isValid bool - }{ - { - description: "base", - resp: fixtureHTTPResponse(), // nolint:bodyclose // false positive, body is closed in the test - expected: map[string]any{ - "key": "value", - }, - isValid: true, - }, - { - description: "empty response", - resp: &http.Response{}, - isValid: true, - expected: nil, - }, - { - description: "nil response", - resp: nil, - isValid: false, - }, - { - description: "empty body", - resp: fixtureHTTPResponse(func(resp *http.Response) { // nolint:bodyclose // false positive, body is closed in the test - resp.Body = nil - }), - isValid: true, - }, - { - description: "invalid body", - resp: fixtureHTTPResponse(func(resp *http.Response) { // nolint:bodyclose // false positive, body is closed in the test - resp.Body = io.NopCloser(bytes.NewReader([]byte("invalid"))) - }), - isValid: false, - }, - } - - for _, tt := range tests { - t.Run(tt.description, func(t *testing.T) { - if tt.resp != nil && tt.resp.Body != nil { - defer func() { - _ = tt.resp.Body.Close() - }() - } - actual, err := dumpRespBody(tt.resp) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("unexpected error: %v", err) - } - if !tt.isValid { - t.Fatalf("expected error, got nil") - } - diff := cmp.Diff(actual, tt.expected) - if diff != "" { - t.Fatalf("Data does not match: %s", diff) - } - }) - } -} - -func TestDumpReqBody(t *testing.T) { - tests := []struct { - description string - req *http.Request - expected map[string]any - isValid bool - }{ - { - description: "base", - req: fixtureHTTPRequest(), - expected: map[string]any{ - "key": "value", - }, - isValid: true, - }, - { - description: "empty request", - req: &http.Request{}, - isValid: true, - expected: nil, - }, - { - description: "nil request", - req: nil, - isValid: false, - }, - { - description: "empty body", - req: fixtureHTTPRequest(func(req *http.Request) { - req.Body = nil - }), - isValid: true, - }, - { - description: "invalid body", - req: fixtureHTTPRequest(func(req *http.Request) { - req.Body = io.NopCloser(bytes.NewReader([]byte("invalid"))) - }), - isValid: false, - }, - } - - for _, tt := range tests { - t.Run(tt.description, func(t *testing.T) { - if tt.req != nil && tt.req.Body != nil { - defer func() { - _ = tt.req.Body.Close() - }() - } - actual, err := dumpReqBody(tt.req) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("unexpected error: %v", err) - } - if !tt.isValid { - t.Fatalf("expected error, got nil") - } - diff := cmp.Diff(actual, tt.expected) - if diff != "" { - t.Fatalf("Data does not match: %s", diff) - } - }) - } -} From d6715dd1a68f1fdb6b7802611b0e4c70cff9bd54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Fri, 3 May 2024 16:48:57 +0100 Subject: [PATCH 9/9] extend other clients with debug middleware --- internal/pkg/services/authorization/client/client.go | 6 ++++++ internal/pkg/services/dns/client/client.go | 6 ++++++ internal/pkg/services/logme/client/client.go | 6 ++++++ internal/pkg/services/mariadb/client/client.go | 6 ++++++ internal/pkg/services/mongodbflex/client/client.go | 6 ++++++ internal/pkg/services/object-storage/client/client.go | 6 ++++++ internal/pkg/services/opensearch/client/client.go | 6 ++++++ internal/pkg/services/postgresflex/client/client.go | 6 ++++++ internal/pkg/services/rabbitmq/client/client.go | 6 ++++++ internal/pkg/services/redis/client/client.go | 6 ++++++ internal/pkg/services/resourcemanager/client/client.go | 6 ++++++ internal/pkg/services/secrets-manager/client/client.go | 6 ++++++ internal/pkg/services/service-account/client/client.go | 6 ++++++ internal/pkg/services/ske/client/client.go | 6 ++++++ 14 files changed, 84 insertions(+) diff --git a/internal/pkg/services/authorization/client/client.go b/internal/pkg/services/authorization/client/client.go index b4f475da7..19c13d663 100644 --- a/internal/pkg/services/authorization/client/client.go +++ b/internal/pkg/services/authorization/client/client.go @@ -29,6 +29,12 @@ func ConfigureClient(p *print.Printer) (*authorization.APIClient, error) { cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + apiClient, err = authorization.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err) diff --git a/internal/pkg/services/dns/client/client.go b/internal/pkg/services/dns/client/client.go index 6555222dd..384bc2cca 100644 --- a/internal/pkg/services/dns/client/client.go +++ b/internal/pkg/services/dns/client/client.go @@ -29,6 +29,12 @@ func ConfigureClient(p *print.Printer) (*dns.APIClient, error) { cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + apiClient, err = dns.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err) diff --git a/internal/pkg/services/logme/client/client.go b/internal/pkg/services/logme/client/client.go index 36bcd8a15..e99e46bf7 100644 --- a/internal/pkg/services/logme/client/client.go +++ b/internal/pkg/services/logme/client/client.go @@ -29,6 +29,12 @@ func ConfigureClient(p *print.Printer) (*logme.APIClient, error) { cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + apiClient, err = logme.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err) diff --git a/internal/pkg/services/mariadb/client/client.go b/internal/pkg/services/mariadb/client/client.go index 06046a36d..4d4dbce7e 100644 --- a/internal/pkg/services/mariadb/client/client.go +++ b/internal/pkg/services/mariadb/client/client.go @@ -29,6 +29,12 @@ func ConfigureClient(p *print.Printer) (*mariadb.APIClient, error) { cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + apiClient, err = mariadb.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err) diff --git a/internal/pkg/services/mongodbflex/client/client.go b/internal/pkg/services/mongodbflex/client/client.go index 77bec7c9b..addcfe34a 100644 --- a/internal/pkg/services/mongodbflex/client/client.go +++ b/internal/pkg/services/mongodbflex/client/client.go @@ -29,6 +29,12 @@ func ConfigureClient(p *print.Printer) (*mongodbflex.APIClient, error) { cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + apiClient, err = mongodbflex.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err) diff --git a/internal/pkg/services/object-storage/client/client.go b/internal/pkg/services/object-storage/client/client.go index 100db6111..f1a3a2147 100644 --- a/internal/pkg/services/object-storage/client/client.go +++ b/internal/pkg/services/object-storage/client/client.go @@ -29,6 +29,12 @@ func ConfigureClient(p *print.Printer) (*objectstorage.APIClient, error) { cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + apiClient, err = objectstorage.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err) diff --git a/internal/pkg/services/opensearch/client/client.go b/internal/pkg/services/opensearch/client/client.go index e18dc4d70..b4036b37c 100644 --- a/internal/pkg/services/opensearch/client/client.go +++ b/internal/pkg/services/opensearch/client/client.go @@ -29,6 +29,12 @@ func ConfigureClient(p *print.Printer) (*opensearch.APIClient, error) { cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + apiClient, err = opensearch.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err) diff --git a/internal/pkg/services/postgresflex/client/client.go b/internal/pkg/services/postgresflex/client/client.go index eb4d52960..3698b1a46 100644 --- a/internal/pkg/services/postgresflex/client/client.go +++ b/internal/pkg/services/postgresflex/client/client.go @@ -29,6 +29,12 @@ func ConfigureClient(p *print.Printer) (*postgresflex.APIClient, error) { cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + apiClient, err = postgresflex.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err) diff --git a/internal/pkg/services/rabbitmq/client/client.go b/internal/pkg/services/rabbitmq/client/client.go index 0c82aa2cd..821037064 100644 --- a/internal/pkg/services/rabbitmq/client/client.go +++ b/internal/pkg/services/rabbitmq/client/client.go @@ -29,6 +29,12 @@ func ConfigureClient(p *print.Printer) (*rabbitmq.APIClient, error) { cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + apiClient, err = rabbitmq.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err) diff --git a/internal/pkg/services/redis/client/client.go b/internal/pkg/services/redis/client/client.go index 58113d565..90e523c85 100644 --- a/internal/pkg/services/redis/client/client.go +++ b/internal/pkg/services/redis/client/client.go @@ -29,6 +29,12 @@ func ConfigureClient(p *print.Printer) (*redis.APIClient, error) { cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + apiClient, err = redis.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err) diff --git a/internal/pkg/services/resourcemanager/client/client.go b/internal/pkg/services/resourcemanager/client/client.go index a250411ba..ce1ae5620 100644 --- a/internal/pkg/services/resourcemanager/client/client.go +++ b/internal/pkg/services/resourcemanager/client/client.go @@ -29,6 +29,12 @@ func ConfigureClient(p *print.Printer) (*resourcemanager.APIClient, error) { cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + apiClient, err = resourcemanager.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err) diff --git a/internal/pkg/services/secrets-manager/client/client.go b/internal/pkg/services/secrets-manager/client/client.go index c5a380803..e6aa7f2b5 100644 --- a/internal/pkg/services/secrets-manager/client/client.go +++ b/internal/pkg/services/secrets-manager/client/client.go @@ -29,6 +29,12 @@ func ConfigureClient(p *print.Printer) (*secretsmanager.APIClient, error) { cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + apiClient, err = secretsmanager.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err) diff --git a/internal/pkg/services/service-account/client/client.go b/internal/pkg/services/service-account/client/client.go index 984394f03..b4ba6919d 100644 --- a/internal/pkg/services/service-account/client/client.go +++ b/internal/pkg/services/service-account/client/client.go @@ -29,6 +29,12 @@ func ConfigureClient(p *print.Printer) (*serviceaccount.APIClient, error) { cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + apiClient, err = serviceaccount.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err) diff --git a/internal/pkg/services/ske/client/client.go b/internal/pkg/services/ske/client/client.go index 9d5c26eb6..36175a964 100644 --- a/internal/pkg/services/ske/client/client.go +++ b/internal/pkg/services/ske/client/client.go @@ -30,6 +30,12 @@ func ConfigureClient(p *print.Printer) (*ske.APIClient, error) { cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01")) } + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + apiClient, err = ske.NewAPIClient(cfgOptions...) if err != nil { p.Debug(print.ErrorLevel, "create new API client: %v", err)