Gqlgen: Support of File Uploads

Created on 13 Sep 2018  路  18Comments  路  Source: 99designs/gqlgen

Are there any plans to support file uploads via: https://github.com/jaydenseric/graphql-multipart-request-spec?

enhancement

Most helpful comment

I made a hacky workaround to use gqlgen graphql handler.... while handling multipart/form-data request. i am using gin-gonic web server framework and this workaround works with apollo-client well.
i am waiting for this feature..!

A middleware factory which parse multipart/form-data request and remember the file streams, and then transform the original HTTP request of multipart/form-data as application/json content type.

package handler

import (
    "bytes"
    "context"
    "encoding/json"
    "github.com/99designs/gqlgen/graphql"
    "github.com/gin-gonic/gin"
    "io/ioutil"
    "log"
    "strings"
   ...
)

/* ref: GraphQL multipart file upload spec: https://github.com/jaydenseric/graphql-multipart-request-spec
this function transform multipart/form-data post request into application/json request and extract file streams
example of multipart/form-data request:

// single operation
curl localhost:3001/graphql \
  -F operations='{ "query": "mutation ($file: Upload!) { singleUpload(file: $file) { id } }", "variables": { "file": null } }' \
  -F map='{ "0": ["variables.file"] }' \
  -F [email protected]

// batched operations
curl localhost:3001/graphql \
  -F operations='[{ "query": "mutation ($file: Upload!) { singleUpload(file: $file) { id } }", "variables": { "file": null } }, { "query": "mutation($files: [Upload!]!) { multipleUpload(files: $files) { id } }", "variables": { "files": [null, null] } }]' \
  -F map='{ "0": ["0.variables.file"], "1": ["1.variables.files.0"], "2": ["1.variables.files.1"] }' \
  -F [email protected] \
  -F [email protected] \
  -F [email protected]
*/

type operation struct {
    Query         string                 `json:"query"`
    OperationName string                 `json:"operationName"`
    Variables     map[string]interface{} `json:"variables"`
}

func parseMultipartRequest(c *gin.Context) (f graphql.RequestMiddleware) {
    f = graphql.DefaultRequestMiddleware

    if c.ContentType() != "multipart/form-data" {
        return
    }
    form, err := c.MultipartForm()
    if err != nil {
        log.Println(err.Error())
        return
    }

    operations := make([]operation, 0)
    for _, operationJSON := range form.Value["operations"] {
        op := operation{}
        err := json.Unmarshal([]byte(operationJSON), &op)
        if err != nil {
            log.Println(err.Error())
            continue
        }
        operations = append(operations, op)
    }

    fileMap := make(map[string][]string)
    err = json.Unmarshal([]byte(form.Value["map"][0]), &fileMap)
    if err != nil {
        log.Println(err.Error())
        return
    }

    files := make(map[string]*model.UploadingFile)
    for fileKey, _ := range fileMap {
        fileHeader, err := c.FormFile(fileKey)
        if err != nil {
            log.Println(fileKey, err.Error())
            continue
        }
        fileReader, err := fileHeader.Open()
        if err != nil {
            log.Println(err.Error())
            continue
        }
        files[fileKey] = &model.UploadingFile{
            Content: fileReader,
            Name: fileHeader.Filename,
            Size: fileHeader.Size,
        }
    }

    // now change the body and content-type
    // it is hacky-way until gqlgen supports multipart/form-data request by self
    var doc interface{} = operations
    if len(operations) == 1 {
        doc = operations[0]
    }
    fakeBody, err := json.Marshal(doc)
    if err != nil {
        log.Println(err.Error())
        return
    }
    c.Request.Header.Set("content-type", "application/json")
    c.Request.Body = ioutil.NopCloser(bytes.NewReader(fakeBody))

    f = func(ctx context.Context, next func(ctx context.Context) []byte) []byte {
        req := graphql.GetRequestContext(ctx)
        variables := req.Variables
        for fileKey, variableFields := range fileMap {
            for _, variableField := range variableFields {
                fields := strings.Split(variableField, ".")
                if len(fields) <= 1 || fields[0] != "variables" { // respect spec: https://github.com/jaydenseric/graphql-multipart-request-spec
                    log.Println("invalid variable field in map", variableField)
                    continue
                }
                var obj = variables
                lastIndex := len(fields) - 2
                for index, path := range fields[1:] {
                    if _, ok := obj[path]; ok {
                        if index == lastIndex {
                            // set file
                            if obj[path], ok = files[fileKey]; ok {
                                log.Println("set file", variableField, files[fileKey])
                            }
                            break
                        } else if objInObj, ok := obj[path].(map[string]interface{}); ok {
                            obj = objInObj
                            continue
                        }
                    }

                    log.Println("invalid variable field in map", variableField)
                    break
                }
            }
        }
        return next(ctx)
    }
    return
}

