diff --git a/CHANGELOG.md b/CHANGELOG.md index 98b6f0c..9033020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * Remove backward compatibility with previous versions to go 1.13 * Add `-watcher` flag to reload the server with any changes on `imposters` folder * Fix searching imposter files mechanism +* Add proxy server feature ## v0.3.3 (2019/05/11) diff --git a/cmd/killgrave/main.go b/cmd/killgrave/main.go index 36cf003..7c43e79 100644 --- a/cmd/killgrave/main.go +++ b/cmd/killgrave/main.go @@ -104,10 +104,16 @@ func runServer(host string, port int, cfg killgrave.Config) server.Server { Handler: handlers.CORS(server.PrepareAccessControl(cfg.CORS)...)(router), } + proxyServer, err := server.NewProxy(cfg.Proxy.Url, cfg.Proxy.Mode) + if err != nil { + log.Fatal(err) + } + s := server.NewServer( cfg.ImpostersPath, router, httpServer, + proxyServer, ) if err := s.Build(); err != nil { log.Fatal(err) diff --git a/internal/config.go b/internal/config.go index f7e0d79..109457c 100644 --- a/internal/config.go +++ b/internal/config.go @@ -11,10 +11,11 @@ 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"` + ImpostersPath string `yaml:"imposters_path"` + Port int `yaml:"port"` + Host string `yaml:"host"` + CORS ConfigCORS `yaml:"cors"` + Proxy ConfigProxy `yaml:"proxy"` } // ConfigCORS representation of section CORS of the yaml @@ -26,6 +27,55 @@ type ConfigCORS struct { AllowCredentials bool `yaml:"allow_credentials"` } +// ConfigProxy is a representation of section proxy of the yaml +type ConfigProxy struct { + Url string `yaml:"url"` + Mode ProxyMode `yaml:"mode"` +} + +// ProxyMode is enumeration of proxy server modes +type ProxyMode uint8 + +const ( + // ProxyNone server is off + ProxyNone ProxyMode = iota + // ProxyMissing handle only missing requests are proxied + ProxyMissing + // ProxyAll all requests are proxied + ProxyAll +) + +func proxyModeParseString(t string) (ProxyMode, error) { + m := map[string]ProxyMode{ + "none": ProxyNone, + "missing": ProxyMissing, + "all": ProxyAll, + } + + p, ok := m[t] + if !ok { + return ProxyNone, fmt.Errorf("unknown proxy mode: %s", t) + } + + return p, nil +} + +// UnmarshalYAML implementation of yaml.Unmarshaler interface +func (mode *ProxyMode) UnmarshalYAML(unmarshal func(interface{}) error) error { + var proxyMode string + if err := unmarshal(&proxyMode); err != nil { + return err + } + + m, err := proxyModeParseString(proxyMode) + if err != nil { + return err + } + + *mode = m + return nil +} + // ConfigOpt function to encapsulate optional parameters type ConfigOpt func(cfg *Config) error diff --git a/internal/config_test.go b/internal/config_test.go index 294803c..09fe426 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -37,6 +37,71 @@ func TestNewConfig(t *testing.T) { } } +func TestProxyModeParseString(t *testing.T) { + testCases := map[string]struct { + input string + expected ProxyMode + err error + }{ + "valid mode": {"all", ProxyAll, nil}, + "unknown mode": {"UnKnOwn1", ProxyNone, errors.New("error")}, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + mode, err := proxyModeParseString(tc.input) + + if err != nil && tc.err == nil { + t.Fatalf("not expected any erros and got %v", err) + } + if err == nil && tc.err != nil { + t.Fatalf("expected an error and got nil") + } + if tc.expected != mode { + t.Fatalf("expected: %v, got: %v", tc.expected, mode) + } + }) + } +} + +func TestProxyModeUnmarshal(t *testing.T) { + testCases := map[string]struct { + input interface{} + expected ProxyMode + err error + }{ + "valid mode all": {"all", ProxyAll, nil}, + "valid mode missing": {"missing", ProxyMissing, nil}, + "valid mode none": {"none", ProxyNone, nil}, + "empty mode": {"", ProxyNone, errors.New("error")}, + "invalid mode": {"nonsens23e", ProxyNone, errors.New("error")}, + "error input": {123, ProxyNone, errors.New("error")}, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + var mode ProxyMode + err := mode.UnmarshalYAML(func(i interface{}) error { + s := i.(*string) + input, ok := tc.input.(string) + if !ok { + return errors.New("error") + } + *s = input + return nil + }) + if err != nil && tc.err == nil { + t.Fatalf("not expected any erros and got %v", err) + } + + if err == nil && tc.err != nil { + t.Fatalf("expected an error and got nil") + } + if tc.expected != mode { + t.Fatalf("expected: %v, got: %v", tc.expected, mode) + } + }) + } +} + func validConfig() Config { return Config{ ImpostersPath: "test/testdata/imposters", diff --git a/internal/server/http/proxy.go b/internal/server/http/proxy.go new file mode 100644 index 0000000..4836a3f --- /dev/null +++ b/internal/server/http/proxy.go @@ -0,0 +1,32 @@ +package http + +import ( + "net/http" + "net/http/httputil" + "net/url" + + killgrave "github.com/friendsofgo/killgrave/internal" +) + +// Proxy represent reverse proxy server. +type Proxy struct { + server *httputil.ReverseProxy + mode killgrave.ProxyMode +} + +// NewProxy creates new proxy server. +func NewProxy(rawurl string, mode killgrave.ProxyMode) (*Proxy, error) { + u, err := url.Parse(rawurl) + if err != nil { + return nil, err + } + reverseProxy := httputil.NewSingleHostReverseProxy(u) + return &Proxy{reverseProxy, mode}, nil +} + +// Handler returns handler that sends request to another server. +func (p *Proxy) Handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + p.server.ServeHTTP(w, r) + } +} diff --git a/internal/server/http/proxy_test.go b/internal/server/http/proxy_test.go new file mode 100644 index 0000000..99c0c1b --- /dev/null +++ b/internal/server/http/proxy_test.go @@ -0,0 +1,66 @@ +package http + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + killgrave "github.com/friendsofgo/killgrave/internal" +) + +func TestNewProxy(t *testing.T) { + testCases := map[string]struct { + rawUrl string + mode killgrave.ProxyMode + err error + }{ + "valid all": {"all", killgrave.ProxyAll, nil}, + "valid mode none": {"none", killgrave.ProxyNone, nil}, + "error rawUrl": {":http!/gogle.com", killgrave.ProxyNone, errors.New("error")}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + proxy, err := NewProxy(tc.rawUrl, tc.mode) + if err != nil && tc.err == nil { + t.Fatalf("not expected any erros and got %v", err) + } + + if err == nil && tc.err != nil { + t.Fatalf("expected an error and got nil") + } + if err != nil { + return + } + if tc.mode != proxy.mode { + t.Fatalf("expected: %v, got: %v", tc.mode, proxy.mode) + } + }) + } +} + +func TestProxyHandler(t *testing.T) { + isRequestHandled := false + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + isRequestHandled = true + })) + defer backend.Close() + + proxy, err := NewProxy(backend.URL, killgrave.ProxyAll) + if err != nil { + t.Fatal("NewProxy failed: ", err) + } + + frontend := httptest.NewServer(proxy.Handler()) + defer frontend.Close() + + _, err = http.Get(frontend.URL) + if err != nil { + t.Fatal("Frontend GET method failed: ", err) + } + if isRequestHandled != true { + t.Fatal("Request was not proxied to backend server") + } + +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 71a8399..67ccd2d 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -30,14 +30,16 @@ type Server struct { impostersPath string router *mux.Router httpServer http.Server + proxy *Proxy } // NewServer initialize the mock server -func NewServer(p string, r *mux.Router, httpServer http.Server) Server { +func NewServer(p string, r *mux.Router, httpServer http.Server, proxyServer *Proxy) Server { return Server{ impostersPath: p, router: r, httpServer: httpServer, + proxy: proxyServer, } } @@ -73,6 +75,9 @@ func PrepareAccessControl(config killgrave.ConfigCORS) (h []handlers.CORSOption) // Build read all the files on the impostersPath and add different // handlers for each imposter func (s *Server) Build() error { + if s.proxy.mode == killgrave.ProxyAll { + s.handleAll(s.proxy.Handler()) + } if _, err := os.Stat(s.impostersPath); os.IsNotExist(err) { return fmt.Errorf("%w: the directory %s doesn't exists", err, s.impostersPath) } @@ -83,6 +88,7 @@ func (s *Server) Build() error { findImposters(s.impostersPath, imposterFileCh) done <- true }() +loop: for { select { case f := <-imposterFileCh: @@ -97,9 +103,13 @@ func (s *Server) Build() error { case <-done: close(imposterFileCh) close(done) - return nil + break loop } } + if s.proxy.mode == killgrave.ProxyMissing { + s.handleAll(s.proxy.Handler()) + } + return nil } // Run run launch a previous configured http server if any error happens while the starting process @@ -155,3 +165,7 @@ func (s Server) unmarshalImposters(imposterFileName string, imposters *[]Imposte } return nil } + +func (s *Server) handleAll(h http.HandlerFunc) { + s.router.PathPrefix("/").HandlerFunc(h) +} diff --git a/internal/server/http/server_test.go b/internal/server/http/server_test.go index e13826f..fceec98 100644 --- a/internal/server/http/server_test.go +++ b/internal/server/http/server_test.go @@ -2,9 +2,11 @@ package http import ( "errors" + "io" "io/ioutil" "log" "net/http" + "net/http/httptest" "os" "testing" @@ -24,9 +26,9 @@ func TestServer_Build(t *testing.T) { server Server err error }{ - {"imposter directory not found", NewServer("failImposterPath", nil, http.Server{}), errors.New("hello")}, - {"malformatted json", NewServer("test/testdata/malformatted_imposters", nil, http.Server{}), nil}, - {"valid imposter", NewServer("test/testdata/imposters", mux.NewRouter(), http.Server{}), nil}, + {"imposter directory not found", NewServer("failImposterPath", nil, http.Server{}, &Proxy{}), errors.New("hello")}, + {"malformatted json", NewServer("test/testdata/malformatted_imposters", nil, http.Server{}, &Proxy{}), nil}, + {"valid imposter", NewServer("test/testdata/imposters", mux.NewRouter(), http.Server{}, &Proxy{}), nil}, } for _, tt := range serverData { @@ -48,6 +50,83 @@ func TestServer_Build(t *testing.T) { } } +func TestBuildProxyMode(t *testing.T) { + proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "Proxied") + })) + defer proxyServer.Close() + makeServer := func(mode killgrave.ProxyMode) (*Server, func()) { + router := mux.NewRouter() + httpServer := http.Server{Handler: router} + proxyServer, err := NewProxy(proxyServer.URL, mode) + if err != nil { + t.Fatal("NewProxy failed: ", err) + } + server := NewServer("test/testdata/imposters", router, httpServer, proxyServer) + return &server, func() { + httpServer.Close() + } + } + testCases := map[string]struct { + mode killgrave.ProxyMode + url string + body string + status int + }{ + "ProxyAll": { + mode: killgrave.ProxyAll, + url: "/testRequest", + body: "Proxied", + status: http.StatusOK, + }, + "ProxyMissing_Hit": { + mode: killgrave.ProxyMissing, + url: "/testRequest", + body: "Handled", + status: http.StatusOK, + }, + "ProxyMissing_Proxied": { + mode: killgrave.ProxyMissing, + url: "/NonExistentURL123", + body: "Proxied", + status: http.StatusOK, + }, + "ProxyNone_Hit": { + mode: killgrave.ProxyNone, + url: "/testRequest", + body: "Handled", + status: http.StatusOK, + }, + "ProxyNone_Missing": { + mode: killgrave.ProxyNone, + url: "/NonExistentURL123", + body: "404 page not found\n", + status: http.StatusNotFound, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + s, cleanUp := makeServer(tc.mode) + defer cleanUp() + s.Build() + + req := httptest.NewRequest("GET", tc.url, nil) + w := httptest.NewRecorder() + + s.router.ServeHTTP(w, req) + response := w.Result() + body, _ := ioutil.ReadAll(response.Body) + + if string(body) != tc.body { + t.Errorf("Expected body: %v, got: %s", tc.body, body) + } + if response.StatusCode != tc.status { + t.Errorf("Expected status code: %v, got: %v", tc.status, response.StatusCode) + } + }) + } +} + func TestServer_AccessControl(t *testing.T) { config := killgrave.Config{ ImpostersPath: "imposters", diff --git a/internal/server/http/test/testdata/imposters/test_request.imp.json b/internal/server/http/test/testdata/imposters/test_request.imp.json new file mode 100644 index 0000000..09d9403 --- /dev/null +++ b/internal/server/http/test/testdata/imposters/test_request.imp.json @@ -0,0 +1,12 @@ +[ + { + "request": { + "method": "GET", + "endpoint": "/testRequest" + }, + "response": { + "status": 200, + "body": "Handled" + } + } +]