Skip to content

Commit

Permalink
Update GQLgen test client to work with multipart form data (take 2) (#…
Browse files Browse the repository at this point in the history
…1661)

* Update GQLgen test client to work with multipart form data

Update the GQLgen to support multipart form data, like those present
within the fileupload examples.

- Add missing space between "unsupported encoding " and failing
  content-type header error

(cherry picked from commit 101842f)

* Add WithFiles client option for fileupload GQLgen client tests

Add a `WithFiles` GQLgen client option to support the fileupload input
within tests, using the core Golang `os` package and File type, which
converts `os.File`s to their appropriate multipart form data within a
request.

- If there are no files this should just simply convert a
  `application/json` Content-Type to supported `multipart/form-data`

(cherry picked from commit 08ef942)

* Update fileupload test to use GQLgen test client

Update the fileupload test to use the GQLgen test client and `WithFiles`
option to remove the need for `createUploadRequest` helper with raw http
posts

- Fix setting the Content Type by using the appropriate `http` package
  function to dectect it

  + https://godoc.org/net/http#DetectContentType

(cherry picked from commit 5e573d5)

* Update WithFiles option test with multipart Reader

(cherry picked from commit 6dfa3cb)

* Update file upload tests `WithFiles` option

Update the file upload tests to use the GQL test client and its
`WithFiles` option to remove the need for a custom raw HTTP post request
builder `createUploadRequest`.

- Also update `WithFiles` option to group & map identical files; e.g.

  ```
    { "0": ["variables.req.0.file", "variables.req.1.file"] }
  ```

(cherry picked from commit 486d9f1)

* Make sure `WithFiles` does not add duplicates to multipart form data

(cherry picked from commit 0c2364d)

* Fix use of byte vs string in `WithFiles` tests

(cherry picked from commit ba10b5b)

* Fix strict withFiles option test for race conditions

Fix a problem with how strict the test's expected response was for tests
with files in their request, since it always expected a strict order of
files input that is somewhat random or dependent on what OS it is
running the test on and/or race condition
  • Loading branch information
Sonna authored Oct 15, 2021
1 parent 7435403 commit 1297835
Show file tree
Hide file tree
Showing 5 changed files with 538 additions and 157 deletions.
10 changes: 7 additions & 3 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io/ioutil"
"net/http"
"net/http/httptest"
"regexp"

"github.com/mitchellh/mapstructure"
)
Expand Down Expand Up @@ -120,15 +121,18 @@ func (p *Client) newRequest(query string, options ...Option) (*http.Request, err
option(bd)
}

switch bd.HTTP.Header.Get("Content-Type") {
case "application/json":
contentType := bd.HTTP.Header.Get("Content-Type")
switch {
case regexp.MustCompile(`multipart/form-data; ?boundary=.*`).MatchString(contentType):
break
case "application/json" == contentType:
requestBody, err := json.Marshal(bd)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
bd.HTTP.Body = ioutil.NopCloser(bytes.NewBuffer(requestBody))
default:
panic("unsupported encoding" + bd.HTTP.Header.Get("Content-Type"))
panic("unsupported encoding " + bd.HTTP.Header.Get("Content-Type"))
}

return bd.HTTP, nil
Expand Down
41 changes: 41 additions & 0 deletions client/client_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package client_test

import (
"bytes"
"encoding/json"
"io/ioutil"
"mime/multipart"
"net/http"
"net/textproto"
"testing"

"github.com/99designs/gqlgen/client"
Expand Down Expand Up @@ -39,6 +42,44 @@ func TestClient(t *testing.T) {
require.Equal(t, "bob", resp.Name)
}

func TestClientMultipartFormData(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bodyBytes, err := ioutil.ReadAll(r.Body)
require.NoError(t, err)
require.Contains(t, string(bodyBytes), `Content-Disposition: form-data; name="operations"`)
require.Contains(t, string(bodyBytes), `{"query":"mutation ($input: Input!) {}","variables":{"file":{}}`)
require.Contains(t, string(bodyBytes), `Content-Disposition: form-data; name="map"`)
require.Contains(t, string(bodyBytes), `{"0":["variables.file"]}`)
require.Contains(t, string(bodyBytes), `Content-Disposition: form-data; name="0"; filename="example.txt"`)
require.Contains(t, string(bodyBytes), `Content-Type: text/plain`)
require.Contains(t, string(bodyBytes), `Hello World`)

w.Write([]byte(`{}`))
})

c := client.New(h)

var resp struct{}
c.MustPost("{ id }", &resp,
func(bd *client.Request) {
bodyBuf := &bytes.Buffer{}
bodyWriter := multipart.NewWriter(bodyBuf)
bodyWriter.WriteField("operations", `{"query":"mutation ($input: Input!) {}","variables":{"file":{}}`)
bodyWriter.WriteField("map", `{"0":["variables.file"]}`)

h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", `form-data; name="0"; filename="example.txt"`)
h.Set("Content-Type", "text/plain")
ff, _ := bodyWriter.CreatePart(h)
ff.Write([]byte("Hello World"))
bodyWriter.Close()

bd.HTTP.Body = ioutil.NopCloser(bodyBuf)
bd.HTTP.Header.Set("Content-Type", bodyWriter.FormDataContentType())
},
)
}

