From feefeadaf60c5f04c4bfcbdb8a3766c5eb43f594 Mon Sep 17 00:00:00 2001 From: Ratko Rudic Date: Fri, 7 Apr 2023 16:43:15 +0200 Subject: [PATCH 1/3] 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. --- .../handler/transport/{http_form.go => http_form_multipart.go} | 0 .../transport/{http_form_test.go => http_form_multipart_test.go} | 0 2 files changed, 0 insertions(+), 0 deletions(-) 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%) 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 From f0aeb3a0ee03d049ebee1bf63ce1a942d497251c Mon Sep 17 00:00:00 2001 From: Ratko Rudic Date: Fri, 7 Apr 2023 20:49:25 +0200 Subject: [PATCH 2/3] 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{}) ``` --- docs/content/recipes/migration-0.11.md | 2 + graphql/handler/transport/headers_test.go | 28 +++++ .../transport/http_form_urlencode_test.go | 88 +++++++++++++ .../handler/transport/http_form_urlencoded.go | 119 ++++++++++++++++++ 4 files changed, 237 insertions(+) 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 d1f55e8de3..258f93ee68 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 c599c92935..4673522de7 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_urlencode_test.go b/graphql/handler/transport/http_form_urlencode_test.go new file mode 100644 index 0000000000..fff30ae620 --- /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 0000000000..4da91a3285 --- /dev/null +++ b/graphql/handler/transport/http_form_urlencoded.go @@ -0,0 +1,119 @@ +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) { + if strings.Contains(bodyString, "\"query\":") { + // body is json + return h.parseJson(bodyString) + + } else if strings.HasPrefix(bodyString, "query=%7B") { + // body is urlencoded + return h.parseEncoded(bodyString) + + } else { + // 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 +} From 7f8b3a65b6d441e1f6604e985241444da2e3fe56 Mon Sep 17 00:00:00 2001 From: Ratko Rudic Date: Sat, 8 Apr 2023 14:45:17 +0200 Subject: [PATCH 3/3] golangci-lint: change ifElseChain to switch. No other changes but this rewrite to switch. --- graphql/handler/transport/http_form_urlencoded.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/graphql/handler/transport/http_form_urlencoded.go b/graphql/handler/transport/http_form_urlencoded.go index 4da91a3285..1b568e5490 100644 --- a/graphql/handler/transport/http_form_urlencoded.go +++ b/graphql/handler/transport/http_form_urlencoded.go @@ -76,15 +76,14 @@ func (h UrlEncodedForm) Do(w http.ResponseWriter, r *http.Request, exec graphql. } func (h UrlEncodedForm) parseBody(bodyString string) (*graphql.RawParams, error) { - if strings.Contains(bodyString, "\"query\":") { + switch { + case strings.Contains(bodyString, "\"query\":"): // body is json return h.parseJson(bodyString) - - } else if strings.HasPrefix(bodyString, "query=%7B") { + case strings.HasPrefix(bodyString, "query=%7B"): // body is urlencoded return h.parseEncoded(bodyString) - - } else { + default: // body is plain text params := &graphql.RawParams{} params.Query = strings.TrimPrefix(bodyString, "query=")