This repository was archived by the owner on Jan 8, 2024. It is now read-only.
forked from machinebox/graphql
-
Notifications
You must be signed in to change notification settings - Fork 0
Add the ability to instrument a graphql request #8
Open
edersonmf
wants to merge
2
commits into
master
Choose a base branch
from
ederson/refactor
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I created the |
||
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 | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could this have a setter function? I'm not sure we could do this since this is a private field 🤔