func TestAddHeader(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "ASDF", r.Header.Get("Test-Key"))
Expand Down
133 changes: 133 additions & 0 deletions client/withfilesoption.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package client

import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"mime/multipart"
"net/http"
"net/textproto"
"os"
"strings"
)

type fileFormDataMap struct {
mapKey string
file *os.File
}

func findFiles(parentMapKey string, variables map[string]interface{}) []*fileFormDataMap {
files := []*fileFormDataMap{}
for key, value := range variables {
if v, ok := value.(map[string]interface{}); ok {
files = append(files, findFiles(parentMapKey+"."+key, v)...)
} else if v, ok := value.([]map[string]interface{}); ok {
for i, arr := range v {
files = append(files, findFiles(fmt.Sprintf(`%s.%s.%d`, parentMapKey, key, i), arr)...)
}
} else if v, ok := value.([]*os.File); ok {
for i, file := range v {
files = append(files, &fileFormDataMap{
mapKey: fmt.Sprintf(`%s.%s.%d`, parentMapKey, key, i),
file: file,
})
}
} else if v, ok := value.(*os.File); ok {
files = append(files, &fileFormDataMap{
mapKey: parentMapKey + "." + key,
file: v,
})
}
}

return files
}

// WithFiles encodes the outgoing request body as multipart form data for file variables
func WithFiles() Option {
return func(bd *Request) {
bodyBuf := &bytes.Buffer{}
bodyWriter := multipart.NewWriter(bodyBuf)

//-b7955bd2e1d17b67ac157b9e9ddb6238888caefc6f3541920a1debad284d
// Content-Disposition: form-data; name="operations"
//
// {"query":"mutation ($input: Input!) {}","variables":{"input":{"file":{}}}
requestBody, _ := json.Marshal(bd)
bodyWriter.WriteField("operations", string(requestBody))

// --b7955bd2e1d17b67ac157b9e9ddb6238888caefc6f3541920a1debad284d
// Content-Disposition: form-data; name="map"
//
// `{ "0":["variables.input.file"] }`
// or
// `{ "0":["variables.input.files.0"], "1":["variables.input.files.1"] }`
// or
// `{ "0": ["variables.input.0.file"], "1": ["variables.input.1.file"] }`
// or
// `{ "0": ["variables.req.0.file", "variables.req.1.file"] }`
mapData := ""
filesData := findFiles("variables", bd.Variables)
filesGroup := [][]*fileFormDataMap{}
for _, fd := range filesData {
foundDuplicate := false
for j, fg := range filesGroup {
f1, _ := fd.file.Stat()
f2, _ := fg[0].file.Stat()
if os.SameFile(f1, f2) {
foundDuplicate = true
filesGroup[j] = append(filesGroup[j], fd)
}
}

if !foundDuplicate {
filesGroup = append(filesGroup, []*fileFormDataMap{fd})
}
}
if len(filesGroup) > 0 {
mapDataFiles := []string{}

for i, fileData := range filesGroup {
mapDataFiles = append(
mapDataFiles,
fmt.Sprintf(`"%d":[%s]`, i, strings.Join(collect(fileData, wrapMapKeyInQuotes), ",")),
)
}

mapData = `{` + strings.Join(mapDataFiles, ",") + `}`
}
bodyWriter.WriteField("map", mapData)

// --b7955bd2e1d17b67ac157b9e9ddb6238888caefc6f3541920a1debad284d
// Content-Disposition: form-data; name="0"; filename="tempFile"
// Content-Type: text/plain; charset=utf-8
// or
// Content-Type: application/octet-stream
//
for i, fileData := range filesGroup {
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%d"; filename="%s"`, i, fileData[0].file.Name()))
b, _ := ioutil.ReadFile(fileData[0].file.Name())
h.Set("Content-Type", http.DetectContentType(b))
ff, _ := bodyWriter.CreatePart(h)
ff.Write(b)
}
bodyWriter.Close()

bd.HTTP.Body = ioutil.NopCloser(bodyBuf)
bd.HTTP.Header.Set("Content-Type", bodyWriter.FormDataContentType())
}
}

func collect(strArr []*fileFormDataMap, f func(s *fileFormDataMap) string) []string {
result := make([]string, len(strArr))
for i, str := range strArr {
result[i] = f(str)
}
return result
}

func wrapMapKeyInQuotes(s *fileFormDataMap) string {
return fmt.Sprintf("\"%s\"", s.mapKey)
}
Loading

0 comments on commit 1297835

Please sign in to comment.