From 4a7b996aa4fb80620b4676c0dc573ff957387283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89derson=20Ferreira?= Date: Wed, 5 Jan 2022 10:45:22 -0300 Subject: [PATCH 1/2] Add the ability to instrument a graphql request A Chain of Responsibility pattern is employed to allow callers adding custom instrumentation targeting graphql request and response objects. A couple good use cases for this are request logging, caching, and certain kinds of auth (e.g. bearer token renewal). In order to proceed of this kind of support a small refactor was required. So, there are somee changes that impact directly the previous structure of this module. --- chain.go | 304 ++++++++++++++++++++++++++++++++++++++ error.go | 2 +- error_test.go | 17 +-- graphql.go | 282 ++++------------------------------- graphql_json_test.go | 20 +-- graphql_multipart_test.go | 20 +-- request.go | 164 +++++++++----------- 7 files changed, 436 insertions(+), 373 deletions(-) create mode 100644 chain.go diff --git a/chain.go b/chain.go new file mode 100644 index 0000000..bf5708e --- /dev/null +++ b/chain.go @@ -0,0 +1,304 @@ +package graphql + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + + "github.com/mitchellh/mapstructure" + "github.com/pkg/errors" +) + +type ( + + // CustomHTTPClient allows a custom http.Client to be used other than the default one provided by golang. + CustomHTTPClient interface { + Do(*http.Request) (*http.Response, error) + } + + // RequestChain is an interface provided by the graphql client + // to give a view into the invocation chain. It's used to give + // the ability of others to intercept the request and handle + // the response for some very specific instrumentation. + // + // A RequestChain must be safe for concurrent use by multiple + // goroutines. + RequestChain interface { + // Action executes a single HTTP transaction, returning + // a Response for the provided Request. + // + // Action should not attempt to interpret the response. In + // particular, Action must return err == nil if it obtained + // a response, regardless of the response's HTTP status code. + // A non-nil err should be reserved for failure to obtain a + // response. Similarly, Action should not attempt to + // handle higher-level protocol details such as redirects, + // authentication, or cookies. + // + // Action should not modify the request, except for + // consuming and closing the Request's Body. Action may + // read fields of the request in a separate goroutine. Callers + // should not mutate or reuse the request until the Response's + // Body has been closed. + // + // Action must always close the body, including on errors, + // but depending on the implementation may do so in a separate + // goroutine even after Action returns. This means that + // callers wanting to reuse the body for subsequent requests + // must arrange to wait for the Close call before doing so. + // + // The Request's URL and Header fields must be initialized. + Action(o Operation) (*GraphResponse, Error) + } + + chain struct { + // 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) + // closeReq will close the defaultRequest body immediately allowing for reuse of client + closeReq bool + endpoint string + httpClient CustomHTTPClient `default:"http.DefaultClient"` + useMultipartForm bool + } + + chainState struct { + error Error + origin *chain + request *http.Request + response *http.Response + operation Operation + } + + queryPayload struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables"` + } + + ChainOption func(*chain) +) + +func NewChain(endpoint string, opts ...ChainOption) RequestChain { + c := &chain{ + endpoint: endpoint, + httpClient: http.DefaultClient, + log: func(string) {}, + } + for _, optionFunc := range opts { + optionFunc(c) + } + return c +} + +// WithHTTPClient specifies the underlying http.Client to use when +// making requests. +// NewClient(endpoint, WithHTTPClient(specificHTTPClient)) +func WithHTTPClient(httpclient CustomHTTPClient) ChainOption { + return func(c *chain) { + c.httpClient = httpclient + } +} + +// UseMultipartForm uses multipart/form-data and activates support for +// files. +func UseMultipartForm() ChainOption { + return func(c *chain) { + c.useMultipartForm = true + } +} + +// ImmediatelyCloseReqBody will close the req body immediately after each request body is ready +func ImmediatelyCloseReqBody() ChainOption { + return func(c *chain) { + c.closeReq = true + } +} + +func (d *chain) Action(o Operation) (*GraphResponse, Error) { + return d.start(o). + validate(). + createRequest(). + call(). + parseResponse() +} + +func (d *chain) start(o Operation) *chainState { + return &chainState{ + origin: d, + operation: o, + } +} + +func (c *chainState) validate() *chainState { + if len(c.operation.Request().Files()) > 0 && !c.origin.useMultipartForm { + c.error = NewExecutionError(errors.New("cannot send files with PostFields option")) + } + return c +} + +func (c *chainState) createRequest() *chainState { + if c.error == nil { + request := c.operation.Request() + requestBody, mimeType, err := c.createRequestBody() + if err != nil { + c.error = err + } + r, e := http.NewRequest(http.MethodPost, c.origin.endpoint, requestBody) + if e != nil { + c.error = NewExecutionError(e) + } + r.Close = c.origin.closeReq + r.Header.Set("Content-Type", mimeType) + r.Header.Set("Accept", "application/json; charset=utf-8") + for key, values := range request.Headers() { + for _, value := range values { + r.Header.Add(key, value) + } + } + c.logf(">> headers: %v", r.Header) + c.request = r + } + return c +} + +func (c *chainState) call() *chainState { + if c.error == nil { + response, err := c.origin.httpClient.Do(c.request) + if err == nil { + if response.StatusCode != http.StatusOK { + c.error = NewRequestError(response) + } + c.response = response + } else { + c.error = NewExecutionError(err) + } + } + return c +} + +func (c *chainState) parseResponse() (*GraphResponse, Error) { + if c.error == nil { + defer c.response.Body.Close() + var buf bytes.Buffer + if _, err := io.Copy(&buf, c.response.Body); err != nil { + return nil, NewExecutionError(errors.Wrap(err, "reading body")) + } + c.logf("<< %s", buf.String()) + resp := c.operation.ResponseBodyAs() + var gr *GraphResponse + if c.operation.IsMutation() { + var results struct { + Data map[string]graphMutationPayload + } + + if err := json.NewDecoder(&buf).Decode(&results); err != nil { + return nil, NewExecutionError(errors.Wrap(err, "decoding response")) + } + gr = &GraphResponse{} + + for _, result := range results.Data { + if !result.Successful { + messages := result.Messages + errs := make([]GraphErr, len(messages)) + + for i, message := range messages { + errs[i] = GraphErr{ + Message: emptyOrString(message.Message), + Code: message.Code, + } + } + + gr.Errors = append(gr.Errors, errs...) + } else { + err := mapstructure.Decode(results.Data, &resp) + if err != nil { + return nil, NewExecutionError(errors.Wrap(err, "decoding response")) + } + } + // The code above only supports payloads with a single mutation + break + } + } else { + gr = &GraphResponse{Data: resp} + if err := json.NewDecoder(&buf).Decode(&gr); err != nil { + return nil, NewExecutionError(errors.Wrap(err, "decoding response")) + } + } + if len(gr.Errors) > 0 { + return nil, NewGraphQLError(gr.Errors, c.response) + } + return gr, nil + } + return nil, c.error +} + +func (c *chainState) createRequestBody() (*bytes.Buffer, string, Error) { + if c.origin.useMultipartForm { + return c.createMultipartBody() + } + return c.createJSONBody() +} + +func (c *chainState) createMultipartBody() (*bytes.Buffer, string, Error) { + var requestBody bytes.Buffer + request := c.operation.Request() + writer := multipart.NewWriter(&requestBody) + if err := writer.WriteField("query", request.Query()); err != nil { + return nil, "", NewExecutionError(errors.Wrap(err, "write query field")) + } + var variablesBuf bytes.Buffer + if len(request.Vars()) > 0 { + variablesField, err := writer.CreateFormField("variables") + if err != nil { + return nil, "", NewExecutionError(errors.Wrap(err, "create variables field")) + } + if err := json.NewEncoder(io.MultiWriter(variablesField, &variablesBuf)).Encode(request.Vars()); err != nil { + return nil, "", NewExecutionError(errors.Wrap(err, "encode variables")) + } + } + files := request.Files() + for i := range files { + part, err := writer.CreateFormFile(files[i].Field, files[i].Name) + if err != nil { + return nil, "", NewExecutionError(errors.Wrap(err, "create form file")) + } + if _, err := io.Copy(part, files[i].R); err != nil { + return nil, "", NewExecutionError(errors.Wrap(err, "preparing file")) + } + } + if err := writer.Close(); err != nil { + return nil, "", NewExecutionError(errors.Wrap(err, "close writer")) + } + c.logf(">> variables: %s", variablesBuf.String()) + c.logf(">> files: %c", len(files)) + c.logf(">> query: %s", request.Query()) + return &requestBody, writer.FormDataContentType(), nil +} + +func (c *chainState) createJSONBody() (*bytes.Buffer, string, Error) { + var requestBody bytes.Buffer + request := c.operation.Request() + requestBodyObj := &queryPayload{ + Query: request.Query(), + Variables: request.Vars(), + } + if err := json.NewEncoder(&requestBody).Encode(requestBodyObj); err != nil { + return nil, "", NewExecutionError(errors.Wrap(err, "encode body")) + } + return &requestBody, "application/json; charset=utf-8", nil +} + +func (c *chainState) logf(format string, args ...interface{}) { + c.origin.log(fmt.Sprintf(format, args...)) +} + +func emptyOrString(pointer *string) string { + if pointer == nil { + return "" + } + return *pointer +} diff --git a/error.go b/error.go index aed510a..d3ff0f2 100644 --- a/error.go +++ b/error.go @@ -94,7 +94,7 @@ func (r *RequestError) Response() *http.Response { } func (r *RequestError) Error() string { - return fmt.Sprintf("request failed with status: %s", r.response.Status) + return fmt.Sprintf("defaultRequest failed with status: %s", r.response.Status) } func (r *RequestError) Errors() []string { diff --git a/error_test.go b/error_test.go index ce26d86..54435cd 100644 --- a/error_test.go +++ b/error_test.go @@ -16,7 +16,6 @@ func TestNewRequestError(t *testing.T) { err := NewRequestError(response) - is.True(err != nil) is.Equal(*err, expected) } @@ -41,7 +40,7 @@ func TestRequestErrorError(t *testing.T) { err := NewRequestError(response) is.True(err != nil) - is.Equal(err.Error(), "request failed with status: Not Found") + is.Equal(err.Error(), "defaultRequest failed with status: Not Found") } func TestRequestErrorErrors(t *testing.T) { @@ -53,7 +52,7 @@ func TestRequestErrorErrors(t *testing.T) { err := NewRequestError(response) is.True(err != nil) - is.Equal(err.Errors(), []string{"request failed with status: Not Found"}) + is.Equal(err.Errors(), []string{"defaultRequest failed with status: Not Found"}) } func TestRequestErrorDetails(t *testing.T) { @@ -69,7 +68,7 @@ func TestRequestErrorDetails(t *testing.T) { is.Equal(len(err.Details()), 1) is.Equal(err.Details()[0], ErrorDetail{ Code: http.StatusText(http.StatusNotFound), - Message: "request failed with status: Not Found", + Message: "defaultRequest failed with status: Not Found", }) } @@ -80,7 +79,6 @@ func TestNewExecutionError(t *testing.T) { err := NewExecutionError(message) - is.True(err != nil) is.Equal(*err, expected) } @@ -142,7 +140,6 @@ func TestNewGraphQLError(t *testing.T) { err := NewGraphQLError(graphqlErrors, response) - is.True(err != nil) is.Equal(*err, expected) } @@ -213,7 +210,7 @@ func TestGraphQLErrorCode(t *testing.T) { }, { Code: "ERROR_CODE", - Message: "miscellaneous message as to why the the request was bad", + Message: "miscellaneous message as to why the the defaultRequest was bad", Path: []string{"field", "path"}, }, } @@ -240,7 +237,7 @@ func TestGraphQLMutationError(t *testing.T) { }, { Code: "ERROR_CODE", - Message: "miscellaneous message as to why the the request failed", + Message: "miscellaneous message as to why the the defaultRequest failed", Path: []string{"field", "path"}, }, } @@ -267,7 +264,7 @@ func TestGraphQLErrorDetails(t *testing.T) { }, { Code: "ERROR_CODE", - Message: "miscellaneous message as to why the the request failed", + Message: "miscellaneous message as to why the the defaultRequest failed", Path: []string{"field", "path"}, }, } @@ -284,7 +281,7 @@ func TestGraphQLErrorDetails(t *testing.T) { }) is.Equal(err.Details()[1], ErrorDetail{ Code: "error_code", - Message: "miscellaneous message as to why the the request failed", + Message: "miscellaneous message as to why the the defaultRequest failed", Domain: "field.path", }) } diff --git a/graphql.go b/graphql.go index ff1aad8..0b46727 100644 --- a/graphql.go +++ b/graphql.go @@ -3,8 +3,8 @@ // // create a client (safe to share across requests) // client := graphql.NewClient("https://machinebox.io/graphql") // -// // make a request -// req := graphql.NewRequest(` +// // make a defaultRequest +// req := graphql.NewQueryOperation(` // query ($key: String!) { // items (id:$key) { // field1 @@ -31,44 +31,16 @@ package graphql import ( - "bytes" "context" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" - - "github.com/mitchellh/mapstructure" - "github.com/pkg/errors" ) type ( // Client is a client for interacting with a GraphQL API. Client struct { - endpoint string - httpClient CustomHttpClient - useMultipartForm bool - - // closeReq will close the request body immediately allowing for reuse of client - closeReq 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) - } - - // CustomHttpClient allows a custom http.Client to be used other than the default one provided by golang. - CustomHttpClient interface { - Do(*http.Request) (*http.Response, error) + Chain RequestChain } - // ClientOption are functions that are passed into NewClient to - // modify the behaviour of the Client. - ClientOption func(*Client) - - graphResponse struct { + GraphResponse struct { Data interface{} `json:"data"` Errors []GraphErr `json:"errors"` } @@ -84,234 +56,40 @@ type ( } ) -// NewClient makes a new Client capable of making GraphQL requests. -// In case no option for http.Client is provided the default one is used in place. -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 +// Deprecated: in favor of NewGraphClient +func NewClient(endpoint string, opts ...ChainOption) *Client { + return NewGraphClient(NewChain(endpoint, opts...)) } -// WithHTTPClient specifies the underlying http.Client to use when -// making requests. -// NewClient(endpoint, WithHTTPClient(specificHTTPClient)) -func WithHTTPClient(httpclient CustomHttpClient) 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 - } -} - -//ImmediatelyCloseReqBody will close the req body immediately after each request body is ready -func ImmediatelyCloseReqBody() ClientOption { - return func(client *Client) { - client.closeReq = true +// NewGraphClient makes a new Client capable of making GraphQL requests. +// In case no option for http.Client is provided the default one is used in place. +func NewGraphClient(chain RequestChain) *Client { + return &Client{ + Chain: chain, } } -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 +// Deprecated: 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 +// If the defaultRequest fails or the server returns an error, the first error // will be returned. -func (c *Client) Run(ctx context.Context, op Operation, resp interface{}) Error { - select { - case <-ctx.Done(): - return NewExecutionError(ctx.Err()) - default: - } - if len(op.Files()) > 0 && !c.useMultipartForm { - return NewExecutionError(errors.New("cannot send files with PostFields option")) - } - if c.useMultipartForm { - return c.runWithPostFields(ctx, op, resp) - } - return c.runWithJSON(ctx, op, resp) -} - -func (c *Client) runWithJSON(ctx context.Context, op Operation, resp interface{}) Error { - req := op.Request() - - 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 NewExecutionError(errors.Wrap(err, "encode body")) - } - r, err := http.NewRequest(http.MethodPost, c.endpoint, &requestBody) - if err != nil { - return NewExecutionError(err) - } - r.Close = c.closeReq - 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 NewExecutionError(err) - } - if res.StatusCode != http.StatusOK { - return NewRequestError(res) - } - defer res.Body.Close() - var buf bytes.Buffer - if _, err := io.Copy(&buf, res.Body); err != nil { - return NewExecutionError(errors.Wrap(err, "reading body")) - } - c.logf("<< %s", buf.String()) - - var gr *graphResponse - switch op.(type) { - case *Mutation: - var results struct { - Data map[string]graphMutationPayload - } - - if err := json.NewDecoder(&buf).Decode(&results); err != nil { - return NewExecutionError(errors.Wrap(err, "decoding response")) - } - gr = &graphResponse{} - - for _, result := range results.Data { - if !result.Successful { - messages := result.Messages - errors := make([]GraphErr, len(messages)) - - for i, message := range messages { - errors[i] = GraphErr{ - Message: emptyOrString(message.Message), - Code: message.Code, - } - } - - gr.Errors = append(gr.Errors, errors...) - } else { - err := mapstructure.Decode(results.Data, &resp) - if err != nil { - return NewExecutionError(errors.Wrap(err, "decoding response")) - } - } - // The code above only supports payloads with a single mutation - break - } - - default: - gr = &graphResponse{Data: resp} - if err := json.NewDecoder(&buf).Decode(&gr); err != nil { - return NewExecutionError(errors.Wrap(err, "decoding response")) - } - } - - if len(gr.Errors) > 0 { - return NewGraphQLError(gr.Errors, res) - } - return nil -} - -func (c *Client) runWithPostFields(ctx context.Context, op Operation, resp interface{}) Error { - req := op.Request() - var requestBody bytes.Buffer - writer := multipart.NewWriter(&requestBody) - if err := writer.WriteField("query", req.q); err != nil { - return NewExecutionError(errors.Wrap(err, "write query field")) - } - var variablesBuf bytes.Buffer - if len(req.vars) > 0 { - variablesField, err := writer.CreateFormField("variables") - if err != nil { - return NewExecutionError(errors.Wrap(err, "create variables field")) - } - if err := json.NewEncoder(io.MultiWriter(variablesField, &variablesBuf)).Encode(req.vars); err != nil { - return NewExecutionError(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 NewExecutionError(errors.Wrap(err, "create form file")) - } - if _, err := io.Copy(part, req.files[i].R); err != nil { - return NewExecutionError(errors.Wrap(err, "preparing file")) - } - } - if err := writer.Close(); err != nil { - return NewExecutionError(errors.Wrap(err, "close writer")) - } - c.logf(">> variables: %s", variablesBuf.String()) - c.logf(">> files: %d", len(req.files)) - c.logf(">> query: %s", req.q) - gr := &graphResponse{ - Data: resp, - } - r, err := http.NewRequest(http.MethodPost, c.endpoint, &requestBody) - if err != nil { - return NewExecutionError(err) - } - r.Close = c.closeReq - 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 NewExecutionError(err) - } - if res.StatusCode != http.StatusOK { - return NewRequestError(res) - } - defer res.Body.Close() - var buf bytes.Buffer - if _, err := io.Copy(&buf, res.Body); err != nil { - return NewExecutionError(errors.Wrap(err, "reading body")) - } - c.logf("<< %s", buf.String()) - if err := json.NewDecoder(&buf).Decode(&gr); err != nil { - return NewExecutionError(errors.Wrap(err, "decoding response")) - } - if len(gr.Errors) > 0 { - return NewGraphQLError(gr.Errors, res) - } - return nil +// +// See: Do +func (c *Client) Run(_ context.Context, op Operation, resp interface{}) Error { + var i interface{} = op + q, ok := i.(*queryOperation) + if ok { + q.ResponseType = resp + } + _, e := c.Do(op) + return e } -func emptyOrString(pointer *string) string { - if pointer == nil { - return "" - } - return *pointer +// Do executes the query and unmarshals the response from the data field +// into the expected response object. +// If the defaultRequest fails or the server returns an error, the first error +// will be returned. +func (c *Client) Do(op Operation) (*GraphResponse, Error) { + return c.Chain.Action(op) } diff --git a/graphql_json_test.go b/graphql_json_test.go index 689516b..b79678f 100644 --- a/graphql_json_test.go +++ b/graphql_json_test.go @@ -6,6 +6,7 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "reflect" "testing" "time" @@ -63,8 +64,8 @@ func TestDoJSONServerError(t *testing.T) { var responseData map[string]interface{} err := client.Run(ctx, NewRequest("query {}"), &responseData) is.Equal(calls, 1) // calls - is.Equal(err.Error(), "request failed with status: 500 Internal Server Error") - is.Equal(err.Errors(), []string{"request failed with status: 500 Internal Server Error"}) + is.Equal(err.Error(), "defaultRequest failed with status: 500 Internal Server Error") + is.Equal(err.Errors(), []string{"defaultRequest failed with status: 500 Internal Server Error"}) is.Equal(err.Response().StatusCode, http.StatusInternalServerError) } @@ -82,7 +83,7 @@ func TestDoJSONBadRequestErr(t *testing.T) { "errors": [ { "path": ["field", "path"], - "message": "miscellaneous message as to why the the request was bad" + "message": "miscellaneous message as to why the the defaultRequest was bad" } ] }`) @@ -98,15 +99,15 @@ func TestDoJSONBadRequestErr(t *testing.T) { query := NewRequest("query {}") err := client.Run(ctx, query, &responseData) is.Equal(calls, 1) // calls - is.Equal(err.Error(), "miscellaneous message as to why the the request was bad") + is.Equal(err.Error(), "miscellaneous message as to why the the defaultRequest was bad") is.Equal(err.Errors(), []string{ - "miscellaneous message as to why the the request was bad", + "miscellaneous message as to why the the defaultRequest was bad", }) is.Equal(err.Response().StatusCode, http.StatusOK) is.Equal(err.Details(), []ErrorDetail{ { Code: "", - Message: "miscellaneous message as to why the the request was bad", + Message: "miscellaneous message as to why the the defaultRequest was bad", Domain: "field.path", }, }) @@ -131,11 +132,11 @@ func TestQueryJSON(t *testing.T) { client := NewClient(srv.URL) req := NewRequest("query {}") - req.Var("username", "matryer") + req.Request().Var("username", "matryer") // check variables is.True(req != nil) - is.Equal(req.Vars()["username"], "matryer") // nolint: staticcheck + is.Equal(req.Request().Vars()["username"], "matryer") // nolint: staticcheck var resp struct { Value string @@ -148,6 +149,7 @@ func TestQueryJSON(t *testing.T) { } func TestDoJSONMutation(t *testing.T) { + reflect.TypeOf(GraphResponse{}) is := is.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -330,7 +332,7 @@ func TestHeader(t *testing.T) { client := NewClient(srv.URL) req := NewRequest("query {}") - req.Header("X-Custom-Header", "123") + req.Request().Header("X-Custom-Header", "123") var resp struct { Value string diff --git a/graphql_multipart_test.go b/graphql_multipart_test.go index 607c585..6bdc002 100644 --- a/graphql_multipart_test.go +++ b/graphql_multipart_test.go @@ -101,7 +101,7 @@ func TestDoErr(t *testing.T) { _, _ = io.WriteString(w, `{ "errors": [ { - "message": "miscellaneous message as to why the the request was bad" + "message": "miscellaneous message as to why the the defaultRequest was bad" } ] }`) @@ -117,7 +117,7 @@ func TestDoErr(t *testing.T) { err := client.Run(ctx, NewRequest("query {}"), &responseData) is.True(err != nil) is.Equal(err.Errors(), []string{ - "miscellaneous message as to why the the request was bad", + "miscellaneous message as to why the the defaultRequest was bad", }) } @@ -141,8 +141,8 @@ func TestDoServerErr(t *testing.T) { defer cancel() var responseData map[string]interface{} err := client.Run(ctx, NewRequest("query {}"), &responseData) - is.Equal(err.Error(), "request failed with status: 500 Internal Server Error") - is.Equal(err.Errors(), []string{"request failed with status: 500 Internal Server Error"}) + is.Equal(err.Error(), "defaultRequest failed with status: 500 Internal Server Error") + is.Equal(err.Errors(), []string{"defaultRequest failed with status: 500 Internal Server Error"}) is.Equal(err.Response().StatusCode, http.StatusInternalServerError) } @@ -158,7 +158,7 @@ func TestDoBadRequestErr(t *testing.T) { _, _ = io.WriteString(w, `{ "errors": [ { - "message": "miscellaneous message as to why the the request was bad" + "message": "miscellaneous message as to why the the defaultRequest was bad" } ] }`) @@ -172,9 +172,9 @@ func TestDoBadRequestErr(t *testing.T) { defer cancel() var responseData map[string]interface{} err := client.Run(ctx, NewRequest("query {}"), &responseData) - is.Equal(err.Error(), "miscellaneous message as to why the the request was bad") + is.Equal(err.Error(), "miscellaneous message as to why the the defaultRequest was bad") is.Equal(err.Errors(), []string{ - "miscellaneous message as to why the the request was bad", + "miscellaneous message as to why the the defaultRequest was bad", }) is.Equal(err.Response().StatusCode, http.StatusOK) } @@ -225,11 +225,11 @@ func TestQuery(t *testing.T) { client := NewClient(srv.URL, UseMultipartForm()) req := NewRequest("query {}") - req.Var("username", "matryer") + req.Request().Var("username", "matryer") // check variables is.True(req != nil) - is.Equal(req.Vars()["username"], "matryer") + is.Equal(req.Request().Vars()["username"], "matryer") var resp struct { Value string @@ -266,7 +266,7 @@ func TestFile(t *testing.T) { client := NewClient(srv.URL, UseMultipartForm()) f := strings.NewReader(`This is a file`) req := NewRequest("query {}") - req.File("file", "filename.txt", f) + req.Request().File("file", "filename.txt", f) err := client.Run(ctx, req, nil) is.NoErr(err) } diff --git a/request.go b/request.go index 1ef9d63..801506a 100644 --- a/request.go +++ b/request.go @@ -5,10 +5,16 @@ import ( "net/http" ) -// Request is a GraphQL request. +// queryOperation is a GraphQL defaultRequest. type ( + // Operation is a graphQL operation Operation interface { - Request() *Req + Request() GraphRequest + ResponseBodyAs() interface{} + IsMutation() bool + } + GraphRequest interface { + Query() string File(string, string, io.Reader) Files() []File Var(string, interface{}) @@ -16,21 +22,21 @@ type ( Header(string, string) Headers() http.Header } - Request struct { - Req *Req - } - Mutation struct { - Req *Req + // queryOperation is the regular graphQL query operation + queryOperation struct { + Req GraphRequest + ResponseType interface{} + // isMutation indicates if query is used to modify data in the data store + isMutation bool } - Req struct { + defaultRequest 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 + // header represent any defaultRequest headers that will be set + // when the defaultRequest is made. + header http.Header } - // File represents a file to upload. File struct { Field string @@ -38,114 +44,90 @@ type ( R io.Reader } ) - -func NewRequest(q string) *Request { - return &Request{ - Req: newReq(q), - } +// Deprecated: in favor of NewGraphOperation +func NewRequest(q string) Operation { + return NewQueryOperation(q, nil) } -func NewMutation(m string) *Mutation { - return &Mutation{ - Req: newReq(m), +// NewQueryOperation creates a new graphql query operation +// Pass in a nil response object to skip response parsing. +func NewQueryOperation(query string, responseType interface{}) Operation { + return &queryOperation{ + Req: newReq(query), + ResponseType: responseType, } } -func (q *Request) Request() *Req { - return q.Req -} - -func (r *Request) Var(key string, value interface{}) { - r.Request().Var(key, value) -} - -func (r *Request) Vars() map[string]interface{} { - return r.Request().Vars() -} - -func (r *Request) Header(key, value string) { - r.Request().Header.Set(key, value) +// Deprecated: in favor of NewMutationOperation +func NewMutation(q string) Operation { + return NewMutationOperation(q, nil) } -func (r *Request) Headers() http.Header { - return r.Request().Header +// NewMutationOperation creates a new graphql mutation operation +// Pass in a nil response object to skip response parsing. +func NewMutationOperation(query string, responseType interface{}) Operation { + return &queryOperation{ + Req: newReq(query), + ResponseType: responseType, + isMutation: true, + } } -func (r *Request) File(fieldname, filename string, reader io.Reader) { - r.Req.File(fieldname, filename, reader) +func (r *queryOperation) Request() GraphRequest { + return r.Req } -func (r *Request) Files() []File { - return r.Req.files +func (r *queryOperation) ResponseBodyAs() interface{} { + return r.ResponseType } -func (m *Mutation) Request() *Req { - return m.Req +func (r *queryOperation) IsMutation() bool { + return r.isMutation } -func (m *Mutation) Var(key string, value interface{}) { - m.Request().Var(key, value) +func (d *defaultRequest) Var(key string, value interface{}) { + if d.vars == nil { + d.vars = make(map[string]interface{}) + } + d.vars[key] = value } -func (m *Mutation) Vars() map[string]interface{} { - return m.Request().Vars() +func (d *defaultRequest) Vars() map[string]interface{} { + return d.vars } -func (m *Mutation) Header(key, value string) { - m.Request().Header.Set(key, value) +func (d *defaultRequest) Header(key, value string) { + d.header.Set(key, value) } -func (m *Mutation) Headers() http.Header { - return m.Request().Header +func (d *defaultRequest) Headers() http.Header { + return d.header } -func (m *Mutation) File(fieldname, filename string, reader io.Reader) { - m.Req.File(fieldname, filename, reader) +// File sets a file to upload. +// Files are only supported with a Client that was created with +// the UseMultipartForm option. +func (d *defaultRequest) File(fieldName, fileName string, reader io.Reader) { + d.files = append(d.files, File{ + Field: fieldName, + Name: fileName, + R: reader, + }) } -func (m *Mutation) Files() []File { - return m.Req.files +func (d *defaultRequest) Files() []File { + return d.files } -// NewRequest makes a new Request with the specified string. -func newReq(q string) *Req { - req := &Req{ +// newReq makes a new queryOperation with the specified string. +func newReq(q string) GraphRequest { + return &defaultRequest{ q: q, - Header: make(map[string][]string), - } - return req -} - -// Var sets a variable. -func (req *Req) Var(key string, value interface{}) { - if req.vars == nil { - req.vars = make(map[string]interface{}) + header: make(map[string][]string), } - req.vars[key] = value -} - -// Vars gets the variables for this Request. -func (req *Req) Vars() map[string]interface{} { - return req.vars -} - -// Files gets the files in this request. -func (req *Req) Files() []File { - return req.files -} - -// Query gets the query string of this request. -func (req *Req) Query() string { - return req.q } -// File sets a file to upload. -// Files are only supported with a Client that was created with -// the UseMultipartForm option. -func (req *Req) File(fieldname, filename string, reader io.Reader) { - req.files = append(req.files, File{ - Field: fieldname, - Name: filename, - R: reader, - }) +// Query gets the query string of this defaultRequest. +func (d *defaultRequest) Query() string { + return d.q } From d93b3817a0535bb66e0e910d7eca51ad87006173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89derson=20Ferreira?= Date: Thu, 27 Jan 2022 16:28:57 -0300 Subject: [PATCH 2/2] Refactor some structs and naming --- chain.go | 22 +++---- error.go | 131 +++++++++++++++++++++++++------------- error_test.go | 125 ++++++++++++++++++------------------ graphql.go | 2 +- graphql_json_test.go | 31 +++++---- graphql_multipart_test.go | 22 +++---- request.go | 53 +++++++++++---- 7 files changed, 227 insertions(+), 159 deletions(-) diff --git a/chain.go b/chain.go index bf5708e..20dce1c 100644 --- a/chain.go +++ b/chain.go @@ -170,11 +170,11 @@ func (c *chainState) call() *chainState { response, err := c.origin.httpClient.Do(c.request) if err == nil { if response.StatusCode != http.StatusOK { - c.error = NewRequestError(response) + c.error = NewHTTPRequestError(response) } c.response = response } else { - c.error = NewExecutionError(err) + c.error = NewExecutionResponseError(err, response) } } return c @@ -185,7 +185,7 @@ func (c *chainState) parseResponse() (*GraphResponse, Error) { defer c.response.Body.Close() var buf bytes.Buffer if _, err := io.Copy(&buf, c.response.Body); err != nil { - return nil, NewExecutionError(errors.Wrap(err, "reading body")) + return nil, NewExecutionResponseError(errors.Wrap(err, "reading body"), c.response) } c.logf("<< %s", buf.String()) resp := c.operation.ResponseBodyAs() @@ -196,17 +196,17 @@ func (c *chainState) parseResponse() (*GraphResponse, Error) { } if err := json.NewDecoder(&buf).Decode(&results); err != nil { - return nil, NewExecutionError(errors.Wrap(err, "decoding response")) + return nil, NewExecutionResponseError(errors.Wrap(err, "decoding response"), c.response) } gr = &GraphResponse{} for _, result := range results.Data { if !result.Successful { messages := result.Messages - errs := make([]GraphErr, len(messages)) + errs := make([]GraphError, len(messages)) for i, message := range messages { - errs[i] = GraphErr{ + errs[i] = GraphError{ Message: emptyOrString(message.Message), Code: message.Code, } @@ -216,7 +216,7 @@ func (c *chainState) parseResponse() (*GraphResponse, Error) { } else { err := mapstructure.Decode(results.Data, &resp) if err != nil { - return nil, NewExecutionError(errors.Wrap(err, "decoding response")) + return nil, NewExecutionResponseError(errors.Wrap(err, "decoding response"), c.response) } } // The code above only supports payloads with a single mutation @@ -225,11 +225,11 @@ func (c *chainState) parseResponse() (*GraphResponse, Error) { } else { gr = &GraphResponse{Data: resp} if err := json.NewDecoder(&buf).Decode(&gr); err != nil { - return nil, NewExecutionError(errors.Wrap(err, "decoding response")) + return nil, NewExecutionResponseError(errors.Wrap(err, "decoding response"), c.response) } } if len(gr.Errors) > 0 { - return nil, NewGraphQLError(gr.Errors, c.response) + return nil, NewGraphRequestError(gr.Errors, c.response) } return gr, nil } @@ -262,11 +262,11 @@ func (c *chainState) createMultipartBody() (*bytes.Buffer, string, Error) { } files := request.Files() for i := range files { - part, err := writer.CreateFormFile(files[i].Field, files[i].Name) + part, err := writer.CreateFormFile(files[i].Field(), files[i].Name()) if err != nil { return nil, "", NewExecutionError(errors.Wrap(err, "create form file")) } - if _, err := io.Copy(part, files[i].R); err != nil { + if _, err := io.Copy(part, files[i].Reader()); err != nil { return nil, "", NewExecutionError(errors.Wrap(err, "preparing file")) } } diff --git a/error.go b/error.go index d3ff0f2..d0dd201 100644 --- a/error.go +++ b/error.go @@ -3,6 +3,7 @@ package graphql import ( "fmt" "net/http" + "strconv" "strings" ) @@ -15,22 +16,28 @@ type ( Details() []ErrorDetail } - RequestError struct { + // HttpRequestError occurs as an error response from a HTTP call. + HttpRequestError struct { response *http.Response } + // ExecutionError means one of 2 things: 1. request failed for some reason and wasn't + // fulfilled; 2. request was fulfilled and a response was given but post response + // processing failed. ExecutionError struct { message error + response *http.Response } - GraphQLError struct { - errors []GraphErr + // GraphRequestError happens when errors were found at the graphql layer. + GraphRequestError struct { + errors []GraphError response *http.Response } - GraphErr struct { + GraphError struct { Code string - Extentions GraphExt + Extensions GraphExt Message string Path []string } @@ -39,31 +46,37 @@ type ( Code string } - ErrorDetail struct { - Code string - Message string - Domain string + ErrorDetail interface { + Code() string + Message() string + Domain() string + } + + errorDetail struct { + code string + message string + domain string } ) var ( // Type assertions - _ Error = &RequestError{} + _ Error = &HttpRequestError{} _ Error = &ExecutionError{} - _ Error = &GraphQLError{} + _ Error = &GraphRequestError{} ) -func (e GraphErr) Error() string { +func (e GraphError) Error() string { return e.Message } -func (e GraphErr) ErrCode() string { +func (e GraphError) ErrCode() string { code := e.Code if len(code) > 0 { return strings.ToLower(code) } - code = e.Extentions.Code + code = e.Extensions.Code if len(code) > 0 { return strings.ToLower(code) } @@ -71,54 +84,78 @@ func (e GraphErr) ErrCode() string { return "" } -func (e GraphErr) ErrPath() string { +func (e GraphError) ErrPath() string { return strings.Join(e.Path, ".") } -func (e GraphErr) ToErrorDetail() ErrorDetail { - return ErrorDetail{ - Code: e.ErrCode(), - Message: e.Message, - Domain: e.ErrPath(), + +func (e *errorDetail) Code() string { + return e.code +} + +func (e *errorDetail) Message() string { + return e.message +} + +func (e *errorDetail) Domain() string { + return e.domain +} + +func (e GraphError) ToErrorDetail() ErrorDetail { + return &errorDetail{ + code: e.ErrCode(), + message: e.Message, + domain: e.ErrPath(), } } -func NewRequestError(response *http.Response) *RequestError { - return &RequestError{ +func NewHTTPRequestError(response *http.Response) *HttpRequestError { + return &HttpRequestError{ response: response, } } -func (r *RequestError) Response() *http.Response { +func (r *HttpRequestError) Response() *http.Response { return r.response } -func (r *RequestError) Error() string { +func (r *HttpRequestError) Error() string { return fmt.Sprintf("defaultRequest failed with status: %s", r.response.Status) } -func (r *RequestError) Errors() []string { +func (r *HttpRequestError) Errors() []string { return []string{r.Error()} } -func (r *RequestError) Code() string { - return http.StatusText(r.response.StatusCode) +func (r *HttpRequestError) Code() string { + if r.response != nil { + return strconv.Itoa(r.response.StatusCode) + } + return "" } -func (r *RequestError) Details() []ErrorDetail { - return []ErrorDetail{ - {Code: r.Code(), Message: r.Error()}, - } +func (r *HttpRequestError) Details() []ErrorDetail { + var e []ErrorDetail + e = append(e, &errorDetail{ + code: r.Code(), + message: r.Error(), + }) + return e } func NewExecutionError(message error) *ExecutionError { + return NewExecutionResponseError(message, nil) +} + +func NewExecutionResponseError(message error, response *http.Response) *ExecutionError { return &ExecutionError{ message: message, + response: response, } } func (e *ExecutionError) Response() *http.Response { - return nil + return e.response } func (e *ExecutionError) Error() string { @@ -130,27 +167,33 @@ func (e *ExecutionError) Errors() []string { } func (e *ExecutionError) Code() string { + if e.response != nil { + return strconv.Itoa(e.response.StatusCode) + } return "" } func (e *ExecutionError) Details() []ErrorDetail { - return []ErrorDetail{ - {Code: e.Code(), Message: e.Error()}, - } + var ed []ErrorDetail + ed = append(ed, &errorDetail{ + code: e.Code(), + message: e.Error(), + }) + return ed } -func NewGraphQLError(errors []GraphErr, response *http.Response) *GraphQLError { - return &GraphQLError{ +func NewGraphRequestError(errors []GraphError, response *http.Response) *GraphRequestError { + return &GraphRequestError{ errors: errors, response: response, } } -func (g *GraphQLError) Response() *http.Response { +func (g *GraphRequestError) Response() *http.Response { return g.response } -func (g *GraphQLError) Code() string { +func (g *GraphRequestError) Code() string { errors := g.errors if len(errors) > 0 { @@ -160,7 +203,7 @@ func (g *GraphQLError) Code() string { return "" } -func (g *GraphQLError) Error() string { +func (g *GraphRequestError) Error() string { errors := g.errors if len(errors) > 0 { @@ -170,8 +213,8 @@ func (g *GraphQLError) Error() string { return "" } -func (g *GraphQLError) Errors() []string { - errors := []string{} +func (g *GraphRequestError) Errors() []string { + var errors []string for _, err := range g.errors { errors = append(errors, err.Error()) } @@ -179,8 +222,8 @@ func (g *GraphQLError) Errors() []string { return errors } -func (g *GraphQLError) Details() []ErrorDetail { - errors := []ErrorDetail{} +func (g *GraphRequestError) Details() []ErrorDetail { + var errors []ErrorDetail for _, err := range g.errors { errors = append(errors, err.ToErrorDetail()) } diff --git a/error_test.go b/error_test.go index 54435cd..87986f7 100644 --- a/error_test.go +++ b/error_test.go @@ -2,78 +2,79 @@ package graphql import ( "net/http" + "strconv" "strings" "testing" - "github.com/matryer/is" + assertIs "github.com/matryer/is" "github.com/pkg/errors" ) func TestNewRequestError(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) response := &http.Response{} - expected := RequestError{response: response} + expected := HttpRequestError{response: response} - err := NewRequestError(response) + err := NewHTTPRequestError(response) is.Equal(*err, expected) } func TestRequestErrorResponse(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) response := &http.Response{ Status: http.StatusText(http.StatusNotFound), } - err := NewRequestError(response) + err := NewHTTPRequestError(response) is.True(err != nil) - is.Equal(err.Response(), response) + is.Equal(err.Response(), response) //nolint:bodyclose } func TestRequestErrorError(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) response := &http.Response{ Status: http.StatusText(http.StatusNotFound), } - err := NewRequestError(response) + err := NewHTTPRequestError(response) is.True(err != nil) is.Equal(err.Error(), "defaultRequest failed with status: Not Found") } func TestRequestErrorErrors(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) response := &http.Response{ Status: http.StatusText(http.StatusNotFound), } - err := NewRequestError(response) + err := NewHTTPRequestError(response) is.True(err != nil) is.Equal(err.Errors(), []string{"defaultRequest failed with status: Not Found"}) } func TestRequestErrorDetails(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) response := &http.Response{ Status: http.StatusText(http.StatusNotFound), StatusCode: http.StatusNotFound, } - err := NewRequestError(response) + err := NewHTTPRequestError(response) is.True(err != nil) is.Equal(len(err.Details()), 1) - is.Equal(err.Details()[0], ErrorDetail{ - Code: http.StatusText(http.StatusNotFound), - Message: "defaultRequest failed with status: Not Found", + is.Equal(err.Details()[0], &errorDetail{ + code: strconv.Itoa(http.StatusNotFound), + message: "defaultRequest failed with status: Not Found", }) } func TestNewExecutionError(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) message := errors.New("some error") expected := ExecutionError{message: message} @@ -83,17 +84,17 @@ func TestNewExecutionError(t *testing.T) { } func TestExecutionErrorResponse(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) message := errors.New("some error") err := NewExecutionError(message) is.True(err != nil) - is.Equal(err.Response(), nil) + is.Equal(err.Response(), nil) //nolint:bodyclose } func TestExecutionErrorError(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) message := errors.New("some error") err := NewExecutionError(message) @@ -103,7 +104,7 @@ func TestExecutionErrorError(t *testing.T) { } func TestExecutionErrorErrors(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) message := errors.New("some error") err := NewExecutionError(message) @@ -113,84 +114,84 @@ func TestExecutionErrorErrors(t *testing.T) { } func TestExecutionErrorDetails(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) message := errors.New("some error") err := NewExecutionError(message) is.True(err != nil) is.Equal(len(err.Details()), 1) - is.Equal(err.Details()[0], ErrorDetail{ - Code: "", - Message: message.Error(), + is.Equal(err.Details()[0], &errorDetail{ + code: "", + message: message.Error(), }) } func TestNewGraphQLError(t *testing.T) { - is := is.New(t) - graphqlErrors := []GraphErr{ + is := assertIs.New(t) + graphqlErrors := []GraphError{ {Message: "some error"}, {Message: "other error"}, } response := &http.Response{} - expected := GraphQLError{ + expected := GraphRequestError{ errors: graphqlErrors, response: response, } - err := NewGraphQLError(graphqlErrors, response) + err := NewGraphRequestError(graphqlErrors, response) is.Equal(*err, expected) } func TestGraphQLErrorResponse(t *testing.T) { - is := is.New(t) - graphqlErrors := []GraphErr{ + is := assertIs.New(t) + graphqlErrors := []GraphError{ {Message: "some error"}, {Message: "other error"}, } response := &http.Response{} - err := NewGraphQLError(graphqlErrors, response) + err := NewGraphRequestError(graphqlErrors, response) is.True(err != nil) - is.Equal(err.Response(), response) + is.Equal(err.Response(), response) //nolint:bodyclose } func TestGraphQLErrorError(t *testing.T) { - is := is.New(t) - graphqlErrors := []GraphErr{ + is := assertIs.New(t) + graphqlErrors := []GraphError{ {Message: "some error"}, {Message: "other error"}, } response := &http.Response{} - err := NewGraphQLError(graphqlErrors, response) + err := NewGraphRequestError(graphqlErrors, response) is.True(err != nil) is.Equal(err.Error(), graphqlErrors[1].Error()) } func TestGraphQLErrorErrorEmptyList(t *testing.T) { - is := is.New(t) - graphqlErrors := []GraphErr{} + is := assertIs.New(t) + var graphqlErrors []GraphError response := &http.Response{} - err := NewGraphQLError(graphqlErrors, response) + err := NewGraphRequestError(graphqlErrors, response) is.True(err != nil) is.Equal(err.Error(), "") } func TestGraphQLErrorErrors(t *testing.T) { - is := is.New(t) - graphqlErrors := []GraphErr{ + is := assertIs.New(t) + graphqlErrors := []GraphError{ {Message: "some error"}, {Message: "other error"}, } response := &http.Response{} - err := NewGraphQLError(graphqlErrors, response) + err := NewGraphRequestError(graphqlErrors, response) is.True(err != nil) is.Equal(err.Errors(), []string{ @@ -200,11 +201,11 @@ func TestGraphQLErrorErrors(t *testing.T) { } func TestGraphQLErrorCode(t *testing.T) { - is := is.New(t) - graphqlErrors := []GraphErr{ + is := assertIs.New(t) + graphqlErrors := []GraphError{ { Message: "secondary message", - Extentions: GraphExt{ + Extensions: GraphExt{ Code: "ANOTHER_ERROR_CODE", }, }, @@ -216,7 +217,7 @@ func TestGraphQLErrorCode(t *testing.T) { } response := &http.Response{} - err := NewGraphQLError(graphqlErrors, response) + err := NewGraphRequestError(graphqlErrors, response) is.True(err != nil) is.Equal(err.Errors(), []string{ @@ -227,11 +228,11 @@ func TestGraphQLErrorCode(t *testing.T) { } func TestGraphQLMutationError(t *testing.T) { - is := is.New(t) - graphqlErrors := []GraphErr{ + is := assertIs.New(t) + graphqlErrors := []GraphError{ { Message: "secondary message", - Extentions: GraphExt{ + Extensions: GraphExt{ Code: "ANOTHER_ERROR_CODE", }, }, @@ -243,7 +244,7 @@ func TestGraphQLMutationError(t *testing.T) { } response := &http.Response{} - err := NewGraphQLError(graphqlErrors, response) + err := NewGraphRequestError(graphqlErrors, response) is.True(err != nil) is.Equal(err.Errors(), []string{ @@ -254,11 +255,11 @@ func TestGraphQLMutationError(t *testing.T) { } func TestGraphQLErrorDetails(t *testing.T) { - is := is.New(t) - graphqlErrors := []GraphErr{ + is := assertIs.New(t) + graphqlErrors := []GraphError{ { Message: "secondary message", - Extentions: GraphExt{ + Extensions: GraphExt{ Code: "ANOTHER_ERROR_CODE", }, }, @@ -270,18 +271,18 @@ func TestGraphQLErrorDetails(t *testing.T) { } response := &http.Response{} - err := NewGraphQLError(graphqlErrors, response) + err := NewGraphRequestError(graphqlErrors, response) is.True(err != nil) is.Equal(len(err.Details()), 2) - is.Equal(err.Details()[0], ErrorDetail{ - Code: "another_error_code", - Message: "secondary message", - Domain: "", + is.Equal(err.Details()[0], &errorDetail{ + code: "another_error_code", + message: "secondary message", + domain: "", }) - is.Equal(err.Details()[1], ErrorDetail{ - Code: "error_code", - Message: "miscellaneous message as to why the the defaultRequest failed", - Domain: "field.path", + is.Equal(err.Details()[1], &errorDetail{ + code: "error_code", + message: "miscellaneous message as to why the the defaultRequest failed", + domain: "field.path", }) } diff --git a/graphql.go b/graphql.go index 0b46727..e0e4da1 100644 --- a/graphql.go +++ b/graphql.go @@ -42,7 +42,7 @@ type ( GraphResponse struct { Data interface{} `json:"data"` - Errors []GraphErr `json:"errors"` + Errors []GraphError `json:"errors"` } graphValidationMessage struct { diff --git a/graphql_json_test.go b/graphql_json_test.go index b79678f..744ee08 100644 --- a/graphql_json_test.go +++ b/graphql_json_test.go @@ -10,11 +10,11 @@ import ( "testing" "time" - "github.com/matryer/is" + assertIs "github.com/matryer/is" ) func TestDoJSON(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -43,7 +43,7 @@ func TestDoJSON(t *testing.T) { } func TestDoJSONServerError(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -70,7 +70,7 @@ func TestDoJSONServerError(t *testing.T) { } func TestDoJSONBadRequestErr(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -104,17 +104,16 @@ func TestDoJSONBadRequestErr(t *testing.T) { "miscellaneous message as to why the the defaultRequest was bad", }) is.Equal(err.Response().StatusCode, http.StatusOK) - is.Equal(err.Details(), []ErrorDetail{ - { - Code: "", - Message: "miscellaneous message as to why the the defaultRequest was bad", - Domain: "field.path", - }, - }) + expectedError := &errorDetail{ + code: "", + message: "miscellaneous message as to why the the defaultRequest was bad", + domain: "field.path", + } + is.Equal(err.Details(), []ErrorDetail{ expectedError }) } func TestQueryJSON(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -150,7 +149,7 @@ func TestQueryJSON(t *testing.T) { func TestDoJSONMutation(t *testing.T) { reflect.TypeOf(GraphResponse{}) - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -199,7 +198,7 @@ func TestDoJSONMutation(t *testing.T) { } func TestDoJSONMutationWithStruct(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -261,7 +260,7 @@ func TestDoJSONMutationWithStruct(t *testing.T) { } func TestDoJSONMutationErr(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -315,7 +314,7 @@ func TestDoJSONMutationErr(t *testing.T) { } func TestHeader(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/graphql_multipart_test.go b/graphql_multipart_test.go index 6bdc002..f80ff5e 100644 --- a/graphql_multipart_test.go +++ b/graphql_multipart_test.go @@ -10,11 +10,11 @@ import ( "testing" "time" - "github.com/matryer/is" + assertIs "github.com/matryer/is" ) func TestWithClient(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int testClient := &http.Client{ Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { @@ -36,7 +36,7 @@ func TestWithClient(t *testing.T) { } func TestDoUseMultipartForm(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -63,7 +63,7 @@ func TestDoUseMultipartForm(t *testing.T) { is.Equal(responseData["something"], "yes") } func TestImmediatelyCloseReqBody(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -91,7 +91,7 @@ func TestImmediatelyCloseReqBody(t *testing.T) { } func TestDoErr(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -122,7 +122,7 @@ func TestDoErr(t *testing.T) { } func TestDoServerErr(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -147,7 +147,7 @@ func TestDoServerErr(t *testing.T) { } func TestDoBadRequestErr(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -180,7 +180,7 @@ func TestDoBadRequestErr(t *testing.T) { } func TestDoNoResponse(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -207,7 +207,7 @@ func TestDoNoResponse(t *testing.T) { } func TestQuery(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -243,14 +243,14 @@ func TestQuery(t *testing.T) { } func TestFile(t *testing.T) { - is := is.New(t) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ file, header, err := r.FormFile("file") is.NoErr(err) - defer file.Close() + t.Cleanup(func() { _ = file.Close() }) is.Equal(header.Filename, "filename.txt") b, err := ioutil.ReadAll(file) diff --git a/request.go b/request.go index 801506a..1beed06 100644 --- a/request.go +++ b/request.go @@ -12,6 +12,7 @@ type ( Request() GraphRequest ResponseBodyAs() interface{} IsMutation() bool + Name() string } GraphRequest interface { Query() string @@ -24,8 +25,9 @@ type ( } // queryOperation is the regular graphQL query operation queryOperation struct { - Req GraphRequest + Req GraphRequest ResponseType interface{} + queryName string // isMutation indicates if query is used to modify data in the data store isMutation bool } @@ -37,42 +39,53 @@ type ( // when the defaultRequest is made. header http.Header } - // File represents a file to upload. - File struct { - Field string - Name string - R io.Reader + File interface { + Field() string + Name() string + Reader() io.Reader + } + // file represents a file to upload. + file struct { + field string + name string + reader io.Reader } ) // Deprecated: in favor of NewGraphOperation func NewRequest(q string) Operation { - return NewQueryOperation(q, nil) + return NewQueryOperation(q, "graphql", nil) } // NewQueryOperation creates a new graphql query operation // Pass in a nil response object to skip response parsing. -func NewQueryOperation(query string, responseType interface{}) Operation { +func NewQueryOperation(query, queryName string, responseType interface{}) Operation { return &queryOperation{ Req: newReq(query), ResponseType: responseType, + queryName: queryName, } } // Deprecated: in favor of NewMutationOperation func NewMutation(q string) Operation { - return NewMutationOperation(q, nil) + return NewMutationOperation(q, "graphql", nil) } // NewMutationOperation creates a new graphql mutation operation // Pass in a nil response object to skip response parsing. -func NewMutationOperation(query string, responseType interface{}) Operation { +func NewMutationOperation(query, queryName string, responseType interface{}) Operation { return &queryOperation{ Req: newReq(query), ResponseType: responseType, isMutation: true, + queryName: queryName, } } +func (r *queryOperation) Name() string { + return r.queryName +} + func (r *queryOperation) Request() GraphRequest { return r.Req } @@ -108,10 +121,10 @@ func (d *defaultRequest) Headers() http.Header { // Files are only supported with a Client that was created with // the UseMultipartForm option. func (d *defaultRequest) File(fieldName, fileName string, reader io.Reader) { - d.files = append(d.files, File{ - Field: fieldName, - Name: fileName, - R: reader, + d.files = append(d.files, &file{ + field: fieldName, + name: fileName, + reader: reader, }) } @@ -131,3 +144,15 @@ func newReq(q string) GraphRequest { func (d *defaultRequest) Query() string { return d.q } + +func (f *file) Name() string { + return f.name +} + +func (f *file) Field() string { + return f.field +} + +func (f *file) Reader() io.Reader { + return f.reader +}