├── .gitignore ├── Gopkg.lock ├── Gopkg.toml ├── README.md ├── example └── contact │ ├── cmd │ └── main.go │ ├── contact.go │ └── graphiql.html ├── middleware.go └── scalar.go /.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smithaitufe/go-graphql-upload/6442a1320276fdd1f4e651d532016c183da717b4/.gitignore -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [solve-meta] 5 | analyzer-name = "dep" 6 | analyzer-version = 1 7 | input-imports = [] 8 | solver-name = "gps-cdcl" 9 | solver-version = 1 10 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://golang.github.io/dep/docs/Gopkg.toml.html 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | 28 | [prune] 29 | go-tests = true 30 | unused-packages = true 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-graphqlupload 2 | 3 | A file upload middleware for gophers. It enables file upload operations to be done in [Go](https://github.com/google/) using [apollo-upload-client](https://github.com/jaydenseric/apollo-upload-client) and [graph-gophers/graphql-go](https://github.com/graph-gophers/graphql-go) packages. 4 | 5 | 6 | ## Usage 7 | 8 | Add the line below to the list of imports 9 | 10 | `github.com/smithaitufe/go-graphqlupload` 11 | 12 | Pass the graphql endpoint handler to the `graphqlupload.Handler` middleware function. 13 | Eg. 14 | 15 | `http.Handler("/graphql", graphqlupload.Handler(relay.Handler...))` 16 | 17 | In your input struct declaration, simply use the `graphqlupload.GraphQLUpload` type as datatype against the file input variable. 18 | 19 | Eg. 20 | 21 | ```og 22 | type contactInput struct { 23 | photo graphqlupload.GraphQLUpload 24 | } 25 | ``` 26 | In the schema definition, add the following `scalar Upload` and you are good to go. 27 | 28 | 29 | The `graphqlupload.GraphQLUpload` has two methods 30 | 31 | `CreateReadStream` returns a reader to the upload file(s) 32 | `WriteFile` accepts a `name` string that indicates where the file should be saved into 33 | 34 | In the example folder, there is a working sample. 35 | 36 | Thanks. 37 | 38 | I hope to improve on it in the coming days 39 | 40 | -------------------------------------------------------------------------------- /example/contact/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/smithaitufe/go-graphql-upload/example/contact" 4 | 5 | func main() { 6 | contact.StartAndListenGraphQL(8099) 7 | } 8 | -------------------------------------------------------------------------------- /example/contact/contact.go: -------------------------------------------------------------------------------- 1 | package contact 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | 11 | graphql "github.com/graph-gophers/graphql-go" 12 | "github.com/rs/cors" 13 | "github.com/smithaitufe/go-graphql-upload" 14 | ) 15 | 16 | var schemastring = ` 17 | schema { 18 | query: Query 19 | mutation: Mutation 20 | } 21 | type Query { 22 | contacts: [Contact]! 23 | } 24 | type Mutation { 25 | createContact(input: ContactInput!): Contact 26 | } 27 | 28 | scalar Upload 29 | 30 | input ContactInput { 31 | firstName: String! 32 | lastName: String! 33 | photo: Upload! 34 | } 35 | 36 | type Contact { 37 | firstName: String! 38 | lastName: String! 39 | } 40 | ` 41 | 42 | func StartAndListenGraphQL(port int) { 43 | schema := graphql.MustParseSchema(schemastring, &schemaResolver{}) 44 | 45 | h := handler{Schema: schema} 46 | http.Handle("/graphiql", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 47 | http.ServeFile(w, r, "../graphiql.html") 48 | })) 49 | http.Handle("/graphql", cors.Default().Handler(graphqlupload.Handler(h))) 50 | if err := http.ListenAndServe(fmt.Sprintf(":%d", port), nil); err != nil { 51 | log.Fatalf("could not start server. reason: %v", err) 52 | } 53 | 54 | } 55 | 56 | type handler struct { 57 | Schema *graphql.Schema 58 | } 59 | 60 | func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 61 | var params struct { 62 | OperationName string `json:"operationName"` 63 | Variables map[string]interface{} `json:"variables"` 64 | Query string `json:"query"` 65 | } 66 | if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { 67 | fmt.Printf("bad request. %v", err.Error()) 68 | http.Error(w, err.Error(), http.StatusBadRequest) 69 | return 70 | } 71 | response := h.Schema.Exec(r.Context(), params.Query, params.OperationName, params.Variables) 72 | responseJSON, err := json.Marshal(response) 73 | if err != nil { 74 | fmt.Println(err.Error()) 75 | http.Error(w, err.Error(), http.StatusInternalServerError) 76 | return 77 | } 78 | w.Header().Set("Content-Type", "application/json") 79 | w.Write(responseJSON) 80 | 81 | } 82 | 83 | type schemaResolver struct{} 84 | 85 | type contactInput struct { 86 | FirstName string 87 | LastName string 88 | Photo graphqlupload.GraphQLUpload 89 | } 90 | type contact struct { 91 | FirstName string 92 | LastName string 93 | } 94 | type contactResolver struct { 95 | c contact 96 | } 97 | 98 | func (r *contactResolver) FirstName() string { 99 | return r.c.FirstName 100 | } 101 | func (r *contactResolver) LastName() string { 102 | return r.c.LastName 103 | } 104 | func (r *schemaResolver) Contacts(ctx context.Context) ([]*contactResolver, error) { 105 | contacts := []contact{ 106 | contact{FirstName: "Smith", LastName: "Samuel"}, 107 | contact{FirstName: "Friday", LastName: "Gabs"}, 108 | contact{FirstName: "Miriam", LastName: "Jude"}, 109 | contact{FirstName: "Stephen", LastName: "Stoke"}, 110 | contact{FirstName: "Rachael", LastName: "Magdalene"}, 111 | contact{FirstName: "Joseph", LastName: "Brown"}, 112 | contact{FirstName: "Sonia", LastName: "Fish"}, 113 | contact{FirstName: "Cynthia", LastName: "Gray"}, 114 | contact{FirstName: "Saint", LastName: "Rose"}, 115 | } 116 | resolvers := make([]*contactResolver, len(contacts)) 117 | for k, v := range contacts { 118 | resolvers[k] = &contactResolver{v} 119 | } 120 | return resolvers, nil 121 | } 122 | func (r *schemaResolver) CreateContact(ctx context.Context, args struct { 123 | Input contactInput 124 | }) (*contactResolver, error) { 125 | // method 1: use the CreateReadStream to get a reader and manipulate it whichever way you want 126 | rd, _ := args.Input.Photo.CreateReadStream() 127 | b2, err := ioutil.ReadAll(rd) 128 | if err != nil { 129 | panic(err) 130 | } 131 | ioutil.WriteFile(args.Input.Photo.FileName, b2[:], 0666) 132 | 133 | // method 2: using WriteFile function. Easily write to any location in the local file system 134 | args.Input.Photo.WriteFile(args.Input.Photo.FileName) 135 | return &contactResolver{contact{FirstName: "Smithies", LastName: "Frank"}}, nil 136 | } 137 | -------------------------------------------------------------------------------- /example/contact/graphiql.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
Loading...
13 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package graphqlupload 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "mime" 9 | "mime/multipart" 10 | "net/http" 11 | "os" 12 | "path/filepath" 13 | "strconv" 14 | "strings" 15 | "sync" 16 | ) 17 | 18 | type postedFileCollection func(key string) (multipart.File, *multipart.FileHeader, error) 19 | type params struct { 20 | OperationName string `json:"operationName"` 21 | Variables interface{} `json:"variables"` 22 | Query interface{} `json:"query"` 23 | Operations map[string]interface{} `json:"operations"` 24 | Map map[string][]string `json:"map"` 25 | } 26 | type fileOperation struct { 27 | Fields interface{} 28 | FileCollection postedFileCollection 29 | MapEntryIndex string 30 | SplittedPath []string 31 | } 32 | 33 | type graphqlUploadError struct { 34 | errorString string 35 | } 36 | 37 | func (e graphqlUploadError) Error() string { 38 | return e.errorString 39 | } 40 | 41 | var ( 42 | errMissingOperationsParam = &graphqlUploadError{"Missing operations parameter"} 43 | errMissingMapParam = &graphqlUploadError{"Missing operations parameter"} 44 | errInvalidMapParam = &graphqlUploadError{"Invalid map parameter"} 45 | errIncompleteRequestProcessing = &graphqlUploadError{"Could not process request"} 46 | addFileChannel = make(chan fileOperation) 47 | ) 48 | 49 | var mapEntries map[string][]string 50 | var singleOperations map[string]interface{} 51 | var wg sync.WaitGroup 52 | 53 | func Handler(next http.Handler) http.Handler { 54 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 55 | if r.Method == http.MethodPost { 56 | v := r.Header.Get("Content-Type") 57 | if v != "" { 58 | mediatype, _, _ := mime.ParseMediaType(v) 59 | if mediatype == "multipart/form-data" { 60 | r.ParseMultipartForm((1 << 20) * 64) 61 | m := r.PostFormValue("map") 62 | o := r.PostFormValue("operations") 63 | 64 | if &o == nil { 65 | http.Error(w, errMissingOperationsParam.Error(), http.StatusBadRequest) 66 | return 67 | } 68 | if &m == nil { 69 | http.Error(w, errMissingMapParam.Error(), http.StatusBadRequest) 70 | return 71 | } 72 | err := json.Unmarshal([]byte(o), &singleOperations) 73 | if err == nil { 74 | err = json.Unmarshal([]byte(m), &mapEntries) 75 | if err == nil { 76 | mo, err := singleTransformation(mapEntries, singleOperations, r.FormFile) 77 | if err != nil { 78 | http.Error(w, errIncompleteRequestProcessing.Error(), http.StatusBadRequest) 79 | return 80 | } 81 | p := params{ 82 | OperationName: r.PostFormValue("operationName"), 83 | Variables: mo["variables"], 84 | Query: mo["query"], 85 | Operations: singleOperations, 86 | Map: mapEntries, 87 | } 88 | body, err := json.Marshal(p) 89 | if err == nil { 90 | r.Body = ioutil.NopCloser(bytes.NewReader(body)) 91 | w.Header().Set("Content-Type", "application/json") 92 | } 93 | } else { 94 | http.Error(w, errInvalidMapParam.Error(), http.StatusBadRequest) 95 | return 96 | } 97 | 98 | } else { 99 | var batchOperations []map[string]interface{} 100 | err := json.Unmarshal([]byte(o), &batchOperations) 101 | if err == nil { 102 | if err := json.Unmarshal([]byte(m), &mapEntries); err == nil { 103 | _ = batchTransformation(mapEntries, batchOperations, r.FormFile) 104 | // p := params{ 105 | // OperationName: r.PostFormValue("operationName"), 106 | // Variables: mo["variables"], 107 | // Query: mo["query"], 108 | // Operations: singleOperations, 109 | // Map: mapEntries, 110 | // } 111 | // body, err := json.Marshal(p) 112 | // if err == nil { 113 | // r.Body = ioutil.NopCloser(bytes.NewReader(body)) 114 | // w.Header().Set("Content-Type", "application/json") 115 | // } 116 | } 117 | } 118 | } 119 | 120 | } 121 | } 122 | } 123 | next.ServeHTTP(w, r) 124 | }) 125 | } 126 | 127 | func singleTransformation(mapEntries map[string][]string, operations map[string]interface{}, p postedFileCollection) (map[string]interface{}, error) { 128 | 129 | for idx, mapEntry := range mapEntries { 130 | for _, entry := range mapEntry { 131 | wg.Add(1) 132 | go func(entry, idx string, operations map[string]interface{}) { 133 | defer wg.Done() 134 | 135 | wg.Add(1) 136 | go func() { 137 | defer wg.Done() 138 | _, _ = addFile() 139 | }() 140 | 141 | entryPaths := strings.Split(entry, ".") 142 | fields := findField(operations, entryPaths[:len(entryPaths)-1]) 143 | 144 | addFileChannel <- fileOperation{ 145 | Fields: fields, 146 | FileCollection: p, 147 | MapEntryIndex: idx, 148 | SplittedPath: entryPaths, 149 | } 150 | }(entry, idx, operations) 151 | 152 | } 153 | } 154 | wg.Wait() 155 | 156 | return operations, nil 157 | } 158 | func batchTransformation(mapEntries map[string][]string, batchOperations []map[string]interface{}, p postedFileCollection) []map[string]interface{} { 159 | for _, mapEntry := range mapEntries { 160 | for _, entry := range mapEntry { 161 | entryPaths := strings.Split(entry, ".") 162 | opIdx, _ := strconv.Atoi(entryPaths[0]) 163 | operations := batchOperations[opIdx] 164 | _ = findField(operations, entryPaths[:len(entryPaths)-1]) 165 | // _ = addFileToOperations(fields, p, idx, entryPaths) 166 | 167 | } 168 | } 169 | return batchOperations 170 | } 171 | func findField(operations interface{}, entryPaths []string) map[string]interface{} { 172 | for i := 0; i < len(entryPaths); i++ { 173 | if arr, ok := operations.([]map[string]interface{}); ok { 174 | operations = arr[i] 175 | return findField(operations, entryPaths) 176 | } else if op, ok := operations.(map[string]interface{}); ok { 177 | operations = op[entryPaths[i]] 178 | } 179 | } 180 | return operations.(map[string]interface{}) 181 | } 182 | 183 | func addFile() (interface{}, error) { 184 | params := <-addFileChannel 185 | file, handle, err := params.FileCollection(params.MapEntryIndex) 186 | if err != nil { 187 | return nil, fmt.Errorf("could not access multipart file. reason: %v", err) 188 | } 189 | defer file.Close() 190 | 191 | data, err := ioutil.ReadAll(file) 192 | if err != nil { 193 | return nil, fmt.Errorf("could not read multipart file. reason: %v", err) 194 | } 195 | f, err := ioutil.TempFile(os.TempDir(), fmt.Sprintf("graphql-upload-%s%s", handle.Filename, filepath.Ext(handle.Filename))) 196 | if err != nil { 197 | return nil, fmt.Errorf("unable to create temp file. reason: %v", err) 198 | } 199 | 200 | _, err = f.Write(data) 201 | if err != nil { 202 | return nil, fmt.Errorf("could not write file. reason: %v", err) 203 | } 204 | mimeType := handle.Header.Get("Content-Type") 205 | if op, ok := params.Fields.([]map[string]interface{}); ok { 206 | fidx, _ := strconv.Atoi(params.SplittedPath[len(params.SplittedPath)-1]) 207 | upload := &GraphQLUpload{ 208 | MIMEType: mimeType, 209 | FileName: handle.Filename, 210 | FilePath: f.Name(), 211 | } 212 | fmt.Printf("%#v", *upload) 213 | op[fidx][params.SplittedPath[len(params.SplittedPath)-1]] = upload 214 | return op, nil 215 | } else if op, ok := params.Fields.(map[string]interface{}); ok { 216 | op[params.SplittedPath[len(params.SplittedPath)-1]] = &GraphQLUpload{ 217 | MIMEType: mimeType, 218 | FileName: handle.Filename, 219 | FilePath: f.Name(), 220 | } 221 | return op, nil 222 | } 223 | return nil, nil 224 | } 225 | -------------------------------------------------------------------------------- /scalar.go: -------------------------------------------------------------------------------- 1 | package graphqlupload 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os" 9 | ) 10 | 11 | type GraphQLUpload struct { 12 | FileName string `json:"filename"` 13 | MIMEType string `json:"mimetype"` 14 | FilePath string `json:"filepath"` 15 | } 16 | 17 | func (_ GraphQLUpload) ImplementsGraphQLType(name string) bool { return name == "Upload" } 18 | func (u *GraphQLUpload) UnmarshalGraphQL(input interface{}) error { 19 | switch input := input.(type) { 20 | case map[string]interface{}: 21 | b, err := json.Marshal(input) 22 | if err != nil { 23 | u = &GraphQLUpload{} 24 | } else { 25 | json.Unmarshal(b, u) 26 | } 27 | return nil 28 | default: 29 | return fmt.Errorf("no implementation for the type specified") 30 | } 31 | } 32 | func createReadStream(u *GraphQLUpload) (io.Reader, error) { 33 | f, err := os.Open(u.FilePath) 34 | if err == nil { 35 | return bufio.NewReader(f), nil 36 | } 37 | return nil, err 38 | } 39 | func (u *GraphQLUpload) CreateReadStream() (io.Reader, error) { 40 | return createReadStream(u) 41 | } 42 | 43 | func (u *GraphQLUpload) WriteFile(name string) error { 44 | rdr, err := createReadStream(u) 45 | if err != nil { 46 | return err 47 | } 48 | fo, err := os.Create(name) 49 | if err != nil { 50 | return err 51 | } 52 | defer func() { 53 | if err := fo.Close(); err != nil { 54 | panic(err) 55 | } 56 | }() 57 | w := bufio.NewWriter(fo) 58 | buf := make([]byte, 1024*1024) 59 | for { 60 | n, err := rdr.Read(buf) 61 | if err != nil && err != io.EOF { 62 | return err 63 | } 64 | if n == 0 { 65 | break 66 | } 67 | if _, err := w.Write(buf[:n]); err != nil { 68 | return err 69 | } 70 | } 71 | 72 | if err = w.Flush(); err != nil { 73 | return err 74 | } 75 | return nil 76 | } 77 | --------------------------------------------------------------------------------