Repository: avenga/couper
Pull request #26 (2020-09-21 11:18:32)

Add post (form) parsing for config variable usage


Patch:
diff --git a/Makefile b/Makefile
index a98eda1a..2ce1ffb8 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,8 @@ test:
 	go test -v -short -race -timeout 30s ./...
 
 test-coverage:
-	go test -short -timeout 30s -covermode=count -coverprofile=ac.coverage ./access_control
+	go test -short -timeout 30s -covermode=count -coverprofile=ac.coverage ./accesscontrol
+	go test -short -timeout 30s -covermode=count -coverprofile=eval.coverage ./eval
 	go test -short -timeout 30s -covermode=count -coverprofile=config.coverage ./config
 	go test -short -timeout 30s -covermode=count -coverprofile=handler.coverage ./handler
 	go test -short -timeout 30s -covermode=count -coverprofile=server.coverage ./server
@@ -11,6 +12,7 @@ test-coverage:
 
 test-coverage-show:
 	go tool cover -html=ac.coverage
+	go tool cover -html=eval.coverage
 	go tool cover -html=config.coverage
 	go tool cover -html=handler.coverage
 	go tool cover -html=server.coverage
diff --git a/eval/context.go b/eval/context.go
index 7aab1af2..29c488a7 100644
--- a/eval/context.go
+++ b/eval/context.go
@@ -3,6 +3,8 @@ package eval
 import (
 	"bytes"
 	"context"
+	"io"
+	"io/ioutil"
 	"net/http"
 	"net/url"
 	"os"
@@ -69,7 +71,7 @@ func NewHTTPContext(baseCtx *hcl.EvalContext, req, bereq *http.Request, beresp *
 		"path":   cty.StringVal(req.URL.Path),
 		"url":    cty.StringVal(newRawURL(req.URL).String()),
 		"query":  seetie.ValuesMapToValue(req.URL.Query()),
-		"post":   seetie.ValuesMapToValue(req.PostForm),
+		"post":   seetie.ValuesMapToValue(parseForm(req).PostForm),
 	}.Merge(newVariable(httpCtx, req.Cookies(), req.Header))))
 
 	if beresp != nil {
@@ -78,9 +80,8 @@ func NewHTTPContext(baseCtx *hcl.EvalContext, req, bereq *http.Request, beresp *
 			"path":   cty.StringVal(bereq.URL.Path),
 			"url":    cty.StringVal(newRawURL(bereq.URL).String()),
 			"query":  seetie.ValuesMapToValue(bereq.URL.Query()),
-			"post":   seetie.ValuesMapToValue(bereq.PostForm),
-		}.
-			Merge(newVariable(httpCtx, bereq.Cookies(), bereq.Header)))
+			"post":   seetie.ValuesMapToValue(parseForm(bereq).PostForm),
+		}.Merge(newVariable(httpCtx, bereq.Cookies(), bereq.Header)))
 		evalCtx.Variables["beresp"] = cty.ObjectVal(ContextMap{
 			"status": cty.StringVal(strconv.Itoa(beresp.StatusCode)),
 		}.Merge(newVariable(httpCtx, beresp.Cookies(), beresp.Header)))
@@ -89,6 +90,48 @@ func NewHTTPContext(baseCtx *hcl.EvalContext, req, bereq *http.Request, beresp *
 	return evalCtx
 }
 
