Skip to content

Commit

Permalink
Refactor test client
Browse files Browse the repository at this point in the history
  • Loading branch information
vektah committed Sep 16, 2019
1 parent 55b2144 commit 4162d11
Show file tree
Hide file tree
Showing 32 changed files with 323 additions and 281 deletions.
161 changes: 78 additions & 83 deletions client/client.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// client is used internally for testing. See readme for alternatives

package client

import (
Expand All @@ -7,82 +8,63 @@ import (
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"

"github.com/mitchellh/mapstructure"
)

// Client for graphql requests
type Client struct {
url string
client *http.Client
}

// New creates a graphql client
func New(url string, client ...*http.Client) *Client {
p := &Client{
url: url,
type (
// Client used for testing GraphQL servers. Not for production use.
Client struct {
h http.Handler
opts []Option
}

if len(client) > 0 {
p.client = client[0]
} else {
p.client = http.DefaultClient
// Option implements a visitor that mutates an outgoing GraphQL request
//
// This is the Option pattern - https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis
Option func(bd *Request)

// Request represents an outgoing GraphQL request
Request struct {
Query string `json:"query"`
Variables map[string]interface{} `json:"variables,omitempty"`
OperationName string `json:"operationName,omitempty"`
HTTP *http.Request `json:"-"`
}
return p
}

type Request struct {
Query string `json:"query"`
Variables map[string]interface{} `json:"variables,omitempty"`
OperationName string `json:"operationName,omitempty"`
}

type Option func(r *Request)

func Var(name string, value interface{}) Option {
return func(r *Request) {
if r.Variables == nil {
r.Variables = map[string]interface{}{}
}

r.Variables[name] = value
// Response is a GraphQL layer response from a handler.
Response struct {
Data interface{}
Errors json.RawMessage
Extensions map[string]interface{}
}
}
)

func Operation(name string) Option {
return func(r *Request) {
r.OperationName = name
// New creates a graphql client
// Options can be set that should be applied to all requests made with this client
func New(h http.Handler, opts ...Option) *Client {
p := &Client{
h: h,
opts: opts,
}

return p
}

// MustPost is a convenience wrapper around Post that automatically panics on error
func (p *Client) MustPost(query string, response interface{}, options ...Option) {
if err := p.Post(query, response, options...); err != nil {
panic(err)
}
}

func (p *Client) mkRequest(query string, options ...Option) Request {
r := Request{
Query: query,
}

for _, option := range options {
option(&r)
}

return r
}

type ResponseData struct {
Data interface{}
Errors json.RawMessage
Extensions map[string]interface{}
}

func (p *Client) Post(query string, response interface{}, options ...Option) (resperr error) {
respDataRaw, resperr := p.RawPost(query, options...)
if resperr != nil {
return resperr
// Post sends a http POST request to the graphql endpoint with the given query then unpacks
// the response into the given object.
func (p *Client) Post(query string, response interface{}, options ...Option) error {
respDataRaw, err := p.RawPost(query, options...)
if err != nil {
return err
}

// we want to unpack even if there is an error, so we can see partial responses
Expand All @@ -94,48 +76,61 @@ func (p *Client) Post(query string, response interface{}, options ...Option) (re
return unpackErr
}

func (p *Client) RawPost(query string, options ...Option) (*ResponseData, error) {
r := p.mkRequest(query, options...)
requestBody, err := json.Marshal(r)
// RawPost is similar to Post, except it skips decoding the raw json response
// unpacked onto Response. This is used to test extension keys which are not
// available when using Post.
func (p *Client) RawPost(query string, options ...Option) (*Response, error) {
r, err := p.newRequest(query, options...)
if err != nil {
return nil, fmt.Errorf("encode: %s", err.Error())
return nil, fmt.Errorf("build: %s", err.Error())
}

rawResponse, err := p.client.Post(p.url, "application/json", bytes.NewBuffer(requestBody))
if err != nil {
return nil, fmt.Errorf("post: %s", err.Error())
}
defer func() {
_ = rawResponse.Body.Close()
}()
w := httptest.NewRecorder()
p.h.ServeHTTP(w, r)

if rawResponse.StatusCode >= http.StatusBadRequest {
responseBody, _ := ioutil.ReadAll(rawResponse.Body)
return nil, fmt.Errorf("http %d: %s", rawResponse.StatusCode, responseBody)
}

responseBody, err := ioutil.ReadAll(rawResponse.Body)
if err != nil {
return nil, fmt.Errorf("read: %s", err.Error())
if w.Code >= http.StatusBadRequest {
return nil, fmt.Errorf("http %d: %s", w.Code, w.Body.String())
}

// decode it into map string first, let mapstructure do the final decode
// because it can be much stricter about unknown fields.
respDataRaw := &ResponseData{}
err = json.Unmarshal(responseBody, &respDataRaw)
respDataRaw := &Response{}
err = json.Unmarshal(w.Body.Bytes(), &respDataRaw)
if err != nil {
return nil, fmt.Errorf("decode: %s", err.Error())
}

return respDataRaw, nil
}

type RawJsonError struct {
json.RawMessage
}
func (p *Client) newRequest(query string, options ...Option) (*http.Request, error) {
bd := &Request{
Query: query,
HTTP: httptest.NewRequest(http.MethodPost, "/", nil),
}
bd.HTTP.Header.Set("Content-Type", "application/json")

// per client options from client.New apply first
for _, option := range options {
option(bd)
}
// per request options
for _, option := range options {
option(bd)
}

switch bd.HTTP.Header.Get("Content-Type") {
case "application/json":
requestBody, err := json.Marshal(bd)
if err != nil {
return nil, fmt.Errorf("encode: %s", err.Error())
}
bd.HTTP.Body = ioutil.NopCloser(bytes.NewBuffer(requestBody))
default:
panic("unsupported encoding" + bd.HTTP.Header.Get("Content-Type"))
}

func (r RawJsonError) Error() string {
return string(r.RawMessage)
return bd.HTTP, nil
}

func unpack(data interface{}, into interface{}) error {
Expand Down
57 changes: 53 additions & 4 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@ import (
"encoding/json"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"

"github.com/99designs/gqlgen/client"
"github.com/stretchr/testify/require"
)

func TestClient(t *testing.T) {
h := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, err := ioutil.ReadAll(r.Body)
if err != nil {
panic(err)
Expand All @@ -27,9 +26,9 @@ func TestClient(t *testing.T) {
if err != nil {
panic(err)
}
}))
})

c := client.New(h.URL)
c := client.New(h)

var resp struct {
Name string
Expand All @@ -39,3 +38,53 @@ func TestClient(t *testing.T) {

require.Equal(t, "bob", resp.Name)
}

func TestAddHeader(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "ASDF", r.Header.Get("Test-Key"))

w.Write([]byte(`{}`))
})

c := client.New(h)

var resp struct{}
c.MustPost("{ id }", &resp,
client.AddHeader("Test-Key", "ASDF"),
)
}

func TestBasicAuth(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
require.True(t, ok)
require.Equal(t, "user", user)
require.Equal(t, "pass", pass)

w.Write([]byte(`{}`))
})

c := client.New(h)

var resp struct{}
c.MustPost("{ id }", &resp,
client.BasicAuth("user", "pass"),
)
}

func TestAddCookie(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("foo")
require.NoError(t, err)
require.Equal(t, "value", c.Value)

w.Write([]byte(`{}`))
})

c := client.New(h)

var resp struct{}
c.MustPost("{ id }", &resp,
client.AddCookie(&http.Cookie{Name: "foo", Value: "value"}),
)
}
12 changes: 12 additions & 0 deletions client/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package client

import "encoding/json"

// RawJsonError is a json formatted error from a GraphQL server.
type RawJsonError struct {
json.RawMessage
}

func (r RawJsonError) Error() string {
return string(r.RawMessage)
}
50 changes: 50 additions & 0 deletions client/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package client

import "net/http"

// Var adds a variable into the outgoing request
func Var(name string, value interface{}) Option {
return func(bd *Request) {
if bd.Variables == nil {
bd.Variables = map[string]interface{}{}
}

bd.Variables[name] = value
}
}

// Operation sets the operation name for the outgoing request
func Operation(name string) Option {
return func(bd *Request) {
bd.OperationName = name
}
}

// Path sets the url that this request will be made against, useful if you are mounting your entire router
// and need to specify the url to the graphql endpoint.
func Path(url string) Option {
return func(bd *Request) {
bd.HTTP.URL.Path = url
}
}

// AddHeader adds a header to the outgoing request. This is useful for setting expected Authentication headers for example.
func AddHeader(key string, value string) Option {
return func(bd *Request) {
bd.HTTP.Header.Add(key, value)
}
}

// BasicAuth authenticates the request using http basic auth.
func BasicAuth(username, password string) Option {
return func(bd *Request) {
bd.HTTP.SetBasicAuth(username, password)
}
}

// AddCookie adds a cookie to the outgoing request
func AddCookie(cookie *http.Cookie) Option {
return func(bd *Request) {
bd.HTTP.AddCookie(cookie)
}
}
Loading

0 comments on commit 4162d11

Please sign in to comment.