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

Request options, better errors #68

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: CI

on:
pull_request:

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@main
- uses: actions/setup-go@main
- run: go test -v ./...
46 changes: 46 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package graphql

import "fmt"

// RequestError represents an error building a request.
type RequestError struct{ Err error }

func (e *RequestError) Error() string { return fmt.Sprintf("request error: %v", e.Err) }
func (e *RequestError) Unwrap() error { return e.Err }

// OptionError represents an error modifiying a request.
type OptionError struct{ Err error }

func (e *OptionError) Error() string { return fmt.Sprintf("request option error: %v", e.Err) }
func (e *OptionError) Unwrap() error { return e.Err }

// ResponseError represents a response error, either with getting a response
// from the server (eg. network error), or reading its body.
type ResponseError struct{ Err error }

func (e *ResponseError) Error() string { return fmt.Sprintf("request option error: %v", e.Err) }
func (e *ResponseError) Unwrap() error { return e.Err }

// ServerError indicates that the server returned a response but it was not what
// we consider a successful one.
type ServerError struct {
Body []byte
Status string
}

func (e *ServerError) Error() string {
return fmt.Sprintf("non-200 OK status code: %v body: %q", e.Status, e.Body)
}

// BodyError indicates that the server responded with the right status code but
// the body was unexpected and it did not parse as a valid GraphQL response.
type BodyError struct {
Body []byte
Err error
}

func (e *BodyError) Error() string {
return fmt.Sprintf("could not parse the body: %v, body: %q", e.Err, e.Body)
}

func (e *BodyError) Unwrap() error { return e.Err }
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module github.com/shurcooL/graphql

go 1.19

require (
github.com/graph-gophers/graphql-go v1.5.0
golang.org/x/net v0.5.0
)
19 changes: 19 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/graph-gophers/graphql-go v1.5.0 h1:fDqblo50TEpD0LY7RXk/LFVYEVqo3+tXMNMPSVXA1yc=
github.com/graph-gophers/graphql-go v1.5.0/go.mod h1:YtmJZDLbF1YYNrlNAuiO5zAStUWc3XZT07iGsVqe1Os=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
go.opentelemetry.io/otel v1.6.3/go.mod h1:7BgNga5fNlF/iZjG06hM3yofffp0ofKCDwSXx1GC4dI=
go.opentelemetry.io/otel/trace v1.6.3/go.mod h1:GNJQusJlUgZl9/TQBPKU/Y/ty+0iVB5fjhKeJGZPGFs=
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
94 changes: 72 additions & 22 deletions graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"

