Skip to content

Commit

Permalink
support for Multipart requests (#13)
Browse files Browse the repository at this point in the history
* Implement file upload types

* Publish fields of Upload struct

* Rollback accidental change

* Remove double encode

* Fix parsing slice of files

* Add test extract files

* Add prepareMultipart test

* Fix some misspells reported by goreportcard.com

* Fix introspection go fmt
  • Loading branch information
obukhov authored Sep 20, 2020
1 parent 50dede0 commit e2dc43c
Show file tree
Hide file tree
Showing 7 changed files with 258 additions and 11 deletions.
118 changes: 118 additions & 0 deletions file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package graphql

import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"strconv"
)

type File interface {
io.Reader
io.Closer
}
type Upload struct {
File File
FileName string
}

type UploadMap []struct {
upload Upload
positions []string
}

func (u *UploadMap) UploadMap() map[string][]string {
var result = make(map[string][]string)

for idx, attachment := range *u {
result[strconv.Itoa(idx)] = attachment.positions
}

return result
}

func (u *UploadMap) NotEmpty() bool {
return len(*u) > 0
}

func (u *UploadMap) Add(upload Upload, varName string) {
*u = append(*u, struct {
upload Upload
positions []string
}{
upload,
[]string{fmt.Sprintf("variables.%s", varName)},
})
}

// function extracts attached files and sets respective variables to null
func extractFiles(input *QueryInput) *UploadMap {
uploadMap := &UploadMap{}

for varName, value := range input.Variables {
switch valueTyped := value.(type) {
case Upload:
uploadMap.Add(valueTyped, varName)
input.Variables[varName] = nil
case []interface{}:
for i, uploadVal := range valueTyped {
if upload, ok := uploadVal.(Upload); ok {
uploadMap.Add(upload, fmt.Sprintf("%s.%d", varName, i))
}
valueTyped[i] = nil
}
input.Variables[varName] = valueTyped
default:
//noop
}
}
return uploadMap
}

func prepareMultipart(payload []byte, uploadMap *UploadMap) (body []byte, contentType string, err error) {
var b = bytes.Buffer{}
var fw io.Writer

w := multipart.NewWriter(&b)

fw, err = w.CreateFormField("operations")
if err != nil {
return
}

_, err = fw.Write(payload)
if err != nil {
return
}

fw, err = w.CreateFormField("map")
if err != nil {
return
}

err = json.NewEncoder(fw).Encode(uploadMap.UploadMap())
if err != nil {
return
}

for index, uploadVariable := range *uploadMap {
fw, err := w.CreateFormFile(strconv.Itoa(index), uploadVariable.upload.FileName)
if err != nil {
return b.Bytes(), w.FormDataContentType(), err
}

_, err = io.Copy(fw, uploadVariable.upload.File)
if err != nil {
return b.Bytes(), w.FormDataContentType(), err
}
}

err = w.Close()
if err != nil {
return
}

return b.Bytes(), w.FormDataContentType(), nil
}
96 changes: 96 additions & 0 deletions file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package graphql

import (
"bytes"
"encoding/json"
"fmt"
"github.com/stretchr/testify/assert"
"io/ioutil"
"strings"
"testing"
)

func TestExtractFiles(t *testing.T) {

upload1 := Upload{nil, "file1"}
upload2 := Upload{nil, "file2"}
upload3 := Upload{nil, "file3"}

input := &QueryInput{
Variables: map[string]interface{}{
"stringParam": "hello world",
"someFile": upload1,
"allFiles": []interface{}{
upload2,
upload3,
},
"integerParam": 10,
},
}

actual := extractFiles(input)

expected := &UploadMap{}
expected.Add(upload1, "someFile")
expected.Add(upload2, "allFiles.0")
expected.Add(upload3, "allFiles.1")

assert.Equal(t, expected, actual)
}

func TestPrepareMultipart(t *testing.T) {
upload1 := Upload{ioutil.NopCloser(bytes.NewBufferString("File1Contents")), "file1"}
upload2 := Upload{ioutil.NopCloser(bytes.NewBufferString("File2Contents")), "file2"}
upload3 := Upload{ioutil.NopCloser(bytes.NewBufferString("File3Contents")), "file3"}

uploadMap := &UploadMap{}
uploadMap.Add(upload1, "someFile")
uploadMap.Add(upload2, "allFiles.0")
uploadMap.Add(upload3, "allFiles.1")

payload, _ := json.Marshal(map[string]interface{}{
"query": "mutation TestFileUpload($someFile: Upload!,$allFiles: [Upload!]!) {upload(file: $someFile) uploadMulti(files: $allFiles)}",
"variables": map[string]interface{}{
"someFile": nil,
"allFiles": []interface{}{nil, nil},
},
"operationName": "TestFileUpload",
})

body, contentType, err := prepareMultipart(payload, uploadMap)

headerParts := strings.Split(contentType, "; boundary=")
rawBody := []string{
"--%[1]s",
"Content-Disposition: form-data; name=\"operations\"",
"",
"{\"operationName\":\"TestFileUpload\",\"query\":\"mutation TestFileUpload($someFile: Upload!,$allFiles: [Upload!]!) {upload(file: $someFile) uploadMulti(files: $allFiles)}\",\"variables\":{\"allFiles\":[null,null],\"someFile\":null}}",
"--%[1]s",
"Content-Disposition: form-data; name=\"map\"",
"",
"{\"0\":[\"variables.someFile\"],\"1\":[\"variables.allFiles.0\"],\"2\":[\"variables.allFiles.1\"]}\n",
"--%[1]s",
"Content-Disposition: form-data; name=\"0\"; filename=\"file1\"",
"Content-Type: application/octet-stream",
"",
"File1Contents",
"--%[1]s",
"Content-Disposition: form-data; name=\"1\"; filename=\"file2\"",
"Content-Type: application/octet-stream",
"",
"File2Contents",
"--%[1]s",
"Content-Disposition: form-data; name=\"2\"; filename=\"file3\"",
"Content-Type: application/octet-stream",
"",
"File3Contents",
"--%[1]s--",
"",
}

expected := fmt.Sprintf(strings.Join(rawBody, "\r\n"), headerParts[1])

assert.Equal(t, "multipart/form-data", headerParts[0])
assert.Equal(t, expected, string(body))
assert.Nil(t, err)
}
3 changes: 1 addition & 2 deletions introspection.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"github.com/vektah/gqlparser/v2/ast"
)


