Skip to content

Commit

Permalink
Merge branch 'main' into benkraft.generate-test
Browse files Browse the repository at this point in the history
  • Loading branch information
benjaminjkraft authored Nov 30, 2024
2 parents b0ca244 + adb9dd6 commit 3b1453c
Show file tree
Hide file tree
Showing 10 changed files with 154 additions and 25 deletions.
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Note that genqlient now requires Go 1.22.5 or higher, and is tested through Go 1
See the [documentation](FAQ.md) for how to `subscribe to an API 'subscription' endpoint`.
- genqlient now supports double-star globs for schema and query files; see [`genqlient.yaml` docs](genqlient.yaml) for more.
- genqlient now generates slices containing all enum values for each enum type.
- genqlient now returns `Is`/`As`-able errors when the HTTP request returns a non-200 status.

### Bug fixes:

Expand Down
13 changes: 12 additions & 1 deletion docs/client_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,18 @@ For more on accessing response objects for interfaces and fragments, see the [op

### Handling errors

In addition to the response-struct, each genqlient-generated helper function returns an error. The response-struct will always be initialized (never nil), even on error. If the request returns a valid GraphQL response containing errors, the returned error will be [`As`-able](https://pkg.go.dev/errors#As) as [`gqlerror.List`](https://pkg.go.dev/github.com/vektah/gqlparser/v2/gqlerror#List), and the struct may be partly-populated (if one field failed but another was computed successfully). If the request fails entirely, the error will be another error (e.g. a [`*url.Error`](https://pkg.go.dev/net/url#Error)), and the response will be blank (but still non-nil).
In addition to the response-struct, each genqlient-generated helper function returns an error. The error may be [`As`-able][As] to one of the following:

- [`gqlerror.List`][gqlerror], if the request returns a valid GraphQL response containing errors; in this case the struct may be partly-populated
- [`graphql.HTTPError`][HTTPError], if there was a valid but non-200 HTTP response
- another error (e.g. a [`*url.Error`][urlError])

In case of a GraphQL error, the response-struct may be partly-populated (if one field failed but another was computed successfully). In other cases it will be blank, but it will always be initialized (never nil), even on error.

[As]: https://pkg.go.dev/errors#As
[gqlerror]: https://pkg.go.dev/github.com/vektah/gqlparser/v2/gqlerror#List
[HTTPError]: https://pkg.go.dev/github.com/Khan/genqlient/graphql#HTTPError
[urlError]: https://pkg.go.dev/net/url#Error

For example, you might do one of the following:
```go
Expand Down
2 changes: 1 addition & 1 deletion docs/subscriptions.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Using genqlient with GraphQL subscriptions

This document describes how to use genqlient to make GraphQL subscriptions. It assumes you already have the basic [client](./client_config.md) set up. Subscription support is fairly new; please report any bugs or missing features!
This document describes how to use genqlient to make GraphQL subscriptions. It assumes you already have the basic [client](./client_config.md) set up. Subscription support is fairly new; please report any bugs or missing features!

## Client setup

Expand Down
10 changes: 3 additions & 7 deletions generate/operation.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,16 @@ func {{.Name}}(
resp_,
)
{{end}}

return {{if eq .Type "subscription"}}dataChan_, subscriptionID_,{{else}}data_, {{if .Config.Extensions -}}resp_.Extensions,{{end -}}{{end}} err_
}

{{if eq .Type "subscription"}}
type {{.Name}}WsResponse struct {
Data *{{.ResponseName}} `json:"data"`
Extensions map[string]interface{} `json:"extensions,omitempty"`
Errors error `json:"errors"`
}
type {{.Name}}WsResponse graphql.BaseResponse[*{{.ResponseName}}]

func {{.Name}}ForwardData(interfaceChan interface{}, jsonRawMsg json.RawMessage) error {
var gqlResp graphql.Response
var wsResp {{.Name}}WsResponse
var wsResp {{.Name}}WsResponse
err := json.Unmarshal(jsonRawMsg, &gqlResp)
if err != nil {
return err
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 11 additions & 6 deletions graphql/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ type Request struct {
OpName string `json:"operationName"`
}

type BaseResponse[T any] struct {
Data T `json:"data"`
Extensions map[string]interface{} `json:"extensions,omitempty"`
Errors gqlerror.List `json:"errors,omitempty"`
}

// Response that contains data returned by the GraphQL API.
//
// Typically, GraphQL APIs will return a JSON payload of the form
Expand All @@ -206,11 +212,7 @@ type Request struct {
// It may additionally contain a key named "extensions", that
// might hold GraphQL protocol extensions. Extensions and Errors
// are optional, depending on the values returned by the server.
type Response struct {
Data interface{} `json:"data"`
Extensions map[string]interface{} `json:"extensions,omitempty"`
Errors gqlerror.List `json:"errors,omitempty"`
}
type Response BaseResponse[any]

func (c *client) MakeRequest(ctx context.Context, req *Request, resp *Response) error {
var httpReq *http.Request
Expand Down Expand Up @@ -242,7 +244,10 @@ func (c *client) MakeRequest(ctx context.Context, req *Request, resp *Response)
if err != nil {
respBody = []byte(fmt.Sprintf("<unreadable: %v>", err))
}
return fmt.Errorf("returned error %v: %s", httpResp.Status, respBody)
return &HTTPError{
StatusCode: httpResp.StatusCode,
Body: string(respBody),
}
}

err = json.NewDecoder(httpResp.Body).Decode(resp)
Expand Down
97 changes: 97 additions & 0 deletions graphql/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package graphql

import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
)

func TestMakeRequest_HTTPError(t *testing.T) {
testCases := []struct {
name string
serverResponseBody string
expectedErrorBody string
serverResponseCode int
expectedStatusCode int
}{
{
name: "400 Bad Request",
serverResponseBody: "Bad Request",
expectedErrorBody: "Bad Request",
serverResponseCode: http.StatusBadRequest,
expectedStatusCode: http.StatusBadRequest,
},
{
name: "429 Too Many Requests",
serverResponseBody: "Rate limit exceeded",
expectedErrorBody: "Rate limit exceeded",
serverResponseCode: http.StatusTooManyRequests,
expectedStatusCode: http.StatusTooManyRequests,
},
{
name: "500 Internal Server Error",
serverResponseBody: "Internal Server Error",
expectedErrorBody: "Internal Server Error",
serverResponseCode: http.StatusInternalServerError,
expectedStatusCode: http.StatusInternalServerError,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.serverResponseCode)
_, err := w.Write([]byte(tc.serverResponseBody))
if err != nil {
t.Fatalf("Failed to write response: %v", err)
}
}))
defer server.Close()

client := NewClient(server.URL, server.Client())
req := &Request{
Query: "query { test }",
}
resp := &Response{}

err := client.MakeRequest(context.Background(), req, resp)

assert.Error(t, err)
var httpErr *HTTPError
assert.True(t, errors.As(err, &httpErr), "Error should be of type *HTTPError")
assert.Equal(t, tc.expectedStatusCode, httpErr.StatusCode)
assert.Equal(t, tc.expectedErrorBody, httpErr.Body)
})
}
}

func TestMakeRequest_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]string{
"test": "success",
},
})
if err != nil {
t.Fatalf("Failed to encode response: %v", err)
}
}))
defer server.Close()

client := NewClient(server.URL, server.Client())
req := &Request{
Query: "query { test }",
}
resp := &Response{}

err := client.MakeRequest(context.Background(), req, resp)

assert.NoError(t, err)
assert.Equal(t, map[string]interface{}{"test": "success"}, resp.Data)
}
14 changes: 14 additions & 0 deletions graphql/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package graphql

import "fmt"

// HTTPError represents an HTTP error with status code and response body.
type HTTPError struct {
Body string
StatusCode int
}

// Error implements the error interface for HTTPError.
func (e *HTTPError) Error() string {
return fmt.Sprintf("returned error %v: %s", e.StatusCode, e.Body)
}
6 changes: 1 addition & 5 deletions internal/integration/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions internal/integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ package integration

import (
"context"
"errors"
"fmt"
"net/http"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vektah/gqlparser/v2/gqlerror"

"github.com/Khan/genqlient/graphql"
"github.com/Khan/genqlient/internal/integration/server"
Expand Down Expand Up @@ -159,6 +161,15 @@ func TestServerError(t *testing.T) {
// response -- and indeed in this case it should even have another field
// (which didn't err) set.
assert.Error(t, err)
t.Logf("Full error: %+v", err)
var gqlErrors gqlerror.List
if !assert.True(t, errors.As(err, &gqlErrors), "Error should be of type gqlerror.List") {
t.Logf("Actual error type: %T", err)
t.Logf("Error message: %v", err)
} else {
assert.Len(t, gqlErrors, 1, "Expected one GraphQL error")
assert.Equal(t, "oh no", gqlErrors[0].Message)
}
assert.NotNil(t, resp)
assert.Equal(t, "1", resp.Me.Id)
}
Expand All @@ -176,6 +187,8 @@ func TestNetworkError(t *testing.T) {
// return resp.Me.Id, err
// without a bunch of extra ceremony.
assert.Error(t, err)
var gqlErrors gqlerror.List
assert.False(t, errors.As(err, &gqlErrors), "Error should not be of type gqlerror.List for network errors")
assert.NotNil(t, resp)
assert.Equal(t, new(failingQueryResponse), resp)
}
Expand Down

0 comments on commit 3b1453c

Please sign in to comment.