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: introduce BindResponseHeaders option #163

Merged
merged 2 commits into from
Feb 14, 2025
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
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
Loading