diff --git a/client/client.go b/client/client.go index 7a2825c952b..e29c5fe5b3d 100644 --- a/client/client.go +++ b/client/client.go @@ -9,6 +9,7 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "regexp" "github.com/mitchellh/mapstructure" ) @@ -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 diff --git a/client/client_test.go b/client/client_test.go index 569151cd8a2..176c48d12ae 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -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" @@ -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")) diff --git a/client/withfilesoption.go b/client/withfilesoption.go new file mode 100644 index 00000000000..eff0d1c25f3 --- /dev/null +++ b/client/withfilesoption.go @@ -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) +} diff --git a/client/withfilesoption_test.go b/client/withfilesoption_test.go new file mode 100644 index 00000000000..48b9e09d0c4 --- /dev/null +++ b/client/withfilesoption_test.go @@ -0,0 +1,237 @@ +package client_test + +import ( + "io" + "io/ioutil" + "mime" + "mime/multipart" + "net/http" + "os" + "regexp" + "strings" + "testing" + + "github.com/99designs/gqlgen/client" + "github.com/stretchr/testify/require" +) + +func TestWithFiles(t *testing.T) { + tempFile1, _ := ioutil.TempFile(os.TempDir(), "tempFile1") + tempFile2, _ := ioutil.TempFile(os.TempDir(), "tempFile2") + tempFile3, _ := ioutil.TempFile(os.TempDir(), "tempFile3") + defer os.Remove(tempFile1.Name()) + defer os.Remove(tempFile2.Name()) + defer os.Remove(tempFile3.Name()) + tempFile1.WriteString(`The quick brown fox jumps over the lazy dog`) + tempFile2.WriteString(`hello world`) + tempFile3.WriteString(`La-Li-Lu-Le-Lo`) + + t.Run("with one file", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + require.NoError(t, err) + require.True(t, strings.HasPrefix(mediaType, "multipart/")) + + mr := multipart.NewReader(r.Body, params["boundary"]) + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + require.NoError(t, err) + + slurp, err := ioutil.ReadAll(p) + require.NoError(t, err) + + contentDisposition := p.Header.Get("Content-Disposition") + + if contentDisposition == `form-data; name="operations"` { + require.EqualValues(t, `{"query":"{ id }","variables":{"file":{}}}`, slurp) + } + if contentDisposition == `form-data; name="map"` { + require.EqualValues(t, `{"0":["variables.file"]}`, slurp) + } + if regexp.MustCompile(`form-data; name="0"; filename=.*`).MatchString(contentDisposition) { + require.Equal(t, `text/plain; charset=utf-8`, p.Header.Get("Content-Type")) + require.EqualValues(t, `The quick brown fox jumps over the lazy dog`, slurp) + } + } + w.Write([]byte(`{}`)) + }) + + c := client.New(h) + + var resp struct{} + c.MustPost("{ id }", &resp, + client.Var("file", tempFile1), + client.WithFiles(), + ) + }) + + t.Run("with multiple files", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + require.NoError(t, err) + require.True(t, strings.HasPrefix(mediaType, "multipart/")) + + mr := multipart.NewReader(r.Body, params["boundary"]) + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + require.NoError(t, err) + + slurp, err := ioutil.ReadAll(p) + require.NoError(t, err) + + contentDisposition := p.Header.Get("Content-Disposition") + + if contentDisposition == `form-data; name="operations"` { + require.EqualValues(t, `{"query":"{ id }","variables":{"input":{"files":[{},{}]}}}`, slurp) + } + if contentDisposition == `form-data; name="map"` { + // returns `{"0":["variables.input.files.0"],"1":["variables.input.files.1"]}` + // but the order of file inputs is unpredictable between different OS systems + require.Contains(t, string(slurp), `{"0":`) + require.Contains(t, string(slurp), `["variables.input.files.0"]`) + require.Contains(t, string(slurp), `,"1":`) + require.Contains(t, string(slurp), `["variables.input.files.1"]`) + require.Contains(t, string(slurp), `}`) + } + if regexp.MustCompile(`form-data; name="[0,1]"; filename=.*`).MatchString(contentDisposition) { + require.Equal(t, `text/plain; charset=utf-8`, p.Header.Get("Content-Type")) + require.Contains(t, []string{ + `The quick brown fox jumps over the lazy dog`, + `hello world`, + }, string(slurp)) + } + } + w.Write([]byte(`{}`)) + }) + + c := client.New(h) + + var resp struct{} + c.MustPost("{ id }", &resp, + client.Var("input", map[string]interface{}{ + "files": []*os.File{tempFile1, tempFile2}, + }), + client.WithFiles(), + ) + }) + + t.Run("with multiple files across multiple variables", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + require.NoError(t, err) + require.True(t, strings.HasPrefix(mediaType, "multipart/")) + + mr := multipart.NewReader(r.Body, params["boundary"]) + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + require.NoError(t, err) + + slurp, err := ioutil.ReadAll(p) + require.NoError(t, err) + + contentDisposition := p.Header.Get("Content-Disposition") + + if contentDisposition == `form-data; name="operations"` { + require.EqualValues(t, `{"query":"{ id }","variables":{"req":{"files":[{},{}],"foo":{"bar":{}}}}}`, slurp) + } + if contentDisposition == `form-data; name="map"` { + // returns `{"0":["variables.req.files.0"],"1":["variables.req.files.1"],"2":["variables.req.foo.bar"]}` + // but the order of file inputs is unpredictable between different OS systems + require.Contains(t, string(slurp), `{"0":`) + require.Contains(t, string(slurp), `["variables.req.files.0"]`) + require.Contains(t, string(slurp), `,"1":`) + require.Contains(t, string(slurp), `["variables.req.files.1"]`) + require.Contains(t, string(slurp), `,"2":`) + require.Contains(t, string(slurp), `["variables.req.foo.bar"]`) + require.Contains(t, string(slurp), `}`) + } + if regexp.MustCompile(`form-data; name="[0,1,2]"; filename=.*`).MatchString(contentDisposition) { + require.Equal(t, `text/plain; charset=utf-8`, p.Header.Get("Content-Type")) + require.Contains(t, []string{ + `The quick brown fox jumps over the lazy dog`, + `La-Li-Lu-Le-Lo`, + `hello world`, + }, string(slurp)) + } + } + w.Write([]byte(`{}`)) + }) + + c := client.New(h) + + var resp struct{} + c.MustPost("{ id }", &resp, + client.Var("req", map[string]interface{}{ + "files": []*os.File{tempFile1, tempFile2}, + "foo": map[string]interface{}{ + "bar": tempFile3, + }, + }), + client.WithFiles(), + ) + }) + + t.Run("with multiple files and file reuse", func(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + require.NoError(t, err) + require.True(t, strings.HasPrefix(mediaType, "multipart/")) + + mr := multipart.NewReader(r.Body, params["boundary"]) + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + require.NoError(t, err) + + slurp, err := ioutil.ReadAll(p) + require.NoError(t, err) + + contentDisposition := p.Header.Get("Content-Disposition") + + if contentDisposition == `form-data; name="operations"` { + require.EqualValues(t, `{"query":"{ id }","variables":{"files":[{},{},{}]}}`, slurp) + } + if contentDisposition == `form-data; name="map"` { + require.EqualValues(t, `{"0":["variables.files.0","variables.files.2"],"1":["variables.files.1"]}`, slurp) + // returns `{"0":["variables.files.0","variables.files.2"],"1":["variables.files.1"]}` + // but the order of file inputs is unpredictable between different OS systems + require.Contains(t, string(slurp), `{"0":`) + require.Contains(t, string(slurp), `["variables.files.0"`) + require.Contains(t, string(slurp), `,"1":`) + require.Contains(t, string(slurp), `"variables.files.1"]`) + require.Contains(t, string(slurp), `"variables.files.2"]`) + require.NotContains(t, string(slurp), `,"2":`) + require.Contains(t, string(slurp), `}`) + } + if regexp.MustCompile(`form-data; name="[0,1]"; filename=.*`).MatchString(contentDisposition) { + require.Equal(t, `text/plain; charset=utf-8`, p.Header.Get("Content-Type")) + require.Contains(t, []string{ + `The quick brown fox jumps over the lazy dog`, + `hello world`, + }, string(slurp)) + } + require.False(t, regexp.MustCompile(`form-data; name="2"; filename=.*`).MatchString(contentDisposition)) + } + w.Write([]byte(`{}`)) + }) + + c := client.New(h) + + var resp struct{} + c.MustPost("{ id }", &resp, + client.Var("files", []*os.File{tempFile1, tempFile2, tempFile1}), + client.WithFiles(), + ) + }) +} diff --git a/example/fileupload/fileupload_test.go b/example/fileupload/fileupload_test.go index 8d68252e009..0973e56ed97 100644 --- a/example/fileupload/fileupload_test.go +++ b/example/fileupload/fileupload_test.go @@ -2,17 +2,14 @@ package fileupload import ( - "bytes" "context" - "fmt" "io" "io/ioutil" - "mime/multipart" - "net/http" "net/http/httptest" - "net/textproto" + "os" "testing" + gqlclient "github.com/99designs/gqlgen/client" "github.com/99designs/gqlgen/example/fileupload/model" "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/handler" @@ -21,10 +18,23 @@ import ( ) func TestFileUpload(t *testing.T) { - client := http.Client{} + resolver := &Stub{} + srv := httptest.NewServer(handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: resolver}))) + defer srv.Close() + gql := gqlclient.New(srv.Config.Handler, gqlclient.Path("/graphql")) + + aTxtFile, _ := ioutil.TempFile(os.TempDir(), "a.txt") + defer os.Remove(aTxtFile.Name()) + aTxtFile.WriteString(`test`) + + a1TxtFile, _ := ioutil.TempFile(os.TempDir(), "a.txt") + b1TxtFile, _ := ioutil.TempFile(os.TempDir(), "b.txt") + defer os.Remove(a1TxtFile.Name()) + defer os.Remove(b1TxtFile.Name()) + a1TxtFile.WriteString(`test1`) + b1TxtFile.WriteString(`test2`) t.Run("valid single file upload", func(t *testing.T) { - resolver := &Stub{} resolver.MutationResolver.SingleUpload = func(ctx context.Context, file graphql.Upload) (*model.File, error) { require.NotNil(t, file) require.NotNil(t, file.File) @@ -39,34 +49,28 @@ func TestFileUpload(t *testing.T) { ContentType: file.ContentType, }, nil } - srv := httptest.NewServer(handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: resolver}))) - defer srv.Close() - operations := `{ "query": "mutation ($file: Upload!) { singleUpload(file: $file) { id, name, content, contentType } }", "variables": { "file": null } }` - mapData := `{ "0": ["variables.file"] }` - files := []file{ - { - mapKey: "0", - name: "a.txt", - content: "test", - contentType: "text/plain", - }, + mutation := `mutation ($file: Upload!) { + singleUpload(file: $file) { + id + name + content + contentType + } + }` + var result struct { + SingleUpload *model.File } - req := createUploadRequest(t, srv.URL, operations, mapData, files) - resp, err := client.Do(req) - require.Nil(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - responseBody, err := ioutil.ReadAll(resp.Body) - require.Nil(t, err) - responseString := string(responseBody) - require.Equal(t, `{"data":{"singleUpload":{"id":1,"name":"a.txt","content":"test","contentType":"text/plain"}}}`, responseString) - err = resp.Body.Close() + err := gql.Post(mutation, &result, gqlclient.Var("file", aTxtFile), gqlclient.WithFiles()) require.Nil(t, err) + require.Equal(t, 1, result.SingleUpload.ID) + require.Contains(t, result.SingleUpload.Name, "a.txt") + require.Equal(t, "test", result.SingleUpload.Content) + require.Equal(t, "text/plain; charset=utf-8", result.SingleUpload.ContentType) }) t.Run("valid single file upload with payload", func(t *testing.T) { - resolver := &Stub{} resolver.MutationResolver.SingleUploadWithPayload = func(ctx context.Context, req model.UploadFile) (*model.File, error) { require.Equal(t, req.ID, 1) require.NotNil(t, req.File) @@ -82,33 +86,28 @@ func TestFileUpload(t *testing.T) { ContentType: req.File.ContentType, }, nil } - srv := httptest.NewServer(handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: resolver}))) - defer srv.Close() - operations := `{ "query": "mutation ($req: UploadFile!) { singleUploadWithPayload(req: $req) { id, name, content, contentType } }", "variables": { "req": {"file": null, "id": 1 } } }` - mapData := `{ "0": ["variables.req.file"] }` - files := []file{ - { - mapKey: "0", - name: "a.txt", - content: "test", - contentType: "text/plain", - }, + mutation := `mutation ($req: UploadFile!) { + singleUploadWithPayload(req: $req) { + id + name + content + contentType + } + }` + var result struct { + SingleUploadWithPayload *model.File } - req := createUploadRequest(t, srv.URL, operations, mapData, files) - resp, err := client.Do(req) - require.Nil(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - responseBody, err := ioutil.ReadAll(resp.Body) - require.Nil(t, err) - require.Equal(t, `{"data":{"singleUploadWithPayload":{"id":1,"name":"a.txt","content":"test","contentType":"text/plain"}}}`, string(responseBody)) - err = resp.Body.Close() + err := gql.Post(mutation, &result, gqlclient.Var("req", map[string]interface{}{"id": 1, "file": aTxtFile}), gqlclient.WithFiles()) require.Nil(t, err) + require.Equal(t, 1, result.SingleUploadWithPayload.ID) + require.Contains(t, result.SingleUploadWithPayload.Name, "a.txt") + require.Equal(t, "test", result.SingleUploadWithPayload.Content) + require.Equal(t, "text/plain; charset=utf-8", result.SingleUploadWithPayload.ContentType) }) t.Run("valid file list upload", func(t *testing.T) { - resolver := &Stub{} resolver.MutationResolver.MultipleUpload = func(ctx context.Context, files []*graphql.Upload) ([]*model.File, error) { require.Len(t, files, 2) var contents []string @@ -128,39 +127,35 @@ func TestFileUpload(t *testing.T) { require.ElementsMatch(t, []string{"test1", "test2"}, contents) return resp, nil } - srv := httptest.NewServer(handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: resolver}))) - defer srv.Close() - operations := `{ "query": "mutation($files: [Upload!]!) { multipleUpload(files: $files) { id, name, content, contentType } }", "variables": { "files": [null, null] } }` - mapData := `{ "0": ["variables.files.0"], "1": ["variables.files.1"] }` - files := []file{ - { - mapKey: "0", - name: "a.txt", - content: "test1", - contentType: "text/plain", - }, - { - mapKey: "1", - name: "b.txt", - content: "test2", - contentType: "text/plain", - }, + mutation := `mutation($files: [Upload!]!) { + multipleUpload(files: $files) { + id + name + content + contentType + } + }` + var result struct { + MultipleUpload []*model.File } - req := createUploadRequest(t, srv.URL, operations, mapData, files) - resp, err := client.Do(req) - require.Nil(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - responseBody, err := ioutil.ReadAll(resp.Body) - require.Nil(t, err) - require.Equal(t, `{"data":{"multipleUpload":[{"id":1,"name":"a.txt","content":"test1","contentType":"text/plain"},{"id":2,"name":"b.txt","content":"test2","contentType":"text/plain"}]}}`, string(responseBody)) - err = resp.Body.Close() + err := gql.Post(mutation, &result, gqlclient.Var("files", []*os.File{a1TxtFile, b1TxtFile}), gqlclient.WithFiles()) require.Nil(t, err) + require.Equal(t, 1, result.MultipleUpload[0].ID) + require.Equal(t, 2, result.MultipleUpload[1].ID) + for _, mu := range result.MultipleUpload { + if mu.Name == "a.txt" { + require.Equal(t, "test1", mu.Content) + } + if mu.Name == "b.txt" { + require.Equal(t, "test2", mu.Content) + } + require.Equal(t, "text/plain; charset=utf-8", mu.ContentType) + } }) t.Run("valid file list upload with payload", func(t *testing.T) { - resolver := &Stub{} resolver.MutationResolver.MultipleUploadWithPayload = func(ctx context.Context, req []*model.UploadFile) ([]*model.File, error) { require.Len(t, req, 2) var ids []int @@ -184,35 +179,35 @@ func TestFileUpload(t *testing.T) { require.ElementsMatch(t, []string{"test1", "test2"}, contents) return resp, nil } - srv := httptest.NewServer(handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: resolver}))) - defer srv.Close() - operations := `{ "query": "mutation($req: [UploadFile!]!) { multipleUploadWithPayload(req: $req) { id, name, content, contentType } }", "variables": { "req": [ { "id": 1, "file": null }, { "id": 2, "file": null } ] } }` - mapData := `{ "0": ["variables.req.0.file"], "1": ["variables.req.1.file"] }` - files := []file{ - { - mapKey: "0", - name: "a.txt", - content: "test1", - contentType: "text/plain", - }, - { - mapKey: "1", - name: "b.txt", - content: "test2", - contentType: "text/plain", - }, + mutation := `mutation($req: [UploadFile!]!) { + multipleUploadWithPayload(req: $req) { + id + name + content + contentType + } + }` + var result struct { + MultipleUploadWithPayload []*model.File } - req := createUploadRequest(t, srv.URL, operations, mapData, files) - resp, err := client.Do(req) - require.Nil(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - responseBody, err := ioutil.ReadAll(resp.Body) - require.Nil(t, err) - require.Equal(t, `{"data":{"multipleUploadWithPayload":[{"id":1,"name":"a.txt","content":"test1","contentType":"text/plain"},{"id":2,"name":"b.txt","content":"test2","contentType":"text/plain"}]}}`, string(responseBody)) - err = resp.Body.Close() + err := gql.Post(mutation, &result, gqlclient.Var("req", []map[string]interface{}{ + {"id": 1, "file": a1TxtFile}, + {"id": 2, "file": b1TxtFile}, + }), gqlclient.WithFiles()) require.Nil(t, err) + require.Equal(t, 1, result.MultipleUploadWithPayload[0].ID) + require.Equal(t, 2, result.MultipleUploadWithPayload[1].ID) + for _, mu := range result.MultipleUploadWithPayload { + if mu.Name == "a.txt" { + require.Equal(t, "test1", mu.Content) + } + if mu.Name == "b.txt" { + require.Equal(t, "test2", mu.Content) + } + require.Equal(t, "text/plain; charset=utf-8", mu.ContentType) + } }) t.Run("valid file list upload with payload and file reuse", func(t *testing.T) { @@ -252,32 +247,39 @@ func TestFileUpload(t *testing.T) { return resp, nil } - operations := `{ "query": "mutation($req: [UploadFile!]!) { multipleUploadWithPayload(req: $req) { id, name, content, contentType } }", "variables": { "req": [ { "id": 1, "file": null }, { "id": 2, "file": null } ] } }` - mapData := `{ "0": ["variables.req.0.file", "variables.req.1.file"] }` - files := []file{ - { - mapKey: "0", - name: "a.txt", - content: "test1", - contentType: "text/plain", - }, - } - test := func(uploadMaxMemory int64) { hndlr := handler.New(NewExecutableSchema(Config{Resolvers: resolver})) hndlr.AddTransport(transport.MultipartForm{MaxMemory: uploadMaxMemory}) srv := httptest.NewServer(hndlr) defer srv.Close() - req := createUploadRequest(t, srv.URL, operations, mapData, files) - resp, err := client.Do(req) - require.Nil(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - responseBody, err := ioutil.ReadAll(resp.Body) - require.Nil(t, err) - require.Equal(t, `{"data":{"multipleUploadWithPayload":[{"id":1,"name":"a.txt","content":"test1","contentType":"text/plain"},{"id":2,"name":"a.txt","content":"test1","contentType":"text/plain"}]}}`, string(responseBody)) - err = resp.Body.Close() + gql := gqlclient.New(srv.Config.Handler, gqlclient.Path("/graphql")) + + mutation := `mutation($req: [UploadFile!]!) { + multipleUploadWithPayload(req: $req) { + id + name + content + contentType + } + }` + var result struct { + MultipleUploadWithPayload []*model.File + } + + err := gql.Post(mutation, &result, gqlclient.Var("req", []map[string]interface{}{ + {"id": 1, "file": a1TxtFile}, + {"id": 2, "file": a1TxtFile}, + }), gqlclient.WithFiles()) require.Nil(t, err) + require.Equal(t, 1, result.MultipleUploadWithPayload[0].ID) + require.Contains(t, result.MultipleUploadWithPayload[0].Name, "a.txt") + require.Equal(t, "test1", result.MultipleUploadWithPayload[0].Content) + require.Equal(t, "text/plain; charset=utf-8", result.MultipleUploadWithPayload[0].ContentType) + require.Equal(t, 2, result.MultipleUploadWithPayload[1].ID) + require.Contains(t, result.MultipleUploadWithPayload[1].Name, "a.txt") + require.Equal(t, "test1", result.MultipleUploadWithPayload[1].Content) + require.Equal(t, "text/plain; charset=utf-8", result.MultipleUploadWithPayload[1].ContentType) } t.Run("payload smaller than UploadMaxMemory, stored in memory", func(t *testing.T) { @@ -289,39 +291,3 @@ func TestFileUpload(t *testing.T) { }) }) } - -type file struct { - mapKey string - name string - content string - contentType string -} - -func createUploadRequest(t *testing.T, url, operations, mapData string, files []file) *http.Request { - bodyBuf := &bytes.Buffer{} - bodyWriter := multipart.NewWriter(bodyBuf) - - err := bodyWriter.WriteField("operations", operations) - require.NoError(t, err) - - err = bodyWriter.WriteField("map", mapData) - require.NoError(t, err) - - for i := range files { - h := make(textproto.MIMEHeader) - h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, files[i].mapKey, files[i].name)) - h.Set("Content-Type", files[i].contentType) - ff, err := bodyWriter.CreatePart(h) - require.NoError(t, err) - _, err = ff.Write([]byte(files[i].content)) - require.NoError(t, err) - } - err = bodyWriter.Close() - require.NoError(t, err) - - req, err := http.NewRequest("POST", fmt.Sprintf("%s/graphql", url), bodyBuf) - require.NoError(t, err) - - req.Header.Set("Content-Type", bodyWriter.FormDataContentType()) - return req -}