Skip to content

Commit

Permalink
feat: support file upload in router (#758)
Browse files Browse the repository at this point in the history
This PR makes possible to support a file upload according to spec:

https://github.com/jaydenseric/graphql-multipart-request-spec

With this PR merged to main and released we can discuss and work on PR
to support the feature in cosmo router:

wundergraph/cosmo#652

Basically, this PR makes possible to pass a temporary file information
stored in file system to structures responsible for graphql resolve
operation. Additionally, introduces a method in nethttpclient.go to
actually perform the multipart http request.

---------

Co-authored-by: pedraumcosta <costa@hcx.us>
Co-authored-by: thisisnithin <nithinkumar5353@gmail.com>
  • Loading branch information
3 people authored Jun 24, 2024
1 parent 4a807c5 commit 627a7ce
Show file tree
Hide file tree
Showing 17 changed files with 435 additions and 75 deletions.
2 changes: 0 additions & 2 deletions v2/pkg/astvalidation/reference/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import (
"gopkg.in/yaml.v2"
)

//go:generate ./gen.sh

func main() {
currDir, _ := os.Getwd()
println(currDir)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1699,6 +1699,11 @@ func (s *Source) replaceEmptyObject(variables []byte) ([]byte, bool) {
return variables, false
}

func (s *Source) LoadWithFiles(ctx context.Context, input []byte, files []httpclient.File, writer io.Writer) (err error) {
input = s.compactAndUnNullVariables(input)
return httpclient.DoMultipartForm(s.httpClient, ctx, input, files, writer)
}

func (s *Source) Load(ctx context.Context, input []byte, writer io.Writer) (err error) {
input = s.compactAndUnNullVariables(input)
return httpclient.Do(s.httpClient, ctx, input, writer)
Expand Down
153 changes: 153 additions & 0 deletions v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"io"
"net/http"
"net/http/httptest"
"os"
"runtime"
"strconv"
"sync"
"testing"
"time"
Expand Down Expand Up @@ -9152,6 +9156,155 @@ func TestSource_Load(t *testing.T) {
})
}

type ExpectedFile struct {
Name string
Size int64
}

type ExpectedRequest struct {
Operations string
Map string
Files []ExpectedFile
}

func verifyMultipartRequest(t *testing.T, r *http.Request, expected ExpectedRequest) {
err := r.ParseMultipartForm(10 << 20)
require.NoError(t, err)

for key, values := range r.MultipartForm.Value {
switch key {
case "operations":
assert.Equal(t, expected.Operations, values[0])
case "map":
assert.Equal(t, expected.Map, values[0])
}
}

for i, expectedFile := range expected.Files {
values, exists := r.MultipartForm.File[strconv.Itoa(i)]
if !exists {
t.Fatalf("expected file %s not found in MultipartForm.File", expectedFile.Name)
}
assert.Equal(t, values[0].Filename, expectedFile.Name)
assert.Equal(t, values[0].Size, expectedFile.Size)
}
}

func TestLoadFiles(t *testing.T) {
if runtime.GOOS == "windows" {
t.SkipNow()
}

t.Run("single file", func(t *testing.T) {
queryString := `mutation($file: Upload!){singleUpload(file: $file)}`
variableString := `{"file":null}`
fileName := uuid.NewString()
fileContent := "hello"

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expectedFiles := []ExpectedFile{{
Name: fileName,
Size: int64(len(fileContent)),
}}
verifyMultipartRequest(t, r.Clone(r.Context()), ExpectedRequest{
Operations: fmt.Sprintf(`{"query":"%s","variables":%s}`, queryString, variableString),
Map: `{ "0" : ["variables.file"] }`,
Files: expectedFiles,
})
body, _ := io.ReadAll(r.Body)
_, _ = fmt.Fprint(w, string(body))
}))
defer ts.Close()

var (
src = &Source{httpClient: &http.Client{}}
serverUrl = ts.URL
variables = []byte(variableString)
query = []byte(queryString)
)

dir := t.TempDir()
f, err := os.CreateTemp(dir, fileName)
assert.NoError(t, err)
err = os.WriteFile(f.Name(), []byte(fileContent), 0644)
assert.NoError(t, err)

var input []byte
input = httpclient.SetInputBodyWithPath(input, variables, "variables")
input = httpclient.SetInputBodyWithPath(input, query, "query")
input = httpclient.SetInputURL(input, []byte(serverUrl))
buf := bytes.NewBuffer(nil)

ctx := context.Background()
require.NoError(t, src.LoadWithFiles(
ctx,
input,
[]httpclient.File{httpclient.NewFile(f.Name(), fileName)},
buf,
))
})

t.Run("multiple files", func(t *testing.T) {
queryString := `mutation($files: [Upload!]!) { multipleUpload(files: $files)}`
variableString := `{"files":[null,null]}`

file1Name := uuid.NewString()
file2Name := uuid.NewString()
file1Content := "test"
file2Content := "hello"

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expectedFiles := []ExpectedFile{{
Name: file1Name,
Size: int64(len(file1Content)),
}, {
Name: file2Name,
Size: int64(len(file2Content)),
}}
verifyMultipartRequest(t, r.Clone(r.Context()), ExpectedRequest{
Operations: fmt.Sprintf(`{"query":"%s","variables":%s}`, queryString, variableString),
Map: `{ "0" : ["variables.files.0"], "1" : ["variables.files.1"] }`,
Files: expectedFiles,
})
body, _ := io.ReadAll(r.Body)
_, _ = fmt.Fprint(w, string(body))
}))
defer ts.Close()

var (
src = &Source{httpClient: &http.Client{}}
serverUrl = ts.URL
variables = []byte(variableString)
query = []byte(queryString)
)

var input []byte
input = httpclient.SetInputBodyWithPath(input, variables, "variables")
input = httpclient.SetInputBodyWithPath(input, query, "query")
input = httpclient.SetInputURL(input, []byte(serverUrl))
buf := bytes.NewBuffer(nil)

dir := t.TempDir()
f1, err := os.CreateTemp(dir, file1Name)
assert.NoError(t, err)
err = os.WriteFile(f1.Name(), []byte(file1Content), 0644)
assert.NoError(t, err)

f2, err := os.CreateTemp(dir, file2Name)
assert.NoError(t, err)
err = os.WriteFile(f2.Name(), []byte(file2Content), 0644)
assert.NoError(t, err)

ctx := context.Background()
require.NoError(t, src.LoadWithFiles(
ctx,
input,
[]httpclient.File{httpclient.NewFile(f1.Name(), file1Name), httpclient.NewFile(f2.Name(), file2Name)},
buf,
))
})
}

func TestUnNullVariables(t *testing.T) {
t.Run("should not unnull variables if not enabled", func(t *testing.T) {
t.Run("two variables, one null", func(t *testing.T) {
Expand Down
26 changes: 26 additions & 0 deletions v2/pkg/engine/datasource/httpclient/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package httpclient

type File interface {
Path() string
Name() string
}

type internalFile struct {
path string
name string
}

func NewFile(path string, name string) File {
return &internalFile{
path: path,
name: name,
}
}

func (f *internalFile) Path() string {
return f.path
}

func (f *internalFile) Name() string {
return f.name
}
Loading

0 comments on commit 627a7ce

Please sign in to comment.