diff --git a/.gitignore b/.gitignore index 2017947..bd64caf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ ui-dev-proxy dist/ +.sequence/ \ No newline at end of file diff --git a/README.md b/README.md index 006e8c5..efac77b 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,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 + "backend": "http://localhost:3000", // backend scheme and host to proxy to. Required + "rewrite": { // optional rewrite rules + "/test-ui/(.*)": "/$1" + } } ``` diff --git a/commands/start.go b/commands/start.go index 7a90af0..50c42ac 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/proxy" + "github.com/JSainsburyPLC/ui-dev-proxy/http/proxy" "github.com/urfave/cli" "log" "net/url" diff --git a/domain/config.go b/domain/config.go index e0352cc..8f18202 100644 --- a/domain/config.go +++ b/domain/config.go @@ -16,10 +16,11 @@ type Config struct { } type Route struct { - Type string `json:"type"` - PathPattern *PathPattern `json:"path_pattern"` - Backend *Backend `json:"backend"` - Mock *Mock `json:"mock"` + Type string `json:"type"` + PathPattern *PathPattern `json:"path_pattern"` + Backend *Backend `json:"backend"` + Mock *Mock `json:"mock"` + Rewrite map[string]string `json:"rewrite"` } type PathPattern struct { diff --git a/go.mod b/go.mod index 87d6b47..c999066 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,9 @@ module github.com/JSainsburyPLC/ui-dev-proxy -go 1.12 +go 1.13 require ( - github.com/steinfletcher/apitest v1.3.8 - github.com/stretchr/objx v0.2.0 // indirect + github.com/steinfletcher/apitest v1.3.13 github.com/stretchr/testify v1.4.0 github.com/urfave/cli v1.22.1 - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/go.sum b/go.sum index b778969..51103b4 100644 --- a/go.sum +++ b/go.sum @@ -3,24 +3,20 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSY github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/steinfletcher/apitest v1.3.8 h1:Q5CrFWbXSo9ocx9pb0IgPw38FKPKfkfEF+3+V35n4M8= -github.com/steinfletcher/apitest v1.3.8/go.mod h1:LOVbGzWvWCiiVE4PZByfhRnA5L00l5uZQEx403xQ4K8= +github.com/steinfletcher/apitest v1.3.13 h1:E0BAXde9dke8jEjK1hqTGvmI20vyyfC+xSdE9nmTc84= +github.com/steinfletcher/apitest v1.3.13/go.mod h1:pCHKMM2TcH1pezw/xbmilaCdK9/dGsoCZBafwaqJ2sY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/urfave/cli v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/proxy/proxy.go b/http/proxy/proxy.go similarity index 85% rename from proxy/proxy.go rename to http/proxy/proxy.go index 3f9ef7b..7600247 100644 --- a/proxy/proxy.go +++ b/http/proxy/proxy.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "github.com/JSainsburyPLC/ui-dev-proxy/domain" + "github.com/JSainsburyPLC/ui-dev-proxy/http/rewrite" + "log" "net/http" "net/http/httputil" @@ -30,7 +32,7 @@ func NewProxy( logger *log.Logger, ) *Proxy { reverseProxy := &httputil.ReverseProxy{ - Director: director(defaultBackend), + Director: director(defaultBackend, logger), ErrorHandler: errorHandler(logger), } return &Proxy{ @@ -56,7 +58,7 @@ func (p *Proxy) Start() { } } -func director(defaultBackend *url.URL) func(req *http.Request) { +func director(defaultBackend *url.URL, logger *log.Logger) func(req *http.Request) { return func(req *http.Request) { route, ok := req.Context().Value(routeCtxKey).(*domain.Route) if !ok { @@ -70,6 +72,26 @@ func director(defaultBackend *url.URL) func(req *http.Request) { 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 { + break + } + } } } diff --git a/proxy/proxy_test.go b/http/proxy/proxy_test.go similarity index 89% rename from proxy/proxy_test.go rename to http/proxy/proxy_test.go index b629c4e..1c71e6f 100644 --- a/proxy/proxy_test.go +++ b/http/proxy/proxy_test.go @@ -61,6 +61,23 @@ 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()). + Get("/test-ui/users/info"). + Expect(t). + Status(http.StatusOK). + Body(`{"user_id": "123"}`). + End() +} + func TestProxy_MockBackend_Failure(t *testing.T) { newApiTest(config(), "http://test-backend", false). Mocks( @@ -194,6 +211,12 @@ func config() domain.Config { } } +func configWithRewrite(rewrite map[string]string) domain.Config { + conf := config() + conf.Routes[0].Rewrite = rewrite + return conf +} + func invalidTypeConfig() domain.Config { mockProxyUrlUserUi, err := url.Parse("http://localhost:3001") if err != nil { diff --git a/http/rewrite/rewrite.go b/http/rewrite/rewrite.go new file mode 100644 index 0000000..0b04e7b --- /dev/null +++ b/http/rewrite/rewrite.go @@ -0,0 +1,58 @@ +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 new file mode 100644 index 0000000..5dc0f76 --- /dev/null +++ b/http/rewrite/rewrite_test.go @@ -0,0 +1,73 @@ +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) + }) + } +}