From 41470f65bdf88f46e56af740442f638619ebbd58 Mon Sep 17 00:00:00 2001 From: Vincent Composieux Date: Sun, 6 Jan 2019 11:47:54 +0100 Subject: [PATCH] Initialize project --- README.md | 67 +++++++++++++++++ middleware.go | 199 ++++++++++++++++++++++++++++++++++++++++++++++++++ scalar.go | 62 ++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 README.md create mode 100644 middleware.go create mode 100644 scalar.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..61493a3 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +graphql-go-upload +================= + +[![GoDoc](https://godoc.org/github.com/eko/graphql-go-upload?status.png)](https://godoc.org/github.com/eko/graphql-go-upload) + +This library exposes a middleware for the [GraphQL-Go](https://github.com/graph-gophers/graphql-go) project in order to expose a new `Upload` scalar type and allow you to send `multipart/form-data` POST requests containing files and fields data. + +Installation +------------ + +```bash +$ dep ensure --add github.com/eko/graphql-go-upload +``` + +Add the middleware handler in your GraphQL project +-------------------------------------------------- + +Once the dependency is installed, simply update your GraphQL project code in order to add this middleware: + +```go +import ( + "github.com/eko/graphql-go-upload" +) + +// ... + +h := handler.GraphQL{ + Schema: graphql.MustParseSchema(schema.String(), root, graphql.MaxParallelism(maxParallelism), graphql.MaxDepth(maxDepth)), + Handler: handler.NewHandler(conf, &m), +} + +mux := mux.NewRouter() +mux.Handle("/graphql", upload.Handler(h)) // Add the middleware here (wrap the original handler) + +s := &http.Server{ + Addr: ":8000", + Handler: mux, +} +``` + +You're ready to use the new middleware! + +Use the new Upload scalar type +------------------------------ + +In order to use the new Upload scalar type, you have to declare it in your GraphQL schema and use it in your mutations, this way: + +```graphql +scalar Upload + +type Mutation { + myUploadMutation(file: Upload!, title: String!): Boolean +} +``` + +Usage on client side +-------------------- + +On a client point of view, requests have to be formed this way: + +``` +curl http://localhost:8000/graphql \ + -F operations='{ "query": "mutation DoUpload($file: Upload!, $title: String!) { upload(file: $file, title: $title) }", "variables": { "file": null, "title": null } }' \ + -F map='{ "file": ["variables.file"], "title": ["variables.title"] }' \ + -F file=@myfile.txt \ + -F title="My content title" +``` \ No newline at end of file diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..ed246dc --- /dev/null +++ b/middleware.go @@ -0,0 +1,199 @@ +package upload + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "mime" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "sync" +) + +type postedFiles func(key string) (multipart.File, *multipart.FileHeader, error) +type graphqlParams struct { + Variables interface{} `json:"variables"` + Query interface{} `json:"query"` + Operations map[string]interface{} `json:"operations"` + Map map[string][]string `json:"map"` +} + +type fileData struct { + Fields interface{} + Files postedFiles + MapEntryIndex string + EntryPaths []string +} + +var ( + mapEntries map[string][]string + operations map[string]interface{} + fileChannel = make(chan fileData) + wg sync.WaitGroup +) + +// Handler is the middleware function that retrieves the incoming HTTP request and +// in case it is a POST and multipart/form-data request, re-maps the field values +// given in the GraphQL format and saves uploaded files. +// +// Here is how to implement the middleware handler (see upload.Handler use below): +// +// h := handler.GraphQL{ +// Schema: graphql.MustParseSchema(schema.String(), root, graphql.MaxParallelism(maxParallelism), graphql.MaxDepth(maxDepth)), +// Handler: handler.NewHandler(conf, &m), +// } +// +// mux := mux.NewRouter() +// mux.Handle("/graphql", upload.Handler(h)) +// +// s := &http.Server{ +// Addr: ":8000", +// Handler: mux, +// } +// +func Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isMiddlewareSupported(r) { + next.ServeHTTP(w, r) + return + } + + r.ParseMultipartForm((1 << 20) * 64) + m := r.PostFormValue("map") + if &m == nil { + http.Error(w, "Missing map field parameter", http.StatusBadRequest) + return + } + + o := r.PostFormValue("operations") + if &o == nil { + http.Error(w, "Missing operations field parameter", http.StatusBadRequest) + return + } + + err := json.Unmarshal([]byte(o), &operations) + if err != nil { + http.Error(w, "Cannot unmarshal operations: malformed query", http.StatusBadRequest) + return + } + + err = json.Unmarshal([]byte(m), &mapEntries) + if err != nil { + http.Error(w, "Cannot unmarshal map entries: malformed query", http.StatusBadRequest) + return + } + + mapOperations(mapEntries, operations, r) + + graphqlParams := graphqlParams{ + Variables: operations["variables"], + Query: operations["query"], + Operations: operations, + Map: mapEntries, + } + + body, err := json.Marshal(graphqlParams) + if err == nil { + r.Body = ioutil.NopCloser(bytes.NewReader(body)) + w.Header().Set("Content-Type", "application/json") + } + + next.ServeHTTP(w, r) + }) +} + +func isMiddlewareSupported(r *http.Request) bool { + if r.Method != http.MethodPost { + return false + } + + contentType := r.Header.Get("Content-Type") + mediatype, _, _ := mime.ParseMediaType(contentType) + if contentType == "" || mediatype != "multipart/form-data" { + return false + } + + return true +} + +func mapOperations(mapEntries map[string][]string, operations map[string]interface{}, r *http.Request) { + for idx, mapEntry := range mapEntries { + for _, entry := range mapEntry { + entryPaths := strings.Split(entry, ".") + fields := findFields(operations, entryPaths[:len(entryPaths)-1]) + + if value := r.PostForm.Get(idx); value != "" { // Form field values + entryPaths := strings.Split(entry, ".") + operations[entryPaths[0]].(map[string]interface{})[entryPaths[1]] = value + } else { // Try to catch an uploaded file + wg.Add(1) + go func() { + defer wg.Done() + mapTemporaryFileToOperations() + }() + + fileChannel <- fileData{ + Fields: fields, + Files: r.FormFile, + MapEntryIndex: idx, + EntryPaths: entryPaths, + } + } + } + } + wg.Wait() +} + +func findFields(operations interface{}, entryPaths []string) map[string]interface{} { + for i := 0; i < len(entryPaths); i++ { + if arr, ok := operations.([]map[string]interface{}); ok { + operations = arr[i] + + return findFields(operations, entryPaths) + } else if op, ok := operations.(map[string]interface{}); ok { + operations = op[entryPaths[i]] + } + } + + return operations.(map[string]interface{}) +} + +func mapTemporaryFileToOperations() error { + params := <-fileChannel + file, handle, err := params.Files(params.MapEntryIndex) + if err != nil { + return fmt.Errorf("Could not access multipart file. Reason: %v", err) + } + defer file.Close() + + data, err := ioutil.ReadAll(file) + if err != nil { + return fmt.Errorf("Could not read multipart file. Reason: %v", err) + } + + f, err := ioutil.TempFile(os.TempDir(), fmt.Sprintf("graphqlupload-*%s", filepath.Ext(handle.Filename))) + if err != nil { + return fmt.Errorf("Unable to create temporary file. Reason: %v", err) + } + + _, err = f.Write(data) + if err != nil { + return fmt.Errorf("Could not write temporary file. Reason: %v", err) + } + + upload := &GraphQLUpload{ + MIMEType: handle.Header.Get("Content-Type"), + Filename: handle.Filename, + Filepath: f.Name(), + } + + if op, ok := params.Fields.(map[string]interface{}); ok { + op[params.EntryPaths[len(params.EntryPaths)-1]] = upload + } + + return nil +} diff --git a/scalar.go b/scalar.go new file mode 100644 index 0000000..49c0004 --- /dev/null +++ b/scalar.go @@ -0,0 +1,62 @@ +package upload + +import ( + "bufio" + "encoding/json" + "errors" + "io" + "os" +) + +// GraphQLUpload is the struct used for the new "Upload" GraphQL scalar type +// +// It allows you to use the Upload type in your GraphQL schema, this way: +// +// scalar Upload +// +// type Mutation { +// upload(file: Upload!, title: String!, description: String!): Boolean +// } +type GraphQLUpload struct { + Filename string `json:"filename"` + MIMEType string `json:"mimetype"` + Filepath string `json:"filepath"` +} + +// ImplementsGraphQLType is implemented to respect the GraphQL-Go Unmarshaler interface. +// It allows to chose the name of the GraphQL scalar type you want to implement +// +// Reference: https://github.com/graph-gophers/graphql-go/blob/bb9738501bd42a6536227b96068349b814379d6e/internal/exec/packer/packer.go#L319 +func (u GraphQLUpload) ImplementsGraphQLType(name string) bool { + return name == "Upload" +} + +// UnmarshalGraphQL is implemented to respect the GraphQL-Go Unmarshaler interface. +// It hydrates the GraphQLUpload struct with input data +// +// Reference: https://github.com/graph-gophers/graphql-go/blob/bb9738501bd42a6536227b96068349b814379d6e/internal/exec/packer/packer.go#L319 +func (u *GraphQLUpload) UnmarshalGraphQL(input interface{}) error { + switch input := input.(type) { + case map[string]interface{}: + data, err := json.Marshal(input) + if err != nil { + u = &GraphQLUpload{} + } else { + json.Unmarshal(data, u) + } + + return nil + default: + return errors.New("Cannot unmarshal received type as a GraphQLUpload type") + } +} + +// GetReader returns the buffer of the uploaded (and temporary saved) file. +func (u *GraphQLUpload) GetReader() (io.Reader, error) { + f, err := os.Open(u.Filepath) + if err == nil { + return bufio.NewReader(f), nil + } + + return nil, err +}