From 58901dae90b3de236bbb5d6897711910c5cc4aab Mon Sep 17 00:00:00 2001 From: Lucas dos Santos Abreu Date: Sun, 17 Jun 2018 19:57:06 -0300 Subject: [PATCH 1/3] validations --- .gitignore | 1 + .travis.yml | 22 ++++++ Makefile | 21 +++++- README.md | 5 +- handler.go | 60 +++++++++------ handler_test.go | 196 ++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 278 insertions(+), 27 deletions(-) create mode 100644 .travis.yml create mode 100644 handler_test.go diff --git a/.gitignore b/.gitignore index 4fd0696..2105f00 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ gin-bin +coverage.out diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b9eb4ab --- /dev/null +++ b/.travis.yml @@ -0,0 +1,22 @@ +language: go + +go: +- 1.9.x +- 1.10.x +- tip + +script: +- make install +- make coverage + +after_success: +- make send-statistics commit=$TRAVIS_COMMIT + +notifications: + email: + on_success: never + on_failure: always + +env: + global: + secure: RW12+yXeYPkvpYnVO1MiLVyTW3LdBqwL5ZgnVoSmDfIAkySmViXsGUji5/nmAWaIWxOqAbOGHeJPRtM6cCUAKSk6kibPTW+mYNWxHaT0j81MRonrFY/3MnPB05JOjyr13zIdwhgsqXvToUSF615lLoSoHuhLlcH+mJR2PF2svav6rkyIrzcPaIz/mstEOQgsVceOY6PQobSRaEh6bSaUDgnCiklNhzgrGdh/idhBft5wD7N07UQ5YQICAGpV3MX5B9HbD2NtCPvxGVDwa8huKP05YcJJ+pKc4BxJLpLdxVE8jgFZ8eE0H6ZmkGrhJHpy2aT053M0P9ctk/JopuvxZHe82FiJnlBxdL3p+k0Cb3bi6JqoDzivKRT6A5G6fM/DeJckU+1dAssDsuCPGIKVH0G0D4fxfCGhG29ljNnsoGkvcAFyQu6XoeEeiDwwqrm80yMmHF8jndJqR0bjZmk7mnFbMCtzRCbwpp5mZQWV7dNgNC4zoWfdy/HraQUihThc4D9uyWvrTt6vw/c5Jz0PoI3dHJH3LEnIiOABCCd+s63Z876qLr/u0hgvfGbKi7qxocBHNdECNkpXKTiy9qZy5BDNP7bkom7ekmMkJdaxJc6HjAmZYTwi/gxrhTLvx6gr15yg8puE1C03XjE1egmnjs6riO4tqduWqdRtQb7y35s= diff --git a/Makefile b/Makefile index c12cb03..f622e78 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,4 @@ +all: help export PORT ?= 8000 @@ -5,12 +6,24 @@ export PORT ?= 8000 help: ## show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' -install: - go get -u -v ./... +install: ## install package dependencies + go get -u -v -t ./... -serve-example: +serve-example: ## start the example server go run examples/main.go -serve-watch-example: +serve-watch-example: ## start the example server watching for changes go get github.com/codegangsta/gin PORT=8001 gin --port ${PORT} --appPort 8001 --build ./examples + +tests: ## run the package's tests + go test -v -race . + +coverage: ## calcs the coverage for the package + go get golang.org/x/tools/cmd/cover + go get github.com/mattn/goveralls + go test -v -covermode=count -coverprofile=coverage.out + +send-statistics: ## send statistics + goveralls -coverprofile=coverage.out -service=travis-ci -repotoken ${COVERALLS_TOKEN} + diff --git a/README.md b/README.md index c323f56..01ddb42 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ GraphQL Multipart Middleware [![](https://img.shields.io/badge/godoc-reference-5272B4.svg)](https://godoc.org/github.com/lucassabreu/graphql-multipart-middleware) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/1f0199e1dd364abcae45fd1f3de3cc25)](https://www.codacy.com/app/lucassabreu/graphql-multipart-middleware?utm_source=github.com&utm_medium=referral&utm_content=lucassabreu/graphql-multipart-middleware&utm_campaign=Badge_Grade) [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Flucassabreu%2Fgraphql-multipart-middleware.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Flucassabreu%2Fgraphql-multipart-middleware?ref=badge_shield) +[![Build Status](https://travis-ci.org/lucassabreu/graphql-multipart-middleware.svg?branch=master)](https://travis-ci.org/lucassabreu/graphql-multipart-middleware) +[![Coverage Status](https://coveralls.io/repos/github/lucassabreu/graphql-multipart-middleware/badge.svg?branch=master)](https://coveralls.io/github/lucassabreu/graphql-multipart-middleware?branch=master) +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Flucassabreu%2Fgraphql-multipart-middleware.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Flucassabreu%2Fgraphql-multipart-middleware?ref=badge_shield) This packages provide a implementation of the graphql multipart request spec created by [@jaydenseric](https://github.com/jaydenseric) to provide support for handling file uploads in a GraphQL server, [click here to see the spec](https://github.com/jaydenseric/graphql-multipart-request-spec). @@ -14,4 +17,4 @@ The package also provide a scalar for the uploaded content called `graphqlmultip ## License -[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Flucassabreu%2Fgraphql-multipart-middleware.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Flucassabreu%2Fgraphql-multipart-middleware?ref=badge_large) \ No newline at end of file +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Flucassabreu%2Fgraphql-multipart-middleware.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Flucassabreu%2Fgraphql-multipart-middleware?ref=badge_large) diff --git a/handler.go b/handler.go index c5e4268..680fac1 100644 --- a/handler.go +++ b/handler.go @@ -1,4 +1,4 @@ -// This packages provide a implementation of the graphql multipart request spec created by [@jaydenseric](https://github.com/jaydenseric) to provide support for handling file uploads in a GraphQL server, [click here to see the spec](https://github.com/jaydenseric/graphql-multipart-request-spec). +// Package graphqlmultipart provide a implementation of the graphql multipart request spec created by [@jaydenseric](https://github.com/jaydenseric) to provide support for handling file uploads in a GraphQL server, [click here to see the spec](https://github.com/jaydenseric/graphql-multipart-request-spec). // // Using the methods `graphqlmultipart.NewHandler` or `graphqlmultipart.NewMiddlewareWrapper` you will be abble to wrap your GraphQL handler and so every request made with the `Content-Type`: `multipart/form-data` will be handled by this package (using a provided GraphQL schema), and other `Content-Types` will be directed to your handler. // @@ -21,19 +21,26 @@ import ( const specURL = "https://github.com/jaydenseric/graphql-multipart-request-spec/tree/v2.0.0" var ( - failedToParseForm = "Failed to parse multipart form" + // FailedToParseFormMessage is shown when it is a multipart/form-data, but its invalid + FailedToParseFormMessage = "Failed to parse multipart form" - operationsFieldMissingMessage = fmt.Sprintf("Field \"operations\" was not found in the form (%s)", specURL) + // OperationsFieldMissingMessage is shown when the operations field is missing + OperationsFieldMissingMessage = fmt.Sprintf("Field \"operations\" was not found in the form (%s)", specURL) - mapFieldMissingMessage = fmt.Sprintf("Field \"map\" was not found in the form (%s)", specURL) + // MapFieldMissingMessage is shown when the map field is missing + MapFieldMissingMessage = fmt.Sprintf("Field \"map\" was not found in the form (%s)", specURL) - invalidMapFieldMessage = fmt.Sprintf("Field \"map\" format is not valid (%s)", specURL) + // InvalidMapFieldMessage is shown when the map field format is invalid + InvalidMapFieldMessage = fmt.Sprintf("Field \"map\" format is not valid (%s)", specURL) - invalidOperationsFieldMessage = fmt.Sprintf("Field \"operations\" format is not valid (%s)", specURL) + // InvalidOperationsFieldMessage is shown when operations field format is not valid + InvalidOperationsFieldMessage = fmt.Sprintf("Field \"operations\" format is not valid (%s)", specURL) - missingFileMessage = fmt.Sprintf("Field %%[1]s is missing, but exists in the map association (%s)", specURL) + // MissingFileMessage is shown when a file is mapped, but not sent + MissingFileMessage = fmt.Sprintf("File \"%%[1]s\" is missing, but exists in the map association (%s)", specURL) - invalidMapPathMessage = fmt.Sprintf("Invalid mapping path \"%%[1]s\" for file %%[2]s (%s)", specURL) + // InvalidMapPathMessage is shown when is not possible to find or populate the variable path + InvalidMapPathMessage = fmt.Sprintf("Invalid mapping path \"%%[1]s\" for file %%[2]s (%s)", specURL) ) // MultipartHandler implements the specification for handling multipart/form-data @@ -64,9 +71,9 @@ func NewMiddlewareWrapper(s *graphql.Schema, maxMemory int64) func(next http.Han } type operationField struct { - Query string `json:"query"` - Variables map[string]interface{} `json:"variables"` - OperationName string `json:"operationName"` + Query string `json:"query"` + Variables *map[string]interface{} `json:"variables"` + OperationName string `json:"operationName"` mapPrefix string } @@ -82,7 +89,7 @@ func (m MultipartHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(m.maxMemory); err != nil { log.Printf("[MultipartHandler] Fail do parse multipart form: %s", err.Error()) - writeError(w, failedToParseForm) + writeError(w, FailedToParseFormMessage) return } @@ -92,31 +99,40 @@ func (m MultipartHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { var ok bool if vs, ok = form.Value["operations"]; !ok { - writeError(w, operationsFieldMissingMessage) + writeError(w, OperationsFieldMissingMessage) return } opsStr := vs[0] if vs, ok = form.Value["map"]; !ok { - writeError(w, mapFieldMissingMessage) + writeError(w, MapFieldMissingMessage) return } fileMapStr := vs[0] fileMap := make(map[string][]string) if err := json.Unmarshal([]byte(fileMapStr), &fileMap); err != nil { - writeError(w, invalidMapFieldMessage) + writeError(w, InvalidMapFieldMessage) return } batching := true - ops := make([]operationField, 1) + ops := make([]operationField, 0) if err := json.Unmarshal([]byte(opsStr), &ops); err != nil { batching = false - if err = json.Unmarshal([]byte(opsStr), &ops[0]); err != nil { - writeError(w, invalidOperationsFieldMessage) + op := operationField{} + err = json.Unmarshal([]byte(opsStr), &op) + if err != nil || len(op.Query) == 0 || op.Variables == nil { + writeError(w, InvalidOperationsFieldMessage) return } + + ops = append(ops, op) + } + + if len(ops) == 0 { + writeError(w, InvalidOperationsFieldMessage) + return } results := make([]*graphql.Result, len(ops)) @@ -147,7 +163,7 @@ func (m MultipartHandler) execute(op operationField, fMap map[string][]string, r for f, ps := range fMap { if _, ok := r.MultipartForm.File[f]; !ok { - errs = append(errs, fmt.Errorf(fmt.Sprintf(missingFileMessage, f))) + errs = append(errs, fmt.Errorf(fmt.Sprintf(MissingFileMessage, f))) continue } @@ -163,10 +179,10 @@ func (m MultipartHandler) execute(op operationField, fMap map[string][]string, r ) if !ok { - errs = append(errs, fmt.Errorf(invalidMapPathMessage, p, f)) + errs = append(errs, fmt.Errorf(InvalidMapPathMessage, p, f)) continue } - op.Variables = vars.(map[string]interface{}) + *op.Variables = vars.(map[string]interface{}) } } @@ -179,7 +195,7 @@ func (m MultipartHandler) execute(op operationField, fMap map[string][]string, r return graphql.Do(graphql.Params{ Schema: *m.Schema, RequestString: op.Query, - VariableValues: op.Variables, + VariableValues: *op.Variables, OperationName: op.OperationName, Context: r.Context(), }) diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..d217b32 --- /dev/null +++ b/handler_test.go @@ -0,0 +1,196 @@ +package graphqlmultipart_test + +import ( + "bytes" + "fmt" + "io/ioutil" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + + graphqlmultipart "github.com/lucassabreu/graphql-multipart-middleware" + "github.com/lucassabreu/graphql-multipart-middleware/testutil" + + "github.com/stretchr/testify/require" +) + +func TestMultipartMiddleware_ForwardsRequestsOtherThanMultipart(t *testing.T) { + newRequest := func(contentType string) *http.Request { + r, _ := http.NewRequest("GET", "/graphql", strings.NewReader("")) + r.Header.Set("Content-type", contentType) + return r + } + + cases := map[string]*http.Request{ + "application/json": newRequest("application/json"), + "application/graphql": newRequest("application/graphql"), + "application/x-www-form-urlencoded": newRequest("application/x-www-form-urlencoded"), + } + + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("reached me")) + }) + + for n, r := range cases { + t.Run(n, func(t *testing.T) { + resp := httptest.NewRecorder() + + mh := graphqlmultipart.NewHandler( + &testutil.Schema, + 1*1024, + h, + ) + + mh.ServeHTTP(resp, r) + + body, _ := ioutil.ReadAll(resp.Result().Body) + + require.Equal(t, string(body), "reached me") + }) + } +} + +func newFileUploadRequest(params map[string]string, files map[string]string) *http.Request { + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + + for paramName, path := range files { + file, err := os.Open(path) + if err != nil { + panic(err) + } + fileContents, err := ioutil.ReadAll(file) + if err != nil { + panic(err) + } + fi, err := file.Stat() + if err != nil { + panic(err) + } + file.Close() + + part, err := writer.CreateFormFile(paramName, fi.Name()) + if err != nil { + panic(err) + } + part.Write(fileContents) + } + + for key, val := range params { + _ = writer.WriteField(key, val) + } + + if err := writer.Close(); err != nil { + panic(err) + } + + r, _ := http.NewRequest("POST", "/graphql", body) + r.Header.Set("Content-Type", writer.FormDataContentType()) + return r +} + +func getJSONError(m string, v ...interface{}) string { + if len(v) > 0 { + m = fmt.Sprintf(m, v...) + } + + return fmt.Sprintf( + "{\"data\":null,\"errors\": [{\"message\":%s, \"locations\":[]}]}", + strconv.Quote(m), + ) +} + +func TestHandlerShouldValidateRequest(t *testing.T) { + type test struct { + req *http.Request + respo string + } + cases := map[string]test{ + "missing_operation_field": test{ + req: newFileUploadRequest(make(map[string]string), make(map[string]string)), + respo: getJSONError(graphqlmultipart.OperationsFieldMissingMessage), + }, + "missing_map_field": test{ + req: newFileUploadRequest( + map[string]string{"operations": "{}"}, + make(map[string]string), + ), + respo: getJSONError(graphqlmultipart.MapFieldMissingMessage), + }, + "invalid_operaction_field": test{ + req: newFileUploadRequest( + map[string]string{ + "operations": "{}", + "map": "{}", + }, + make(map[string]string), + ), + respo: getJSONError(graphqlmultipart.InvalidOperationsFieldMessage), + }, + "invalid_operaction_field_empty_array": test{ + req: newFileUploadRequest( + map[string]string{ + "operations": "[]", + "map": "{}", + }, + make(map[string]string), + ), + respo: getJSONError(graphqlmultipart.InvalidOperationsFieldMessage), + }, + "missing_file": test{ + req: newFileUploadRequest( + map[string]string{ + "operations": "[{\"query\":\"query hero{id}\",\"variables\":{}}]", + "map": "{\"file\":[]}", + }, + make(map[string]string), + ), + respo: "[" + getJSONError(graphqlmultipart.MissingFileMessage, "file") + "]", + }, + "invalid_map_path": test{ + req: newFileUploadRequest( + map[string]string{ + "operations": "{\"query\":\"query hero{id}\",\"variables\":{}}", + "map": "{\"file\":[\"variables.file\"]}", + }, + map[string]string{"file": "handler.go"}, + ), + respo: getJSONError(graphqlmultipart.InvalidMapPathMessage, "variables.file", "file"), + }, + "invalid_map_path_batching": test{ + req: newFileUploadRequest( + map[string]string{ + "operations": "[{\"query\":\"query hero{id}\",\"variables\":{}}]", + "map": "{\"file\":[\"0.variables.file\"]}", + }, + map[string]string{"file": "handler.go"}, + ), + respo: "[" + getJSONError(graphqlmultipart.InvalidMapPathMessage, "0.variables.file", "file") + "]", + }, + } + + mh := graphqlmultipart.NewHandler( + &testutil.Schema, + 1*1024, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Should not have forwarded the request")) + }), + ) + + for name, test := range cases { + t.Run(name, func(t *testing.T) { + + resp := httptest.NewRecorder() + + mh.ServeHTTP(resp, test.req) + + body, _ := ioutil.ReadAll(resp.Result().Body) + + require.JSONEq(t, string(body), test.respo) + }) + } +} From 3d46730fc0871d4273d05ccf2dd7da0d0daccb7a Mon Sep 17 00:00:00 2001 From: Lucas dos Santos Abreu Date: Thu, 21 Jun 2018 20:54:11 -0300 Subject: [PATCH 2/3] upload scalar --- handler.go | 2 +- handler_test.go | 67 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/handler.go b/handler.go index 680fac1..2452a99 100644 --- a/handler.go +++ b/handler.go @@ -174,7 +174,7 @@ func (m MultipartHandler) execute(op operationField, fMap map[string][]string, r vars, ok := injectFile( r.MultipartForm.File[f][0], - op.Variables, + *op.Variables, p[len(op.mapPrefix):], ) diff --git a/handler_test.go b/handler_test.go index d217b32..9ae9508 100644 --- a/handler_test.go +++ b/handler_test.go @@ -194,3 +194,70 @@ func TestHandlerShouldValidateRequest(t *testing.T) { }) } } + +func TestHandler_SchemaCanAccessUploads(t *testing.T) { + type test struct { + req *http.Request + respo string + } + + cases := map[string]test{ + "simple": test{ + req: newFileUploadRequest( + map[string]string{ + "operations": `{ + "query":"query($file:Upload) { upload(file: $file){ filename } }", + "variables":{"file":null} + }`, + "map": `{"file":["variables.file"]}`, + }, + map[string]string{"file": "handler.go"}, + ), + respo: `{"data":{"upload":{"filename":"handler.go"}}}`, + }, + "batching": test{ + req: newFileUploadRequest( + map[string]string{ + "operations": `[ + { + "query":"query ($file: Upload){ upload(file:$file){filename} }", + "variables":{"file":null} + }, + { + "query":"query ($other: Upload, $file: Upload) { other: upload(file:$other) {filename}, file:upload(file:$file) {filename} }", + "variables":{"other":null,"file":null} + } + ]`, + "map": `{"file":["0.variables.file","1.variables.file"],"other_file":["1.variables.other"]}`, + }, + map[string]string{"file": "handler.go", "other_file": "handler.go"}, + ), + respo: `[ + {"data":{"upload":{"filename":"handler.go"}}}, + { + "data":{ + "other":{"filename":"handler.go"}, + "file":{"filename":"handler.go"} + } + } + ]`, + }, + } + + mh := graphqlmultipart.NewHandler( + &testutil.Schema, + 1*1024, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("should not have forwarded the request")) + }), + ) + + for name, test := range cases { + t.Run(name, func(t *testing.T) { + resp := httptest.NewRecorder() + mh.ServeHTTP(resp, test.req) + body, _ := ioutil.ReadAll(resp.Result().Body) + require.JSONEq(t, string(body), test.respo) + }) + } +} From cc49b8aaa4dc6a6110ee0674d9c8b8b84b34fb72 Mon Sep 17 00:00:00 2001 From: Lucas dos Santos Abreu Date: Thu, 21 Jun 2018 21:18:16 -0300 Subject: [PATCH 3/3] simple_deeper --- handler.go | 2 +- handler_test.go | 20 ++++++++++++++++++++ testutil/schema.go | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/handler.go b/handler.go index 2452a99..a6d8032 100644 --- a/handler.go +++ b/handler.go @@ -205,7 +205,7 @@ func injectFile(f *multipart.FileHeader, vars interface{}, path string) (interfa var field, next string field = path - if i := strings.Index(".", path); i != -1 { + if i := strings.Index(path, "."); i != -1 { field = path[0:i] next = path[i+1:] } diff --git a/handler_test.go b/handler_test.go index 9ae9508..50d8848 100644 --- a/handler_test.go +++ b/handler_test.go @@ -242,6 +242,26 @@ func TestHandler_SchemaCanAccessUploads(t *testing.T) { } ]`, }, + "simple_deeper": test{ + req: newFileUploadRequest( + map[string]string{ + "operations": `{ + "query":"query($files: [Upload]) { uploads(files: $files){ filename } }", + "variables":{"files":[null,null]} + }`, + "map": `{"file":["variables.files.0","variables.files.1"]}`, + }, + map[string]string{"file": "handler.go"}, + ), + respo: `{ + "data":{ + "uploads":[ + {"filename":"handler.go"}, + {"filename":"handler.go"} + ] + } + }`, + }, } mh := graphqlmultipart.NewHandler( diff --git a/testutil/schema.go b/testutil/schema.go index cdd86c7..584f3ad 100644 --- a/testutil/schema.go +++ b/testutil/schema.go @@ -84,6 +84,39 @@ func init() { return r, nil }, }, + "uploads": &graphql.Field{ + Name: "UploadQuery", + Description: "Receives a Uploaded file and returns its metadata", + Args: graphql.FieldConfigArgument{ + "files": &graphql.ArgumentConfig{ + Type: graphql.NewList(graphqlmultipart.Upload), + }, + }, + Type: graphql.NewList(uploadedType), + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + files := p.Args["files"].([]interface{}) + rs := make([]uploadedFile, len(files)) + for i, f := range files { + file := f.(*multipart.FileHeader) + r := uploadedFile{ + Filename: file.Filename, + Size: file.Size, + Headers: make([]uploadedHeader, len(file.Header)), + } + + j := 0 + for n, vs := range file.Header { + r.Headers[j] = uploadedHeader{ + Name: n, + Values: vs, + } + j++ + } + rs[i] = r + } + return rs, nil + }, + }, }, }), })