From ae98872078895672eb7849863c6ad2ee81db17a3 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 17 Jul 2024 06:58:30 +0300 Subject: [PATCH] Add multipart/form-data transport to support file uploads (#268) This can be tested with a request like: curl --url http://localhost:8082/query \ --form 'operations={"query":"mutation upload($fileA: Upload!, $fileB: Upload!) {\n\tuploadGizmoFile(upload: $fileA)\n\tuploadGadgetFile(upload: {\n\t\tupload: $fileB\n\t})\n}","variables":{"fileA":null,"fileB":null}}' \ --form 'map={ "a": ["variables.fileA"], "b": ["variables.fileB"] }' \ --form a=@a.txt \ --form b=@b.txt --------- Co-authored-by: Adam Sven Johnson --- client.go | 151 +++++++++++++++++- client_test.go | 91 +++++++++++ config.go | 1 + docker-compose.yaml | 12 +- docs/plugins.md | 13 ++ .../.gitignore | 3 + .../Dockerfile | 11 ++ .../Makefile | 18 +++ .../README.md | 32 ++++ .../go.mod | 26 +++ .../go.sum | 54 +++++++ .../gqlgen.yml | 10 ++ .../main.go | 24 +++ .../resolver.go | 60 +++++++ .../schema.graphql | 45 ++++++ .../tools.go | 8 + gateway.go | 4 +- middleware.go | 24 +-- 18 files changed, 572 insertions(+), 15 deletions(-) create mode 100644 examples/gqlgen-multipart-file-upload-service/.gitignore create mode 100644 examples/gqlgen-multipart-file-upload-service/Dockerfile create mode 100644 examples/gqlgen-multipart-file-upload-service/Makefile create mode 100644 examples/gqlgen-multipart-file-upload-service/README.md create mode 100644 examples/gqlgen-multipart-file-upload-service/go.mod create mode 100644 examples/gqlgen-multipart-file-upload-service/go.sum create mode 100644 examples/gqlgen-multipart-file-upload-service/gqlgen.yml create mode 100644 examples/gqlgen-multipart-file-upload-service/main.go create mode 100644 examples/gqlgen-multipart-file-upload-service/resolver.go create mode 100644 examples/gqlgen-multipart-file-upload-service/schema.graphql create mode 100644 examples/gqlgen-multipart-file-upload-service/tools.go diff --git a/client.go b/client.go index eb4fbc62..73e9b506 100644 --- a/client.go +++ b/client.go @@ -8,11 +8,15 @@ import ( "fmt" "io" "math" + "mime" + "mime/multipart" "net/http" + "net/textproto" "os" "strings" "time" + "github.com/99designs/gqlgen/graphql" "github.com/prometheus/client_golang/prometheus" "github.com/vektah/gqlparser/v2/ast" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" @@ -131,11 +135,11 @@ func (c *GraphQLClient) Request(ctx context.Context, url string, request *Reques return err } - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(request); err != nil { + buf, contentType, err := request.requestBody() + if err != nil { return traceErr(fmt.Errorf("unable to encode request body: %w", err)) - } + } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf) if err != nil { return traceErr(fmt.Errorf("unable to create request: %w", err)) @@ -145,7 +149,7 @@ func (c *GraphQLClient) Request(ctx context.Context, url string, request *Reques httpReq.Header = request.Headers.Clone() } - httpReq.Header.Set("Content-Type", "application/json; charset=utf-8") + httpReq.Header.Set("Content-Type", contentType) httpReq.Header.Set("Accept", "application/json") if c.UserAgent != "" { @@ -246,6 +250,88 @@ func (r *Request) WithVariables(variables map[string]interface{}) *Request { return r } +// isMultipart returns true if the request contains a graphql.Upload object +// implying that the downstream request needs to be a multipart/form-data request +func (r *Request) isMultipart() bool { + stack := []map[string]any{r.Variables} + for len(stack) > 0 { + currentItem := stack[len(stack)-1] + stack = stack[:len(stack)-1] + for _, v := range currentItem { + switch v := v.(type) { + case graphql.Upload, *graphql.Upload, []graphql.Upload, []*graphql.Upload: + return true + case map[string]any: + stack = append(stack, v) + } + } + } + return false +} + +func (r *Request) requestBody() (bytes.Buffer, string, error) { + var buf bytes.Buffer + var err error + contentType := "application/json; charset=utf-8" + if r.isMultipart() { + buf, contentType, err = multipartBody(r) + if err != nil { + return buf, "", fmt.Errorf("unable to encode multipart request body: %w", err) + } + return buf, contentType, nil + } + if err = json.NewEncoder(&buf).Encode(r); err != nil { + return buf, "", fmt.Errorf("unable to encode request body: %w", err) + } + return buf, contentType, nil +} + +func multipartBody(r *Request) (bytes.Buffer, string, error) { + files, fileMap := prepareUploadsFromVariables(r.Variables) + + var buf bytes.Buffer + mpw := multipart.NewWriter(&buf) + fw, err := mpw.CreateFormField("operations") + if err != nil { + return buf, "", err + } + if err = json.NewEncoder(fw).Encode(r); err != nil { + return buf, "", err + } + fw, err = mpw.CreateFormField("map") + if err != nil { + return buf, "", err + } + if err = json.NewEncoder(fw).Encode(fileMap); err != nil { + return buf, "", err + } + for fileIndex := range fileMap { + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{ + "name": fileIndex, + "filename": files[fileIndex].Filename, + })) + if ct := files[fileIndex].ContentType; ct != "" { + h.Set("Content-Type", files[fileIndex].ContentType) + } else { + h.Set("Content-Type", "application/octet-stream") + } + innerFw, fileErr := mpw.CreatePart(h) + if fileErr != nil { + return buf, "", fileErr + } + _, ioErr := io.Copy(innerFw, files[fileIndex].File) + if ioErr != nil { + return buf, "", ioErr + } + } + err = mpw.Close() + if err != nil { + return buf, "", err + } + return buf, mpw.FormDataContentType(), nil +} + // Response is a GraphQL response type Response struct { Errors GraphqlErrors `json:"errors"` @@ -275,3 +361,60 @@ func (e GraphqlErrors) Error() string { func GenerateUserAgent(operation string) string { return fmt.Sprintf("Bramble/%s (%s)", Version, operation) } + +func prepareUploadsFromVariables(variables map[string]any) (map[string]graphql.Upload, map[string][]string) { + type stackItem struct { + path string + data map[string]interface{} + } + + stack := []stackItem{{path: "variables", data: variables}} + + index := 0 + fileMap := map[string][]string{} + files := map[string]graphql.Upload{} + for len(stack) > 0 { + currentItem := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + for key, value := range currentItem.data { + currentPath := currentItem.path + "." + key + + switch v := value.(type) { + case graphql.Upload, *graphql.Upload: + currentItem.data[key] = nil + fileIndex := fmt.Sprintf("file%d", index) + fileMap[fileIndex] = []string{currentPath} + index += 1 + switch v := v.(type) { + case graphql.Upload: + files[fileIndex] = v + case *graphql.Upload: + files[fileIndex] = *v + } + case []graphql.Upload: + currentItem.data[key] = make([]*struct{}, len(v)) + for i, file := range v { + elemPath := fmt.Sprintf("%s.%d", currentPath, i) + fileIndex := fmt.Sprintf("file%d", index) + fileMap[fileIndex] = []string{elemPath} + index += 1 + files[fileIndex] = file + } + case []*graphql.Upload: + currentItem.data[key] = make([]*struct{}, len(v)) + for i, file := range v { + elemPath := fmt.Sprintf("%s.%d", currentPath, i) + fileIndex := fmt.Sprintf("file%d", index) + fileMap[fileIndex] = []string{elemPath} + index += 1 + files[fileIndex] = *file + } + case map[string]any: + stack = append(stack, stackItem{data: v, path: currentPath}) + default: + } + } + } + return files, fileMap +} diff --git a/client_test.go b/client_test.go index e4d3b68e..6db8ef2f 100644 --- a/client_test.go +++ b/client_test.go @@ -8,6 +8,7 @@ import ( "net/url" "testing" + "github.com/99designs/gqlgen/graphql" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -102,3 +103,93 @@ func TestGraphqlClient(t *testing.T) { assert.Equal(t, "response exceeded maximum size of 1 bytes", err.Error()) }) } +func TestMultipartClient(t *testing.T) { + nestedMap := map[string]any{ + "node1": map[string]any{ + "node11": map[string]any{ + "leaf111": graphql.Upload{}, + "leaf112": "someThing", + "node113": map[string]any{"leaf1131": graphql.Upload{}}, + }, + "leaf12": 42, + "leaf13": graphql.Upload{}, + }, + "node2": map[string]any{ + "leaf21": false, + "node21": map[string]any{ + "leaf211": &graphql.Upload{}, + }, + }, + "node3": graphql.Upload{}, + "node4": []graphql.Upload{{}, {}}, + "node5": []*graphql.Upload{{}, {}}, + } + + t.Run("parseMultipartVariables", func(t *testing.T) { + _, fileMap := prepareUploadsFromVariables(nestedMap) + fileMapKeys := []string{} + fileMapValues := []string{} + for k, v := range fileMap { + fileMapKeys = append(fileMapKeys, k) + fileMapValues = append(fileMapValues, v...) + } + assert.ElementsMatch(t, fileMapKeys, []string{"file0", "file1", "file2", "file3", "file4", "file5", "file6", "file7", "file8"}) + assert.ElementsMatch(t, fileMapValues, []string{ + "variables.node1.node11.node113.leaf1131", + "variables.node1.node11.leaf111", + "variables.node1.leaf13", + "variables.node2.node21.leaf211", + "variables.node3", + "variables.node4.0", + "variables.node4.1", + "variables.node5.0", + "variables.node5.1", + }) + assert.Equal( + t, + map[string]any{ + "node1": map[string]any{ + "node11": map[string]any{ + "leaf111": nil, + "leaf112": "someThing", + "node113": map[string]any{"leaf1131": nil}, + }, + "leaf12": 42, + "leaf13": nil, + }, + "node2": map[string]any{ + "leaf21": false, + "node21": map[string]any{ + "leaf211": nil, + }, + }, + "node3": nil, + "node4": []*struct{}{nil, nil}, + "node5": []*struct{}{nil, nil}, + }, + nestedMap, + ) + }) + + t.Run("multipart request", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{ "data": {"root": "multipart response"} }`)) + })) + + c := NewClient() + req := &Request{Headers: make(http.Header)} + req.Headers.Set("Content-Type", "multipart/form-data") + + var res struct { + Root string + } + err := c.Request( + context.Background(), + srv.URL, + req, + &res, + ) + require.NoError(t, err) + assert.Equal(t, "multipart response", res.Root) + }) +} diff --git a/config.go b/config.go index 078f1eb7..673ddd1e 100644 --- a/config.go +++ b/config.go @@ -52,6 +52,7 @@ type Config struct { PollIntervalDuration time.Duration MaxRequestsPerQuery int64 `json:"max-requests-per-query"` MaxServiceResponseSize int64 `json:"max-service-response-size"` + MaxFileUploadSize int64 `json:"max-file-upload-size"` Telemetry TelemetryConfig `json:"telemetry"` Plugins []PluginConfig // Config extensions that can be shared among plugins diff --git a/docker-compose.yaml b/docker-compose.yaml index 4d5dd53e..24d12be3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,6 +10,16 @@ services: retries: 5 expose: - 8080 + gqlgen-multipart-file-upload-service: + build: + context: examples/gqlgen-multipart-file-upload-service + healthcheck: &healthcheck + test: wget -qO - http://localhost:8080/health + interval: 5s + timeout: 1s + retries: 5 + expose: + - 8080 graph-gophers-service: healthcheck: *healthcheck build: @@ -34,7 +44,7 @@ services: configs: [gateway] command: ["-config", "gateway"] environment: - - BRAMBLE_SERVICE_LIST=http://gqlgen-service:8080/query http://graph-gophers-service:8080/query http://slow-service:8080/query http://nodejs-service:8080/query + - BRAMBLE_SERVICE_LIST=http://gqlgen-service:8080/query http://gqlgen-multipart-file-upload-service:8080/query http://graph-gophers-service:8080/query http://slow-service:8080/query http://nodejs-service:8080/query ports: - 8082:8082 - 8083:8083 diff --git a/docs/plugins.md b/docs/plugins.md index 424e17a3..507b8464 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -29,6 +29,19 @@ Add `CORS` headers to queries. } ``` +## Headers + +Allow headers to passthrough to downstream services. + +```json +{ + "name": "headers", + "config": { + "allowed-headers": ["X-Custom-Header"] + } +} +``` + ## JWT Auth The JWT auth plugin validates that the request contains a valid JWT and diff --git a/examples/gqlgen-multipart-file-upload-service/.gitignore b/examples/gqlgen-multipart-file-upload-service/.gitignore new file mode 100644 index 00000000..33646f6e --- /dev/null +++ b/examples/gqlgen-multipart-file-upload-service/.gitignore @@ -0,0 +1,3 @@ +gqlgen-service +generated.go +models_gen.go diff --git a/examples/gqlgen-multipart-file-upload-service/Dockerfile b/examples/gqlgen-multipart-file-upload-service/Dockerfile new file mode 100644 index 00000000..72a8df5b --- /dev/null +++ b/examples/gqlgen-multipart-file-upload-service/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.22-alpine3.19 + +ENV CGO_ENABLED=0 + +WORKDIR /go/src/app + +COPY . . + +RUN go generate . +RUN go get +CMD go run . diff --git a/examples/gqlgen-multipart-file-upload-service/Makefile b/examples/gqlgen-multipart-file-upload-service/Makefile new file mode 100644 index 00000000..1bd68ad4 --- /dev/null +++ b/examples/gqlgen-multipart-file-upload-service/Makefile @@ -0,0 +1,18 @@ +ARTIFACT=gqlgen-service +DEF=gqlgen.yml schema.graphql +GEN=models_gen.go generated.go + +build: $(ARTIFACT) + +.PHONY: clean +clean: + rm -f $(ARTIFACT) $(GEN) + +.PHONY: generate +generate: $(GEN) + +$(GEN): $(DEF) + go generate + +gqlgen-service: $(GEN) $(wildcard *.go) + go build diff --git a/examples/gqlgen-multipart-file-upload-service/README.md b/examples/gqlgen-multipart-file-upload-service/README.md new file mode 100644 index 00000000..8141fc80 --- /dev/null +++ b/examples/gqlgen-multipart-file-upload-service/README.md @@ -0,0 +1,32 @@ +# Example gqlgen based service with multipart file upload + +This is an example service that exposes a very simple mutations: + + uploadGizmoFile(upload: Upload!) String + uploadGadgetFile(upload: GadgetInput!): String + +_Note: we have not added `gqlgen` related generated files to git; must `go generate .` before use_ + +To upload file you can use curl to send file to the gateway: + +``` +curl --request POST \ + --url http://localhost:8082/query \ + --header 'content-type: multipart/form-data' \ + --form 'operations={"query":"mutation uploadGizmoFile($upload: Upload!) {uploadGizmoFile(upload: $upload)}","variables":{"upload":null},"operationName":"uploadGizmoFile"}' \ + --form 'map={"file1": ["variables.upload"]}' \ + --form 'file1=@"sample_file.txt"' +``` + +With input type: + +``` +curl --request POST \ + --url http://localhost:8082/query \ + --header 'Content-Type: multipart/form-data' \ + --form 'operations={"query":"mutation uploadGadgetFile($upload: GadgetInput!) {uploadGadgetFile(upload: $upload)}","variables":{"upload":{"upload": null}},"operationName":"uploadGadgetFile"}' \ + --form 'map={"file1": ["variables.upload.upload"]}' \ + --form 'file1=@"sample_file.txt"' +``` + +Note: you **must** pass `Content-Type` headers for file upload to work. So add `Content-Type` to `allowed-headers` to `headers` plugin. diff --git a/examples/gqlgen-multipart-file-upload-service/go.mod b/examples/gqlgen-multipart-file-upload-service/go.mod new file mode 100644 index 00000000..bb6bf5b9 --- /dev/null +++ b/examples/gqlgen-multipart-file-upload-service/go.mod @@ -0,0 +1,26 @@ +module github.com/movio/bramble/examples/gqlgen-service + +go 1.22 + +require ( + github.com/99designs/gqlgen v0.17.44 + github.com/go-faker/faker/v4 v4.0.0-beta.3 + github.com/vektah/gqlparser/v2 v2.5.11 +) + +require ( + github.com/agnivade/levenshtein v1.1.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sosodev/duration v1.2.0 // indirect + github.com/urfave/cli/v2 v2.27.1 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.17.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/examples/gqlgen-multipart-file-upload-service/go.sum b/examples/gqlgen-multipart-file-upload-service/go.sum new file mode 100644 index 00000000..7a39523b --- /dev/null +++ b/examples/gqlgen-multipart-file-upload-service/go.sum @@ -0,0 +1,54 @@ +github.com/99designs/gqlgen v0.17.44 h1:OS2wLk/67Y+vXM75XHbwRnNYJcbuJd4OBL76RX3NQQA= +github.com/99designs/gqlgen v0.17.44/go.mod h1:UTCu3xpK2mLI5qcMNw+HKDiEL77it/1XtAjisC4sLwM= +github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/go-faker/faker/v4 v4.0.0-beta.3 h1:zjTxJMHn7Po7OCPKY+VjO6mNQ4ZzE7PoBjb2sUNHVPs= +github.com/go-faker/faker/v4 v4.0.0-beta.3/go.mod h1:uuNc0PSRxF8nMgjGrrrU4Nw5cF30Jc6Kd0/FUTTYbhg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sosodev/duration v1.2.0 h1:pqK/FLSjsAADWY74SyWDCjOcd5l7H8GSnnOGEB9A1Us= +github.com/sosodev/duration v1.2.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= +github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/vektah/gqlparser/v2 v2.5.11 h1:JJxLtXIoN7+3x6MBdtIP59TP1RANnY7pXOaDnADQSf8= +github.com/vektah/gqlparser/v2 v2.5.11/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/gqlgen-multipart-file-upload-service/gqlgen.yml b/examples/gqlgen-multipart-file-upload-service/gqlgen.yml new file mode 100644 index 00000000..3631cb03 --- /dev/null +++ b/examples/gqlgen-multipart-file-upload-service/gqlgen.yml @@ -0,0 +1,10 @@ +schema: +- schema.graphql +exec: + filename: generated.go +model: + filename: models_gen.go +resolver: + filename: resolver.go + type: Resolver +autobind: [] diff --git a/examples/gqlgen-multipart-file-upload-service/main.go b/examples/gqlgen-multipart-file-upload-service/main.go new file mode 100644 index 00000000..63c6f8d2 --- /dev/null +++ b/examples/gqlgen-multipart-file-upload-service/main.go @@ -0,0 +1,24 @@ +//go:generate go run github.com/99designs/gqlgen +package main + +import ( + _ "embed" + "fmt" + "log" + "net/http" + "os" +) + +func main() { + addr := os.Getenv("ADDR") + if addr == "" { + addr = ":8080" + } + + http.Handle("/query", newResolver()) + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "OK") + }) + log.Printf("example %s running on %s", name, addr) + log.Fatal(http.ListenAndServe(addr, nil)) +} diff --git a/examples/gqlgen-multipart-file-upload-service/resolver.go b/examples/gqlgen-multipart-file-upload-service/resolver.go new file mode 100644 index 00000000..5c6f2bd0 --- /dev/null +++ b/examples/gqlgen-multipart-file-upload-service/resolver.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + _ "embed" + "fmt" + "net/http" + + "github.com/99designs/gqlgen/graphql" + "github.com/99designs/gqlgen/graphql/handler" +) + +var name = "gqlgen-service" +var version = "0.1.0" + +//go:embed schema.graphql +var schema string + +func newResolver() http.Handler { + c := Config{ + Resolvers: &Resolver{ + service: Service{ + Name: name, + Version: version, + Schema: schema, + }, + }, + Directives: DirectiveRoot{ + // Support the @boundary directive as a no-op + Boundary: func(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) { + return next(ctx) + }, + }, + } + return handler.NewDefaultServer(NewExecutableSchema(c)) +} + +type Resolver struct { + service Service +} + +func (r *Resolver) Query() QueryResolver { + return r +} + +func (r *Resolver) Mutation() MutationResolver { + return r +} + +func (r *Resolver) Service(ctx context.Context) (*Service, error) { + return &r.service, nil +} + +func (r *Resolver) UploadGizmoFile(ctx context.Context, upload graphql.Upload) (string, error) { + return fmt.Sprintf("%s: %d bytes %s", upload.Filename, upload.Size, upload.ContentType), nil +} +func (r *Resolver) UploadGadgetFile(ctx context.Context, input GadgetInput) (string, error) { + upload := input.Upload + return fmt.Sprintf("%s: %d bytes %s", upload.Filename, upload.Size, upload.ContentType), nil +} diff --git a/examples/gqlgen-multipart-file-upload-service/schema.graphql b/examples/gqlgen-multipart-file-upload-service/schema.graphql new file mode 100644 index 00000000..1515eaba --- /dev/null +++ b/examples/gqlgen-multipart-file-upload-service/schema.graphql @@ -0,0 +1,45 @@ +""" +This is the prerequisite schema required for federation by the gateway +""" +directive @boundary on OBJECT | FIELD_DEFINITION + +scalar Upload + +""" +The `Service` type provides the gateway with a schema to merge into the graph +and a name/version to reference the service by +""" +type Service { + """ + name of the service + """ + name: String! + """ + the service version tag + """ + version: String! + """ + a string of the complete schema + """ + schema: String! +} + +type Query { + """ + The service query is used by the gateway when the service is first registered + """ + service: Service! +} + +input GadgetInput { + upload: Upload! +} + +type Mutation { + """ + Mutation to upload file using multipart request spec: + https://github.com/jaydenseric/graphql-multipart-request-spec. + """ + uploadGizmoFile(upload: Upload!): String! + uploadGadgetFile(upload: GadgetInput!): String! +} diff --git a/examples/gqlgen-multipart-file-upload-service/tools.go b/examples/gqlgen-multipart-file-upload-service/tools.go new file mode 100644 index 00000000..932931d3 --- /dev/null +++ b/examples/gqlgen-multipart-file-upload-service/tools.go @@ -0,0 +1,8 @@ +//go:build tools + +package tools + +import ( + _ "github.com/99designs/gqlgen" + _ "github.com/99designs/gqlgen/graphql/introspection" +) diff --git a/gateway.go b/gateway.go index d8c48487..7e668737 100644 --- a/gateway.go +++ b/gateway.go @@ -51,7 +51,9 @@ func (g *Gateway) Router(cfg *Config) http.Handler { gatewayHandler.AddTransport(transport.Options{}) gatewayHandler.AddTransport(transport.GET{}) gatewayHandler.AddTransport(transport.POST{}) - gatewayHandler.AddTransport(transport.MultipartForm{}) + gatewayHandler.AddTransport(transport.MultipartForm{ + MaxUploadSize: cfg.MaxFileUploadSize, + }) if !cfg.DisableIntrospection { gatewayHandler.Use(extension.Introspection{}) } diff --git a/middleware.go b/middleware.go index a3703517..e2a0eddd 100644 --- a/middleware.go +++ b/middleware.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "mime" "net/http" "strings" @@ -101,18 +102,23 @@ func monitoringMiddleware(h http.Handler) http.Handler { } func addRequestBody(e *event, r *http.Request, buf bytes.Buffer) { - contentType := r.Header.Get("Content-Type") + contentType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) e.addField("request.content-type", contentType) - if r.Method != http.MethodHead && - r.Method != http.MethodGet && - contentType == "application/json" { - var payload interface{} - if err := json.Unmarshal(buf.Bytes(), &payload); err == nil { - e.addField("request.body", &payload) - } else { + if r.Method != http.MethodHead && r.Method != http.MethodGet { + switch { + case contentType == "application/json": + var payload interface{} + if err := json.Unmarshal(buf.Bytes(), &payload); err == nil { + e.addField("request.body", &payload) + } else { + e.addField("request.body", buf.String()) + e.addField("request.error", err) + } + case contentType == "multipart/form-data": + e.addField("request.body", fmt.Sprintf("%d bytes", len(buf.Bytes()))) + default: e.addField("request.body", buf.String()) - e.addField("request.error", err) } } else { e.addField("request.body", buf.String())