package client import ( "bytes" "context" "encoding/json" "fmt" "io" "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]any `json:"variables"` Uploads map[string]graphql.Upload `json:"-"` } type InternalAuth struct { Name string `json:"name,omitempty"` ClientID string `json:"client_id"` NodeID string `json:"node_id"` } func Do(ctx context.Context, username string, svc string, query GraphQLQuery, result any, ) error { conf := config.ForContext(ctx) origin := config.GetAPI(conf, svc, false) 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", contentType) req.Header.Add("User-Agent", fmt.Sprintf("%s -- SourceHut core-go (https://git.sr.ht/~sircmpwn/core-go)", config.ServiceName(ctx))) auth := InternalAuth{ Name: username, ClientID: config.ServiceName(ctx), // TODO: Populate this: NodeID: "core-go", } authBlob, err := json.Marshal(&auth) if err != nil { panic(err) // Programmer error } req.Header.Add("Authorization", fmt.Sprintf("Internal %s", crypto.Encrypt(authBlob))) resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode != 200 { return fmt.Errorf("%s returned status %d: %s", svc, resp.StatusCode, string(respBody)) } var respData struct { Data any `json:"data"` Errors gqlerror.List `json:"errors"` } respData.Data = result if err := json.Unmarshal(respBody, &respData); err != nil { return fmt.Errorf("failed to parse GraphQL response: %v", err) } if len(respData.Errors) == 1 { return respData.Errors[0] } else if len(respData.Errors) > 1 { return respData.Errors } return nil }