From 89a6861d5b39e892021dc1cc38139eecf015f139 Mon Sep 17 00:00:00 2001 From: Toan Nguyen Date: Fri, 14 Feb 2025 10:48:54 +0700 Subject: [PATCH 1/2] feat: add an option to allow binding response headers --- README.md | 29 ++++++++++++++++++++++++----- graphql.go | 11 +++++++++++ graphql_test.go | 26 ++++++++++++++++++++++++-- option.go | 16 ++++++++++++++++ query.go | 4 ++++ subscription_graphql_ws_test.go | 10 ++++------ 6 files changed, 83 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 299f545..e15cc92 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ For more information, see package [`github.com/shurcooL/githubv4`](https://githu - [Installation](#installation) - [Usage](#usage) - [Authentication](#authentication) + - [WithRequestModifier](#withrequestmodifier) + - [OAuth2](#oauth2) - [Simple Query](#simple-query) - [Arguments and Variables](#arguments-and-variables) - [Custom scalar tag](#custom-scalar-tag) @@ -37,6 +39,7 @@ For more information, see package [`github.com/shurcooL/githubv4`](https://githu - [Custom WebSocket client](#custom-websocket-client) - [Options](#options-1) - [Execute pre-built query](#execute-pre-built-query) + - [Get extensions from response](#get-extensions-from-response) - [With operation name (deprecated)](#with-operation-name-deprecated) - [Raw bytes response](#raw-bytes-response) - [Multiple mutations with ordered map](#multiple-mutations-with-ordered-map) @@ -67,7 +70,22 @@ client := graphql.NewClient("https://example.com/graphql", nil) ### Authentication -Some GraphQL servers may require authentication. The `graphql` package does not directly handle authentication. Instead, when creating a new client, you're expected to pass an `http.Client` that performs authentication. The easiest and recommended way to do this is to use the [`golang.org/x/oauth2`](https://golang.org/x/oauth2) package. You'll need an OAuth token with the right scopes. Then: +Some GraphQL servers may require authentication. The `graphql` package does not directly handle authentication. Instead, when creating a new client, you're expected to pass an `http.Client` that performs authentication. + +#### WithRequestModifier + +Use `WithRequestModifier` method to inject headers into the request before sending to the GraphQL server. + +```go +client := graphql.NewClient(endpoint, http.DefaultClient). + WithRequestModifier(func(r *http.Request) { + r.Header.Set("Authorization", "random-token") + }) +``` + +#### OAuth2 + +The easiest and recommended way to do this is to use the [`golang.org/x/oauth2`](https://golang.org/x/oauth2) package. You'll need an OAuth token with the right scopes. Then: ```Go import "golang.org/x/oauth2" @@ -736,6 +754,7 @@ client.Query(ctx context.Context, q interface{}, variables map[string]interface{ ``` Currently, there are 3 option types: + - `operation_name` - `operation_directive` - `bind_extensions` @@ -982,11 +1001,11 @@ Because the GraphQL query string is generated in runtime using reflection, it is ## 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. | +| 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 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. | +| [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 diff --git a/graphql.go b/graphql.go index 31bad9d..7c102b7 100644 --- a/graphql.go +++ b/graphql.go @@ -163,10 +163,20 @@ func (c *Client) request(ctx context.Context, query string, variables map[string if c.debug { e = e.withRequest(request, reqReader) } + return nil, nil, nil, nil, Errors{e} } + defer resp.Body.Close() + if options != nil && options.headers != nil { + for key, values := range resp.Header { + for _, value := range values { + options.headers.Add(key, value) + } + } + } + r := resp.Body if resp.Header.Get("Content-Encoding") == "gzip" { @@ -350,6 +360,7 @@ func (c *Client) WithRequestModifier(f RequestModifier) *Client { url: c.url, httpClient: c.httpClient, requestModifier: f, + debug: c.debug, } } diff --git a/graphql_test.go b/graphql_test.go index 031fbc2..0b92dbb 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -503,7 +503,8 @@ func TestClient_BindExtensions(t *testing.T) { t.Fatalf("got q.User.Name: %q, want: %q", got, want) } - err = client.Query(context.Background(), &q, map[string]interface{}{}, graphql.BindExtensions(&ext)) + headers := http.Header{} + err = client.Query(context.Background(), &q, map[string]interface{}{}, graphql.BindExtensions(&ext), graphql.BindResponseHeaders(&headers)) if err != nil { t.Fatal(err) } @@ -518,11 +519,22 @@ func TestClient_BindExtensions(t *testing.T) { if got, want := ext.Domain, "users"; got != want { t.Errorf("got ext.Domain: %q, want: %q", got, want) } + + if len(headers) != 1 { + t.Error("got empty headers, want 1") + } + + if got, want := headers.Get("content-type"), "application/json"; got != want { + t.Errorf("got headers[content-type]: %q, want: %s", got, want) + } } // Test exec pre-built query, return raw json string and map // with extensions func TestClient_Exec_QueryRawWithExtensions(t *testing.T) { + testResponseHeader := "X-Test-Response" + testResponseHeaderValue := "graphql" + mux := http.NewServeMux() mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { body := mustRead(req.Body) @@ -530,6 +542,7 @@ func TestClient_Exec_QueryRawWithExtensions(t *testing.T) { t.Errorf("got body: %v, want %v", got, want) } w.Header().Set("Content-Type", "application/json") + w.Header().Set(testResponseHeader, testResponseHeaderValue) mustWrite(w, `{"data": {"user": {"name": "Gopher"}}, "extensions": {"id": 1, "domain": "users"}}`) }) client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}}) @@ -539,7 +552,8 @@ func TestClient_Exec_QueryRawWithExtensions(t *testing.T) { Domain string `json:"domain"` } - _, extensions, err := client.ExecRawWithExtensions(context.Background(), "{user{id,name}}", map[string]interface{}{}) + headers := http.Header{} + _, extensions, err := client.ExecRawWithExtensions(context.Background(), "{user{id,name}}", map[string]interface{}{}, graphql.BindResponseHeaders(&headers)) if err != nil { t.Fatal(err) } @@ -559,6 +573,14 @@ func TestClient_Exec_QueryRawWithExtensions(t *testing.T) { if got, want := ext.Domain, "users"; got != want { t.Errorf("got ext.Domain: %q, want: %q", got, want) } + + if len(headers) != 2 { + t.Error("got empty headers, want 2") + } + + if headerValue := headers.Get(testResponseHeader); headerValue != testResponseHeaderValue { + t.Errorf("got headers[%s]: %q, want: %s", testResponseHeader, headerValue, testResponseHeaderValue) + } } // localRoundTripper is an http.RoundTripper that executes HTTP transactions diff --git a/option.go b/option.go index b89d7f9..b3578c0 100644 --- a/option.go +++ b/option.go @@ -1,5 +1,7 @@ package graphql +import "net/http" + // OptionType represents the logic of graphql query construction type OptionType string @@ -46,3 +48,17 @@ func (ono bindExtensionsOption) Type() OptionType { func BindExtensions(value any) Option { return bindExtensionsOption{value: value} } + +// bind the struct pointer to return headers from response +type bindResponseHeadersOption struct { + value *http.Header +} + +func (ono bindResponseHeadersOption) Type() OptionType { + return "bind_extensions" +} + +// BindExtensionsBindResponseHeaders bind the header response to the pointer +func BindResponseHeaders(value *http.Header) Option { + return bindResponseHeadersOption{value: value} +} diff --git a/query.go b/query.go index 0954cce..28b3cb3 100644 --- a/query.go +++ b/query.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "net/http" "reflect" "sort" "strconv" @@ -17,6 +18,7 @@ type constructOptionsOutput struct { operationName string operationDirectives []string extensions any + headers *http.Header } func (coo constructOptionsOutput) OperationDirectivesString() string { @@ -36,6 +38,8 @@ func constructOptions(options []Option) (*constructOptionsOutput, error) { output.operationName = opt.name case bindExtensionsOption: output.extensions = opt.value + case bindResponseHeadersOption: + output.headers = opt.value default: if opt.Type() != OptionTypeOperationDirective { return nil, fmt.Errorf("invalid query option type: %s", option.Type()) diff --git a/subscription_graphql_ws_test.go b/subscription_graphql_ws_test.go index 53b0c01..13dde99 100644 --- a/subscription_graphql_ws_test.go +++ b/subscription_graphql_ws_test.go @@ -34,12 +34,10 @@ type user_insert_input map[string]interface{} func hasura_setupClients(protocol SubscriptionProtocolType) (*Client, *SubscriptionClient) { endpoint := fmt.Sprintf("%s/v1/graphql", hasuraTestHost) - client := NewClient(endpoint, &http.Client{Transport: headerRoundTripper{ - setHeaders: func(req *http.Request) { - req.Header.Set("x-hasura-admin-secret", hasuraTestAdminSecret) - }, - rt: http.DefaultTransport, - }}) + client := NewClient(endpoint, http.DefaultClient). + WithRequestModifier(func(r *http.Request) { + r.Header.Set("x-hasura-admin-secret", hasuraTestAdminSecret) + }) subscriptionClient := NewSubscriptionClient(endpoint). WithProtocol(protocol). From 03705bd4211c91d9d0890a935dff6496c9e7565b Mon Sep 17 00:00:00 2001 From: Toan Nguyen Date: Fri, 14 Feb 2025 11:03:00 +0700 Subject: [PATCH 2/2] fix lint and add docs --- README.md | 19 ++++++++++++++++++- option.go | 2 +- subscription_graphql_ws_test.go | 10 ---------- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e15cc92..fb5a067 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ For more information, see package [`github.com/shurcooL/githubv4`](https://githu - [Options](#options-1) - [Execute pre-built query](#execute-pre-built-query) - [Get extensions from response](#get-extensions-from-response) + - [Get headers from response](#get-headers-from-response) - [With operation name (deprecated)](#with-operation-name-deprecated) - [Raw bytes response](#raw-bytes-response) - [Multiple mutations with ordered map](#multiple-mutations-with-ordered-map) @@ -753,11 +754,12 @@ type Option interface { client.Query(ctx context.Context, q interface{}, variables map[string]interface{}, options ...Option) error ``` -Currently, there are 3 option types: +Currently, there are 4 option types: - `operation_name` - `operation_directive` - `bind_extensions` +- `bind_response_headers` The operation name option is built-in because it is unique. We can use the option directly with `OperationName`. @@ -882,6 +884,21 @@ if err != nil { fmt.Println("Extensions:", extensions) ``` +### Get headers from response + +Use the `BindResponseHeaders` option to bind headers from the response. + +```go +headers := http.Header{} +err := client.Query(context.TODO(), &q, map[string]any{}, graphql.BindResponseHeaders(&headers)) +if err != nil { + panic(err) +} + +fmt.Println(headers.Get("content-type")) +// application/json +``` + ### With operation name (deprecated) ```Go diff --git a/option.go b/option.go index b3578c0..cfadfc1 100644 --- a/option.go +++ b/option.go @@ -55,7 +55,7 @@ type bindResponseHeadersOption struct { } func (ono bindResponseHeadersOption) Type() OptionType { - return "bind_extensions" + return "bind_response_headers" } // BindExtensionsBindResponseHeaders bind the header response to the pointer diff --git a/subscription_graphql_ws_test.go b/subscription_graphql_ws_test.go index 13dde99..b75fb5e 100644 --- a/subscription_graphql_ws_test.go +++ b/subscription_graphql_ws_test.go @@ -20,16 +20,6 @@ const ( hasuraTestAdminSecret = "hasura" ) -type headerRoundTripper struct { - setHeaders func(req *http.Request) - rt http.RoundTripper -} - -func (h headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - h.setHeaders(req) - return h.rt.RoundTrip(req) -} - type user_insert_input map[string]interface{} func hasura_setupClients(protocol SubscriptionProtocolType) (*Client, *SubscriptionClient) {