diff --git a/README.md b/README.md index 8b12570..ca1aa1f 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,9 @@ Flags: -s, --secure Run mock server using TLS (https) -v, --version Version of Killgrave -w, --watcher File watcher will reload the server on each file change + -l, --log-level The higher the log level the more detail you get (default 0, 1 adds requests, 2 adds request body) + --log-body-max The maximum size of body that will be returned, will cut off if body is longer, default size is 512 + -d, --dump-requests-path Print requests out to specified file ``` ### Using Killgrave by config file @@ -182,6 +185,9 @@ cors: allow_credentials: true watcher: true secure: true +log_level: 1 +log_body_max: 256 +dump_requests_path: "/abc/def.log ``` As you can see, you can configure all the options in a very easy way. For the above example, the file tree looks as follows, with the current working directory being `mymock`. diff --git a/internal/app/cmd/cmd.go b/internal/app/cmd/cmd.go index 4ddbe0b..1fa9a11 100644 --- a/internal/app/cmd/cmd.go +++ b/internal/app/cmd/cmd.go @@ -11,7 +11,7 @@ import ( killgrave "github.com/friendsofgo/killgrave/internal" server "github.com/friendsofgo/killgrave/internal/server/http" - "github.com/gorilla/handlers" + sc "github.com/friendsofgo/killgrave/internal/serverconfig" "github.com/gorilla/mux" "github.com/radovskyb/watcher" "github.com/spf13/cobra" @@ -26,23 +26,32 @@ const ( _defaultPort = 3000 _defaultProxyMode = killgrave.ProxyNone _defaultStrictSlash = true - - _impostersFlag = "imposters" - _configFlag = "config" - _hostFlag = "host" - _portFlag = "port" - _watcherFlag = "watcher" - _secureFlag = "secure" - _proxyModeFlag = "proxy-mode" - _proxyURLFlag = "proxy-url" + _defaultLogLevel = 0 + _defaultLogBodyMax = 512 + + _impostersFlag = "imposters" + _configFlag = "config" + _hostFlag = "host" + _portFlag = "port" + _watcherFlag = "watcher" + _secureFlag = "secure" + _proxyModeFlag = "proxy-mode" + _proxyURLFlag = "proxy-url" + _logLevelFlag = "log-level" + _logBodyMaxFlag = "log-body-max" + _dumpRequestsPathFlag = "dump-requests-path" ) var ( - errGetDataFromImpostersFlag = errors.New("error trying to get data from imposters flag") - errGetDataFromHostFlag = errors.New("error trying to get data from host flag") - errGetDataFromPortFlag = errors.New("error trying to get data from port flag") - errGetDataFromSecureFlag = errors.New("error trying to get data from secure flag") - errMandatoryURL = errors.New("the field proxy-url is mandatory if you selected a proxy mode") + errGetDataFromImpostersFlag = errors.New("error trying to get data from imposters flag") + errGetDataFromHostFlag = errors.New("error trying to get data from host flag") + errGetDataFromPortFlag = errors.New("error trying to get data from port flag") + errGetDataFromSecureFlag = errors.New("error trying to get data from secure flag") + errGetDataFromLogLevelFlag = errors.New("error trying to get data from log-level flag") + errGetDataLogLevelInvalid = errors.New("error setting log-level, must be between 0 and 2 inclusive") + errGetDataFromLogBodyMaxFlag = errors.New("error trying to get data from log-body-max flag") + errGetDataFromDumpRequestsPathFlag = errors.New("error trying to get data from dump-requests-path flag") + errMandatoryURL = errors.New("the field proxy-url is mandatory if you selected a proxy mode") ) // NewKillgraveCmd returns cobra.Command to run killgrave command @@ -77,6 +86,9 @@ func NewKillgraveCmd() *cobra.Command { rootCmd.Flags().BoolP(_secureFlag, "s", false, "Run mock server using TLS (https)") rootCmd.Flags().StringP(_proxyModeFlag, "m", _defaultProxyMode.String(), "Proxy mode, the options are all, missing or none") rootCmd.Flags().StringP(_proxyURLFlag, "u", "", "The url where the proxy will redirect to") + rootCmd.Flags().IntP(_logLevelFlag, "l", _defaultLogLevel, "Log level, the options are 0, 1, 2. Default is 0, 1 adds requests, 2 adds request body") + rootCmd.Flags().Int(_logBodyMaxFlag, _defaultLogBodyMax, fmt.Sprintf("The maximum size of body that will be returned, will cut off if body is longer, default size is %s", _defaultLogBodyMax)) + rootCmd.Flags().StringP(_dumpRequestsPathFlag, "d", "", "Path the requests will be dumped to") rootCmd.SetVersionTemplate("Killgrave version: {{.Version}}\n") @@ -89,6 +101,16 @@ func runHTTP(cmd *cobra.Command, cfg killgrave.Config) error { signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + // setup the log file and its deferred close if needed + if len(cfg.DumpRequestsPath) > 0 && cfg.LogLevel > 0 { + file, err := os.OpenFile(cfg.DumpRequestsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatalf("Failed to open file: %+v", err) + } + cfg.LogWriter = file + defer file.Close() + } + srv := runServer(cfg) watcherFlag, _ := cmd.Flags().GetBool(_watcherFlag) @@ -115,8 +137,7 @@ func runServer(cfg killgrave.Config) server.Server { httpAddr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) httpServer := http.Server{ - Addr: httpAddr, - Handler: handlers.CORS(server.PrepareAccessControl(cfg.CORS)...)(router), + Addr: httpAddr, } proxyServer, err := server.NewProxy(cfg.Proxy.Url, cfg.Proxy.Mode) @@ -135,6 +156,10 @@ func runServer(cfg killgrave.Config) server.Server { proxyServer, cfg.Secure, imposterFs, + sc.WithCORSOptions(server.PrepareAccessControl(cfg.CORS)), + sc.WithLogLevel(cfg.LogLevel), + sc.WithLogBodyMax(cfg.LogBodyMax), + sc.WithLogWriter(cfg.LogWriter), ) if err := s.Build(); err != nil { log.Fatal(err) @@ -185,7 +210,25 @@ func prepareConfig(cmd *cobra.Command) (killgrave.Config, error) { return killgrave.Config{}, fmt.Errorf("%v: %w", err, errGetDataFromSecureFlag) } - cfg, err := killgrave.NewConfig(impostersPath, host, port, secure) + logLevel, err := cmd.Flags().GetInt(_logLevelFlag) + if err != nil { + return killgrave.Config{}, fmt.Errorf("%v: %w", err, errGetDataFromLogLevelFlag) + } + if logLevel < 0 || logLevel > 2 { + return killgrave.Config{}, fmt.Errorf("%v: %w", err, errGetDataLogLevelInvalid) + } + + logBodyMax, err := cmd.Flags().GetInt(_logBodyMaxFlag) + if err != nil { + return killgrave.Config{}, fmt.Errorf("%v: %w", err, errGetDataFromLogBodyMaxFlag) + } + + dumpRequestsPath, err := cmd.Flags().GetString(_dumpRequestsPathFlag) + if err != nil { + return killgrave.Config{}, fmt.Errorf("%v: %w", err, errGetDataFromDumpRequestsPathFlag) + } + + cfg, err := killgrave.NewConfig(impostersPath, host, port, secure, logLevel, logBodyMax, dumpRequestsPath) if err != nil { return killgrave.Config{}, err } diff --git a/internal/config.go b/internal/config.go index e2b1e70..766f2a7 100644 --- a/internal/config.go +++ b/internal/config.go @@ -3,6 +3,7 @@ package killgrave import ( "errors" "fmt" + "io" "io/ioutil" "os" "path" @@ -12,13 +13,17 @@ import ( // Config representation of config file yaml type Config struct { - ImpostersPath string `yaml:"imposters_path"` - Port int `yaml:"port"` - Host string `yaml:"host"` - CORS ConfigCORS `yaml:"cors"` - Proxy ConfigProxy `yaml:"proxy"` - Secure bool `yaml:"secure"` - Watcher bool `yaml:"watcher"` + ImpostersPath string `yaml:"imposters_path"` + Port int `yaml:"port"` + Host string `yaml:"host"` + CORS ConfigCORS `yaml:"cors"` + Proxy ConfigProxy `yaml:"proxy"` + Secure bool `yaml:"secure"` + Watcher bool `yaml:"watcher"` + LogLevel int `yaml:"log_level"` + LogBodyMax int `yaml:"log_body_max"` + DumpRequestsPath string `yaml:"dump_requests_path"` + LogWriter io.Writer } // ConfigCORS representation of section CORS of the yaml @@ -111,7 +116,7 @@ func (cfg *Config) ConfigureProxy(proxyMode ProxyMode, proxyURL string) { type ConfigOpt func(cfg *Config) error // NewConfig initialize the config -func NewConfig(impostersPath, host string, port int, secure bool) (Config, error) { +func NewConfig(impostersPath, host string, port int, secure bool, logLevel, logBodyMax int, dumpRequestsPath string) (Config, error) { if impostersPath == "" { return Config{}, errEmptyImpostersPath } @@ -125,10 +130,13 @@ func NewConfig(impostersPath, host string, port int, secure bool) (Config, error } cfg := Config{ - ImpostersPath: impostersPath, - Host: host, - Port: port, - Secure: secure, + ImpostersPath: impostersPath, + Host: host, + Port: port, + Secure: secure, + LogLevel: logLevel, + LogBodyMax: logBodyMax, + DumpRequestsPath: dumpRequestsPath, } return cfg, nil diff --git a/internal/config_test.go b/internal/config_test.go index 38c0076..daf7883 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -203,7 +203,7 @@ func TestNewConfig(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := NewConfig(tt.args.impostersPath, tt.args.host, tt.args.port, false) + got, err := NewConfig(tt.args.impostersPath, tt.args.host, tt.args.port, false, 0, 0, "") assert.Equal(t, tt.err, err) assert.Equal(t, tt.want, got) }) @@ -221,7 +221,7 @@ func TestConfig_ConfigureProxy(t *testing.T) { }, } - got, err := NewConfig("imposters", "localhost", 80, false) + got, err := NewConfig("imposters", "localhost", 80, false, 0, 0, "") assert.NoError(t, err) got.ConfigureProxy(ProxyAll, "https://friendsofgo.tech") diff --git a/internal/server/http/dump.go b/internal/server/http/dump.go new file mode 100644 index 0000000..5e8b1e9 --- /dev/null +++ b/internal/server/http/dump.go @@ -0,0 +1,148 @@ +package http + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "io" + "log" + "net/http" + "os" + "strings" + "sync" + + "github.com/gorilla/handlers" +) + +var binaryContentTypes = []string{ + "application/octet-stream", + "image/", + "audio/", + "video/", + "application/pdf", +} + +// RequestData struct to hold request data +type RequestData struct { + Method string `json:"method"` + Host string `json:"host"` + URL string `json:"url"` + Header http.Header `json:"header"` + Status int `json:"statusCode,omitempty"` + Body string `json:"body,omitempty"` +} + +func getRequestData(r *http.Request, status int, body string) *RequestData { + return &RequestData{ + Method: r.Method, + Host: r.Host, + URL: r.URL.String(), + Header: r.Header, + Status: status, + Body: body, + } +} + +// ToJSON converts the RequestData struct to JSON. +func (rd *RequestData) toJSON() ([]byte, error) { + jsonData, err := json.Marshal(rd) + if err != nil { + return nil, err + } + return jsonData, nil +} + +// isBinaryContent checks to see if the body is a common binary content type +func isBinaryContent(r *http.Request) bool { + contentType := r.Header.Get("Content-Type") + for _, binaryType := range binaryContentTypes { + if strings.HasPrefix(contentType, binaryType) { + return true + } + } + return false +} + +func shouldRecordRequest(s *Server) bool { + return s.serverCfg.LogWriter != nil && s.dumpCh != nil +} + +func getBody(r *http.Request, s *Server) string { + if s.serverCfg.LogLevel == 0 && !shouldRecordRequest(s) { + return "" + } + // Use io.LimitedReader to read up to the max allowed bytes + limitedReader := &io.LimitedReader{R: r.Body, N: int64(s.serverCfg.LogBodyMax)} + bodyBytes, err := io.ReadAll(limitedReader) + if err != nil { + log.Printf("Error reading request body: %v\n", err) + return "" + } + // Reset the body + r.Body = io.NopCloser(io.MultiReader(bytes.NewReader(bodyBytes), r.Body)) + + body := base64.StdEncoding.EncodeToString(bodyBytes) + // if content is not binary, get it as a string + if !isBinaryContent(r) { + body = string(bodyBytes) + } + return body +} + +// CustomLoggingHandler provides a way to supply a custom log formatter +// while taking advantage of the mechanisms in this package +func CustomLoggingHandler(out io.Writer, h http.Handler, s *Server) http.Handler { + return handlers.CustomLoggingHandler(out, h, func(writer io.Writer, params handlers.LogFormatterParams) { + body := getBody(params.Request, s) + requestData := getRequestData(params.Request, params.StatusCode, body) + + // log the request + if s.serverCfg.LogLevel > 0 { + // if we add other formats to log in we can switch here and return the bytes + data, err := requestData.toJSON() + if err != nil { + log.Printf("Error encoding request data: %+v\n", err) + return + } + + data = append(data, '\n') + writer.Write(data) + if shouldRecordRequest(s) { + recordRequest(&data, s) + } + } + }) +} + +func recordRequest(request *[]byte, s *Server) { + select { + case s.dumpCh <- request: + // Successfully sent the request data to the channel + default: + // Handle the case where the channel is full + log.Println("request dump channel is full, could not write request") + } +} + +// Goroutine function to write requests to a JSON file +func RequestWriter(ctx context.Context, wg *sync.WaitGroup, writer io.Writer, requestChan <-chan *[]byte) { + defer wg.Done() + + for { + select { + case requestData := <-requestChan: + if requestData == nil { + return // channel closed + } + + writer.Write(*requestData) + // call Sync if writer is *os.File + if f, ok := writer.(*os.File); ok { + f.Sync() + } + case <-ctx.Done(): + return // context cancelled + } + } +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index d334e84..e1476bf 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -6,8 +6,10 @@ import ( _ "embed" "log" "net/http" + "sync" killgrave "github.com/friendsofgo/killgrave/internal" + sc "github.com/friendsofgo/killgrave/internal/serverconfig" "github.com/gorilla/handlers" "github.com/gorilla/mux" ) @@ -24,9 +26,6 @@ var ( defaultCORSExposedHeaders = []string{"Cache-Control", "Content-Language", "Content-Type", "Expires", "Last-Modified", "Pragma"} ) -// ServerOpt function that allow modify the current server -type ServerOpt func(s *Server) - // Server definition of mock server type Server struct { router *mux.Router @@ -34,16 +33,30 @@ type Server struct { proxy *Proxy secure bool imposterFs ImposterFs + serverCfg *sc.ServerConfig + dumpCh chan *[]byte + wg *sync.WaitGroup + ctx context.Context + cancel context.CancelFunc } // NewServer initialize the mock server -func NewServer(r *mux.Router, httpServer *http.Server, proxyServer *Proxy, secure bool, fs ImposterFs) Server { +func NewServer(r *mux.Router, httpServer *http.Server, proxyServer *Proxy, secure bool, fs ImposterFs, options ...sc.ServerOption) Server { + ctx, cancel := context.WithCancel(context.Background()) + cfg := &sc.ServerConfig{} + for _, opt := range options { + opt(cfg) + } return Server{ router: r, httpServer: httpServer, proxy: proxyServer, secure: secure, imposterFs: fs, + serverCfg: cfg, + wg: &sync.WaitGroup{}, + ctx: ctx, + cancel: cancel, } } @@ -85,6 +98,22 @@ func (s *Server) Build() error { return nil } + // only instantiate the request dump if we need it + if s.dumpCh == nil && s.serverCfg.LogWriter != nil { + s.dumpCh = make(chan *[]byte, 1000) + + // Start the RequestWriter goroutine with context + s.wg.Add(1) + go RequestWriter(s.ctx, s.wg, s.serverCfg.LogWriter, s.dumpCh) + } + + // setup the logging handler + var handler http.Handler = s.router + if s.serverCfg.LogLevel > 0 || shouldRecordRequest(s) { + handler = CustomLoggingHandler(log.Writer(), handler, s) + } + s.httpServer.Handler = handlers.CORS(s.serverCfg.CORSOptions...)(handler) + var impostersCh = make(chan []Imposter) var done = make(chan struct{}) @@ -146,10 +175,16 @@ func (s *Server) run(secure bool) error { // Shutdown shutdown the current http server func (s *Server) Shutdown() error { log.Println("stopping server...") - if err := s.httpServer.Shutdown(context.TODO()); err != nil { + if err := s.httpServer.Shutdown(s.ctx); err != nil { log.Fatalf("Server Shutdown Failed:%+v", err) } + // Cancel the context to stop the RequestWriter goroutine + s.cancel() + + // wait for all goroutines to finish + s.wg.Wait() + return nil } diff --git a/internal/server/http/server_test.go b/internal/server/http/server_test.go index 26ea559..c00a449 100644 --- a/internal/server/http/server_test.go +++ b/internal/server/http/server_test.go @@ -1,16 +1,22 @@ package http import ( + "bytes" "crypto/tls" + "encoding/json" + "fmt" "io" "log" "net/http" "net/http/httptest" "os" + "path/filepath" + "strings" "testing" "time" killgrave "github.com/friendsofgo/killgrave/internal" + sc "github.com/friendsofgo/killgrave/internal/serverconfig" "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -210,3 +216,195 @@ func TestBuildSecureMode(t *testing.T) { }) } } + +func TestBuildLogRequests(t *testing.T) { + testCases := map[string]struct { + method string + path string + contentType string + body string + logLevel int + expectedLog string + expectedStatus int + }{ + "GET valid imposter request": { + method: "GET", + path: "/yamlTestDumpRequest", + contentType: "text/plain", + body: "Dumped", + logLevel: 1, + expectedLog: "{\"method\":\"GET\",\"host\":\"example.com\",\"url\":\"/yamlTestDumpRequest\",\"header\":{\"Content-Type\":[\"text/plain\"]},\"statusCode\":200,\"body\":\"Dumped\"}\n", + expectedStatus: http.StatusOK, + }, + "GET valid imposter request with body": { + method: "GET", + path: "/yamlTestDumpRequest", + contentType: "text/plain", + body: "Dumped", + logLevel: 2, + expectedLog: "{\"method\":\"GET\",\"host\":\"example.com\",\"url\":\"/yamlTestDumpRequest\",\"header\":{\"Content-Type\":[\"text/plain\"]},\"statusCode\":200,\"body\":\"Dumped\"}\n", + expectedStatus: http.StatusOK, + }, + "GET valid imposter binary request": { + method: "GET", + path: "/yamlTestDumpRequest", + contentType: "application/octet-stream", + body: "Dumped", + logLevel: 1, + expectedLog: "{\"method\":\"GET\",\"host\":\"example.com\",\"url\":\"/yamlTestDumpRequest\",\"header\":{\"Content-Type\":[\"application/octet-stream\"]},\"statusCode\":200,\"body\":\"RHVtcGVk\"}\n", + expectedStatus: http.StatusOK, + }, + "GET valid imposter binary request with body": { + method: "GET", + path: "/yamlTestDumpRequest", + contentType: "application/octet-stream", + body: "Dumped", + logLevel: 2, + expectedLog: "{\"method\":\"GET\",\"host\":\"example.com\",\"url\":\"/yamlTestDumpRequest\",\"header\":{\"Content-Type\":[\"application/octet-stream\"]},\"statusCode\":200,\"body\":\"RHVtcGVk\"}\n", + expectedStatus: http.StatusOK, + }, + "GET valid imposter request no body": { + method: "GET", + path: "/yamlTestDumpRequest", + contentType: "text/plain", + body: "", + logLevel: 2, + expectedLog: "{\"method\":\"GET\",\"host\":\"example.com\",\"url\":\"/yamlTestDumpRequest\",\"header\":{\"Content-Type\":[\"text/plain\"]},\"statusCode\":200}\n", + expectedStatus: http.StatusOK, + }, + "GET invalid imposter request": { + method: "GET", + path: "/doesnotexist", + contentType: "text/plain", + body: "Dumped", + logLevel: 1, + expectedLog: "{\"method\":\"GET\",\"host\":\"example.com\",\"url\":\"/doesnotexist\",\"header\":{\"Content-Type\":[\"text/plain\"]},\"statusCode\":404,\"body\":\"Dumped\"}\n", + expectedStatus: http.StatusNotFound, + }, + "GET invalid imposter request with body": { + method: "GET", + path: "/doesnotexist", + contentType: "text/plain", + body: "Dumped", + logLevel: 2, + expectedLog: "{\"method\":\"GET\",\"host\":\"example.com\",\"url\":\"/doesnotexist\",\"header\":{\"Content-Type\":[\"text/plain\"]},\"statusCode\":404,\"body\":\"Dumped\"}\n", + expectedStatus: http.StatusNotFound, + }, + "GET invalid imposter binary request with body": { + method: "GET", + path: "/doesnotexist", + contentType: "video/mp4", + body: "Dumped", + logLevel: 2, + expectedLog: "{\"method\":\"GET\",\"host\":\"example.com\",\"url\":\"/doesnotexist\",\"header\":{\"Content-Type\":[\"video/mp4\"]},\"statusCode\":404,\"body\":\"RHVtcGVk\"}\n", + expectedStatus: http.StatusNotFound, + }, + "GET invalid imposter request no body": { + method: "GET", + path: "/doesnotexist", + contentType: "text/plain", + body: "", + logLevel: 2, + expectedLog: "{\"method\":\"GET\",\"host\":\"example.com\",\"url\":\"/doesnotexist\",\"header\":{\"Content-Type\":[\"text/plain\"]},\"statusCode\":404}\n", + expectedStatus: http.StatusNotFound, + }, + } + for name, tc := range testCases { + name := name + tc := tc + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + log.SetOutput(&buf) + defer func() { + log.SetOutput(os.Stderr) + }() + + imposterFs, err := NewImposterFS("test/testdata/imposters") + assert.NoError(t, err) + server := NewServer(mux.NewRouter(), &http.Server{}, &Proxy{}, false, imposterFs, sc.WithLogLevel(tc.logLevel), sc.WithLogBodyMax(512)) + err = server.Build() + assert.NoError(t, err) + + w := httptest.NewRecorder() + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + req.Header.Set("Content-Type", tc.contentType) + + server.httpServer.Handler.ServeHTTP(w, req) + + response := w.Result() + assert.Equal(t, tc.expectedStatus, response.StatusCode, "Expected status code: %v, got: %v", tc.expectedStatus, response.StatusCode) + + // verify the request is dumped in the logs + assert.Contains(t, buf.String(), tc.expectedLog, "Expect request dumped on logs failed") + }) + } +} + +func TestBuildRecordRequests(t *testing.T) { + var buf bytes.Buffer + log.SetOutput(&buf) + defer func() { + log.SetOutput(os.Stderr) + }() + tempDir, err := os.MkdirTemp("", "testdir") + assert.NoError(t, err, "Failed to create temporary directory") + defer os.RemoveAll(tempDir) + dumpFile := filepath.Join(tempDir, "dump_requests.log") + + imposterFs, err := NewImposterFS("test/testdata/imposters") + assert.NoError(t, err) + w := httptest.NewRecorder() + file, err := os.OpenFile(dumpFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatalf("Failed to open file: %+v", err) + } + defer file.Close() + server := NewServer(mux.NewRouter(), &http.Server{}, &Proxy{}, false, imposterFs, sc.WithLogWriter(file), sc.WithLogLevel(2), sc.WithLogBodyMax(8)) + err = server.Build() + assert.NoError(t, err) + + expectedBodies := []string{"Dumped1", "", "longer t"} + req1 := httptest.NewRequest("GET", "/yamlTestDumpRequest", strings.NewReader(expectedBodies[0])) + req2 := httptest.NewRequest("GET", "/yamlTestDumpRequest", strings.NewReader(expectedBodies[1])) + req3 := httptest.NewRequest("GET", "/yamlTestDumpRequest", strings.NewReader("longer than allowed")) + server.httpServer.Handler.ServeHTTP(w, req1) + server.httpServer.Handler.ServeHTTP(w, req2) + server.httpServer.Handler.ServeHTTP(w, req3) + + // wait for channel to print out the requests + time.Sleep(1 * time.Second) + + // check recoreded request dumps + reqs, err := getRecordedRequests(dumpFile) + assert.NoError(t, err, "Failed to read requests from file") + assert.Equal(t, 3, len(reqs), "Expect 2 requests to be dumped in file failed") + for i, expectedBody := range expectedBodies { + assert.Equal(t, expectedBody, reqs[i].Body, "Expect request body to be dumped in file failed") + } +} + +// getRecordedRequests reads the requests from the file and returns them as a slice of RequestData +func getRecordedRequests(filePath string) ([]RequestData, error) { + // Read the file contents + fileContent, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("error reading file: %w", err) + } + + // Split the contents by the newline separator + requestDumps := strings.Split(string(fileContent), "\n") + requestsData := []RequestData{} + for _, requestDump := range requestDumps { + if requestDump == "" { + continue + } + // Unmarshal the JSON string into the RequestData struct + rd := RequestData{} + err := json.Unmarshal([]byte(requestDump), &rd) + if err != nil { + return nil, fmt.Errorf("error unmarshaling JSON: %w", err) + } + requestsData = append(requestsData, rd) + } + return requestsData, nil +} diff --git a/internal/server/http/test/testdata/imposters/test_request.imp.yaml b/internal/server/http/test/testdata/imposters/test_request.imp.yaml index 0d49b8e..fcb2f70 100644 --- a/internal/server/http/test/testdata/imposters/test_request.imp.yaml +++ b/internal/server/http/test/testdata/imposters/test_request.imp.yaml @@ -4,4 +4,11 @@ endpoint: /yamlTestRequest response: status: 200 - body: "Yaml Handled" \ No newline at end of file + body: "Yaml Handled" +- request: + method: GET + endpoint: /yamlTestDumpRequest + dump: true + response: + status: 200 + body: "Yaml Dump Handled" diff --git a/internal/serverconfig/serverconfig.go b/internal/serverconfig/serverconfig.go new file mode 100644 index 0000000..6da11f9 --- /dev/null +++ b/internal/serverconfig/serverconfig.go @@ -0,0 +1,40 @@ +package serverconfig + +import ( + "io" + + "github.com/gorilla/handlers" +) + +type ServerConfig struct { + CORSOptions []handlers.CORSOption + LogLevel int + LogBodyMax int + LogWriter io.Writer +} + +type ServerOption func(*ServerConfig) + +func WithCORSOptions(corsOptions []handlers.CORSOption) ServerOption { + return func(cfg *ServerConfig) { + cfg.CORSOptions = corsOptions + } +} + +func WithLogLevel(logLevel int) ServerOption { + return func(cfg *ServerConfig) { + cfg.LogLevel = logLevel + } +} + +func WithLogBodyMax(logBodyMax int) ServerOption { + return func(cfg *ServerConfig) { + cfg.LogBodyMax = logBodyMax + } +} + +func WithLogWriter(logWriter io.Writer) ServerOption { + return func(cfg *ServerConfig) { + cfg.LogWriter = logWriter + } +}