A scalar type which represent file uploading stream

package model

import (
    "io"
)

// scalar type
type UploadingFile struct {
    Content io.Reader
    Name string
    Size int64
}

// GraphQL JSON -> UploadingFile
func (f *UploadingFile) UnmarshalGQL(gql interface{}) (err error) {
    // this scalar type will be unmarshaled while parsing multipart/form-data body
    // ref: ../handler/multipart.go
    if v, ok := gql.(*UploadingFile); ok {
        if v != nil {
            *f = *v
        }
    }
    return
}

// UploadingFile -> GraphQL JSON (RFC3339)
func (f UploadingFile) MarshalGQL(w io.Writer) {
    w.Write([]byte("null"))
}

Now attach the middleware into graphql handler

package handler

import (
    "context"
    "github.com/99designs/gqlgen/graphql"
    graphqlHandler "github.com/99designs/gqlgen/handler"
    ...
)


...

        gqlHandler := graphqlHandler.GraphQL(
            // create root schema with context derived values
            schema.NewExecutableSchema(schema.Config{
                Resolvers:  schema.NewResolverRoot(viewer, locale),
                Directives: schema.NewDirectiveRoot(viewer),
            }),

            ....

            // multipart/form-data parsing middleware
            graphqlHandler.RequestMiddleware(parseMultipartRequest(c)),
           ...

           // other options
        )

       gqlHandler.ServeHTTP(c.Writer, c.Request)

...

All 18 comments

Why you want to upload files via graphql?

@vetcher I would like an easy way to do file uploads in GraphQL, using a spec that works with the Apollo Client.

I too would like this, just to keep my server only having one endpoint, and to save on duplication of things like authentication logic.

I'm also interested 馃槃

Does anyone have seen a Go implementation of this specification?

Thanks,

cc @vektah @vvakame

I'm interested as-well.

@sneko The closest thing I've seen would be this project graphql-upload. Though it seems to be made for graphql-go.

I made a hacky workaround to use gqlgen graphql handler.... while handling multipart/form-data request. i am using gin-gonic web server framework and this workaround works with apollo-client well.
i am waiting for this feature..!

A middleware factory which parse multipart/form-data request and remember the file streams, and then transform the original HTTP request of multipart/form-data as application/json content type.

package handler

import (
    "bytes"
    "context"
    "encoding/json"
    "github.com/99designs/gqlgen/graphql"
    "github.com/gin-gonic/gin"
    "io/ioutil"
    "log"
    "strings"
   ...
)

/* ref: GraphQL multipart file upload spec: https://github.com/jaydenseric/graphql-multipart-request-spec
this function transform multipart/form-data post request into application/json request and extract file streams
example of multipart/form-data request:

// single operation
curl localhost:3001/graphql \
  -F operations='{ "query": "mutation ($file: Upload!) { singleUpload(file: $file) { id } }", "variables": { "file": null } }' \
  -F map='{ "0": ["variables.file"] }' \
  -F [email protected]

// batched operations
curl localhost:3001/graphql \
  -F operations='[{ "query": "mutation ($file: Upload!) { singleUpload(file: $file) { id } }", "variables": { "file": null } }, { "query": "mutation($files: [Upload!]!) { multipleUpload(files: $files) { id } }", "variables": { "files": [null, null] } }]' \
  -F map='{ "0": ["0.variables.file"], "1": ["1.variables.files.0"], "2": ["1.variables.files.1"] }' \
  -F [email protected] \
  -F [email protected] \
  -F [email protected]
*/

type operation struct {
    Query         string                 `json:"query"`
    OperationName string                 `json:"operationName"`
    Variables     map[string]interface{} `json:"variables"`
}

