Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Transport for application/x-www-form-urlencoded content type #2611

Merged
merged 3 commits into from
Apr 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/content/recipes/migration-0.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ response. Supported transports are:
- GET
- JSON POST
- Multipart form
- UrlEncoded form
- GRAPHQL
- Websockets

Expand All @@ -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{})
```

Expand Down
28 changes: 28 additions & 0 deletions graphql/handler/transport/headers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
88 changes: 88 additions & 0 deletions graphql/handler/transport/http_form_urlencode_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
})
}
118 changes: 118 additions & 0 deletions graphql/handler/transport/http_form_urlencoded.go
Original file line number Diff line number Diff line change
@@ -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, &params)
if err != nil {
return nil, err
}

return params, nil
}