Skip to content

Commit

Permalink
feat: introduce BindResponseHeaders option (#163)
Browse files Browse the repository at this point in the history
* add BindResponseHeaders option to allow binding response headers
  • Loading branch information
hgiasac authored Feb 14, 2025
1 parent 47ee315 commit 3b34c44
Show file tree
Hide file tree
Showing 6 changed files with 101 additions and 24 deletions.
48 changes: 42 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -37,6 +39,8 @@ 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)
- [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)
Expand Down Expand Up @@ -67,7 +71,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"
Expand Down Expand Up @@ -735,10 +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`.
Expand Down Expand Up @@ -863,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
Expand Down Expand Up @@ -982,11 +1018,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
Expand Down
11 changes: 11 additions & 0 deletions graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -350,6 +360,7 @@ func (c *Client) WithRequestModifier(f RequestModifier) *Client {
url: c.url,
httpClient: c.httpClient,
requestModifier: f,
debug: c.debug,
}
}

Expand Down
26 changes: 24 additions & 2 deletions graphql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -518,18 +519,30 @@ 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)
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")
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}})
Expand All @@ -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)
}
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions option.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package graphql

import "net/http"

// OptionType represents the logic of graphql query construction
type OptionType string

Expand Down Expand Up @@ -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_response_headers"
}

// BindExtensionsBindResponseHeaders bind the header response to the pointer
func BindResponseHeaders(value *http.Header) Option {
return bindResponseHeadersOption{value: value}
}
4 changes: 4 additions & 0 deletions query.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"sort"
"strconv"
Expand All @@ -17,6 +18,7 @@ type constructOptionsOutput struct {
operationName string
operationDirectives []string
extensions any
headers *http.Header
}

func (coo constructOptionsOutput) OperationDirectivesString() string {
Expand All @@ -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())
Expand Down
20 changes: 4 additions & 16 deletions subscription_graphql_ws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,14 @@ 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) {
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).
Expand Down

0 comments on commit 3b34c44

Please sign in to comment.