// IntrospectRemoteSchema is used to build a RemoteSchema by firing the introspection query
// at a remote service and reconstructing the schema object from the response
func IntrospectRemoteSchema(url string) (*RemoteSchema, error) {
Expand Down Expand Up @@ -52,7 +51,7 @@ func IntrospectAPI(queryer Queryer) (*ast.Schema, error) {
result := IntrospectionQueryResult{}

input := &QueryInput{
Query: IntrospectionQuery,
Query: IntrospectionQuery,
OperationName: "IntrospectionQuery",
}

Expand Down
20 changes: 19 additions & 1 deletion queryer.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type QueryInput struct {
Variables map[string]interface{} `json:"variables"`
}

// String returns a guarenteed unique string that can be used to identify the input
// String returns a guaranteed unique string that can be used to identify the input
func (i *QueryInput) String() string {
// let's just marshal the input
marshaled, err := json.Marshal(i)
Expand Down Expand Up @@ -121,6 +121,24 @@ func (q *NetworkQueryer) SendQuery(ctx context.Context, payload []byte) ([]byte,
acc := req.WithContext(ctx)
acc.Header.Set("Content-Type", "application/json")

return q.sendRequest(acc)
}

// SendMultipart is responsible for sending multipart request to the desingated URL
func (q *NetworkQueryer) SendMultipart(ctx context.Context, payload []byte, contentType string) ([]byte, error) {
// construct the initial request we will send to the client
req, err := http.NewRequest("POST", q.URL, bytes.NewBuffer(payload))
if err != nil {
return nil, err
}
// add the current context to the request
acc := req.WithContext(ctx)
acc.Header.Set("Content-Type", contentType)

return q.sendRequest(acc)
}

func (q *NetworkQueryer) sendRequest(acc *http.Request) ([]byte, error) {
// we could have any number of middlewares that we have to go through so
for _, mware := range q.Middlewares {
err := mware(acc)
Expand Down
2 changes: 1 addition & 1 deletion queryerMultiOp.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type MultiOpQueryer struct {
loader *dataloader.Loader
}

// NewMultiOpQueryer returns a MultiOpQueryer with the provided paramters
// NewMultiOpQueryer returns a MultiOpQueryer with the provided parameters
func NewMultiOpQueryer(url string, interval time.Duration, maxBatchSize int) *MultiOpQueryer {
queryer := &MultiOpQueryer{
MaxBatchSize: maxBatchSize,
Expand Down
28 changes: 22 additions & 6 deletions queryerNetwork.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ package graphql
import (
"context"
"encoding/json"
"net/http"

"github.com/mitchellh/mapstructure"
"net/http"
)

// SingleRequestQueryer sends the query to a url and returns the response
Expand Down Expand Up @@ -43,6 +42,9 @@ func (q *SingleRequestQueryer) URL() string {

// Query sends the query to the designated url and returns the response.
func (q *SingleRequestQueryer) Query(ctx context.Context, input *QueryInput, receiver interface{}) error {
// check if query contains attached files
uploadMap := extractFiles(input)

// the payload
payload, err := json.Marshal(map[string]interface{}{
"query": input.Query,
Expand All @@ -53,10 +55,24 @@ func (q *SingleRequestQueryer) Query(ctx context.Context, input *QueryInput, rec
return err
}

// send that query to the api and write the appropriate response to the receiver
response, err := q.queryer.SendQuery(ctx, payload)
if err != nil {
return err
var response []byte
if uploadMap.NotEmpty() {
body, contentType, err := prepareMultipart(payload, uploadMap)

responseBody, err := q.queryer.SendMultipart(ctx, body, contentType)
if err != nil {
return err
}

response = responseBody
} else {
// send that query to the api and write the appropriate response to the receiver
responseBody, err := q.queryer.SendQuery(ctx, payload)
if err != nil {
return err
}

response = responseBody
}

result := map[string]interface{}{}
Expand Down
2 changes: 1 addition & 1 deletion queryer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ func TestQueryerWithMiddlewares(t *testing.T) {
return &http.Response{
StatusCode: http.StatusExpectationFailed,
// Send response to be tested
Body: ioutil.NopCloser(bytes.NewBufferString("Did not recieve the right header")),
Body: ioutil.NopCloser(bytes.NewBufferString("Did not receive the right header")),
// Must be set to non-nil value or it panics
Header: make(http.Header),
}
Expand Down

0 comments on commit e2dc43c

Please sign in to comment.