diff --git a/docs/content/recipes/migration-0.11.md b/docs/content/recipes/migration-0.11.md index de64f137971..d1f55e8de34 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 + - GRAPHQL - Websockets new usage looks like this @@ -41,6 +42,7 @@ srv.AddTransport(transport.Options{}) srv.AddTransport(transport.GET{}) srv.AddTransport(transport.POST{}) srv.AddTransport(transport.MultipartForm{}) +srv.AddTransport(transport.GRAPHQL{}) ``` ### New handler extension API diff --git a/graphql/handler/transport/headers_test.go b/graphql/handler/transport/headers_test.go index 5e8160861a2..c599c929359 100644 --- a/graphql/handler/transport/headers_test.go +++ b/graphql/handler/transport/headers_test.go @@ -21,7 +21,7 @@ func TestHeadersWithPOST(t *testing.T) { h := testserver.New() h.AddTransport(transport.POST{}) - resp := doRequest(h, "POST", "/graphql", `{"query":"{ name }"}`) + resp := doRequest(h, "POST", "/graphql", `{"query":"{ name }"}`, "application/json") assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, 1, len(resp.Header())) assert.Equal(t, "application/json", resp.Header().Get("Content-Type")) @@ -36,7 +36,7 @@ func TestHeadersWithPOST(t *testing.T) { h := testserver.New() h.AddTransport(transport.POST{ResponseHeaders: headers}) - resp := doRequest(h, "POST", "/graphql", `{"query":"{ name }"}`) + resp := doRequest(h, "POST", "/graphql", `{"query":"{ name }"}`, "application/json") 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")) @@ -50,7 +50,7 @@ func TestHeadersWithGET(t *testing.T) { h := testserver.New() h.AddTransport(transport.GET{}) - resp := doRequest(h, "GET", "/graphql?query={name}", "") + resp := doRequest(h, "GET", "/graphql?query={name}", "", "application/json") assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, 1, len(resp.Header())) assert.Equal(t, "application/json", resp.Header().Get("Content-Type")) @@ -65,7 +65,7 @@ func TestHeadersWithGET(t *testing.T) { h := testserver.New() h.AddTransport(transport.GET{ResponseHeaders: headers}) - resp := doRequest(h, "GET", "/graphql?query={name}", "") + resp := doRequest(h, "GET", "/graphql?query={name}", "", "application/json") 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")) @@ -73,6 +73,34 @@ func TestHeadersWithGET(t *testing.T) { }) } +func TestHeadersWithGRAPHQL(t *testing.T) { + t.Run("Headers not set", func(t *testing.T) { + h := testserver.New() + h.AddTransport(transport.GRAPHQL{}) + + resp := doRequest(h, "POST", "/graphql", `{ name }`, "application/graphql") + 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-qraphql"}, + } + + h := testserver.New() + h.AddTransport(transport.GRAPHQL{ResponseHeaders: headers}) + + resp := doRequest(h, "POST", "/graphql", `{ name }`, "application/graphql") + 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-qraphql", 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_get_test.go b/graphql/handler/transport/http_get_test.go index 307b2b90357..5e46d231855 100644 --- a/graphql/handler/transport/http_get_test.go +++ b/graphql/handler/transport/http_get_test.go @@ -14,36 +14,36 @@ func TestGET(t *testing.T) { h.AddTransport(transport.GET{}) t.Run("success", func(t *testing.T) { - resp := doRequest(h, "GET", "/graphql?query={name}", ``) + resp := doRequest(h, "GET", "/graphql?query={name}", ``, "application/json") assert.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) }) t.Run("has json content-type header", func(t *testing.T) { - resp := doRequest(h, "GET", "/graphql?query={name}", ``) + resp := doRequest(h, "GET", "/graphql?query={name}", ``, "application/json") assert.Equal(t, "application/json", resp.Header().Get("Content-Type")) }) t.Run("decode failure", func(t *testing.T) { - resp := doRequest(h, "GET", "/graphql?query={name}&variables=notjson", "") + resp := doRequest(h, "GET", "/graphql?query={name}&variables=notjson", "", "application/json") assert.Equal(t, http.StatusBadRequest, resp.Code, resp.Body.String()) assert.Equal(t, `{"errors":[{"message":"variables could not be decoded"}],"data":null}`, resp.Body.String()) }) t.Run("invalid variable", func(t *testing.T) { - resp := doRequest(h, "GET", `/graphql?query=query($id:Int!){find(id:$id)}&variables={"id":false}`, "") + resp := doRequest(h, "GET", `/graphql?query=query($id:Int!){find(id:$id)}&variables={"id":false}`, "", "application/json") assert.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String()) assert.Equal(t, `{"errors":[{"message":"cannot use bool as Int","path":["variable","id"],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":null}`, resp.Body.String()) }) t.Run("parse failure", func(t *testing.T) { - resp := doRequest(h, "GET", "/graphql?query=!", "") + resp := doRequest(h, "GET", "/graphql?query=!", "", "application/json") assert.Equal(t, http.StatusUnprocessableEntity, resp.Code, resp.Body.String()) assert.Equal(t, `{"errors":[{"message":"Unexpected !","locations":[{"line":1,"column":1}],"extensions":{"code":"GRAPHQL_PARSE_FAILED"}}],"data":null}`, resp.Body.String()) }) t.Run("no mutations", func(t *testing.T) { - resp := doRequest(h, "GET", "/graphql?query=mutation{name}", "") + resp := doRequest(h, "GET", "/graphql?query=mutation{name}", "", "application/json") assert.Equal(t, http.StatusNotAcceptable, resp.Code, resp.Body.String()) assert.Equal(t, `{"errors":[{"message":"GET requests only allow query operations"}],"data":null}`, resp.Body.String()) }) diff --git a/graphql/handler/transport/http_graphql.go b/graphql/handler/transport/http_graphql.go new file mode 100644 index 00000000000..701e0714cf2 --- /dev/null +++ b/graphql/handler/transport/http_graphql.go @@ -0,0 +1,98 @@ +package transport + +import ( + "log" + "mime" + "net/http" + "net/url" + "strings" + + "github.com/vektah/gqlparser/v2/gqlerror" + + "github.com/99designs/gqlgen/graphql" +) + +// GRAPHQL implements the application/graphql side of the HTTP transport +// see: https://graphql.org/learn/serving-over-http/#post-request +// If the "application/graphql" Content-Type header is present, treat +// the HTTP POST body contents as the GraphQL query string. +type GRAPHQL 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 = GRAPHQL{} + +func (h GRAPHQL) 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/graphql" +} + +func (h GRAPHQL) 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 request body: %+v", err) + resp := exec.DispatchError(ctx, gqlerror.List{gqlErr}) + log.Printf("could not get request body: %+v", err.Error()) + writeJson(w, resp) + } + + params.Query, err = cleanupBody(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)) +} + +// Makes sure we strip "query=" keyword from body and +// that body is not url escaped +func cleanupBody(body string) (out string, err error) { + // Some clients send 'query=' at the start of body payload. Let's remove + // it to get GQL query only. + body = strings.TrimPrefix(body, "query=") + + // Body payload can be url encoded or not. We check if %7B - "{" character + // is where query starts. If it is, query is url encoded. + if strings.HasPrefix(body, "%7B") { + body, err = url.QueryUnescape(body) + + if err != nil { + return body, err + } + } + + return body, err +} diff --git a/graphql/handler/transport/http_graphql_test.go b/graphql/handler/transport/http_graphql_test.go new file mode 100644 index 00000000000..1a8824718bc --- /dev/null +++ b/graphql/handler/transport/http_graphql_test.go @@ -0,0 +1,99 @@ +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 TestGRAPHQL(t *testing.T) { + h := testserver.New() + h.AddTransport(transport.GRAPHQL{}) + + t.Run("success", func(t *testing.T) { + resp := doGraphqlRequest(h, "POST", "/graphql", `{ name }`) + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) + }) + + t.Run("success even if url encoded", func(t *testing.T) { + resp := doGraphqlRequest(h, "POST", "/graphql", `%7B%20name%20%7D`) + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) + }) + + t.Run("parse failure", func(t *testing.T) { + resp := doGraphqlRequest(h, "POST", "/graphql", `{"!"}`) + 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 String","locations":[{"line":1,"column":3}],"extensions":{"code":"GRAPHQL_PARSE_FAILED"}}],"data":null}`, resp.Body.String()) + }) + + t.Run("validation failure", func(t *testing.T) { + resp := doGraphqlRequest(h, "POST", "/graphql", `{ title }`) + 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":"Cannot query field \"title\" on type \"Query\".","locations":[{"line":1,"column":3}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":null}`, resp.Body.String()) + }) + + t.Run("execution failure", func(t *testing.T) { + resp := doGraphqlRequest(h, "POST", "/graphql", `mutation { name }`) + assert.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + assert.Equal(t, resp.Header().Get("Content-Type"), "application/json") + assert.Equal(t, `{"errors":[{"message":"mutations are not supported"}],"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/graphql", + "application/graphql; charset=utf-8", + } + + for _, contentType := range validContentTypes { + t.Run(fmt.Sprintf("allow for content type %s", contentType), func(t *testing.T) { + resp := doReq(h, "POST", "/graphql", `{ 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()) + }) + } + }) +} + +func doGraphqlRequest(handler http.Handler, method string, target string, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, target, strings.NewReader(body)) + r.Header.Set("Content-Type", "application/graphql") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, r) + return w +} diff --git a/graphql/handler/transport/http_post_test.go b/graphql/handler/transport/http_post_test.go index b104118e6fd..a26ba12912e 100644 --- a/graphql/handler/transport/http_post_test.go +++ b/graphql/handler/transport/http_post_test.go @@ -17,41 +17,41 @@ func TestPOST(t *testing.T) { h.AddTransport(transport.POST{}) t.Run("success", func(t *testing.T) { - resp := doRequest(h, "POST", "/graphql", `{"query":"{ name }"}`) + resp := doRequest(h, "POST", "/graphql", `{"query":"{ name }"}`, "application/json") assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String()) }) t.Run("decode failure", func(t *testing.T) { - resp := doRequest(h, "POST", "/graphql", "notjson") + resp := doRequest(h, "POST", "/graphql", "notjson", "application/json") assert.Equal(t, http.StatusBadRequest, resp.Code, resp.Body.String()) assert.Equal(t, resp.Header().Get("Content-Type"), "application/json") assert.Equal(t, `{"errors":[{"message":"json request body could not be decoded: invalid character 'o' in literal null (expecting 'u') body:notjson"}],"data":null}`, resp.Body.String()) }) t.Run("parse failure", func(t *testing.T) { - resp := doRequest(h, "POST", "/graphql", `{"query": "!"}`) + resp := doRequest(h, "POST", "/graphql", `{"query": "!"}`, "application/json") 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 !","locations":[{"line":1,"column":1}],"extensions":{"code":"GRAPHQL_PARSE_FAILED"}}],"data":null}`, resp.Body.String()) }) t.Run("validation failure", func(t *testing.T) { - resp := doRequest(h, "POST", "/graphql", `{"query": "{ title }"}`) + resp := doRequest(h, "POST", "/graphql", `{"query": "{ title }"}`, "application/json") 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":"Cannot query field \"title\" on type \"Query\".","locations":[{"line":1,"column":3}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":null}`, resp.Body.String()) }) t.Run("invalid variable", func(t *testing.T) { - resp := doRequest(h, "POST", "/graphql", `{"query": "query($id:Int!){find(id:$id)}","variables":{"id":false}}`) + resp := doRequest(h, "POST", "/graphql", `{"query": "query($id:Int!){find(id:$id)}","variables":{"id":false}}`, "application/json") 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":"cannot use bool as Int","path":["variable","id"],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}],"data":null}`, resp.Body.String()) }) t.Run("execution failure", func(t *testing.T) { - resp := doRequest(h, "POST", "/graphql", `{"query": "mutation { name }"}`) + resp := doRequest(h, "POST", "/graphql", `{"query": "mutation { name }"}`, "application/json") assert.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) assert.Equal(t, resp.Header().Get("Content-Type"), "application/json") assert.Equal(t, `{"errors":[{"message":"mutations are not supported"}],"data":null}`, resp.Body.String()) @@ -85,10 +85,6 @@ func TestPOST(t *testing.T) { invalidContentTypes := []string{ "", "text/plain", - - // These content types are currently not supported, but they are supported by other GraphQL servers, like express-graphql. - "application/x-www-form-urlencoded", - "application/graphql", } for _, tc := range invalidContentTypes { @@ -101,9 +97,9 @@ func TestPOST(t *testing.T) { }) } -func doRequest(handler http.Handler, method string, target string, body string) *httptest.ResponseRecorder { +func doRequest(handler http.Handler, method string, target string, body string, contentType string) *httptest.ResponseRecorder { r := httptest.NewRequest(method, target, strings.NewReader(body)) - r.Header.Set("Content-Type", "application/json") + r.Header.Set("Content-Type", contentType) w := httptest.NewRecorder() handler.ServeHTTP(w, r) diff --git a/graphql/handler/transport/options_test.go b/graphql/handler/transport/options_test.go index fee62752126..f0812c58fb4 100644 --- a/graphql/handler/transport/options_test.go +++ b/graphql/handler/transport/options_test.go @@ -14,7 +14,7 @@ func TestOptions(t *testing.T) { t.Run("responds to options requests with default methods", func(t *testing.T) { h := testserver.New() h.AddTransport(transport.Options{}) - resp := doRequest(h, "OPTIONS", "/graphql?query={me{name}}", ``) + resp := doRequest(h, "OPTIONS", "/graphql?query={me{name}}", ``, "application/json") assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, "OPTIONS, GET, POST", resp.Header().Get("Allow")) }) @@ -24,7 +24,7 @@ func TestOptions(t *testing.T) { h.AddTransport(transport.Options{ AllowedMethods: []string{http.MethodOptions, http.MethodPost, http.MethodHead}, }) - resp := doRequest(h, "OPTIONS", "/graphql?query={me{name}}", ``) + resp := doRequest(h, "OPTIONS", "/graphql?query={me{name}}", ``, "application/json") assert.Equal(t, http.StatusOK, resp.Code) assert.Equal(t, "OPTIONS, POST, HEAD", resp.Header().Get("Allow")) }) @@ -32,7 +32,7 @@ func TestOptions(t *testing.T) { t.Run("responds to head requests", func(t *testing.T) { h := testserver.New() h.AddTransport(transport.Options{}) - resp := doRequest(h, "HEAD", "/graphql?query={me{name}}", ``) + resp := doRequest(h, "HEAD", "/graphql?query={me{name}}", ``, "application/json") assert.Equal(t, http.StatusMethodNotAllowed, resp.Code) }) }