From 01dd6ca875b55fe32ac2e9e9915d2f4726e35627 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Sun, 17 Nov 2024 20:08:38 +0000 Subject: [PATCH] feat: proxy can capture request body and headers. --- cmd/proxy.go | 9 ++++++++- impostermodel/model.go | 15 +++++++++++---- proxy/content.go | 36 ++++++++++++++++++++++++++++++++++++ proxy/proxy.go | 5 +++-- proxy/recorder.go | 33 ++++++++++++++++++++++++++++++++- proxy/rewrite.go | 29 ++--------------------------- 6 files changed, 92 insertions(+), 35 deletions(-) create mode 100644 proxy/content.go diff --git a/cmd/proxy.go b/cmd/proxy.go index 28fbbb4..a87f48b 100644 --- a/cmd/proxy.go +++ b/cmd/proxy.go @@ -28,6 +28,8 @@ var proxyFlags = struct { port int outputDir string rewrite bool + captureRequestBody bool + captureRequestHeaders bool ignoreDuplicateRequests bool recordOnlyResponseHeaders []string flatResponseFileStructure bool @@ -52,6 +54,8 @@ var proxyCmd = &cobra.Command{ outputDir = workingDir } options := proxy.RecorderOptions{ + CaptureRequestBody: proxyFlags.captureRequestBody, + CaptureRequestHeaders: proxyFlags.captureRequestHeaders, IgnoreDuplicateRequests: proxyFlags.ignoreDuplicateRequests, RecordOnlyResponseHeaders: proxyFlags.recordOnlyResponseHeaders, FlatResponseFileStructure: proxyFlags.flatResponseFileStructure, @@ -63,6 +67,8 @@ var proxyCmd = &cobra.Command{ func init() { proxyCmd.Flags().IntVarP(&proxyFlags.port, "port", "p", 8080, "Port on which to listen") proxyCmd.Flags().StringVarP(&proxyFlags.outputDir, "output-dir", "o", "", "Directory in which HTTP exchanges are recorded (default: current working directory)") + proxyCmd.Flags().BoolVar(&proxyFlags.captureRequestBody, "capture-request-body", false, "Capture the request body") + proxyCmd.Flags().BoolVar(&proxyFlags.captureRequestHeaders, "capture-request-headers", false, "Capture the request headers") proxyCmd.Flags().BoolVarP(&proxyFlags.rewrite, "rewrite-urls", "r", false, "Rewrite upstream URL in response body to proxy URL") proxyCmd.Flags().BoolVarP(&proxyFlags.ignoreDuplicateRequests, "ignore-duplicate-requests", "i", true, "Ignore duplicate requests with same method and URI") proxyCmd.Flags().StringSliceVarP(&proxyFlags.recordOnlyResponseHeaders, "response-headers", "H", nil, "Record only these response headers") @@ -82,12 +88,13 @@ func proxyUpstream(upstream string, port int, dir string, rewrite bool, options _, _ = fmt.Fprintf(writer, "ok\n") }) mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { - proxy.Handle(upstream, writer, request, func(statusCode int, respBody *[]byte, respHeaders *http.Header) (*[]byte, *http.Header) { + proxy.Handle(upstream, writer, request, func(reqBody *[]byte, statusCode int, respBody *[]byte, respHeaders *http.Header) (*[]byte, *http.Header) { if rewrite { respBody = proxy.Rewrite(respHeaders, respBody, upstream, port) } recorderC <- proxy.HttpExchange{ Request: request, + RequestBody: reqBody, StatusCode: statusCode, ResponseBody: respBody, ResponseHeaders: respHeaders, diff --git a/impostermodel/model.go b/impostermodel/model.go index d68441f..4ec6909 100644 --- a/impostermodel/model.go +++ b/impostermodel/model.go @@ -25,11 +25,18 @@ type ResponseConfig struct { Headers *map[string]string `json:"headers,omitempty"` } +type RequestBody struct { + Operator string `json:"operator"` + Value string `json:"value"` +} + type Resource struct { - Path string `json:"path"` - Method string `json:"method"` - QueryParams *map[string]string `json:"queryParams,omitempty"` - Response *ResponseConfig `json:"response,omitempty"` + Path string `json:"path"` + Method string `json:"method"` + QueryParams *map[string]string `json:"queryParams,omitempty"` + RequestBody *RequestBody `json:"requestBody,omitempty"` + RequestHeaders *map[string]string `json:"requestHeaders,omitempty"` + Response *ResponseConfig `json:"response,omitempty"` } type PluginConfig struct { diff --git a/proxy/content.go b/proxy/content.go new file mode 100644 index 0000000..29316f2 --- /dev/null +++ b/proxy/content.go @@ -0,0 +1,36 @@ +package proxy + +import ( + "mime" + "regexp" +) + +// textMediaTypes are the media types that are eligible for +// request capture and response rewriting. +var textMediaTypes = []string{ + "text/.+", + "application/javascript", + "application/json", + "application/xml", + "application/x-www-form-urlencoded", +} + +// isTextContentType returns true if the media type is eligible for +// request capture and response rewriting. +func isTextContentType(contentType string) bool { + if contentType == "" { + logger.Warnf("missing content type") + return false + } + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + logger.Warnf("failed to parse content type: %v: %v", contentType, err) + return false + } + for _, rewriteMediaType := range textMediaTypes { + if matched, _ := regexp.MatchString(rewriteMediaType, mediaType); matched { + return true + } + } + return false +} diff --git a/proxy/proxy.go b/proxy/proxy.go index 2d1331e..46c89d9 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -30,6 +30,7 @@ import ( type HttpExchange struct { Request *http.Request + RequestBody *[]byte StatusCode int ResponseBody *[]byte ResponseHeaders *http.Header @@ -80,7 +81,7 @@ func Handle( upstream string, w http.ResponseWriter, req *http.Request, - listener func(statusCode int, respBody *[]byte, respHeaders *http.Header) (*[]byte, *http.Header), + listener func(reqBody *[]byte, statusCode int, respBody *[]byte, respHeaders *http.Header) (*[]byte, *http.Header), ) { startTime := time.Now() @@ -101,7 +102,7 @@ func Handle( return } - responseBody, respHeaders = listener(statusCode, responseBody, respHeaders) + responseBody, respHeaders = listener(requestBody, statusCode, responseBody, respHeaders) err = sendResponse(w, respHeaders, statusCode, responseBody, client) if err != nil { diff --git a/proxy/recorder.go b/proxy/recorder.go index 9bda24b..97396f8 100644 --- a/proxy/recorder.go +++ b/proxy/recorder.go @@ -31,6 +31,8 @@ import ( ) type RecorderOptions struct { + CaptureRequestBody bool + CaptureRequestHeaders bool IgnoreDuplicateRequests bool RecordOnlyResponseHeaders []string FlatResponseFileStructure bool @@ -162,7 +164,12 @@ func getResponseFile( } } -func buildResource(dir string, options RecorderOptions, exchange HttpExchange, respFile string) (impostermodel.Resource, error) { +func buildResource( + dir string, + options RecorderOptions, + exchange HttpExchange, + respFile string, +) (impostermodel.Resource, error) { req := *exchange.Request response := &impostermodel.ResponseConfig{ StatusCode: exchange.StatusCode, @@ -188,6 +195,30 @@ func buildResource(dir string, options RecorderOptions, exchange HttpExchange, r } resource.QueryParams = &queryParams } + if options.CaptureRequestHeaders && len(req.Header) > 0 { + headers := make(map[string]string) + for headerName, headerValues := range req.Header { + shouldSkip := stringutil.Contains(skipProxyHeaders, headerName) || stringutil.Contains(skipRecordHeaders, headerName) + if !shouldSkip && len(headerValues) > 0 { + headers[headerName] = headerValues[0] + } + } + resource.RequestHeaders = &headers + } + if options.CaptureRequestBody && exchange.RequestBody != nil { + contentType := req.Header.Get("Content-Type") + if !isTextContentType(contentType) { + logger.Debugf("unsupported content type '%s' for capture - skipping request body capture", contentType) + } else { + reqBody := *exchange.RequestBody + if len(reqBody) > 0 { + resource.RequestBody = &impostermodel.RequestBody{ + Value: string(reqBody), + Operator: "EqualTo", + } + } + } + } if len(*exchange.ResponseHeaders) > 0 { headers := make(map[string]string) for headerName, headerValues := range *exchange.ResponseHeaders { diff --git a/proxy/rewrite.go b/proxy/rewrite.go index 909f887..438b6a8 100644 --- a/proxy/rewrite.go +++ b/proxy/rewrite.go @@ -19,38 +19,13 @@ package proxy import ( "bytes" "fmt" - "mime" "net/http" - "regexp" ) -var rewriteMediaTypes = []string{ - "text/.+", - "application/javascript", - "application/json", - "application/xml", -} - func Rewrite(respHeaders *http.Header, respBody *[]byte, upstream string, port int) *[]byte { contentType := (*respHeaders).Get("Content-Type") - if contentType == "" { - logger.Warnf("no content type - skipping rewrite") - return respBody - } - mediaType, _, err := mime.ParseMediaType(contentType) - if err != nil { - logger.Warnf("failed to parse content type - skipping rewrite: %v", err) - return respBody - } - rewrite := false - for _, rewriteMediaType := range rewriteMediaTypes { - if matched, _ := regexp.MatchString(rewriteMediaType, mediaType); matched { - rewrite = true - break - } - } - if !rewrite { - logger.Debugf("unsupported content type %s for rewrite - skipping rewrite", mediaType) + if !isTextContentType(contentType) { + logger.Debugf("unsupported content type '%s' for rewrite - skipping rewrite", contentType) return respBody } rewritten := bytes.ReplaceAll(*respBody, []byte(upstream), []byte(fmt.Sprintf("http://localhost:%d", port)))