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
}