From 73ae2122aa29533ada7b9f703c07c3492573f45b Mon Sep 17 00:00:00 2001 From: Brett Buddin Date: Wed, 6 May 2020 11:14:27 -0400 Subject: [PATCH] feat(kit/feature): Add HTTP request proxy. --- kit/feature/http_proxy.go | 65 ++++++++++++++++ kit/feature/http_proxy_test.go | 137 +++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 kit/feature/http_proxy.go create mode 100644 kit/feature/http_proxy_test.go diff --git a/kit/feature/http_proxy.go b/kit/feature/http_proxy.go new file mode 100644 index 0000000000..7cd7fbb0c9 --- /dev/null +++ b/kit/feature/http_proxy.go @@ -0,0 +1,65 @@ +package feature + +import ( + "context" + "net/http" + "net/http/httputil" + "net/url" + + "go.uber.org/zap" +) + +// HTTPProxy is an HTTP proxy that's guided by a feature flag. If the feature flag +// presented to it is enabled, it will perform the proxying behavior. Otherwise +// it will be a no-op. +type HTTPProxy struct { + proxy *httputil.ReverseProxy + logger *zap.Logger + enabler ProxyEnabler +} + +// NewHTTPProxy returns a new Proxy. +func NewHTTPProxy(dest *url.URL, logger *zap.Logger, enabler ProxyEnabler) *HTTPProxy { + return &HTTPProxy{ + proxy: newReverseProxy(dest, enabler.Key()), + logger: logger, + enabler: enabler, + } +} + +// Do performs the proxying. It returns whether or not the request was proxied. +func (p *HTTPProxy) Do(w http.ResponseWriter, r *http.Request) bool { + if p.enabler.Enabled(r.Context()) { + p.proxy.ServeHTTP(w, r) + return true + } + return false +} + +const ( + // headerProxyFlag is the HTTP header for enriching the request and response + // with the feature flag key that precipitated the proxying behavior. + headerProxyFlag = "X-Proxy-Flag" +) + +// newReverseProxy creates a new single-host reverse proxy. +func newReverseProxy(dest *url.URL, enablerKey string) *httputil.ReverseProxy { + proxy := httputil.NewSingleHostReverseProxy(dest) + + defaultDirector := proxy.Director + proxy.Director = func(r *http.Request) { + defaultDirector(r) + r.Header.Set(headerProxyFlag, enablerKey) + } + proxy.ModifyResponse = func(r *http.Response) error { + r.Header.Set(headerProxyFlag, enablerKey) + return nil + } + return proxy +} + +// ProxyEnabler is a boolean feature flag. +type ProxyEnabler interface { + Key() string + Enabled(ctx context.Context, fs ...Flagger) bool +} diff --git a/kit/feature/http_proxy_test.go b/kit/feature/http_proxy_test.go new file mode 100644 index 0000000000..a5e6ce7a28 --- /dev/null +++ b/kit/feature/http_proxy_test.go @@ -0,0 +1,137 @@ +package feature + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zaptest" +) + +const ( + destBody = "hello from destination" + srcBody = "hello from source" + flagKey = "fancy-feature" +) + +func TestHTTPProxy_Proxying(t *testing.T) { + en := enabler{key: flagKey, state: true} + logger := zaptest.NewLogger(t) + resp, err := testHTTPProxy(logger, en) + if err != nil { + t.Error(err) + } + + proxyFlag := resp.Header.Get("X-Proxy-Flag") + if proxyFlag != flagKey { + t.Error("X-Proxy-Flag header not populated") + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Error(err) + } + + bodyStr := string(body) + if bodyStr != destBody { + t.Errorf("expected body of destination handler, but got: %q", bodyStr) + } +} + +func TestHTTPProxy_DefaultBehavior(t *testing.T) { + en := enabler{key: flagKey, state: false} + logger := zaptest.NewLogger(t) + resp, err := testHTTPProxy(logger, en) + if err != nil { + t.Error(err) + } + + proxyFlag := resp.Header.Get("X-Proxy-Flag") + if proxyFlag != "" { + t.Error("X-Proxy-Flag header populated") + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Error(err) + } + + bodyStr := string(body) + if bodyStr != srcBody { + t.Errorf("expected body of source handler, but got: %q", bodyStr) + } +} + +func TestHTTPProxy_RequestHeader(t *testing.T) { + h := func(w http.ResponseWriter, r *http.Request) { + proxyFlag := r.Header.Get("X-Proxy-Flag") + if proxyFlag != flagKey { + t.Error("expected X-Proxy-Flag to contain feature flag key") + } + } + + s := httptest.NewServer(http.HandlerFunc(h)) + defer s.Close() + + sURL, err := url.Parse(s.URL) + if err != nil { + t.Error(err) + } + + logger := zaptest.NewLogger(t) + en := enabler{key: flagKey, state: true} + proxy := NewHTTPProxy(sURL, logger, en) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://example.com/foo", nil) + srcHandler(proxy)(w, r) +} + +func testHTTPProxy(logger *zap.Logger, enabler ProxyEnabler) (*http.Response, error) { + s := httptest.NewServer(http.HandlerFunc(destHandler)) + defer s.Close() + + sURL, err := url.Parse(s.URL) + if err != nil { + return nil, err + } + + proxy := NewHTTPProxy(sURL, logger, enabler) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://example.com/foo", nil) + srcHandler(proxy)(w, r) + + return w.Result(), nil +} + +func destHandler(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, destBody) +} + +func srcHandler(proxy *HTTPProxy) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if proxy.Do(w, r) { + return + } + fmt.Fprint(w, srcBody) + } +} + +type enabler struct { + key string + state bool +} + +func (e enabler) Key() string { + return e.key +} + +func (e enabler) Enabled(context.Context, ...Flagger) bool { + return e.state +}