From 562662dcaf9ec186bb20458d9ed07d3d4ab61b3e Mon Sep 17 00:00:00 2001 From: Robin Jarry Date: Mon, 25 Nov 2024 16:09:26 +0100 Subject: [PATCH] graphql: add support for multipart file uploads Inspire from the python implementation in core.sr.ht. Link: https://git.sr.ht/~sircmpwn/core.sr.ht/tree/7bcbc8ba8f4e/item/srht/graphql/client.py Signed-off-by: Robin Jarry --- client/graphql.go | 106 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 11 deletions(-) diff --git a/client/graphql.go b/client/graphql.go index 59ce62e8dfeba275c3786b8f1abe2e8ef4aeb250..6ccb71307905bcfd5cd321f6ca3afcdce2d9d2c6 100644 --- a/client/graphql.go +++ b/client/graphql.go @@ -5,18 +5,23 @@ import ( "context" "encoding/json" "fmt" + "io" "io/ioutil" + "mime/multipart" "net/http" + "net/textproto" "git.sr.ht/~sircmpwn/core-go/config" "git.sr.ht/~sircmpwn/core-go/crypto" + "github.com/99designs/gqlgen/graphql" "github.com/vektah/gqlparser/v2/gqlerror" ) type GraphQLQuery struct { - Query string `json:"query"` - Variables map[string]interface{} `json:"variables"` + Query string `json:"query"` + Variables map[string]interface{} `json:"variables"` + Uploads map[string]graphql.Upload `json:"-"` } type InternalAuth struct { @@ -26,13 +31,8 @@ type InternalAuth struct { } func Do(ctx context.Context, username string, svc string, - query GraphQLQuery, result interface{}) error { - - body, err := json.Marshal(query) - if err != nil { - panic(err) // Programmer error - } - + query GraphQLQuery, result interface{}, +) error { conf := config.ForContext(ctx) origin, _ := conf.Get(svc, "api-origin") if origin == "" { @@ -42,13 +42,97 @@ func Do(ctx context.Context, username string, svc string, panic(fmt.Errorf("No %s origin specified in config.ini", svc)) } - reader := bytes.NewBuffer(body) + var ( + contentType string + reader io.Reader + ) + + if len(query.Uploads) > 0 { + var ( + body *multipart.Writer + buf bytes.Buffer + filemap map[string][]string + uploads []graphql.Upload + h textproto.MIMEHeader + ) + + // Prepare the "map" field which associates the index of each + // uploaded file to a variable name. Related 'null' values are + // required in the variables map. + filemap = make(map[string][]string) + uploads = make([]graphql.Upload, 0, len(query.Uploads)) + i := 0 + for name, upload := range query.Uploads { + filemap[fmt.Sprintf("%d", i)] = []string{ + fmt.Sprintf("variables.%s", name), + } + query.Variables[name] = nil + uploads = append(uploads, upload) + i++ + } + + // Create a new multipart body + body = multipart.NewWriter(&buf) + + // Add the GraphQLQuery object serialized as JSON as a first + // multipart field named "operations". + h = make(textproto.MIMEHeader) + h.Add("Content-Disposition", `form-data; name="operations"`) + h.Add("Content-Type", "application/json") + pw, _ := body.CreatePart(h) + if err := json.NewEncoder(pw).Encode(query); err != nil { + panic(err) // Programmer error + } + + // The second multipart field is the filemap we created + // previously. The field name must be "map" and be serialized + // as JSON. + h = make(textproto.MIMEHeader) + h.Add("Content-Disposition", `form-data; name="map"`) + h.Add("Content-Type", "application/json") + pw, _ = body.CreatePart(h) + if err := json.NewEncoder(pw).Encode(filemap); err != nil { + panic(err) // Programmer error + } + + // Finally, add one multipart field per upload. Use the upload + // index as field name. The filename value is optional and can + // remain empty. If the content-type is not set, assume + // application/octet-stream as default. + for i, upload := range uploads { + h = make(textproto.MIMEHeader) + h.Add("Content-Disposition", fmt.Sprintf( + `form-data; name="%d"; filename=%q`, i, upload.Filename)) + if upload.ContentType == "" { + upload.ContentType = "application/octet-stream" + } + h.Add("Content-Type", upload.ContentType) + pw, _ = body.CreatePart(h) + if _, err := io.Copy(pw, upload.File); err != nil { + return err + } + } + if err := body.Close(); err != nil { + return err + } + reader = &buf + contentType = fmt.Sprintf( + "multipart/form-data; boundary=%s", body.Boundary()) + } else { + body, err := json.Marshal(query) + if err != nil { + panic(err) // Programmer error + } + reader = bytes.NewBuffer(body) + contentType = "application/json" + } + req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/query", origin), reader) if err != nil { return err } - req.Header.Add("Content-Type", "application/json") + req.Header.Add("Content-Type", contentType) auth := InternalAuth{ Name: username, ClientID: config.ServiceName(ctx),