Skip to content

Commit

Permalink
Transport for application/x-www-form-urlencoded content type (#2611)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
RatkoR authored Apr 8, 2023
1 parent 8b38c0e commit 50c2829
Show file tree
Hide file tree
Showing 6 changed files with 236 additions and 0 deletions.
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
File renamed without changes.
File renamed without changes.
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
}

0 comments on commit 50c2829

Please sign in to comment.