From 1d68c4a5fcebe8396cd7e369274f2148f6aee4f1 Mon Sep 17 00:00:00 2001 From: Toan Nguyen Date: Sat, 29 Jun 2024 14:22:01 +0700 Subject: [PATCH] feat: decode extensions from response via BindExtensions option (#146) * feat: decode extensions from response via BindExtensions option --- README.md | 203 ++++++++++++---------- example/subscription/subscription_test.go | 3 + graphql.go | 129 ++++++++------ graphql_test.go | 54 +++++- option.go | 20 ++- query.go | 23 ++- query_test.go | 16 +- 7 files changed, 286 insertions(+), 162 deletions(-) diff --git a/README.md b/README.md index 6ba5878..61d5fd4 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,54 @@ -go-graphql-client -======= +# go-graphql-client [![Unit tests](https://github.com/hasura/go-graphql-client/actions/workflows/test.yml/badge.svg)](https://github.com/hasura/go-graphql-client/actions/workflows/test.yml) **Preface:** This is a fork of `https://github.com/shurcooL/graphql` with extended features (subscription client, named operation) -The subscription client follows Apollo client specification https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md, using websocket protocol with https://github.com/nhooyr/websocket, a minimal and idiomatic WebSocket library for Go. +The subscription client follows Apollo client specification https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md, using WebSocket protocol with https://github.com/nhooyr/websocket, a minimal and idiomatic WebSocket library for Go. Package `graphql` provides a GraphQL client implementation. For more information, see package [`github.com/shurcooL/githubv4`](https://github.com/shurcooL/githubv4), which is a specialized version targeting GitHub GraphQL API v4. That package is driving the feature development. -**Note**: Before v0.8.0, `QueryRaw`, `MutateRaw` and `Subscribe` methods return `*json.RawMessage`. This output type is redundant to be decoded. From v0.8.0, the output type is changed to `[]byte`. +**Note**: Before v0.8.0, `QueryRaw`, `MutateRaw`, and `Subscribe` methods return `*json.RawMessage`. This output type is redundant to be decoded. From v0.8.0, the output type is changed to `[]byte`. - [go-graphql-client](#go-graphql-client) - - [Installation](#installation) - - [Usage](#usage) - - [Authentication](#authentication) - - [Simple Query](#simple-query) - - [Arguments and Variables](#arguments-and-variables) - - [Custom scalar tag](#custom-scalar-tag) - - [Skip GraphQL field](#skip-graphql-field) - - [Inline Fragments](#inline-fragments) - - [Specify GraphQL type name](#specify-graphql-type-name) - - [Mutations](#mutations) - - [Mutations Without Fields](#mutations-without-fields) - - [Subscription](#subscription) - - [Usage](#usage-1) - - [Subscribe](#subscribe) - - [Stop the subscription](#stop-the-subscription) - - [Authentication](#authentication-1) - - [Options](#options) - - [Subscription Protocols](#subscription-protocols) - - [Handle connection error](#handle-connection-error) - - [Events](#events) - - [Custom HTTP Client](#custom-http-client) - - [Custom WebSocket client](#custom-websocket-client) - - [Options](#options-1) - - [Execute pre-built query](#execute-pre-built-query) - - [With operation name (deprecated)](#with-operation-name-deprecated) - - [Raw bytes response](#raw-bytes-response) - - [Multiple mutations with ordered map](#multiple-mutations-with-ordered-map) - - [Debugging and Unit test](#debugging-and-unit-test) - - [Directories](#directories) - - [References](#references) - - [License](#license) + - [Installation](#installation) + - [Usage](#usage) + - [Authentication](#authentication) + - [Simple Query](#simple-query) + - [Arguments and Variables](#arguments-and-variables) + - [Custom scalar tag](#custom-scalar-tag) + - [Skip GraphQL field](#skip-graphql-field) + - [Inline Fragments](#inline-fragments) + - [Specify GraphQL type name](#specify-graphql-type-name) + - [Mutations](#mutations) + - [Mutations Without Fields](#mutations-without-fields) + - [Subscription](#subscription) + - [Usage](#usage-1) + - [Subscribe](#subscribe) + - [Stop the subscription](#stop-the-subscription) + - [Authentication](#authentication-1) + - [Options](#options) + - [Subscription Protocols](#subscription-protocols) + - [Handle connection error](#handle-connection-error) + - [Events](#events) + - [Custom HTTP Client](#custom-http-client) + - [Custom WebSocket client](#custom-websocket-client) + - [Options](#options-1) + - [Execute pre-built query](#execute-pre-built-query) + - [With operation name (deprecated)](#with-operation-name-deprecated) + - [Raw bytes response](#raw-bytes-response) + - [Multiple mutations with ordered map](#multiple-mutations-with-ordered-map) + - [Debugging and Unit test](#debugging-and-unit-test) + - [Directories](#directories) + - [References](#references) + - [License](#license) ## Installation -`go-graphql-client` requires Go version 1.20 or later. For older Go versions: +`go-graphql-client` requires Go version 1.20 or later. For older Go versions: + - **>= 1.16 < 1.20**: downgrade the library to version v0.9.x - **< 1.16**: downgrade the library version below v0.7.1. @@ -188,7 +188,8 @@ if err != nil { } ``` -Variables get encoded as normal json. So if you supply a struct for a variable and want to rename fields, you can do this like that: +Variables get encoded as normal JSON. So if you supply a struct for a variable and want to rename fields, you can do this like this: + ```Go type Dimensions struct { Width int `json:"ship_width"`, @@ -213,6 +214,7 @@ variables := map[string]interface{}{ err := client.Mutate(context.TODO(), &mutation, variables) ``` + which will set `ship_dimensions` to an object with the properties `ship_width` and `ship_height`. ### Custom scalar tag @@ -344,7 +346,7 @@ fmt.Println(q.Hero.Height) ### Specify GraphQL type name -The GraphQL type is automatically inferred from Go type by reflection. However, it's cumbersome in some use cases, e.g lowercase names. In Go, a type name with a first lowercase letter is considered private. If we need to reuse it for other packages, there are 2 approaches: type alias or implement `GetGraphQLType` method. +The GraphQL type is automatically inferred from Go type by reflection. However, it's cumbersome in some use cases, e.g. lowercase names. In Go, a type name with a first lowercase letter is considered private. If we need to reuse it for other packages, there are 2 approaches: type alias or implement `GetGraphQLType` method. ```go type UserReviewInput struct { @@ -533,7 +535,7 @@ if err != nil { #### Stop the subscription -You can programmatically stop the subscription while the client is running by using the `Unsubscribe` method, or returning a special error to stop it in the callback. +You can programmatically stop the subscription while the client is running by using the `Unsubscribe` method or returning a special error to stop it in the callback. ```Go subscriptionId, err := client.Subscribe(&query, nil, func(dataValue []byte, errValue error) error { @@ -580,7 +582,7 @@ client := graphql.NewSubscriptionClient(serverEndpoint). "Authorization": []string{"Bearer random-secret"}, }, }) -``` +``` #### Options @@ -608,6 +610,7 @@ client. #### Subscription Protocols The subscription client supports 2 protocols: + - [subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md) (default) - [graphql-ws](https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md) @@ -669,7 +672,7 @@ client.WithWebSocketOptions(WebsocketOptions{ #### Custom WebSocket client -By default the subscription client uses [nhooyr WebSocket client](https://github.com/nhooyr/websocket). If you need to customize the client, or prefer using [Gorilla WebSocket](https://github.com/gorilla/websocket), let's follow the Websocket interface and replace the constructor with `WithWebSocket` method: +By default, the subscription client uses [nhooyr WebSocket client](https://github.com/nhooyr/websocket). If you need to customize the client or prefer using [Gorilla WebSocket](https://github.com/gorilla/websocket), let's follow the WebSocket interface and replace the constructor with `WithWebSocket` method: ```go // WebsocketHandler abstracts WebSocket connection functions @@ -721,7 +724,7 @@ client.Run() ### Options -There are extensible parts in the GraphQL query that we sometimes use. They are optional so that we shouldn't required them in the method. To make it flexible, we can abstract these options as optional arguments that follow this interface. +There are extensible parts in the GraphQL query that we sometimes use. They are optional so we shouldn't require them in the method. To make it flexible, we can abstract these options as optional arguments that follow this interface. ```go type Option interface { @@ -732,7 +735,12 @@ type Option interface { client.Query(ctx context.Context, q interface{}, variables map[string]interface{}, options ...Option) error ``` -Currently we support 2 option types: `operation_name` and `operation_directive`. The operation name option is built-in because it is unique. We can use the option directly with `OperationName` +Currently, there are 3 option types: +- `operation_name` +- `operation_directive` +- `bind_extensions` + +The operation name option is built-in because it is unique. We can use the option directly with `OperationName`. ```go // query MyQuery { @@ -741,7 +749,7 @@ Currently we support 2 option types: `operation_name` and `operation_directive`. client.Query(ctx, &q, variables, graphql.OperationName("MyQuery")) ``` -In contrast, operation directive is various and customizable on different GraphQL servers. There isn't any built-in directive in the library. You need to define yourself. For example: +In contrast, operation directives are various and customizable on different GraphQL servers. There isn't any built-in directive in the library. You need to define yourself. For example: ```go // define @cached directive for Hasura queries @@ -770,7 +778,7 @@ client.Query(ctx, &q, variables, graphql.OperationName("MyQuery"), cachedDirecti ### Execute pre-built query -The `Exec` function allows you to executing pre-built queries. While using reflection to build queries is convenient as you get some resemblance of type safety, it gets very cumbersome when you need to create queries semi-dynamically. For instance, imagine you are building a CLI tool to query data from a graphql endpoint and you want users to be able to narrow down the query by passing cli flags or something. +The `Exec` function allows you to execute pre-built queries. While using reflection to build queries is convenient as you get some resemblance of type safety, it gets very cumbersome when you need to create queries semi-dynamically. For instance, imagine you are building a CLI tool to query data from a graphql endpoint and you want users to be able to narrow down the query by passing CLI flags or something. ```Go // filters would be built dynamically somehow from the command line flags @@ -818,6 +826,29 @@ if err != nil { err = json.Unmarshal(raw, &res) ``` +### Get extensions from response + +The response map may also contain an entry with the `extensions` key. To decode this field you need to bind a struct or map pointer. The client will optionally unmarshal the field using JSON decoder. + +```go +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{}{}, graphql.BindExtensions(&ext)) +if err != nil { + t.Fatal(err) +} +``` + Additionally, if you need information about the extensions returned in the response use `ExecRawWithExtensions`. This function returns a map with extensions as the second variable. ```Go @@ -834,8 +865,6 @@ fmt.Println("Extensions:", extensions) ### With operation name (deprecated) -Operation name is still on API decision plan https://github.com/shurcooL/graphql/issues/12. However, in my opinion separate methods are easier choice to avoid breaking changes - ```Go func (c *Client) NamedQuery(ctx context.Context, name string, q interface{}, variables map[string]interface{}) error @@ -846,7 +875,7 @@ func (sc *SubscriptionClient) NamedSubscribe(name string, v interface{}, variabl ### Raw bytes response -In the case we developers want to decode JSON response ourself. Moreover, the default `UnmarshalGraphQL` function isn't ideal with complicated nested interfaces +In the case when we developers want to decode JSON response ourselves. Moreover, the default `UnmarshalGraphQL` function isn't ideal with complicated nested interfaces ```Go func (c *Client) QueryRaw(ctx context.Context, q interface{}, variables map[string]interface{}) ([]byte, error) @@ -860,7 +889,7 @@ func (c *Client) NamedMutateRaw(ctx context.Context, name string, q interface{}, ### Multiple mutations with ordered map -You might need to make multiple mutations in single query. It's not very convenient with structs +You might need to make multiple mutations in a single query. It's not very convenient with structs so you can use ordered map `[][2]interface{}` instead. For example, to make the following GraphQL mutation: @@ -898,41 +927,42 @@ variables := map[string]interface{}{ ### Debugging and Unit test -Enable debug mode with the `WithDebug` function. If the request is failed, the request and response information will be included in `extensions[].internal` property. +Enable debug mode with the `WithDebug` function. If the request fails, the request and response information will be included in `extensions[].internal` property. ```json { - "errors": [ - { - "message":"Field 'user' is missing required arguments: login", - "extensions": { - "internal": { - "request": { - "body":"{\"query\":\"{user{name}}\"}", - "headers": { - "Content-Type": ["application/json"] - } - }, - "response": { - "body":"{\"errors\": [{\"message\": \"Field 'user' is missing required arguments: login\",\"locations\": [{\"line\": 7,\"column\": 3}]}]}", - "headers": { - "Content-Type": ["application/json"] - } - } - } - }, - "locations": [ - { - "line":7, - "column":3 - } - ] - } - ] + "errors": [ + { + "message": "Field 'user' is missing required arguments: login", + "extensions": { + "internal": { + "request": { + "body": "{\"query\":\"{user{name}}\"}", + "headers": { + "Content-Type": ["application/json"] + } + }, + "response": { + "body": "{\"errors\": [{\"message\": \"Field 'user' is missing required arguments: login\",\"locations\": [{\"line\": 7,\"column\": 3}]}]}", + "headers": { + "Content-Type": ["application/json"] + } + } + } + }, + "locations": [ + { + "line": 7, + "column": 3 + } + ] + } + ] } ``` For debugging queries, you can use `Construct*` functions to see what the generated query looks like: + ```go // ConstructQuery build GraphQL query string from struct and variables func ConstructQuery(v interface{}, variables map[string]interface{}, options ...Option) (string, error) @@ -948,25 +978,22 @@ func ConstructSubscription(v interface{}, variables map[string]interface{}, opti func UnmarshalGraphQL(data []byte, v interface{}) error ``` -Because the GraphQL query string is generated in runtime using reflection, it isn't really safe. To assure the GraphQL query is expected, it's necessary to write some unit test for query construction. +Because the GraphQL query string is generated in runtime using reflection, it isn't really safe. To ensure the GraphQL query is expected, it's necessary to write some unit tests for query construction. -Directories ------------ +## Directories | Path | Synopsis | -|----------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------| +| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | [example/graphqldev](https://godoc.org/github.com/shurcooL/graphql/example/graphqldev) | graphqldev is a test program currently being used for developing graphql package. | -| [ident](https://godoc.org/github.com/shurcooL/graphql/ident) | Package ident provides functions for parsing and converting identifier names between various naming convention. | +| [ident](https://godoc.org/github.com/shurcooL/graphql/ident) | Package ident provides functions for parsing and converting identifier names between various naming conventions. | | [internal/jsonutil](https://godoc.org/github.com/shurcooL/graphql/internal/jsonutil) | Package jsonutil provides a function for decoding JSON into a GraphQL query data structure. | -References ----------- +## References + - https://github.com/shurcooL/graphql - https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md - https://github.com/nhooyr/websocket +## License -License -------- - -- [MIT License](LICENSE) +- [MIT License](LICENSE) diff --git a/example/subscription/subscription_test.go b/example/subscription/subscription_test.go index 1bf65cf..336b262 100644 --- a/example/subscription/subscription_test.go +++ b/example/subscription/subscription_test.go @@ -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") } diff --git a/graphql.go b/graphql.go index cda5bde..31bad9d 100644 --- a/graphql.go +++ b/graphql.go @@ -47,28 +47,28 @@ 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))...) } @@ -76,13 +76,13 @@ func (c *Client) NamedMutate(ctx context.Context, name string, m interface{}, va // 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))...) } @@ -90,18 +90,18 @@ func (c *Client) NamedQueryRaw(ctx context.Context, name string, q interface{}, // 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 @@ -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, @@ -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 @@ -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 @@ -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 { @@ -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 } @@ -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 } @@ -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, @@ -435,14 +453,14 @@ 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 @@ -450,16 +468,20 @@ func (e Error) withRequest(req *http.Request, bodyReader io.Reader) Error { 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 } @@ -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) } @@ -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" ) diff --git a/graphql_test.go b/graphql_test.go index 10394a0..031fbc2 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -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) { @@ -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{}{}) diff --git a/option.go b/option.go index 88ac865..b89d7f9 100644 --- a/option.go +++ b/option.go @@ -4,8 +4,6 @@ package graphql type OptionType string const ( - // optionTypeOperationName is private because it's option is built-in and unique - optionTypeOperationName OptionType = "operation_name" OptionTypeOperationDirective OptionType = "operation_directive" ) @@ -15,8 +13,6 @@ type Option interface { // Type returns the supported type of the renderer // available types: operation_name and operation_directive Type() OptionType - // String returns the query component string - String() string } // operationNameOption represents the operation name render component @@ -25,7 +21,7 @@ type operationNameOption struct { } func (ono operationNameOption) Type() OptionType { - return optionTypeOperationName + return "operation_name" } func (ono operationNameOption) String() string { @@ -36,3 +32,17 @@ func (ono operationNameOption) String() string { func OperationName(name string) Option { return operationNameOption{name} } + +// bind the struct pointer to decode extensions from response +type bindExtensionsOption struct { + value any +} + +func (ono bindExtensionsOption) Type() OptionType { + return "bind_extensions" +} + +// BindExtensions bind the struct pointer to decode extensions from json response +func BindExtensions(value any) Option { + return bindExtensionsOption{value: value} +} diff --git a/query.go b/query.go index dfd6edc..0954cce 100644 --- a/query.go +++ b/query.go @@ -16,6 +16,7 @@ import ( type constructOptionsOutput struct { operationName string operationDirectives []string + extensions any } func (coo constructOptionsOutput) OperationDirectivesString() string { @@ -30,13 +31,21 @@ func constructOptions(options []Option) (*constructOptionsOutput, error) { output := &constructOptionsOutput{} for _, option := range options { - switch option.Type() { - case optionTypeOperationName: - output.operationName = option.String() - case OptionTypeOperationDirective: - output.operationDirectives = append(output.operationDirectives, option.String()) + switch opt := option.(type) { + case operationNameOption: + output.operationName = opt.name + case bindExtensionsOption: + output.extensions = opt.value default: - return nil, fmt.Errorf("invalid query option type: %s", option.Type()) + if opt.Type() != OptionTypeOperationDirective { + return nil, fmt.Errorf("invalid query option type: %s", option.Type()) + } + if d, ok := option.(fmt.Stringer); ok { + output.operationDirectives = append(output.operationDirectives, d.String()) + } else { + return nil, fmt.Errorf("please implement the fmt.Stringer interface for %s option", OptionTypeOperationDirective) + } + } } @@ -234,7 +243,7 @@ func writeQuery(w io.Writer, t reflect.Type, v reflect.Value, inline bool) error } case reflect.Struct: // If the type implements json.Unmarshaler, it's a scalar. Don't expand it. - if reflect.PtrTo(t).Implements(jsonUnmarshaler) { + if reflect.PointerTo(t).Implements(jsonUnmarshaler) { return nil } if t.AssignableTo(idType) { diff --git a/query_test.go b/query_test.go index e73de3a..6263772 100644 --- a/query_test.go +++ b/query_test.go @@ -342,13 +342,15 @@ func TestConstructQuery(t *testing.T) { want: `{viewer{login,databaseId}}`, }, } - for _, tc := range tests { - got, err := ConstructQuery(tc.inV, tc.inVariables, tc.options...) - if err != nil { - t.Error(err) - } else if got != tc.want { - t.Errorf("\ngot: %q\nwant: %q\n", got, tc.want) - } + for i, tc := range tests { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + got, err := ConstructQuery(tc.inV, tc.inVariables, tc.options...) + if err != nil { + t.Error(err) + } else if got != tc.want { + t.Errorf("\ngot: %q\nwant: %q\n", got, tc.want) + } + }) } }