From 50c2829c367bc8cdc6dfc6e00688ad4de69238c5 Mon Sep 17 00:00:00 2001 From: RatkoR Date: Sat, 8 Apr 2023 18:00:39 +0200 Subject: [PATCH] Transport for application/x-www-form-urlencoded content type (#2611) * Renamed 'form' transport to 'form_multipart'. There are multiple ways form data can be encoded. 'multipart' is just one of them - there are also 'application/x-www-form-urlencoded' (which will be added in next commit) and 'text/plain' encodings. Let each encoding have it's own form_xxxx file and tests. * Adds transport for application/x-www-form-urlencoded content type. This commit adds transport that handles form POST with content type set to 'application/x-www-form-urlencoded'. Form body can be json, urlencoded parameters or plain text. Example: ``` curl -X POST 'http://server/query' -d '{name}' -H "Content-Type: application/x-www-form-urlencoded" ``` Enable it in your GQL server with: ``` srv.AddTransport(transport.UrlEncodedForm{}) ``` * golangci-lint: change ifElseChain to switch. No other changes but this rewrite to switch. --- docs/content/recipes/migration-0.11.md | 2 + graphql/handler/transport/headers_test.go | 28 +++++ .../{http_form.go => http_form_multipart.go} | 0 ...rm_test.go => http_form_multipart_test.go} | 0 .../transport/http_form_urlencode_test.go | 88 +++++++++++++ .../handler/transport/http_form_urlencoded.go | 118 ++++++++++++++++++ 6 files changed, 236 insertions(+) rename graphql/handler/transport/{http_form.go => http_form_multipart.go} (100%) rename graphql/handler/transport/{http_form_test.go => http_form_multipart_test.go} (100%) create mode 100644 graphql/handler/transport/http_form_urlencode_test.go create mode 100644 graphql/handler/transport/http_form_urlencoded.go diff --git a/docs/content/recipes/migration-0.11.md b/docs/content/recipes/migration-0.11.md index d1f55e8de34..258f93ee681 100644 --- a/docs/content/recipes/migration-0.11.md +++ b/docs/content/recipes/migration-0.11.md @@ -28,6 +28,7 @@ response. Supported transports are: - GET - JSON POST - Multipart form + - UrlEncoded form - GRAPHQL - Websockets @@ -42,6 +43,7 @@ srv.AddTransport(transport.Options{}) srv.AddTransport(transport.GET{}) srv.AddTransport(transport.POST{}) srv.AddTransport(transport.MultipartForm{}) +srv.AddTransport(transport.UrlEncodedForm{}) srv.AddTransport(transport.GRAPHQL{}) ``` diff --git a/graphql/handler/transport/headers_test.go b/graphql/handler/transport/headers_test.go index c599c929359..4673522de71 100644 --- a/graphql/handler/transport/headers_test.go +++ b/graphql/handler/transport/headers_test.go @@ -101,6 +101,34 @@ func TestHeadersWithGRAPHQL(t *testing.T) { }) } +func TestHeadersWithFormUrlEncoded(t *testing.T) { + t.Run("Headers not set", func(t *testing.T) { + h := testserver.New() + h.AddTransport(transport.UrlEncodedForm{}) + + resp := doRequest(h, "POST", "/graphql", `{ name }`, "application/x-www-form-urlencoded") + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, 1, len(resp.Header())) + assert.Equal(t, "application/json", resp.Header().Get("Content-Type")) + }) + + t.Run("Headers set", func(t *testing.T) { + headers := map[string][]string{ + "Content-Type": {"application/json; charset: utf8"}, + "Other-Header": {"dummy-get-urlencoded-form"}, + } + + h := testserver.New() + h.AddTransport(transport.UrlEncodedForm{ResponseHeaders: headers}) + + resp := doRequest(h, "POST", "/graphql", `{ name }`, "application/x-www-form-urlencoded") + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, 2, len(resp.Header())) + assert.Equal(t, "application/json; charset: utf8", resp.Header().Get("Content-Type")) + assert.Equal(t, "dummy-get-urlencoded-form", resp.Header().Get("Other-Header")) + }) +} + func TestHeadersWithMULTIPART(t *testing.T) { t.Run("Headers not set", func(t *testing.T) { es := &graphql.ExecutableSchemaMock{ diff --git a/graphql/handler/transport/http_form.go b/graphql/handler/transport/http_form_multipart.go similarity index 100% rename from graphql/handler/transport/http_form.go rename to graphql/handler/transport/http_form_multipart.go diff --git a/graphql/handler/transport/http_form_test.go b/graphql/handler/transport/http_form_multipart_test.go similarity index 100% rename from graphql/handler/transport/http_form_test.go rename to graphql/handler/transport/http_form_multipart_test.go diff --git a/graphql/handler/transport/http_form_urlencode_test.go b/graphql/handler/transport/http_form_urlencode_test.go new file mode 100644 index 00000000000..fff30ae6208 --- /dev/null +++ b/graphql/handler/transport/http_form_urlencode_test.go @@ -0,0 +1,88 @@ +package transport_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/99designs/gqlgen/graphql/handler/testserver" + "github.com/99designs/gqlgen/graphql/handler/transport" + "github.com/stretchr/testify/assert" +) + +func TestUrlEncodedForm(t *testing.T) { + h := testserver.New() + h.AddTransport(transport.UrlEncodedForm{}) + + t.Run("success json", func(t *testing.T) { + resp := doRequest(h, "POST", "/graphql", `{"query":"{ name }"}`, "application/x-www-form-urlencoded") + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) + }) + + t.Run("success urlencoded", func(t *testing.T) { + resp := doRequest(h, "POST", "/graphql", `query=%7B%20name%20%7D`, "application/x-www-form-urlencoded") + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) + }) + + t.Run("success plain", func(t *testing.T) { + resp := doRequest(h, "POST", "/graphql", `query={ name }`, "application/x-www-form-urlencoded") + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) + }) + + t.Run("decode failure json", func(t *testing.T) { + resp := doRequest(h, "POST", "/graphql", "notjson", "application/x-www-form-urlencoded") + assert.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String()) + assert.Equal(t, resp.Header().Get("Content-Type"), "application/json") + assert.Equal(t, `{"errors":[{"message":"Unexpected Name \"notjson\"","locations":[{"line":1,"column":1}],"extensions":{"code":"GRAPHQL_PARSE_FAILED"}}],"data":null}`, resp.Body.String()) + }) + + t.Run("decode failure urlencoded", func(t *testing.T) { + resp := doRequest(h, "POST", "/graphql", "query=%7Bnot-good", "application/x-www-form-urlencoded") + assert.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String()) + assert.Equal(t, resp.Header().Get("Content-Type"), "application/json") + assert.Equal(t, `{"errors":[{"message":"Expected Name, found \u003cInvalid\u003e","locations":[{"line":1,"column":6}],"extensions":{"code":"GRAPHQL_PARSE_FAILED"}}],"data":null}`, resp.Body.String()) + }) + + t.Run("validate content type", func(t *testing.T) { + doReq := func(handler http.Handler, method string, target string, body string, contentType string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, target, strings.NewReader(body)) + if contentType != "" { + r.Header.Set("Content-Type", contentType) + } + w := httptest.NewRecorder() + + handler.ServeHTTP(w, r) + return w + } + + validContentTypes := []string{ + "application/x-www-form-urlencoded", + } + + for _, contentType := range validContentTypes { + t.Run(fmt.Sprintf("allow for content type %s", contentType), func(t *testing.T) { + resp := doReq(h, "POST", "/graphql", `{"query":"{ name }"}`, contentType) + assert.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) + }) + } + + invalidContentTypes := []string{ + "", + "text/plain", + } + + for _, tc := range invalidContentTypes { + t.Run(fmt.Sprintf("reject for content type %s", tc), func(t *testing.T) { + resp := doReq(h, "POST", "/graphql", `{"query":"{ name }"}`, tc) + assert.Equal(t, http.StatusBadRequest, resp.Code, resp.Body.String()) + assert.Equal(t, fmt.Sprintf(`{"errors":[{"message":"%s"}],"data":null}`, "transport not supported"), resp.Body.String()) + }) + } + }) +} diff --git a/graphql/handler/transport/http_form_urlencoded.go b/graphql/handler/transport/http_form_urlencoded.go new file mode 100644 index 00000000000..1b568e54907 --- /dev/null +++ b/graphql/handler/transport/http_form_urlencoded.go @@ -0,0 +1,118 @@ +package transport + +import ( + "io" + "log" + "mime" + "net/http" + "net/url" + "strings" + + "github.com/vektah/gqlparser/v2/gqlerror" + + "github.com/99designs/gqlgen/graphql" +) + +// FORM implements the application/x-www-form-urlencoded side of the default HTTP transport +type UrlEncodedForm struct { + // Map of all headers that are added to graphql response. If not + // set, only one header: Content-Type: application/json will be set. + ResponseHeaders map[string][]string +} + +var _ graphql.Transport = UrlEncodedForm{} + +func (h UrlEncodedForm) Supports(r *http.Request) bool { + if r.Header.Get("Upgrade") != "" { + return false + } + + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + return false + } + + return r.Method == "POST" && mediaType == "application/x-www-form-urlencoded" +} + +func (h UrlEncodedForm) Do(w http.ResponseWriter, r *http.Request, exec graphql.GraphExecutor) { + ctx := r.Context() + writeHeaders(w, h.ResponseHeaders) + params := &graphql.RawParams{} + start := graphql.Now() + params.Headers = r.Header + params.ReadTime = graphql.TraceTiming{ + Start: start, + End: graphql.Now(), + } + + bodyString, err := getRequestBody(r) + if err != nil { + gqlErr := gqlerror.Errorf("could not get form body: %+v", err) + resp := exec.DispatchError(ctx, gqlerror.List{gqlErr}) + log.Printf("could not get json request body: %+v", err.Error()) + writeJson(w, resp) + } + + params, err = h.parseBody(bodyString) + if err != nil { + gqlErr := gqlerror.Errorf("could not cleanup body: %+v", err) + resp := exec.DispatchError(ctx, gqlerror.List{gqlErr}) + log.Printf("could not cleanup body: %+v", err.Error()) + writeJson(w, resp) + } + + rc, OpErr := exec.CreateOperationContext(ctx, params) + if OpErr != nil { + w.WriteHeader(statusFor(OpErr)) + resp := exec.DispatchError(graphql.WithOperationContext(ctx, rc), OpErr) + writeJson(w, resp) + return + } + + var responses graphql.ResponseHandler + responses, ctx = exec.DispatchOperation(ctx, rc) + writeJson(w, responses(ctx)) +} + +func (h UrlEncodedForm) parseBody(bodyString string) (*graphql.RawParams, error) { + switch { + case strings.Contains(bodyString, "\"query\":"): + // body is json + return h.parseJson(bodyString) + case strings.HasPrefix(bodyString, "query=%7B"): + // body is urlencoded + return h.parseEncoded(bodyString) + default: + // body is plain text + params := &graphql.RawParams{} + params.Query = strings.TrimPrefix(bodyString, "query=") + + return params, nil + } +} + +func (h UrlEncodedForm) parseEncoded(bodyString string) (*graphql.RawParams, error) { + params := &graphql.RawParams{} + + query, err := url.QueryUnescape(bodyString) + if err != nil { + return nil, err + } + + params.Query = strings.TrimPrefix(query, "query=") + + return params, nil +} + +func (h UrlEncodedForm) parseJson(bodyString string) (*graphql.RawParams, error) { + params := &graphql.RawParams{} + bodyReader := io.NopCloser(strings.NewReader(bodyString)) + + err := jsonDecode(bodyReader, ¶ms) + if err != nil { + return nil, err + } + + return params, nil +}