Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: decode extensions from response via BindExtensions option #146

Merged
merged 3 commits into from
Jun 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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