diff --git a/internal/gqlclient/gqlclient.go b/internal/gqlclient/gqlclient.go index d9e4c1ae60..bb1003465a 100644 --- a/internal/gqlclient/gqlclient.go +++ b/internal/gqlclient/gqlclient.go @@ -1,3 +1,34 @@ +// Package graphql provides a low level GraphQL client. +// +// // create a client (safe to share across requests) +// client := graphql.NewClient("https://machinebox.io/graphql") +// +// // make a request +// req := graphql.NewRequest(` +// query ($key: String!) { +// items (id:$key) { +// field1 +// field2 +// field3 +// } +// } +// `) +// +// // set any variables +// req.Var("key", "value") +// +// // run it and capture the response +// var respData ResponseStruct +// if err := client.Run(ctx, req, &respData); err != nil { +// log.Fatal(err) +// } +// +// # Specify client +// +// To specify your own http.Client, use the WithHTTPClient option: +// +// httpclient := &http.Client{} +// client := graphql.NewClient("https://machinebox.io/graphql", graphql.WithHTTPClient(httpclient)) package gqlclient import ( @@ -14,7 +45,6 @@ import ( "github.com/ActiveState/cli/internal/constants" "github.com/ActiveState/cli/internal/errs" - "github.com/ActiveState/cli/internal/graphql" "github.com/ActiveState/cli/internal/logging" "github.com/ActiveState/cli/internal/profile" "github.com/ActiveState/cli/internal/singleton/uniqid" @@ -23,17 +53,6 @@ import ( "github.com/pkg/errors" ) -type File struct { - Field string - Name string - R io.Reader -} - -type Request0 interface { - Query() string - Vars() map[string]interface{} -} - type Request interface { Query() string Vars() (map[string]interface{}, error) @@ -44,9 +63,10 @@ type RequestWithFiles interface { Files() []File } -type Header map[string][]string - -type graphqlClient = graphql.Client +type RequestWithHeaders interface { + Request + Headers() map[string][]string +} // StandardizedErrors works around API's that don't follow the graphql standard // It looks redundant because it needs to address two different API responses. @@ -74,13 +94,6 @@ func (e StandardizedErrors) Values() []string { return errs } -type graphResponse struct { - Data interface{} - Error string - Message string - Errors []graphErr -} - type graphErr struct { Message string } @@ -93,51 +106,71 @@ type BearerTokenProvider interface { BearerToken() string } +type PostProcessor interface { + PostProcess() error +} + +// Client is a client for interacting with a GraphQL API. type Client struct { - *graphqlClient - url string - tokenProvider BearerTokenProvider - timeout time.Duration + url string + httpClient *http.Client + useMultipartForm bool + tokenProvider BearerTokenProvider + timeout time.Duration + + // Log is called with various debug information. + // To log to standard out, use: + // client.Log = func(s string) { log.Println(s) } + Log func(s string) } -func NewWithOpts(url string, timeout time.Duration, opts ...graphql.ClientOption) *Client { +func NewWithOpts(url string, timeout time.Duration, opts ...ClientOption) *Client { if timeout == 0 { timeout = time.Second * 60 } - client := &Client{ - graphqlClient: graphql.NewClient(url, opts...), - timeout: timeout, - url: url, - } + c := newClient(url, opts...) if os.Getenv(constants.DebugServiceRequestsEnvVarName) == "true" { - client.EnableDebugLog() + c.EnableDebugLog() } - return client + c.timeout = timeout + + return c } func New(url string, timeout time.Duration) *Client { - return NewWithOpts(url, timeout, graphql.WithHTTPClient(api.NewHTTPClient())) + return NewWithOpts(url, timeout, WithHTTPClient(api.NewHTTPClient())) } -// EnableDebugLog turns on debug logging -func (c *Client) EnableDebugLog() { - c.graphqlClient.Log = func(s string) { logging.Debug("graphqlClient log message: %s", s) } +// newClient makes a new Client capable of making GraphQL requests. +func newClient(endpoint string, opts ...ClientOption) *Client { + c := &Client{ + url: endpoint, + Log: func(string) {}, + } + for _, optionFunc := range opts { + optionFunc(c) + } + if c.httpClient == nil { + c.httpClient = http.DefaultClient + } + return c } -func (c *Client) SetTokenProvider(tokenProvider BearerTokenProvider) { - c.tokenProvider = tokenProvider +func (c *Client) logf(format string, args ...interface{}) { + c.Log(fmt.Sprintf(format, args...)) } -func (c *Client) SetDebug(b bool) { - c.graphqlClient.Log = func(string) {} - if b { - c.graphqlClient.Log = func(s string) { - fmt.Fprintln(os.Stderr, s) - } +func (c *Client) EnableDebugLog() { + c.Log = func(s string) { + logging.Debug("graphqlClient log message: %s", s) } } +func (c *Client) SetTokenProvider(tokenProvider BearerTokenProvider) { + c.tokenProvider = tokenProvider +} + func (c *Client) Run(request Request, response interface{}) error { ctx := context.Background() if c.timeout != 0 { @@ -145,69 +178,203 @@ func (c *Client) Run(request Request, response interface{}) error { ctx, cancel = context.WithTimeout(ctx, c.timeout) defer cancel() } - err := c.RunWithContext(ctx, request, response) - return err // Needs var so the cancel defer triggers at the right time -} - -type PostProcessor interface { - PostProcess() error + if err := c.RunWithContext(ctx, request, response); err != nil { + return NewRequestError(err, request) + } + return nil } -func (c *Client) RunWithContext(ctx context.Context, request Request, response interface{}) (rerr error) { +// RunWithContext executes the query and unmarshals the response from the data field +// into the response object. +// Pass in a nil response object to skip response parsing. +// If the request fails or the server returns an error, the first error +// will be returned. +func (c *Client) RunWithContext(ctx context.Context, req Request, resp interface{}) (rerr error) { defer func() { if rerr != nil { return } - if postProcessor, ok := response.(PostProcessor); ok { + if postProcessor, ok := resp.(PostProcessor); ok { rerr = postProcessor.PostProcess() } }() - name := strutils.Summarize(request.Query(), 25) + name := strutils.Summarize(req.Query(), 25) defer profile.Measure(fmt.Sprintf("gqlclient:RunWithContext:(%s)", name), time.Now()) - if fileRequest, ok := request.(RequestWithFiles); ok { - return c.runWithFiles(ctx, fileRequest, response) + select { + case <-ctx.Done(): + return ctx.Err() + default: } - vars, err := request.Vars() + gqlRequest := newRequest(req.Query()) + vars, err := req.Vars() if err != nil { - return errs.Wrap(err, "Could not get variables") + return errs.Wrap(err, "Could not get vars") } + gqlRequest.vars = vars - graphRequest := graphql.NewRequest(request.Query()) - for key, value := range vars { - graphRequest.Var(key, value) + var bearerToken string + if c.tokenProvider != nil { + bearerToken = c.tokenProvider.BearerToken() + if bearerToken != "" { + gqlRequest.Header.Set("Authorization", "Bearer "+bearerToken) + } } - if fileRequest, ok := request.(RequestWithFiles); ok { - for _, file := range fileRequest.Files() { - graphRequest.File(file.Field, file.Name, file.R) + gqlRequest.Header.Set("X-Requestor", uniqid.Text()) + + if header, ok := req.(RequestWithHeaders); ok { + for key, values := range header.Headers() { + for _, value := range values { + gqlRequest.Header.Add(key, value) + } } } - var bearerToken string - if c.tokenProvider != nil { - bearerToken = c.tokenProvider.BearerToken() - if bearerToken != "" { - graphRequest.Header.Set("Authorization", "Bearer "+bearerToken) + if fileRequest, ok := req.(RequestWithFiles); ok { + gqlRequest.files = fileRequest.Files() + return c.runWithFiles(ctx, gqlRequest, resp) + } + + if c.useMultipartForm { + return c.runWithPostFields(ctx, gqlRequest, resp) + } + + return c.runWithJSON(ctx, gqlRequest, resp) +} + +func (c *Client) runWithJSON(ctx context.Context, req *gqlRequest, resp interface{}) error { + var requestBody bytes.Buffer + requestBodyObj := struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables"` + }{ + Query: req.q, + Variables: req.vars, + } + if err := json.NewEncoder(&requestBody).Encode(requestBodyObj); err != nil { + return errors.Wrap(err, "encode body") + } + c.logf(">> variables: %v", req.vars) + c.logf(">> query: %s", req.q) + + intermediateResp := make(map[string]interface{}) + gr := &graphResponse{ + Data: &intermediateResp, + } + r, err := http.NewRequest(http.MethodPost, c.url, &requestBody) + if err != nil { + return err + } + r.Header.Set("Content-Type", "application/json; charset=utf-8") + r.Header.Set("Accept", "application/json; charset=utf-8") + for key, values := range req.Header { + for _, value := range values { + r.Header.Add(key, value) } } + c.logf(">> headers: %v", r.Header) + r = r.WithContext(ctx) + res, err := c.httpClient.Do(r) + if err != nil { + return err + } + defer res.Body.Close() + var buf bytes.Buffer + if _, err := io.Copy(&buf, res.Body); err != nil { + return errors.Wrap(err, "reading body") + } + c.logf("<< %s", buf.String()) + if err := json.NewDecoder(&buf).Decode(&gr); err != nil { + return errors.Wrap(err, "decoding response") + } + if len(gr.Errors) > 0 { + // return first error + return gr.Errors[0] + } - graphRequest.Header.Set("X-Requestor", uniqid.Text()) + return c.marshalResponse(intermediateResp, resp) +} - if err := c.graphqlClient.Run(ctx, graphRequest, &response); err != nil { - return NewRequestError(err, request) +func (c *Client) runWithPostFields(ctx context.Context, req *gqlRequest, resp interface{}) error { + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + if err := writer.WriteField("query", req.q); err != nil { + return errors.Wrap(err, "write query field") + } + var variablesBuf bytes.Buffer + if len(req.vars) > 0 { + variablesField, err := writer.CreateFormField("variables") + if err != nil { + return errors.Wrap(err, "create variables field") + } + if err := json.NewEncoder(io.MultiWriter(variablesField, &variablesBuf)).Encode(req.vars); err != nil { + return errors.Wrap(err, "encode variables") + } } - return nil + for i := range req.files { + part, err := writer.CreateFormFile(req.files[i].Field, req.files[i].Name) + if err != nil { + return errors.Wrap(err, "create form file") + } + if _, err := io.Copy(part, req.files[i].R); err != nil { + return errors.Wrap(err, "preparing file") + } + } + + if err := writer.Close(); err != nil { + return errors.Wrap(err, "close writer") + } + + c.logf(">> variables: %s", variablesBuf.String()) + c.logf(">> query: %s", req.q) + c.logf(">> files: %d", len(req.files)) + intermediateResp := make(map[string]interface{}) + gr := &graphResponse{ + Data: &intermediateResp, + } + r, err := http.NewRequest(http.MethodPost, c.url, &requestBody) + if err != nil { + return err + } + r.Header.Set("Content-Type", writer.FormDataContentType()) + r.Header.Set("Accept", "application/json; charset=utf-8") + for key, values := range req.Header { + for _, value := range values { + r.Header.Add(key, value) + } + } + c.logf(">> headers: %v", r.Header) + r = r.WithContext(ctx) + res, err := c.httpClient.Do(r) + if err != nil { + return err + } + defer res.Body.Close() + var buf bytes.Buffer + if _, err := io.Copy(&buf, res.Body); err != nil { + return errors.Wrap(err, "reading body") + } + c.logf("<< %s", buf.String()) + if err := json.NewDecoder(&buf).Decode(&gr); err != nil { + return errors.Wrap(err, "decoding response") + } + if len(gr.Errors) > 0 { + // return first error + return gr.Errors[0] + } + + return c.marshalResponse(intermediateResp, resp) } -type JsonRequest struct { +type jsonRequest struct { Query string `json:"query"` Variables map[string]interface{} `json:"variables"` } -func (c *Client) runWithFiles(ctx context.Context, gqlReq RequestWithFiles, response interface{}) error { +func (c *Client) runWithFiles(ctx context.Context, request *gqlRequest, response interface{}) error { // Construct the multi-part request. bodyReader, bodyWriter := io.Pipe() @@ -221,11 +388,7 @@ func (c *Client) runWithFiles(ctx context.Context, gqlReq RequestWithFiles, resp mw := multipart.NewWriter(bodyWriter) req.Header.Set("Content-Type", "multipart/form-data; boundary="+mw.Boundary()) - vars, err := gqlReq.Vars() - if err != nil { - return errs.Wrap(err, "Could not get variables") - } - + vars := request.vars varJson, err := json.Marshal(vars) if err != nil { return errs.Wrap(err, "Could not marshal vars") @@ -244,8 +407,8 @@ func (c *Client) runWithFiles(ctx context.Context, gqlReq RequestWithFiles, resp return } - jsonReq := JsonRequest{ - Query: gqlReq.Query(), + jsonReq := jsonRequest{ + Query: request.q, Variables: vars, } jsonReqV, err := json.Marshal(jsonReq) @@ -259,20 +422,20 @@ func (c *Client) runWithFiles(ctx context.Context, gqlReq RequestWithFiles, resp } // Map - if len(gqlReq.Files()) > 0 { + if len(request.files) > 0 { mapField, err := mw.CreateFormField("map") if err != nil { reqErrChan <- errs.Wrap(err, "Could not create form field map") return } - for n, f := range gqlReq.Files() { + for n, f := range request.files { if _, err := mapField.Write([]byte(fmt.Sprintf(`{"%d": ["%s"]}`, n, f.Field))); err != nil { reqErrChan <- errs.Wrap(err, "Could not write map field") return } } // File upload - for n, file := range gqlReq.Files() { + for n, file := range request.files { part, err := mw.CreateFormFile(fmt.Sprintf("%d", n), file.Name) if err != nil { reqErrChan <- errs.Wrap(err, "Could not create form file") @@ -288,10 +451,10 @@ func (c *Client) runWithFiles(ctx context.Context, gqlReq RequestWithFiles, resp } }() - c.Log(fmt.Sprintf(">> query: %s", gqlReq.Query())) + c.Log(fmt.Sprintf(">> query: %s", request.q)) c.Log(fmt.Sprintf(">> variables: %s", string(varJson))) fnames := []string{} - for _, file := range gqlReq.Files() { + for _, file := range request.files { fnames = append(fnames, file.Name) } c.Log(fmt.Sprintf(">> files: %v", fnames)) @@ -391,3 +554,107 @@ func (c *Client) runWithFiles(ctx context.Context, gqlReq RequestWithFiles, resp } return json.Unmarshal(data, response) } + +func (c *Client) marshalResponse(intermediateResp map[string]interface{}, resp interface{}) error { + if resp == nil { + return nil + } + + if len(intermediateResp) == 1 { + for _, val := range intermediateResp { + data, err := json.Marshal(val) + if err != nil { + return errors.Wrap(err, "remarshaling response") + } + return json.Unmarshal(data, resp) + } + } + + data, err := json.Marshal(intermediateResp) + if err != nil { + return errors.Wrap(err, "remarshaling response") + } + return json.Unmarshal(data, resp) +} + +// WithHTTPClient specifies the underlying http.Client to use when +// making requests. +// +// NewClient(endpoint, WithHTTPClient(specificHTTPClient)) +func WithHTTPClient(httpclient *http.Client) ClientOption { + return func(client *Client) { + client.httpClient = httpclient + } +} + +// UseMultipartForm uses multipart/form-data and activates support for +// files. +func UseMultipartForm() ClientOption { + return func(client *Client) { + client.useMultipartForm = true + } +} + +// ClientOption are functions that are passed into NewClient to +// modify the behaviour of the Client. +type ClientOption func(*Client) + +type GraphErr struct { + Message string `json:"message"` + Extensions map[string]interface{} `json:"extensions"` +} + +func (e GraphErr) Error() string { + return "graphql: " + e.Message +} + +type graphResponse struct { + Data interface{} + Errors []GraphErr +} + +// Request is a GraphQL request. +type gqlRequest struct { + q string + vars map[string]interface{} + files []File + + // Header represent any request headers that will be set + // when the request is made. + Header http.Header +} + +// newRequest makes a new Request with the specified string. +func newRequest(q string) *gqlRequest { + req := &gqlRequest{ + q: q, + Header: make(map[string][]string), + } + return req +} + +// Var sets a variable. +func (req *gqlRequest) Var(key string, value interface{}) { + if req.vars == nil { + req.vars = make(map[string]interface{}) + } + req.vars[key] = value +} + +// File sets a file to upload. +// Files are only supported with a Client that was created with +// the UseMultipartForm option. +func (req *gqlRequest) File(fieldname, filename string, r io.Reader) { + req.files = append(req.files, File{ + Field: fieldname, + Name: filename, + R: r, + }) +} + +// File represents a File to upload. +type File struct { + Field string + Name string + R io.Reader +} diff --git a/internal/graphql/graphql_json_test.go b/internal/gqlclient/gqlclient_json_test.go similarity index 82% rename from internal/graphql/graphql_json_test.go rename to internal/gqlclient/gqlclient_json_test.go index 3cd6dde068..4dce0b22c2 100644 --- a/internal/graphql/graphql_json_test.go +++ b/internal/gqlclient/gqlclient_json_test.go @@ -1,4 +1,4 @@ -package graphql +package gqlclient import ( "context" @@ -32,12 +32,12 @@ func TestDoJSON(t *testing.T) { defer srv.Close() ctx := context.Background() - client := NewClient(srv.URL) + client := newClient(srv.URL) ctx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() var responseData map[string]interface{} - err := client.Run(ctx, &Request{q: "query {}"}, &responseData) + err := client.RunWithContext(ctx, &TestRequest{"query {}", nil, nil}, &responseData) is.NoErr(err) is.Equal(calls, 1) // calls is.Equal(responseData["something"], "yes") @@ -59,10 +59,10 @@ func TestQueryJSON(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() - client := NewClient(srv.URL) + client := newClient(srv.URL) - req := NewRequest("query {}") - req.Var("username", "matryer") + req := NewTestRequest("query {}") + req.vars["username"] = "matryer" // check variables is.True(req != nil) @@ -71,7 +71,7 @@ func TestQueryJSON(t *testing.T) { var resp struct { Value string } - err := client.Run(ctx, req, &resp) + err := client.RunWithContext(ctx, req, &resp) is.NoErr(err) is.Equal(calls, 1) @@ -99,15 +99,14 @@ func TestHeader(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() - client := NewClient(srv.URL) - - req := NewRequest("query {}") - req.Header.Set("X-Custom-Header", "123") + client := newClient(srv.URL) + req := NewTestRequest("query {}") + req.headers["X-Custom-Header"] = []string{"123"} var resp struct { Value string } - err := client.Run(ctx, req, &resp) + err := client.RunWithContext(ctx, req, &resp) is.NoErr(err) is.Equal(calls, 1) diff --git a/internal/graphql/graphql_multipart_test.go b/internal/gqlclient/gqlclient_multipart_test.go similarity index 79% rename from internal/graphql/graphql_multipart_test.go rename to internal/gqlclient/gqlclient_multipart_test.go index 7ae97b5a9f..bf05030ecd 100644 --- a/internal/graphql/graphql_multipart_test.go +++ b/internal/gqlclient/gqlclient_multipart_test.go @@ -1,4 +1,4 @@ -package graphql +package gqlclient import ( "context" @@ -27,10 +27,9 @@ func TestWithClient(t *testing.T) { } ctx := context.Background() - client := NewClient("", WithHTTPClient(testClient), UseMultipartForm()) + client := newClient("", WithHTTPClient(testClient), UseMultipartForm()) - req := NewRequest(``) - client.Run(ctx, req, nil) + client.RunWithContext(ctx, NewTestRequest("query {}"), nil) is.Equal(calls, 1) // calls } @@ -54,12 +53,12 @@ func TestDoUseMultipartForm(t *testing.T) { defer srv.Close() ctx := context.Background() - client := NewClient(srv.URL, UseMultipartForm()) + client := newClient(srv.URL, UseMultipartForm()) ctx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() var responseData map[string]interface{} - err := client.Run(ctx, &Request{q: "query {}"}, &responseData) + err := client.RunWithContext(ctx, NewTestRequest("query {}"), &responseData) is.NoErr(err) is.Equal(calls, 1) // calls is.Equal(responseData["something"], "yes") @@ -82,12 +81,12 @@ func TestDoErr(t *testing.T) { defer srv.Close() ctx := context.Background() - client := NewClient(srv.URL, UseMultipartForm()) + client := newClient(srv.URL, UseMultipartForm()) ctx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() var responseData map[string]interface{} - err := client.Run(ctx, &Request{q: "query {}"}, &responseData) + err := client.RunWithContext(ctx, NewTestRequest("query {}"), &responseData) is.True(err != nil) is.Equal(err.Error(), "graphql: Something went wrong") } @@ -109,11 +108,11 @@ func TestDoNoResponse(t *testing.T) { defer srv.Close() ctx := context.Background() - client := NewClient(srv.URL, UseMultipartForm()) + client := newClient(srv.URL, UseMultipartForm()) ctx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() - err := client.Run(ctx, &Request{q: "query {}"}, nil) + err := client.RunWithContext(ctx, NewTestRequest("query {}"), nil) is.NoErr(err) is.Equal(calls, 1) // calls } @@ -134,10 +133,10 @@ func TestQuery(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() - client := NewClient(srv.URL, UseMultipartForm()) + client := newClient(srv.URL, UseMultipartForm()) - req := NewRequest("query {}") - req.Var("username", "matryer") + req := NewTestRequest("query {}") + req.vars["username"] = "matryer" // check variables is.True(req != nil) @@ -146,7 +145,7 @@ func TestQuery(t *testing.T) { var resp struct { Value string } - err := client.Run(ctx, req, &resp) + err := client.RunWithContext(ctx, req, &resp) is.NoErr(err) is.Equal(calls, 1) @@ -160,7 +159,7 @@ func TestFile(t *testing.T) { var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ - file, header, err := r.FormFile("file") + file, header, err := r.FormFile("0") is.NoErr(err) defer file.Close() is.Equal(header.Filename, "filename.txt") @@ -175,11 +174,12 @@ func TestFile(t *testing.T) { defer srv.Close() ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() - client := NewClient(srv.URL, UseMultipartForm()) + client := newClient(srv.URL, UseMultipartForm()) f := strings.NewReader(`This is a file`) - req := NewRequest("query {}") - req.File("file", "filename.txt", f) - err := client.Run(ctx, req, nil) + req := NewTestRequestWithFiles("query {}") + req.files = append(req.files, File{Field: "file", Name: "filename.txt", R: f}) + var resp string + err := client.RunWithContext(ctx, req, &resp) is.NoErr(err) } diff --git a/internal/gqlclient/gqlclient_shared_test.go b/internal/gqlclient/gqlclient_shared_test.go new file mode 100644 index 0000000000..da62126795 --- /dev/null +++ b/internal/gqlclient/gqlclient_shared_test.go @@ -0,0 +1,43 @@ +package gqlclient + +type TestRequest struct { + q string + vars map[string]interface{} + headers map[string][]string +} + +func NewTestRequest(q string) *TestRequest { + return &TestRequest{ + q: q, + vars: make(map[string]interface{}), + headers: make(map[string][]string), + } +} + +func (r *TestRequest) Query() string { + return r.q +} + +func (r *TestRequest) Vars() (map[string]interface{}, error) { + return r.vars, nil +} + +func (r *TestRequest) Headers() map[string][]string { + return r.headers +} + +type TestRequestWithFiles struct { + *TestRequest + files []File +} + +func NewTestRequestWithFiles(q string) *TestRequestWithFiles { + return &TestRequestWithFiles{ + TestRequest: NewTestRequest(q), + files: make([]File, 0), + } +} + +func (r *TestRequestWithFiles) Files() []File { + return r.files +} diff --git a/internal/graphql/graphql_test.go b/internal/gqlclient/gqlclient_test.go similarity index 92% rename from internal/graphql/graphql_test.go rename to internal/gqlclient/gqlclient_test.go index 1a24c533f3..9cde0c9abf 100644 --- a/internal/graphql/graphql_test.go +++ b/internal/gqlclient/gqlclient_test.go @@ -1,4 +1,4 @@ -package graphql +package gqlclient import ( "context" @@ -25,7 +25,7 @@ func TestClient_SingleField(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL) + client := newClient(server.URL) tests := []struct { name string @@ -104,10 +104,10 @@ func TestClient_SingleField(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := NewRequest(tt.query) + req := NewTestRequest(tt.query) var resp interface{} - err := client.Run(context.Background(), req, &resp) + err := client.RunWithContext(context.Background(), req, &resp) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -145,7 +145,7 @@ func TestClient_MultipleFields(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL) + client := newClient(server.URL) tests := []struct { name string @@ -179,10 +179,10 @@ func TestClient_MultipleFields(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := NewRequest(tt.query) + req := NewTestRequest(tt.query) var resp interface{} - err := client.Run(context.Background(), req, &resp) + err := client.RunWithContext(context.Background(), req, &resp) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/graphql/graphql.go b/internal/graphql/graphql.go deleted file mode 100644 index 780a2c6d46..0000000000 --- a/internal/graphql/graphql.go +++ /dev/null @@ -1,321 +0,0 @@ -// Package graphql provides a low level GraphQL client. -// -// // create a client (safe to share across requests) -// client := graphql.NewClient("https://machinebox.io/graphql") -// -// // make a request -// req := graphql.NewRequest(` -// query ($key: String!) { -// items (id:$key) { -// field1 -// field2 -// field3 -// } -// } -// `) -// -// // set any variables -// req.Var("key", "value") -// -// // run it and capture the response -// var respData ResponseStruct -// if err := client.Run(ctx, req, &respData); err != nil { -// log.Fatal(err) -// } -// -// # Specify client -// -// To specify your own http.Client, use the WithHTTPClient option: -// -// httpclient := &http.Client{} -// client := graphql.NewClient("https://machinebox.io/graphql", graphql.WithHTTPClient(httpclient)) -package graphql - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" - - "github.com/pkg/errors" -) - -// Client is a client for interacting with a GraphQL API. -type Client struct { - endpoint string - httpClient *http.Client - useMultipartForm bool - - // Log is called with various debug information. - // To log to standard out, use: - // client.Log = func(s string) { log.Println(s) } - Log func(s string) -} - -// NewClient makes a new Client capable of making GraphQL requests. -func NewClient(endpoint string, opts ...ClientOption) *Client { - c := &Client{ - endpoint: endpoint, - Log: func(string) {}, - } - for _, optionFunc := range opts { - optionFunc(c) - } - if c.httpClient == nil { - c.httpClient = http.DefaultClient - } - return c -} - -func (c *Client) logf(format string, args ...interface{}) { - c.Log(fmt.Sprintf(format, args...)) -} - -// Run executes the query and unmarshals the response from the data field -// into the response object. -// Pass in a nil response object to skip response parsing. -// If the request fails or the server returns an error, the first error -// will be returned. -func (c *Client) Run(ctx context.Context, req *Request, resp interface{}) error { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - if len(req.files) > 0 && !c.useMultipartForm { - return errors.New("cannot send files with PostFields option") - } - if c.useMultipartForm { - return c.runWithPostFields(ctx, req, resp) - } - return c.runWithJSON(ctx, req, resp) -} - -func (c *Client) runWithJSON(ctx context.Context, req *Request, resp interface{}) error { - var requestBody bytes.Buffer - requestBodyObj := struct { - Query string `json:"query"` - Variables map[string]interface{} `json:"variables"` - }{ - Query: req.q, - Variables: req.vars, - } - if err := json.NewEncoder(&requestBody).Encode(requestBodyObj); err != nil { - return errors.Wrap(err, "encode body") - } - c.logf(">> variables: %v", req.vars) - c.logf(">> query: %s", req.q) - - intermediateResp := make(map[string]interface{}) - gr := &graphResponse{ - Data: &intermediateResp, - } - r, err := http.NewRequest(http.MethodPost, c.endpoint, &requestBody) - if err != nil { - return err - } - r.Header.Set("Content-Type", "application/json; charset=utf-8") - r.Header.Set("Accept", "application/json; charset=utf-8") - for key, values := range req.Header { - for _, value := range values { - r.Header.Add(key, value) - } - } - c.logf(">> headers: %v", r.Header) - r = r.WithContext(ctx) - res, err := c.httpClient.Do(r) - if err != nil { - return err - } - defer res.Body.Close() - var buf bytes.Buffer - if _, err := io.Copy(&buf, res.Body); err != nil { - return errors.Wrap(err, "reading body") - } - c.logf("<< %s", buf.String()) - if err := json.NewDecoder(&buf).Decode(&gr); err != nil { - return errors.Wrap(err, "decoding response") - } - if len(gr.Errors) > 0 { - // return first error - return gr.Errors[0] - } - - return c.marshalResponse(intermediateResp, resp) -} - -func (c *Client) runWithPostFields(ctx context.Context, req *Request, resp interface{}) error { - var requestBody bytes.Buffer - writer := multipart.NewWriter(&requestBody) - if err := writer.WriteField("query", req.q); err != nil { - return errors.Wrap(err, "write query field") - } - var variablesBuf bytes.Buffer - if len(req.vars) > 0 { - variablesField, err := writer.CreateFormField("variables") - if err != nil { - return errors.Wrap(err, "create variables field") - } - if err := json.NewEncoder(io.MultiWriter(variablesField, &variablesBuf)).Encode(req.vars); err != nil { - return errors.Wrap(err, "encode variables") - } - } - for i := range req.files { - part, err := writer.CreateFormFile(req.files[i].Field, req.files[i].Name) - if err != nil { - return errors.Wrap(err, "create form file") - } - if _, err := io.Copy(part, req.files[i].R); err != nil { - return errors.Wrap(err, "preparing file") - } - } - if err := writer.Close(); err != nil { - return errors.Wrap(err, "close writer") - } - c.logf(">> variables: %s", variablesBuf.String()) - c.logf(">> files: %d", len(req.files)) - c.logf(">> query: %s", req.q) - intermediateResp := make(map[string]interface{}) - gr := &graphResponse{ - Data: &intermediateResp, - } - r, err := http.NewRequest(http.MethodPost, c.endpoint, &requestBody) - if err != nil { - return err - } - r.Header.Set("Content-Type", writer.FormDataContentType()) - r.Header.Set("Accept", "application/json; charset=utf-8") - for key, values := range req.Header { - for _, value := range values { - r.Header.Add(key, value) - } - } - c.logf(">> headers: %v", r.Header) - r = r.WithContext(ctx) - res, err := c.httpClient.Do(r) - if err != nil { - return err - } - defer res.Body.Close() - var buf bytes.Buffer - if _, err := io.Copy(&buf, res.Body); err != nil { - return errors.Wrap(err, "reading body") - } - c.logf("<< %s", buf.String()) - if err := json.NewDecoder(&buf).Decode(&gr); err != nil { - return errors.Wrap(err, "decoding response") - } - if len(gr.Errors) > 0 { - // return first error - return gr.Errors[0] - } - - return c.marshalResponse(intermediateResp, resp) -} - -func (c *Client) marshalResponse(intermediateResp map[string]interface{}, resp interface{}) error { - if resp == nil { - return nil - } - - if len(intermediateResp) == 1 { - for _, val := range intermediateResp { - data, err := json.Marshal(val) - if err != nil { - return errors.Wrap(err, "remarshaling response") - } - return json.Unmarshal(data, resp) - } - } - - data, err := json.Marshal(intermediateResp) - if err != nil { - return errors.Wrap(err, "remarshaling response") - } - return json.Unmarshal(data, resp) -} - -// WithHTTPClient specifies the underlying http.Client to use when -// making requests. -// -// NewClient(endpoint, WithHTTPClient(specificHTTPClient)) -func WithHTTPClient(httpclient *http.Client) ClientOption { - return func(client *Client) { - client.httpClient = httpclient - } -} - -// UseMultipartForm uses multipart/form-data and activates support for -// files. -func UseMultipartForm() ClientOption { - return func(client *Client) { - client.useMultipartForm = true - } -} - -// ClientOption are functions that are passed into NewClient to -// modify the behaviour of the Client. -type ClientOption func(*Client) - -type GraphErr struct { - Message string `json:"message"` - Extensions map[string]interface{} `json:"extensions"` -} - -func (e GraphErr) Error() string { - return "graphql: " + e.Message -} - -type graphResponse struct { - Data interface{} - Errors []GraphErr -} - -// Request is a GraphQL request. -type Request struct { - q string - vars map[string]interface{} - files []file - - // Header represent any request headers that will be set - // when the request is made. - Header http.Header -} - -// NewRequest makes a new Request with the specified string. -func NewRequest(q string) *Request { - req := &Request{ - q: q, - Header: make(map[string][]string), - } - return req -} - -// Var sets a variable. -func (req *Request) Var(key string, value interface{}) { - if req.vars == nil { - req.vars = make(map[string]interface{}) - } - req.vars[key] = value -} - -// File sets a file to upload. -// Files are only supported with a Client that was created with -// the UseMultipartForm option. -func (req *Request) File(fieldname, filename string, r io.Reader) { - req.files = append(req.files, file{ - Field: fieldname, - Name: filename, - R: r, - }) -} - -// file represents a file to upload. -type file struct { - Field string - Name string - R io.Reader -} diff --git a/pkg/platform/model/buildplanner/build.go b/pkg/platform/model/buildplanner/build.go index 812d40385a..9abd93156d 100644 --- a/pkg/platform/model/buildplanner/build.go +++ b/pkg/platform/model/buildplanner/build.go @@ -10,7 +10,6 @@ import ( "github.com/ActiveState/cli/internal/errs" "github.com/ActiveState/cli/internal/gqlclient" - "github.com/ActiveState/cli/internal/graphql" "github.com/ActiveState/cli/internal/locale" "github.com/ActiveState/cli/internal/logging" "github.com/ActiveState/cli/internal/rtutils/ptr" @@ -153,11 +152,11 @@ func (b *BuildPlanner) fetchCommit(commitID strfmt.UUID, owner, project string, // "data": null // } func processBuildPlannerError(bpErr error, fallbackMessage string) error { - graphqlErr := &graphql.GraphErr{} - if errors.As(bpErr, graphqlErr) { - code, ok := graphqlErr.Extensions[codeExtensionKey].(string) + gqlclientErr := &gqlclient.GraphErr{} + if errors.As(bpErr, gqlclientErr) { + code, ok := gqlclientErr.Extensions[codeExtensionKey].(string) if ok && code == clientDeprecationErrorKey { - return &response.BuildPlannerError{Err: locale.NewExternalError("err_buildplanner_deprecated", "Encountered deprecation error: {{.V0}}", graphqlErr.Message)} + return &response.BuildPlannerError{Err: locale.NewExternalError("err_buildplanner_deprecated", "Encountered deprecation error: {{.V0}}", gqlclientErr.Message)} } } if locale.IsInputError(bpErr) { diff --git a/pkg/platform/model/buildplanner/buildplanner.go b/pkg/platform/model/buildplanner/buildplanner.go index dd566307ba..6233807447 100644 --- a/pkg/platform/model/buildplanner/buildplanner.go +++ b/pkg/platform/model/buildplanner/buildplanner.go @@ -4,7 +4,6 @@ import ( "time" "github.com/ActiveState/cli/internal/gqlclient" - "github.com/ActiveState/cli/internal/graphql" "github.com/ActiveState/cli/internal/logging" "github.com/ActiveState/cli/pkg/platform/api" "github.com/ActiveState/cli/pkg/platform/authentication" @@ -41,7 +40,7 @@ func NewBuildPlannerModel(auth *authentication.Auth, cache cacher) *BuildPlanner bpURL := api.GetServiceURL(api.ServiceBuildPlanner).String() logging.Debug("Using build planner at: %s", bpURL) - gqlClient := gqlclient.NewWithOpts(bpURL, 0, graphql.WithHTTPClient(api.NewHTTPClient())) + gqlClient := gqlclient.NewWithOpts(bpURL, 0, gqlclient.WithHTTPClient(api.NewHTTPClient())) if auth != nil && auth.Authenticated() { gqlClient.SetTokenProvider(auth) diff --git a/pkg/platform/model/svc.go b/pkg/platform/model/svc.go index 5cfca41cfa..d6e4af9ae3 100644 --- a/pkg/platform/model/svc.go +++ b/pkg/platform/model/svc.go @@ -12,7 +12,6 @@ import ( "github.com/ActiveState/cli/internal/errs" "github.com/ActiveState/cli/internal/gqlclient" "github.com/ActiveState/cli/internal/graph" - "github.com/ActiveState/cli/internal/graphql" "github.com/ActiveState/cli/internal/logging" "github.com/ActiveState/cli/internal/profile" "github.com/ActiveState/cli/internal/rtutils/ptr" @@ -31,7 +30,7 @@ func NewSvcModel(port string) *SvcModel { localURL := "http://127.0.0.1" + port + "/query" return &SvcModel{ - client: gqlclient.NewWithOpts(localURL, 0, graphql.WithHTTPClient(&http.Client{})), + client: gqlclient.NewWithOpts(localURL, 0, gqlclient.WithHTTPClient(&http.Client{})), } }