Skip to content

Commit

Permalink
feat: decode extensions from response via BindExtensions option (#146)
Browse files Browse the repository at this point in the history
* feat: decode extensions from response via BindExtensions option
  • Loading branch information
hgiasac authored Jun 29, 2024
1 parent 5a8b2ec commit 1d68c4a
Show file tree
Hide file tree
Showing 7 changed files with 286 additions and 162 deletions.
203 changes: 115 additions & 88 deletions README.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions example/subscription/subscription_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,9 @@ func testSubscription_LifeCycleEvents(t *testing.T, syncMode bool) {
}
}

// workaround for race condition
time.Sleep(time.Second)

if atomic.LoadInt32(&wasConnected) != 1 {
t.Fatalf("expected OnConnected event, got none")
}
Expand Down
129 changes: 76 additions & 53 deletions graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,61 +47,61 @@ func NewClient(url string, httpClient Doer) *Client {
// Query executes a single GraphQL query request,
// with a query derived from q, populating the response into it.
// q should be a pointer to struct that corresponds to the GraphQL schema.
func (c *Client) Query(ctx context.Context, q interface{}, variables map[string]interface{}, options ...Option) error {
func (c *Client) Query(ctx context.Context, q any, variables map[string]any, options ...Option) error {
return c.do(ctx, queryOperation, q, variables, options...)
}

// NamedQuery executes a single GraphQL query request, with operation name
//
// Deprecated: this is the shortcut of Query method, with NewOperationName option
func (c *Client) NamedQuery(ctx context.Context, name string, q interface{}, variables map[string]interface{}, options ...Option) error {
func (c *Client) NamedQuery(ctx context.Context, name string, q any, variables map[string]any, options ...Option) error {
return c.do(ctx, queryOperation, q, variables, append(options, OperationName(name))...)
}

// Mutate executes a single GraphQL mutation request,
// with a mutation derived from m, populating the response into it.
// m should be a pointer to struct that corresponds to the GraphQL schema.
func (c *Client) Mutate(ctx context.Context, m interface{}, variables map[string]interface{}, options ...Option) error {
func (c *Client) Mutate(ctx context.Context, m any, variables map[string]any, options ...Option) error {
return c.do(ctx, mutationOperation, m, variables, options...)
}

// NamedMutate executes a single GraphQL mutation request, with operation name
//
// Deprecated: this is the shortcut of Mutate method, with NewOperationName option
func (c *Client) NamedMutate(ctx context.Context, name string, m interface{}, variables map[string]interface{}, options ...Option) error {
func (c *Client) NamedMutate(ctx context.Context, name string, m any, variables map[string]any, options ...Option) error {
return c.do(ctx, mutationOperation, m, variables, append(options, OperationName(name))...)
}

// Query executes a single GraphQL query request,
// with a query derived from q, populating the response into it.
// q should be a pointer to struct that corresponds to the GraphQL schema.
// return raw bytes message.
func (c *Client) QueryRaw(ctx context.Context, q interface{}, variables map[string]interface{}, options ...Option) ([]byte, error) {
func (c *Client) QueryRaw(ctx context.Context, q any, variables map[string]any, options ...Option) ([]byte, error) {
return c.doRaw(ctx, queryOperation, q, variables, options...)
}

// NamedQueryRaw executes a single GraphQL query request, with operation name
// return raw bytes message.
func (c *Client) NamedQueryRaw(ctx context.Context, name string, q interface{}, variables map[string]interface{}, options ...Option) ([]byte, error) {
func (c *Client) NamedQueryRaw(ctx context.Context, name string, q any, variables map[string]any, options ...Option) ([]byte, error) {
return c.doRaw(ctx, queryOperation, q, variables, append(options, OperationName(name))...)
}

// MutateRaw executes a single GraphQL mutation request,
// with a mutation derived from m, populating the response into it.
// m should be a pointer to struct that corresponds to the GraphQL schema.
// return raw bytes message.
func (c *Client) MutateRaw(ctx context.Context, m interface{}, variables map[string]interface{}, options ...Option) ([]byte, error) {
func (c *Client) MutateRaw(ctx context.Context, m any, variables map[string]any, options ...Option) ([]byte, error) {
return c.doRaw(ctx, mutationOperation, m, variables, options...)
}

// NamedMutateRaw executes a single GraphQL mutation request, with operation name
// return raw bytes message.
func (c *Client) NamedMutateRaw(ctx context.Context, name string, m interface{}, variables map[string]interface{}, options ...Option) ([]byte, error) {
func (c *Client) NamedMutateRaw(ctx context.Context, name string, m any, variables map[string]any, options ...Option) ([]byte, error) {
return c.doRaw(ctx, mutationOperation, m, variables, append(options, OperationName(name))...)
}

// buildAndRequest the common method that builds and send graphql request
func (c *Client) buildAndRequest(ctx context.Context, op operationType, v interface{}, variables map[string]interface{}, options ...Option) ([]byte, *http.Response, io.Reader, Errors) {
// buildQueryAndOptions the common method to build query and options
func (c *Client) buildQueryAndOptions(op operationType, v any, variables map[string]any, options ...Option) (string, *constructOptionsOutput, error) {
var query string
var err error
var optionOutput *constructOptionsOutput
Expand All @@ -110,18 +110,18 @@ func (c *Client) buildAndRequest(ctx context.Context, op operationType, v interf
query, optionOutput, err = constructQuery(v, variables, options...)
case mutationOperation:
query, optionOutput, err = constructMutation(v, variables, options...)
default:
err = fmt.Errorf("invalid operation type: %v", op)
}

if err != nil {
return nil, nil, nil, Errors{newError(ErrGraphQLEncode, err)}
return "", nil, Errors{newError(ErrGraphQLEncode, err)}
}

data, _, resp, respBuf, errs := c.request(ctx, query, variables, optionOutput)
return data, resp, respBuf, errs
return query, optionOutput, nil
}

// Request the common method that send graphql request
func (c *Client) request(ctx context.Context, query string, variables map[string]interface{}, options *constructOptionsOutput) ([]byte, []byte, *http.Response, io.Reader, Errors) {
func (c *Client) request(ctx context.Context, query string, variables map[string]any, options *constructOptionsOutput) ([]byte, []byte, *http.Response, io.Reader, Errors) {
in := GraphQLRequestPayload{
Query: query,
Variables: variables,
Expand Down Expand Up @@ -248,35 +248,45 @@ func (c *Client) request(ctx context.Context, query string, variables map[string

// do executes a single GraphQL operation.
// return raw message and error
func (c *Client) doRaw(ctx context.Context, op operationType, v interface{}, variables map[string]interface{}, options ...Option) ([]byte, error) {
data, _, _, err := c.buildAndRequest(ctx, op, v, variables, options...)
if len(err) > 0 {
return data, err
func (c *Client) doRaw(ctx context.Context, op operationType, v any, variables map[string]any, options ...Option) ([]byte, error) {
query, optionsOutput, err := c.buildQueryAndOptions(op, v, variables, options...)
if err != nil {
return nil, err
}
data, _, _, _, errs := c.request(ctx, query, variables, optionsOutput)
if len(errs) > 0 {
return data, errs
}

return data, nil
}

// do executes a single GraphQL operation and unmarshal json.
func (c *Client) do(ctx context.Context, op operationType, v interface{}, variables map[string]interface{}, options ...Option) error {
data, resp, respBuf, errs := c.buildAndRequest(ctx, op, v, variables, options...)
return c.processResponse(v, data, resp, respBuf, errs)
func (c *Client) do(ctx context.Context, op operationType, v any, variables map[string]any, options ...Option) error {
query, optionsOutput, err := c.buildQueryAndOptions(op, v, variables, options...)
if err != nil {
return err
}
data, extData, resp, respBuf, errs := c.request(ctx, query, variables, optionsOutput)

return c.processResponse(v, data, optionsOutput.extensions, extData, resp, respBuf, errs)
}

// Executes a pre-built query and unmarshals the response into v. Unlike the Query method you have to specify in the query the
// fields that you want to receive as they are not inferred from v. This method is useful if you need to build the query dynamically.
func (c *Client) Exec(ctx context.Context, query string, v interface{}, variables map[string]interface{}, options ...Option) error {
func (c *Client) Exec(ctx context.Context, query string, v any, variables map[string]any, options ...Option) error {
optionsOutput, err := constructOptions(options)
if err != nil {
return err
}

data, _, resp, respBuf, errs := c.request(ctx, query, variables, optionsOutput)
return c.processResponse(v, data, resp, respBuf, errs)
data, extData, resp, respBuf, errs := c.request(ctx, query, variables, optionsOutput)
return c.processResponse(v, data, optionsOutput.extensions, extData, resp, respBuf, errs)
}

// Executes a pre-built query and returns the raw json message. Unlike the Query method you have to specify in the query the
// fields that you want to receive as they are not inferred from the interface. This method is useful if you need to build the query dynamically.
func (c *Client) ExecRaw(ctx context.Context, query string, variables map[string]interface{}, options ...Option) ([]byte, error) {
func (c *Client) ExecRaw(ctx context.Context, query string, variables map[string]any, options ...Option) ([]byte, error) {
optionsOutput, err := constructOptions(options)
if err != nil {
return nil, err
Expand All @@ -289,10 +299,10 @@ func (c *Client) ExecRaw(ctx context.Context, query string, variables map[string
return data, nil
}

// Executes a pre-built query and returns the raw json message and a map with extensions (values also as raw json objects). Unlike the
// ExecRawWithExtensions execute a pre-built query and returns the raw json message and a map with extensions (values also as raw json objects). Unlike the
// Query method you have to specify in the query the fields that you want to receive as they are not inferred from the interface. This method
// is useful if you need to build the query dynamically.
func (c *Client) ExecRawWithExtensions(ctx context.Context, query string, variables map[string]interface{}, options ...Option) ([]byte, []byte, error) {
func (c *Client) ExecRawWithExtensions(ctx context.Context, query string, variables map[string]any, options ...Option) ([]byte, []byte, error) {
optionsOutput, err := constructOptions(options)
if err != nil {
return nil, nil, err
Expand All @@ -305,7 +315,7 @@ func (c *Client) ExecRawWithExtensions(ctx context.Context, query string, variab
return data, ext, nil
}

func (c *Client) processResponse(v interface{}, data []byte, resp *http.Response, respBuf io.Reader, errs Errors) error {
func (c *Client) processResponse(v any, data []byte, extensions any, rawExtensions []byte, resp *http.Response, respBuf io.Reader, errs Errors) error {
if len(data) > 0 {
err := jsonutil.UnmarshalGraphQL(data, v)
if err != nil {
Expand All @@ -317,6 +327,14 @@ func (c *Client) processResponse(v interface{}, data []byte, resp *http.Response
}
}

if len(rawExtensions) > 0 && extensions != nil {
err := json.Unmarshal(rawExtensions, extensions)
if err != nil {
we := newError(ErrGraphQLExtensionsDecode, err)
errs = append(errs, we)
}
}

if len(errs) > 0 {
return errs
}
Expand Down Expand Up @@ -352,13 +370,13 @@ func (c *Client) WithDebug(debug bool) *Client {
type Errors []Error

type Error struct {
Message string `json:"message"`
Extensions map[string]interface{} `json:"extensions"`
Message string `json:"message"`
Extensions map[string]any `json:"extensions"`
Locations []struct {
Line int `json:"line"`
Column int `json:"column"`
} `json:"locations"`
Path []interface{} `json:"path"`
Path []any `json:"path"`
err error
}

Expand Down Expand Up @@ -390,22 +408,22 @@ func (e Errors) Unwrap() []error {
return errs
}

func (e Error) getInternalExtension() map[string]interface{} {
func (e Error) getInternalExtension() map[string]any {
if e.Extensions == nil {
return make(map[string]interface{})
return make(map[string]any)
}

if ex, ok := e.Extensions["internal"]; ok {
return ex.(map[string]interface{})
return ex.(map[string]any)
}

return make(map[string]interface{})
return make(map[string]any)
}

func newError(code string, err error) Error {
return Error{
Message: err.Error(),
Extensions: map[string]interface{}{
Extensions: map[string]any{
"code": code,
},
err: err,
Expand Down Expand Up @@ -435,31 +453,35 @@ func (e Error) withRequest(req *http.Request, bodyReader io.Reader) Error {
if err != nil {
internal["error"] = err
} else {
internal["request"] = map[string]interface{}{
internal["request"] = map[string]any{
"headers": req.Header,
"body": string(bodyBytes),
}
}

if e.Extensions == nil {
e.Extensions = make(map[string]interface{})
e.Extensions = make(map[string]any)
}
e.Extensions["internal"] = internal
return e
}

func (e Error) withResponse(res *http.Response, bodyReader io.Reader) Error {
internal := e.getInternalExtension()
bodyBytes, err := io.ReadAll(bodyReader)
if err != nil {
internal["error"] = err
} else {
internal["response"] = map[string]interface{}{
"headers": res.Header,
"body": string(bodyBytes),
}

response := map[string]any{
"headers": res.Header,
}

if bodyReader != nil {
bodyBytes, err := io.ReadAll(bodyReader)
if err != nil {
internal["error"] = err
} else {
response["body"] = string(bodyBytes)
}
}
internal["response"] = response
e.Extensions["internal"] = internal
return e
}
Expand All @@ -470,7 +492,7 @@ func (e Error) withResponse(res *http.Response, bodyReader io.Reader) Error {
// The implementation is created on top of the JSON tokenizer available
// in "encoding/json".Decoder.
// This function is re-exported from the internal package
func UnmarshalGraphQL(data []byte, v interface{}) error {
func UnmarshalGraphQL(data []byte, v any) error {
return jsonutil.UnmarshalGraphQL(data, v)
}

Expand All @@ -481,9 +503,10 @@ const (
mutationOperation
// subscriptionOperation // Unused.

ErrRequestError = "request_error"
ErrJsonEncode = "json_encode_error"
ErrJsonDecode = "json_decode_error"
ErrGraphQLEncode = "graphql_encode_error"
ErrGraphQLDecode = "graphql_decode_error"
ErrRequestError = "request_error"
ErrJsonEncode = "json_encode_error"
ErrJsonDecode = "json_decode_error"
ErrGraphQLEncode = "graphql_encode_error"
ErrGraphQLDecode = "graphql_decode_error"
ErrGraphQLExtensionsDecode = "graphql_extensions_decode_error"
)
54 changes: 52 additions & 2 deletions graphql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,56 @@ func TestClient_Exec_QueryRaw(t *testing.T) {
}
}

func TestClient_BindExtensions(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) {
body := mustRead(req.Body)
if got, want := body, `{"query":"{user{id,name}}"}`+"\n"; got != want {
t.Errorf("got body: %v, want %v", got, want)
}
w.Header().Set("Content-Type", "application/json")
mustWrite(w, `{"data": {"user": {"name": "Gopher"}}, "extensions": {"id": 1, "domain": "users"}}`)
})
client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}})

var q struct {
User struct {
ID string `graphql:"id"`
Name string `graphql:"name"`
}
}

var ext struct {
ID int `json:"id"`
Domain string `json:"domain"`
}

err := client.Query(context.Background(), &q, map[string]interface{}{})
if err != nil {
t.Fatal(err)
}

if got, want := q.User.Name, "Gopher"; got != want {
t.Fatalf("got q.User.Name: %q, want: %q", got, want)
}

err = client.Query(context.Background(), &q, map[string]interface{}{}, graphql.BindExtensions(&ext))
if err != nil {
t.Fatal(err)
}

if got, want := q.User.Name, "Gopher"; got != want {
t.Fatalf("got q.User.Name: %q, want: %q", got, want)
}

if got, want := ext.ID, 1; got != want {
t.Errorf("got ext.ID: %q, want: %q", got, want)
}
if got, want := ext.Domain, "users"; got != want {
t.Errorf("got ext.Domain: %q, want: %q", got, want)
}
}

// Test exec pre-built query, return raw json string and map
// with extensions
func TestClient_Exec_QueryRawWithExtensions(t *testing.T) {
Expand All @@ -485,8 +535,8 @@ func TestClient_Exec_QueryRawWithExtensions(t *testing.T) {
client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}})

var ext struct {
ID int `graphql:"id"`
Domain string `graphql:"domain"`
ID int `json:"id"`
Domain string `json:"domain"`
}

_, extensions, err := client.ExecRawWithExtensions(context.Background(), "{user{id,name}}", map[string]interface{}{})
Expand Down
Loading

0 comments on commit 1d68c4a

Please sign in to comment.