+const defaultMaxMemory = 32 << 20 // 32 MB
+
+type readCloser struct {
+	io.Reader
+	closer io.Closer
+}
+
+func newReadCloser(r io.Reader, c io.Closer) *readCloser {
+	return &readCloser{Reader: r, closer: c}
+}
+
+func (rc readCloser) Close() error {
+	return rc.closer.Close()
+}
+
+// parseForm populates the request PostForm field.
+// As Proxy we should not consume the request body.
+// Create a copy, buffer and reset via GetBody method.
+func parseForm(r *http.Request) *http.Request {
+	if r.Body == nil {
+		return r
+	}
+
+	switch r.Method {
+	case http.MethodPut, http.MethodPatch, http.MethodPost:
+		if r.GetBody == nil {
+			bodyBytes, err := ioutil.ReadAll(r.Body)
+			if err != nil {
+				panic(err)
+			}
+			r.GetBody = func() (io.ReadCloser, error) {
+				return newReadCloser(bytes.NewBuffer(bodyBytes), r.Body), nil
+			}
+		}
+
+		r.Body, _ = r.GetBody()
+		_ = r.ParseMultipartForm(defaultMaxMemory)
+		r.Body, _ = r.GetBody()
+	}
+	return r
+}
+
 func newRawURL(u *url.URL) *url.URL {
 	rawURL := *u
 	rawURL.RawQuery = ""
diff --git a/handler/proxy.go b/handler/proxy.go
index 37b1ec88..7c406612 100644
--- a/handler/proxy.go
+++ b/handler/proxy.go
@@ -176,6 +176,11 @@ func (p *Proxy) roundtrip(rw http.ResponseWriter, req *http.Request) {
 	p.director(outreq)
 	outreq.Close = false
 
+	// Deal with req.post access on the way back
+	if outreq.GetBody != nil {
+		req.GetBody = outreq.GetBody
+	}
+
 	reqUpType := upgradeType(outreq.Header)
 	removeConnectionHeaders(outreq.Header)
 


Test patch:
diff --git a/eval/context_test.go b/eval/context_test.go
new file mode 100644
index 00000000..52fbfdcd
--- /dev/null
+++ b/eval/context_test.go
@@ -0,0 +1,88 @@
+package eval
+
+import (
+	"context"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/hashicorp/hcl/v2"
+	"github.com/hashicorp/hcl/v2/hclsimple"
+
+	"github.com/avenga/couper/config/request"
+)
+
+func TestNewHTTPContext(t *testing.T) {
+	newBeresp := func(br *http.Request) *http.Response {
+		return &http.Response{
+			StatusCode: http.StatusOK,
+			Status:     "OK",
+			Request:    br,
+			Proto:      br.Proto,
+			ProtoMajor: br.ProtoMajor,
+			ProtoMinor: br.ProtoMinor,
+		}
+	}
+
+	type EvalTestContext struct {
+		ReqHeader string `hcl:"header,optional"`
+		Method    string `hcl:"method,optional"`
+		QueryVar  string `hcl:"query_var,optional"`
+		PostVar   string `hcl:"post_var,optional"`
+	}
+
+	baseCtx := NewENVContext(nil)
+
+	tests := []struct {
+		name      string
+		reqMethod string
+		body      io.Reader
+		query     string
+		baseCtx   *hcl.EvalContext
+		hcl       string
+		want      EvalTestContext
+	}{
+		{"Variables / POST", http.MethodPost, strings.NewReader(`user=hans`), "", baseCtx, `
+		post_var = req.post.user
+		method = req.method
+`, EvalTestContext{PostVar: "hans", Method: http.MethodPost}},
+		{"Variables / Query", http.MethodGet, nil, "?name=peter", baseCtx, `
+		query_var = req.query.name
+		method = req.method
+		header = req.headers.user-agent
+`, EvalTestContext{QueryVar: "peter", Method: http.MethodGet, ReqHeader: "test/v1"}},
+		{"Variables / Headers", http.MethodGet, nil, "", baseCtx, `
+		header = req.headers.user-agent
+`, EvalTestContext{ReqHeader: "test/v1"}},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req := httptest.NewRequest(tt.reqMethod, "https://couper.io/"+tt.query, tt.body)
+			*req = *req.Clone(context.WithValue(req.Context(), request.Endpoint, "couper-proxy"))
+			if tt.body != nil {
+				req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+			}
+			req.Header.Set("User-Agent", "test/v1")
+
+			bereq := req.Clone(context.Background())
+			beresp := newBeresp(bereq)
+
+			got := NewHTTPContext(tt.baseCtx, req, bereq, beresp)
+			got.Functions = nil // we are not interested in a functions test
+
+			var hclResult EvalTestContext
+			err := hclsimple.Decode("test.hcl", []byte(tt.hcl), got, &hclResult)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if !reflect.DeepEqual(hclResult, tt.want) {
+				t.Errorf("NewHTTPContext()\ngot:\t%v\nwant:\t%v", hclResult, tt.want)
+			}
+		})
+	}
+}
diff --git a/handler/proxy_test.go b/handler/proxy_test.go
index eece7ca0..66729100 100644
--- a/handler/proxy_test.go
+++ b/handler/proxy_test.go
@@ -2,12 +2,15 @@ package handler
 
 import (
 	"context"
+	"io"
 	"net/http"
 	"net/http/httptest"
+	"strings"
 	"testing"
 	"time"
 
 	"github.com/hashicorp/hcl/v2"
+	"github.com/hashicorp/hcl/v2/hclsimple"
 	"github.com/sirupsen/logrus"
 	logrustest "github.com/sirupsen/logrus/hooks/test"
 
@@ -424,34 +427,100 @@ func TestProxy_director(t *testing.T) {
 	}
 }
 
