diff --git a/chain.go b/chain.go new file mode 100644 index 0000000..20dce1c --- /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 = NewHTTPRequestError(response) + } + c.response = response + } else { + c.error = NewExecutionResponseError(err, response) + } + } + 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, NewExecutionResponseError(errors.Wrap(err, "reading body"), c.response) + } + 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, NewExecutionResponseError(errors.Wrap(err, "decoding response"), c.response) + } + gr = &GraphResponse{} + + for _, result := range results.Data { + if !result.Successful { + messages := result.Messages + errs := make([]GraphError, len(messages)) + + for i, message := range messages { + errs[i] = GraphError{ + 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, NewExecutionResponseError(errors.Wrap(err, "decoding response"), c.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, NewExecutionResponseError(errors.Wrap(err, "decoding response"), c.response) + } + } + if len(gr.Errors) > 0 { + return nil, NewGraphRequestError(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].Reader()); 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..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 { - return fmt.Sprintf("request failed with status: %s", r.response.Status) +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 ce26d86..87986f7 100644 --- a/error_test.go +++ b/error_test.go @@ -2,100 +2,99 @@ 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.True(err != nil) 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(), "request failed with status: Not Found") + 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{"request failed with status: Not Found"}) + 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: "request 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} err := NewExecutionError(message) - is.True(err != nil) is.Equal(*err, expected) } 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) @@ -105,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) @@ -115,85 +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.True(err != nil) 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{ @@ -203,23 +201,23 @@ 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", }, }, { 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"}, }, } response := &http.Response{} - err := NewGraphQLError(graphqlErrors, response) + err := NewGraphRequestError(graphqlErrors, response) is.True(err != nil) is.Equal(err.Errors(), []string{ @@ -230,23 +228,23 @@ 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", }, }, { 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"}, }, } response := &http.Response{} - err := NewGraphQLError(graphqlErrors, response) + err := NewGraphRequestError(graphqlErrors, response) is.True(err != nil) is.Equal(err.Errors(), []string{ @@ -257,34 +255,34 @@ 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", }, }, { 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"}, }, } 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 request 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 ff1aad8..e0e4da1 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,46 +31,18 @@ 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"` + Errors []GraphError `json:"errors"` } graphValidationMessage struct { @@ -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..744ee08 100644 --- a/graphql_json_test.go +++ b/graphql_json_test.go @@ -6,14 +6,15 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "reflect" "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++ @@ -42,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++ @@ -63,13 +64,13 @@ 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) } 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++ @@ -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,22 +99,21 @@ 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", - 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) { @@ -131,11 +131,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,7 +148,8 @@ func TestQueryJSON(t *testing.T) { } func TestDoJSONMutation(t *testing.T) { - is := is.New(t) + reflect.TypeOf(GraphResponse{}) + is := assertIs.New(t) var calls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -197,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++ @@ -259,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++ @@ -313,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) { @@ -330,7 +331,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..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++ @@ -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,12 +117,12 @@ 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", }) } 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++ @@ -141,13 +141,13 @@ 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) } 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++ @@ -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,15 +172,15 @@ 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) } 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) { @@ -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 @@ -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) @@ -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..1beed06 100644 --- a/request.go +++ b/request.go @@ -5,10 +5,17 @@ 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 + Name() string + } + GraphRequest interface { + Query() string File(string, string, io.Reader) Files() []File Var(string, interface{}) @@ -16,136 +23,136 @@ 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{} + queryName string + // 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 - Name string - R io.Reader + File interface { + Field() string + Name() string + Reader() io.Reader } -) - -func NewRequest(q string) *Request { - return &Request{ - Req: newReq(q), + // file represents a file to upload. + file struct { + field string + name string + reader io.Reader } -} - -func NewMutation(m string) *Mutation { - return &Mutation{ - Req: newReq(m), +) +// Deprecated: in favor of NewGraphOperation +func NewRequest(q string) Operation { + 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, queryName string, responseType interface{}) Operation { + return &queryOperation{ + Req: newReq(query), + ResponseType: responseType, + queryName: queryName, } } -func (q *Request) Request() *Req { - return q.Req -} - -func (r *Request) Var(key string, value interface{}) { - r.Request().Var(key, value) +// Deprecated: in favor of NewMutationOperation +func NewMutation(q string) Operation { + return NewMutationOperation(q, "graphql", nil) } -func (r *Request) Vars() map[string]interface{} { - return r.Request().Vars() -} - -func (r *Request) Header(key, value string) { - r.Request().Header.Set(key, value) +// NewMutationOperation creates a new graphql mutation operation +// Pass in a nil response object to skip response parsing. +func NewMutationOperation(query, queryName string, responseType interface{}) Operation { + return &queryOperation{ + Req: newReq(query), + ResponseType: responseType, + isMutation: true, + queryName: queryName, + } } -func (r *Request) Headers() http.Header { - return r.Request().Header +func (r *queryOperation) Name() string { + return r.queryName } -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, + reader: 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 +// Query gets the query string of this defaultRequest. +func (d *defaultRequest) Query() string { + return d.q } -// Files gets the files in this request. -func (req *Req) Files() []File { - return req.files +func (f *file) Name() string { + return f.name } -// Query gets the query string of this request. -func (req *Req) Query() string { - return req.q +func (f *file) Field() string { + return f.field } -// 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, - }) +func (f *file) Reader() io.Reader { + return f.reader }