diff --git a/README.md b/README.md index 6762e0a..2b177a2 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,10 @@ See `examples/config.json` "type": "proxy", // Required "path_pattern": "^/test-ui/.*", // regex to match request path. Required "backend": "http://localhost:3000", // backend scheme and host to proxy to. Required - "rewrite": { // optional rewrite rules - "/test-ui/(.*)": "/$1" - } + "rewrite": [{ // rewrite rules. Optional + "path_pattern": "/test-ui/(.*)", + "to": "/$1", + }], } ``` @@ -76,6 +77,19 @@ See `examples/config.json` } ``` +### Redirect type rules + +```json +{ + "type": "redirect", + "path_pattern": "^/test-ui/(.*)", + "redirect": { + "to": "http://localhost:3000/$1", + "type": "temporary" + } +} +``` + ## Development ### Release diff --git a/commands/start.go b/commands/start.go index 50c42ac..7a90af0 100644 --- a/commands/start.go +++ b/commands/start.go @@ -2,7 +2,7 @@ package commands import ( "github.com/JSainsburyPLC/ui-dev-proxy/domain" - "github.com/JSainsburyPLC/ui-dev-proxy/http/proxy" + "github.com/JSainsburyPLC/ui-dev-proxy/proxy" "github.com/urfave/cli" "log" "net/url" diff --git a/domain/config.go b/domain/config.go index 8f18202..b56128a 100644 --- a/domain/config.go +++ b/domain/config.go @@ -7,8 +7,9 @@ import ( ) const ( - RouteTypeProxy = "proxy" - RouteTypeMock = "mock" + RouteTypeProxy = "proxy" + RouteTypeMock = "mock" + RouteTypeRedirect = "redirect" ) type Config struct { @@ -16,11 +17,22 @@ type Config struct { } type Route struct { - Type string `json:"type"` - PathPattern *PathPattern `json:"path_pattern"` - Backend *Backend `json:"backend"` - Mock *Mock `json:"mock"` - Rewrite map[string]string `json:"rewrite"` + Type string `json:"type"` + PathPattern *PathPattern `json:"path_pattern"` + Backend *Backend `json:"backend"` + Mock *Mock `json:"mock"` + Rewrite []Rewrite `json:"rewrite"` + Redirect *Redirect `json:"redirect"` +} + +type Rewrite struct { + PathPattern *PathPattern `json:"path_pattern"` + To string `json:"to"` +} + +type Redirect struct { + To string `json:"to"` + Type string `json:"type"` // either permanent or temporary. Defaults to permanent if not provided } type PathPattern struct { diff --git a/domain/mocks.go b/domain/mocks.go index 35d6f07..d88ea0d 100644 --- a/domain/mocks.go +++ b/domain/mocks.go @@ -54,7 +54,7 @@ func NewMatcher() Matcher { } } -// Matches a mock against all matchers +// Match matches a mock against all matchers func (m Matcher) Match(r *http.Request, mock Mock) bool { found := true for _, matcher := range m.matchers { diff --git a/examples/config.json b/examples/config.json index 8f0c8d1..1f616ab 100644 --- a/examples/config.json +++ b/examples/config.json @@ -25,6 +25,23 @@ ] } } + }, + { + "type": "proxy", + "path_pattern": "^/test-ui/.*", + "backend": "http://localhost:3000", + "rewrite": [{ + "path_pattern": "/test-ui/(.*)", + "to": "/$1" + }] + }, + { + "type": "redirect", + "path_pattern": "^/test-ui/(.*)", + "redirect": { + "to": "http://localhost:3000/$1", + "type": "temporary" + } } ] } diff --git a/file/config.go b/file/config.go index 9eff8a8..7702da7 100644 --- a/file/config.go +++ b/file/config.go @@ -3,11 +3,13 @@ package file import ( "encoding/json" "errors" - "github.com/JSainsburyPLC/ui-dev-proxy/domain" + "fmt" "io/ioutil" "os" "path/filepath" "strings" + + "github.com/JSainsburyPLC/ui-dev-proxy/domain" ) func ConfigProvider() domain.ConfigProvider { @@ -30,6 +32,13 @@ func ConfigProvider() domain.ConfigProvider { for _, r := range c.Routes { if r.Type != domain.RouteTypeMock { + if r.Redirect != nil { + redirectType := r.Redirect.Type + if redirectType != "permanent" && redirectType != "temporary" { + return domain.Config{}, fmt.Errorf("invalid redirect type '%s'", redirectType) + } + } + continue } diff --git a/http/rewrite/rewrite.go b/http/rewrite/rewrite.go deleted file mode 100644 index 0b04e7b..0000000 --- a/http/rewrite/rewrite.go +++ /dev/null @@ -1,58 +0,0 @@ -package rewrite - -import ( - "fmt" - "net/http" - "net/url" - "path" - "regexp" -) - -type Rule struct { - pattern string - to string - regexp *regexp.Regexp -} - -func NewRule(pattern, to string) (Rule, error) { - reg, err := regexp.Compile(pattern) - if err != nil { - return Rule{}, err - } - - return Rule{ - pattern: pattern, - to: to, - regexp: reg, - }, nil -} - -func (r *Rule) Rewrite(req *http.Request) (bool, error) { - oriPath := req.URL.Path - - if !r.regexp.MatchString(oriPath) { - return false, nil - } - - to := path.Clean(r.Replace(req.URL)) - u, e := url.Parse(to) - if e != nil { - return false, fmt.Errorf("rewritten URL is not valid. %w", e) - } - - req.URL.Path = u.Path - req.URL.RawPath = u.RawPath - if u.RawQuery != "" { - req.URL.RawQuery = u.RawQuery - } - - return true, nil -} - -func (r *Rule) Replace(u *url.URL) string { - uri := u.RequestURI() - patternRegexp := regexp.MustCompile(r.pattern) - match := patternRegexp.FindStringSubmatchIndex(uri) - result := patternRegexp.ExpandString([]byte(""), r.to, uri, match) - return string(result[:]) -} diff --git a/http/rewrite/rewrite_test.go b/http/rewrite/rewrite_test.go deleted file mode 100644 index 5dc0f76..0000000 --- a/http/rewrite/rewrite_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package rewrite_test - -import ( - "net/http" - "testing" - - "github.com/JSainsburyPLC/ui-dev-proxy/http/rewrite" - "github.com/stretchr/testify/assert" -) - -func TestRewrite(t *testing.T) { - tests := map[string]struct { - pattern string - to string - before string - after string - matched bool - }{ - "constant": { - pattern: "/a", - to: "/b", - before: "/a", - after: "/b", - matched: true, - }, - "preserves original URL if no match": { - pattern: "/a", - to: "/b", - before: "/c", - after: "/c", - matched: false, - }, - "match group": { - pattern: "/api/(.*)", - to: "/$1", - before: "/api/my-endpoint", - after: "/my-endpoint", - matched: true, - }, - "multiple match groups": { - pattern: "/a/(.*)/b/(.*)", - to: "/x/y/$1/z/$2", - before: "/a/oo/b/qq", - after: "/x/y/oo/z/qq", - matched: true, - }, - "encoded characters": { - pattern: "/a/(.*)", - to: "/b/$1", - before: "/a/x-1%2F", - after: "/b/x-1%2F", - matched: true, - }, - } - for name, test := range tests { - t.Run(name, func(t *testing.T) { - req, err := http.NewRequest("GET", test.before, nil) - if err != nil { - t.Fatalf("failed to create request %v %v", test, err) - } - rule, err := rewrite.NewRule(test.pattern, test.to) - if err != nil { - t.Fatal(err) - } - - matched, err := rule.Rewrite(req) - - assert.NoError(t, err) - assert.Equal(t, test.after, req.URL.EscapedPath()) - assert.Equal(t, test.matched, matched) - }) - } -} diff --git a/http/proxy/proxy.go b/proxy/proxy.go similarity index 73% rename from http/proxy/proxy.go rename to proxy/proxy.go index 7600247..95f593a 100644 --- a/http/proxy/proxy.go +++ b/proxy/proxy.go @@ -5,14 +5,14 @@ import ( "encoding/json" "errors" "fmt" - "github.com/JSainsburyPLC/ui-dev-proxy/domain" - "github.com/JSainsburyPLC/ui-dev-proxy/http/rewrite" - "log" "net/http" "net/http/httputil" "net/url" + "path" "time" + + "github.com/JSainsburyPLC/ui-dev-proxy/domain" ) const routeCtxKey = "route" @@ -68,27 +68,19 @@ func director(defaultBackend *url.URL, logger *log.Logger) func(req *http.Reques req.Host = defaultBackend.Host return } + // if route is set redirect to route backend req.URL.Scheme = route.Backend.Scheme req.URL.Host = route.Backend.Host req.Host = route.Backend.Host // apply any defined rewrite rules - for pattern, to := range route.Rewrite { - rule, err := rewrite.NewRule(pattern, to) - if err != nil { - logger.Println(fmt.Sprintf("error creating rewrite rule. %v", err)) - continue - } - - matched, err := rule.Rewrite(req) - if err != nil { - logger.Println(fmt.Sprintf("failed to rewrite request. %v", err)) - continue - } - - // recursive rewrites are not supported, exit on first rewrite - if matched { + for _, rule := range route.Rewrite { + if matches := rule.PathPattern.MatchString(path.Clean(req.URL.Path)); matches { + if err := rewrite(rule, req); err != nil { + logger.Println(fmt.Sprintf("failed to rewrite request. %v", err)) + continue + } break } } @@ -131,6 +123,16 @@ func handler( logger.Printf("directing to route backend '%s'\n", matchedRoute.Backend.Host) r = r.WithContext(context.WithValue(r.Context(), routeCtxKey, matchedRoute)) reverseProxy.ServeHTTP(w, r) + case domain.RouteTypeRedirect: + to := replaceURL(matchedRoute.PathPattern, matchedRoute.Redirect.To, r.URL) + u, err := url.Parse(to) + if err != nil { + logger.Printf(err.Error()) + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("Bad gateway")) + } + + http.Redirect(w, r, u.String(), redirectStatusCode(matchedRoute.Redirect.Type)) case domain.RouteTypeMock: if !mocksEnabled { logger.Println("directing to default backend") @@ -150,6 +152,13 @@ func matchRoute(conf domain.Config, matcher domain.Matcher, r *http.Request, moc if route.PathPattern.MatchString(r.URL.Path) { return &route, nil } + case domain.RouteTypeRedirect: + if route.Redirect == nil { + return nil, errors.New("missing redirect in config") + } + if route.PathPattern.MatchString(r.URL.Path) { + return &route, nil + } case domain.RouteTypeMock: if mocksEnabled { if route.Mock == nil { @@ -192,3 +201,33 @@ func addCookie(w http.ResponseWriter, cookie domain.Cookie) { } http.SetCookie(w, &c) } + +func rewrite(rule domain.Rewrite, req *http.Request) error { + to := path.Clean(replaceURL(rule.PathPattern, rule.To, req.URL)) + u, e := url.Parse(to) + if e != nil { + return fmt.Errorf("rewritten URL is not valid. %w", e) + } + + req.URL.Path = u.Path + req.URL.RawPath = u.RawPath + if u.RawQuery != "" { + req.URL.RawQuery = u.RawQuery + } + + return nil +} + +func replaceURL(pattern *domain.PathPattern, to string, u *url.URL) string { + uri := u.RequestURI() + match := pattern.FindStringSubmatchIndex(uri) + result := pattern.ExpandString([]byte(""), to, uri, match) + return string(result[:]) +} + +func redirectStatusCode(method string) int { + if method == "permanent" || method == "" { + return http.StatusMovedPermanently + } + return http.StatusFound +} diff --git a/http/proxy/proxy_test.go b/proxy/proxy_test.go similarity index 63% rename from http/proxy/proxy_test.go rename to proxy/proxy_test.go index 1c71e6f..242e723 100644 --- a/http/proxy/proxy_test.go +++ b/proxy/proxy_test.go @@ -1,14 +1,15 @@ package proxy import ( - "github.com/JSainsburyPLC/ui-dev-proxy/domain" - "github.com/steinfletcher/apitest" "io/ioutil" "log" "net/http" "net/url" "regexp" "testing" + + "github.com/JSainsburyPLC/ui-dev-proxy/domain" + "github.com/steinfletcher/apitest" ) func newApiTest( @@ -61,20 +62,106 @@ func TestProxy_ProxyBackend_UserProxy_Success(t *testing.T) { End() } -func TestProxy_ProxyBackend_RewriteURL(t *testing.T) { - newApiTest(configWithRewrite(map[string]string{ - "/test-ui/(.*)": "/rewrite-ui/$1", - }), "http://test-backend", false). - Mocks(apitest.NewMock(). - Get("http://localhost:3001/rewrite-ui/users/info"). - RespondWith(). - Status(http.StatusOK). - Body(`{"user_id": "123"}`). - End()). +func TestProxy_Rewrite(t *testing.T) { + tests := map[string]struct { + pattern string + to string + before string + after string + }{ + "constant": { + pattern: "/test-ui/users/info", + to: "/rewrite-ui/users/info", + before: "/test-ui/users/info", + after: "/rewrite-ui/users/info", + }, + "preserves original URL if no match": { + pattern: "^/other-ui/(.*)", + to: "/rewrite-ui/$1", + before: "/test-ui/users/info", + after: "/test-ui/users/info", + }, + "match group": { + pattern: "^/test-ui/(.*)", + to: "/rewrite-ui/$1", + before: "/test-ui/users/info", + after: "/rewrite-ui/users/info", + }, + "multiple match groups": { + pattern: "^/test-(.*)/users/(.*)", + to: "/rewrite-$1/$2", + before: "/test-ui/users/info", + after: "/rewrite-ui/info", + }, + "encoded characters": { + pattern: "^/test-ui/users/(.*)", + to: "/rewrite-ui/$1", + before: "/test-ui/users/x-1%2F", + after: "/rewrite-ui/x-1%2F", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mockProxyUrlUserUi, _ := url.Parse("http://localhost:3001") + route := domain.Route{ + Type: "proxy", + PathPattern: &domain.PathPattern{Regexp: regexp.MustCompile("^/test-ui/users/.*")}, + Backend: &domain.Backend{URL: mockProxyUrlUserUi}, + Rewrite: []domain.Rewrite{{ + PathPattern: &domain.PathPattern{Regexp: regexp.MustCompile(test.pattern)}, + To: test.to, + }}, + } + + newApiTest(configWithRoutes(route), "http://test-backend", false). + Mocks(apitest.NewMock(). + Get("http://localhost:3001" + test.after). + RespondWith(). + Status(http.StatusOK). + Body(`{"user_id": "123"}`). + End()). + Get(test.before). + Expect(t). + Status(http.StatusOK). + Body(`{"user_id": "123"}`). + End() + }) + } +} + +func TestProxy_ProxyBackend_Redirect_Temporary(t *testing.T) { + route := domain.Route{ + Type: "redirect", + PathPattern: &domain.PathPattern{Regexp: regexp.MustCompile("/test-ui/(.*)")}, + Redirect: &domain.Redirect{ + To: "http://www.domain2.com/redirect-ui/$1", + Type: "temporary", + }, + } + + newApiTest(configWithRoutes(route), "http://test-backend", false). Get("/test-ui/users/info"). Expect(t). - Status(http.StatusOK). - Body(`{"user_id": "123"}`). + Status(http.StatusFound). + Header("Location", "http://www.domain2.com/redirect-ui/users/info"). + End() +} + +func TestProxy_ProxyBackend_Redirect_Permanent(t *testing.T) { + route := domain.Route{ + Type: "redirect", + PathPattern: &domain.PathPattern{Regexp: regexp.MustCompile("/test-ui/(.*)")}, + Redirect: &domain.Redirect{ + To: "http://www.domain2.com/redirect-ui/$1", + Type: "permanent", + }, + } + + newApiTest(configWithRoutes(route), "http://test-backend", false). + Get("/test-ui/users/info"). + Expect(t). + Status(http.StatusMovedPermanently). + Header("Location", "http://www.domain2.com/redirect-ui/users/info"). End() } @@ -211,9 +298,9 @@ func config() domain.Config { } } -func configWithRewrite(rewrite map[string]string) domain.Config { +func configWithRoutes(routes ...domain.Route) domain.Config { conf := config() - conf.Routes[0].Rewrite = rewrite + conf.Routes = routes return conf }