func parseMultipartRequest(c *gin.Context) (f graphql.RequestMiddleware) {
    f = graphql.DefaultRequestMiddleware

    if c.ContentType() != "multipart/form-data" {
        return
    }
    form, err := c.MultipartForm()
    if err != nil {
        log.Println(err.Error())
        return
    }

    operations := make([]operation, 0)
    for _, operationJSON := range form.Value["operations"] {
        op := operation{}
        err := json.Unmarshal([]byte(operationJSON), &op)
        if err != nil {
            log.Println(err.Error())
            continue
        }
        operations = append(operations, op)
    }

    fileMap := make(map[string][]string)
    err = json.Unmarshal([]byte(form.Value["map"][0]), &fileMap)
    if err != nil {
        log.Println(err.Error())
        return
    }

    files := make(map[string]*model.UploadingFile)
    for fileKey, _ := range fileMap {
        fileHeader, err := c.FormFile(fileKey)
        if err != nil {
            log.Println(fileKey, err.Error())
            continue
        }
        fileReader, err := fileHeader.Open()
        if err != nil {
            log.Println(err.Error())
            continue
        }
        files[fileKey] = &model.UploadingFile{
            Content: fileReader,
            Name: fileHeader.Filename,
            Size: fileHeader.Size,
        }
    }

    // now change the body and content-type
    // it is hacky-way until gqlgen supports multipart/form-data request by self
    var doc interface{} = operations
    if len(operations) == 1 {
        doc = operations[0]
    }
    fakeBody, err := json.Marshal(doc)
    if err != nil {
        log.Println(err.Error())
        return
    }
    c.Request.Header.Set("content-type", "application/json")
    c.Request.Body = ioutil.NopCloser(bytes.NewReader(fakeBody))

    f = func(ctx context.Context, next func(ctx context.Context) []byte) []byte {
        req := graphql.GetRequestContext(ctx)
        variables := req.Variables
        for fileKey, variableFields := range fileMap {
            for _, variableField := range variableFields {
                fields := strings.Split(variableField, ".")
                if len(fields) <= 1 || fields[0] != "variables" { // respect spec: https://github.com/jaydenseric/graphql-multipart-request-spec
                    log.Println("invalid variable field in map", variableField)
                    continue
                }
                var obj = variables
                lastIndex := len(fields) - 2
                for index, path := range fields[1:] {
                    if _, ok := obj[path]; ok {
                        if index == lastIndex {
                            // set file
                            if obj[path], ok = files[fileKey]; ok {
                                log.Println("set file", variableField, files[fileKey])
                            }
                            break
                        } else if objInObj, ok := obj[path].(map[string]interface{}); ok {
                            obj = objInObj
                            continue
                        }
                    }

                    log.Println("invalid variable field in map", variableField)
                    break
                }
            }
        }
        return next(ctx)
    }
    return
}

A scalar type which represent file uploading stream

package model

import (
    "io"
)

// scalar type
type UploadingFile struct {
    Content io.Reader
    Name string
    Size int64
}

// GraphQL JSON -> UploadingFile
func (f *UploadingFile) UnmarshalGQL(gql interface{}) (err error) {
    // this scalar type will be unmarshaled while parsing multipart/form-data body
    // ref: ../handler/multipart.go
    if v, ok := gql.(*UploadingFile); ok {
        if v != nil {
            *f = *v
        }
    }
    return
}

// UploadingFile -> GraphQL JSON (RFC3339)
func (f UploadingFile) MarshalGQL(w io.Writer) {
    w.Write([]byte("null"))
}

Now attach the middleware into graphql handler

package handler

import (
    "context"
    "github.com/99designs/gqlgen/graphql"
    graphqlHandler "github.com/99designs/gqlgen/handler"
    ...
)


...

        gqlHandler := graphqlHandler.GraphQL(
            // create root schema with context derived values
            schema.NewExecutableSchema(schema.Config{
                Resolvers:  schema.NewResolverRoot(viewer, locale),
                Directives: schema.NewDirectiveRoot(viewer),
            }),

            ....

            // multipart/form-data parsing middleware
            graphqlHandler.RequestMiddleware(parseMultipartRequest(c)),
           ...

           // other options
        )

       gqlHandler.ServeHTTP(c.Writer, c.Request)

...

@dehypnosis would you try to make a pull request for this?

Hi @MShoaei I hope to be submiting a pull request this week. I already have it working, but I need to add some test to it. I also added support to submit the upload with a request mutation payload, so you can submit some extra fields with the file.

@hantonelli if you're planning a PR can you please ensure it's against next? We aren't accepting new features against master currently.

@mathewbyrne Sure!

Also, for a feature of this size it would be really great if you could write up a proposal as a separate issue with your planned approach for the development team to look at. It's really difficult to accept large PRs without a bit of context beforehand.

Sorry we're working on getting some contribution guidelines in place soon that will codify some of this.

@mathewbyrne Sure, I will.

Really looking forward for this feature!

@robert-zaremba @mathewbyrne I'm still working on the PR and proposal, hope to finish it soon and, that they would be good enough or at least a good start to add this feature :)

I have been using this for about a year now.
It is just a middleware.

You can try it too.

@smithaitufe thanks for that !

@mathewbyrne Is this planned to be integrated into the gqlgen lib ?

It's not on our roadmap currently, but I think it makes sense to include at some point yes.

@mathewbyrne I finally find the time to make the proposal and the PR :)

Was this page helpful?
0 / 5 - 0 ratings

Related issues

JulienBreux picture JulienBreux  路  3Comments

bieber picture bieber  路  4Comments

andrewmunro picture andrewmunro  路  4Comments

steebchen picture steebchen  路  3Comments

cemremengu picture cemremengu  路  3Comments