Expand All @@ -14,45 +13,48 @@ import (

// Client is a GraphQL client.
type Client struct {
url string // GraphQL server URL.
httpClient *http.Client
url string // GraphQL server URL.
httpClient *http.Client
requestOptions []RequestOption
}

// NewClient creates a GraphQL client targeting the specified GraphQL server URL.
// If httpClient is nil, then http.DefaultClient is used.
func NewClient(url string, httpClient *http.Client) *Client {
func NewClient(url string, httpClient *http.Client, opts ...RequestOption) *Client {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &Client{
url: url,
httpClient: httpClient,
url: url,
httpClient: httpClient,
requestOptions: opts,
}
}

// 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{}) error {
return c.do(ctx, queryOperation, q, variables)
func (c *Client) Query(ctx context.Context, q interface{}, variables map[string]interface{}, opts ...RequestOption) error {
return c.do(ctx, queryOperation, q, variables, opts)
}

// 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{}) error {
return c.do(ctx, mutationOperation, m, variables)
func (c *Client) Mutate(ctx context.Context, m interface{}, variables map[string]interface{}, opts ...RequestOption) error {
return c.do(ctx, mutationOperation, m, variables, opts)
}

// do executes a single GraphQL operation.
func (c *Client) do(ctx context.Context, op operationType, v interface{}, variables map[string]interface{}) error {
func (c *Client) do(ctx context.Context, op operationType, v interface{}, variables map[string]interface{}, opts []RequestOption) error {
var query string
switch op {
case queryOperation:
query = constructQuery(v, variables)
case mutationOperation:
query = constructMutation(v, variables)
}

in := struct {
Query string `json:"query"`
Variables map[string]interface{} `json:"variables,omitempty"`
Expand All @@ -65,35 +67,66 @@ func (c *Client) do(ctx context.Context, op operationType, v interface{}, variab
if err != nil {
return err
}
resp, err := ctxhttp.Post(ctx, c.httpClient, c.url, "application/json", &buf)

req, err := http.NewRequest(http.MethodPost, c.url, &buf)
if err != nil {
return err
return &RequestError{Err: err}
}
req.Header.Set("Content-Type", "application/json")

var allOpts []RequestOption
allOpts = append(allOpts, c.requestOptions...)
allOpts = append(allOpts, opts...)

for _, opt := range allOpts {
if err := opt(req); err != nil {
return &OptionError{Err: err}
}
}

resp, err := ctxhttp.Do(ctx, c.httpClient, req)
if err != nil {
return &ResponseError{Err: err}
}
defer resp.Body.Close()

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return &ResponseError{Err: err}
}

if resp.StatusCode != http.StatusOK {
body, _ := ioutil.ReadAll(resp.Body)
return fmt.Errorf("non-200 OK status code: %v body: %q", resp.Status, body)
return &ServerError{
Body: body,
Status: resp.Status,
}
}

var out struct {
Data *json.RawMessage
Errors errors
//Extensions interface{} // Unused.
Data *json.RawMessage
Errors errors
Extensions interface{}
}
err = json.NewDecoder(resp.Body).Decode(&out)
if err != nil {
// TODO: Consider including response body in returned error, if deemed helpful.
return err
return &BodyError{Err: err, Body: body}
}

if out.Data != nil {
err := jsonutil.UnmarshalGraphQL(*out.Data, v)
if err != nil {
// TODO: Consider including response body in returned error, if deemed helpful.
return err
return &BodyError{Err: err, Body: body}
}
}

if len(out.Errors) > 0 {
if out.Extensions != nil {
return newErrorsWithExtensions(out.Errors, out.Extensions)
}

return out.Errors
}

return nil
}

Expand Down Expand Up @@ -121,3 +154,20 @@ const (
mutationOperation
//subscriptionOperation // Unused.
)

type ErrorsWithExtensions struct {
errors errors
extensions interface{}
}

func newErrorsWithExtensions(err errors, extensions interface{}) ErrorsWithExtensions {
return ErrorsWithExtensions{errors: err, extensions: extensions}
}

func (e ErrorsWithExtensions) Error() string {
return e.errors[0].Message
}

func (e ErrorsWithExtensions) Extensions() interface{} {
return e.extensions
}
60 changes: 60 additions & 0 deletions graphql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package graphql_test

import (
"context"
"errors"
"io"
"io/ioutil"
"net/http"
Expand Down Expand Up @@ -101,6 +102,65 @@ func TestClient_Query_noDataWithErrorResponse(t *testing.T) {
}
}

func TestClient_Query_noDataWithErrorAndExtensionsResponse(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
mustWrite(w, `{
"errors": [
{
"message": "Field 'user' is missing required arguments: login",
"locations": [
{
"line": 7,
"column": 3
}
]
}
],
"extensions": {
"code": "MISSING_ARGUMENTS"
}
}`)
})
client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}})

var q struct {
User struct {
Name graphql.String
}
}
err := client.Query(context.Background(), &q, nil)

if err == nil {
t.Fatal("got error: nil, want: non-nil")
}

if got, want := err.Error(), "Field 'user' is missing required arguments: login"; got != want {
t.Errorf("got error: %v, want: %v", got, want)
}

var graphErr graphql.ErrorsWithExtensions
if !errors.As(err, &graphErr) {
t.Fatalf("got error: %T, want: %T", err, graphErr)
}

extensions := graphErr.Extensions()
asMap, ok := extensions.(map[string]interface{})

if !ok {
t.Fatalf("got error: %T, want: %T", graphErr.Extensions(), asMap)
}

if got, want := asMap["code"], "MISSING_ARGUMENTS"; got != want {
t.Errorf("got error: %v, want: %v", got, want)
}

if q.User.Name != "" {
t.Errorf("got non-empty q.User.Name: %v", q.User.Name)
}
}

func TestClient_Query_errorStatusCode(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) {
Expand Down
15 changes: 15 additions & 0 deletions request_option.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package graphql

import "net/http"

// RequestOption allows you to modify the request before sending it to the
// server.
type RequestOption func(*http.Request) error

// WithHeader sets a header on the request.
func WithHeader(key, value string) RequestOption {
return func(r *http.Request) error {
r.Header.Set(key, value)
return nil
}
}