-func TestProxy_modifyResponse(t *testing.T) {
+func TestProxy_ServeHTTP_Eval(t *testing.T) {
 	type fields struct {
 		evalContext *hcl.EvalContext
-		log         *logrus.Entry
 		options     *ProxyOptions
 	}
 
-	type args struct {
-		res *http.Response
-	}
+	type header map[string]string
 
 	type testCase struct {
-		name    string
-		fields  fields
-		args    args
-		wantErr bool
+		name       string
+		fields     fields
+		hcl        string
+		method     string
+		body       io.Reader
+		wantHeader header
+		wantErr    bool
+	}
+
+	baseCtx := eval.NewENVContext(nil)
+	log, hook := logrustest.NewNullLogger()
+
+	type hclBody struct {
+		Inline hcl.Body `hcl:",remain"`
 	}
 
+	origin := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+		if r.Method == http.MethodPost {
+			if err := r.ParseForm(); err != nil {
+				t.Fatal(err)
+			}
+		}
+
+		rw.WriteHeader(http.StatusNoContent)
+	}))
+	defer origin.Close()
+
+	opts := &ProxyOptions{BackendName: "test-origin", Origin: "http://" + origin.Listener.Addr().String(), CORS: &CORSOptions{}}
+
 	tests := []testCase{
-		// TODO: Add test cases.
+		{"GET use req.Header", fields{baseCtx, opts}, `
+		response_headers = {
+			X-Method = req.method
+		}`, http.MethodGet, nil, header{"X-Method": http.MethodGet}, false},
+		{"POST use req.post", fields{baseCtx, opts}, `
+		response_headers = {
+			X-Method = req.method
+			X-Post = req.post.foo
+		}`, http.MethodPost, strings.NewReader(`foo=bar`), header{
+			"X-Method": http.MethodPost,
+			"X-Post":   "bar",
+		}, false},
 	}
+
 	for _, tt := range tests {
+		hook.Reset()
 		t.Run(tt.name, func(t *testing.T) {
-			_, err := NewProxy(tt.fields.options, tt.fields.log, tt.fields.evalContext)
+			var remain hclBody
+			err := hclsimple.Decode("test.hcl", []byte(tt.hcl), baseCtx, &remain)
 			if err != nil {
 				t.Fatal(err)
 			}
-			// TODO: test me
+			tt.fields.options.Context = append(tt.fields.options.Context, remain.Inline)
+			p, err := NewProxy(tt.fields.options, log.WithContext(context.Background()), tt.fields.evalContext)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			req := httptest.NewRequest(tt.method, "http://couper.io", tt.body)
+			rw := httptest.NewRecorder()
+
+			if tt.body != nil {
+				req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+			}
+
+			p.ServeHTTP(rw, req)
+			res := rw.Result()
+
+			if res == nil {
+				t.Fatal("Expected a response")
+				t.Log(hook.LastEntry().String())
+			}
+
+			if res.StatusCode != http.StatusNoContent {
+				t.Errorf("Expected StatusNoContent 204, got: %q %d", res.Status, res.StatusCode)
+				t.Log(hook.LastEntry().String())
+			}
+
+			for k, v := range tt.wantHeader {
+				if got := res.Header.Get(k); got != v {
+					t.Errorf("Expected value for header %q: %q, got: %q", k, v, got)
+					t.Log(hook.LastEntry().String())
+				}
+			}
+
 		})
 	}
 }