Skip to content

Commit db3b688

Browse files
committed
initial simple client
1 parent 715de81 commit db3b688

File tree

4 files changed

+366
-0
lines changed

4 files changed

+366
-0
lines changed

client.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package graphql
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"mime/multipart"
10+
"net/http"
11+
12+
"github.com/pkg/errors"
13+
"golang.org/x/net/context/ctxhttp"
14+
)
15+
16+
// Client accesses a GraphQL API.
17+
type client struct {
18+
endpoint string
19+
httpclient *http.Client
20+
}
21+
22+
// Do executes a query request and returns the response.
23+
func (c *client) Do(ctx context.Context, request *Request, response interface{}) error {
24+
if err := ctx.Err(); err != nil {
25+
return err
26+
}
27+
var requestBody bytes.Buffer
28+
writer := multipart.NewWriter(&requestBody)
29+
if err := writer.WriteField("query", request.q); err != nil {
30+
return errors.Wrap(err, "write query field")
31+
}
32+
if len(request.vars) > 0 {
33+
variablesField, err := writer.CreateFormField("variables")
34+
if err != nil {
35+
return errors.Wrap(err, "create variables field")
36+
}
37+
if err := json.NewEncoder(variablesField).Encode(request.vars); err != nil {
38+
return errors.Wrap(err, "encode variables")
39+
}
40+
}
41+
for i := range request.files {
42+
filename := fmt.Sprintf("file-%d", i+1)
43+
if i == 0 {
44+
// just use "file" for the first one
45+
filename = "file"
46+
}
47+
part, err := writer.CreateFormFile(filename, request.files[i].Name)
48+
if err != nil {
49+
return errors.Wrap(err, "create form file")
50+
}
51+
if _, err := io.Copy(part, request.files[i].R); err != nil {
52+
return errors.Wrap(err, "preparing file")
53+
}
54+
}
55+
if err := writer.Close(); err != nil {
56+
return errors.Wrap(err, "close writer")
57+
}
58+
var graphResponse = struct {
59+
Data interface{}
60+
Errors []graphErr
61+
}{
62+
Data: response,
63+
}
64+
req, err := http.NewRequest(http.MethodPost, c.endpoint, &requestBody)
65+
if err != nil {
66+
return err
67+
}
68+
req.Header.Set("Content-Type", writer.FormDataContentType())
69+
req.Header.Set("Accept", "application/json")
70+
res, err := ctxhttp.Do(ctx, c.httpclient, req)
71+
if err != nil {
72+
return err
73+
}
74+
defer res.Body.Close()
75+
var buf bytes.Buffer
76+
if _, err := io.Copy(&buf, res.Body); err != nil {
77+
return errors.Wrap(err, "reading body")
78+
}
79+
if err := json.NewDecoder(&buf).Decode(&graphResponse); err != nil {
80+
return errors.Wrap(err, "decoding response")
81+
}
82+
if len(graphResponse.Errors) > 0 {
83+
// return first error
84+
return graphResponse.Errors[0]
85+
}
86+
return nil
87+
}
88+
89+
type graphErr struct {
90+
Message string
91+
}
92+
93+
func (e graphErr) Error() string {
94+
return "graphql: " + e.Message
95+
}
96+
97+
// Request is a GraphQL request.
98+
type Request struct {
99+
q string
100+
vars map[string]interface{}
101+
files []file
102+
}
103+
104+
// NewRequest makes a new Request with the specified string.
105+
func NewRequest(q string) *Request {
106+
req := &Request{
107+
q: q,
108+
}
109+
return req
110+
}
111+
112+
// Run executes the query and unmarshals the response into response.
113+
func (req *Request) Run(ctx context.Context, response interface{}) error {
114+
client := fromContext(ctx)
115+
if client == nil {
116+
return errors.New("inappropriate context")
117+
}
118+
return client.Do(ctx, req, response)
119+
}
120+
121+
// Var sets a variable.
122+
func (req *Request) Var(key string, value interface{}) {
123+
if req.vars == nil {
124+
req.vars = make(map[string]interface{})
125+
}
126+
req.vars[key] = value
127+
}
128+
129+
// File sets a file to upload.
130+
func (req *Request) File(filename string, r io.Reader) {
131+
req.files = append(req.files, file{
132+
Name: filename,
133+
R: r,
134+
})
135+
}
136+
137+
// file represents a file to upload.
138+
type file struct {
139+
Name string
140+
R io.Reader
141+
}

client_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package graphql
2+
3+
import (
4+
"context"
5+
"io"
6+
"io/ioutil"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
"time"
12+
13+
"github.com/matryer/is"
14+
)
15+
16+
func TestDo(t *testing.T) {
17+
is := is.New(t)
18+
var calls int
19+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+
calls++
21+
is.Equal(r.Method, http.MethodPost)
22+
query := r.FormValue("query")
23+
is.Equal(query, `query {}`)
24+
io.WriteString(w, `{
25+
"data": {
26+
"something": "yes"
27+
}
28+
}`)
29+
}))
30+
defer srv.Close()
31+
c := &client{
32+
endpoint: srv.URL,
33+
httpclient: &http.Client{
34+
Timeout: 1 * time.Second,
35+
},
36+
}
37+
ctx := context.Background()
38+
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
39+
defer cancel()
40+
var responseData map[string]interface{}
41+
err := c.Do(ctx, &Request{q: "query {}"}, &responseData)
42+
is.NoErr(err)
43+
is.Equal(calls, 1) // calls
44+
is.Equal(responseData["something"], "yes")
45+
}
46+
47+
func TestDoErr(t *testing.T) {
48+
is := is.New(t)
49+
var calls int
50+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
51+
calls++
52+
is.Equal(r.Method, http.MethodPost)
53+
query := r.FormValue("query")
54+
is.Equal(query, `query {}`)
55+
io.WriteString(w, `{
56+
"errors": [{
57+
"message": "Something went wrong"
58+
}]
59+
}`)
60+
}))
61+
defer srv.Close()
62+
c := &client{
63+
endpoint: srv.URL,
64+
httpclient: &http.Client{
65+
Timeout: 1 * time.Second,
66+
},
67+
}
68+
ctx := context.Background()
69+
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
70+
defer cancel()
71+
var responseData map[string]interface{}
72+
err := c.Do(ctx, &Request{q: "query {}"}, &responseData)
73+
is.True(err != nil)
74+
is.Equal(err.Error(), "graphql: Something went wrong")
75+
}
76+
77+
func TestDoNoResponse(t *testing.T) {
78+
is := is.New(t)
79+
var calls int
80+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
81+
calls++
82+
is.Equal(r.Method, http.MethodPost)
83+
query := r.FormValue("query")
84+
is.Equal(query, `query {}`)
85+
io.WriteString(w, `{
86+
"data": {
87+
"something": "yes"
88+
}
89+
}`)
90+
}))
91+
defer srv.Close()
92+
c := &client{
93+
endpoint: srv.URL,
94+
httpclient: &http.Client{
95+
Timeout: 1 * time.Second,
96+
},
97+
}
98+
ctx := context.Background()
99+
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
100+
defer cancel()
101+
err := c.Do(ctx, &Request{q: "query {}"}, nil)
102+
is.NoErr(err)
103+
is.Equal(calls, 1) // calls
104+
}
105+
106+
func TestQuery(t *testing.T) {
107+
is := is.New(t)
108+
109+
var calls int
110+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
111+
calls++
112+
query := r.FormValue("query")
113+
is.Equal(query, "query {}")
114+
is.Equal(r.FormValue("variables"), `{"username":"matryer"}`+"\n")
115+
_, err := io.WriteString(w, `{"data":{"value":"some data"}}`)
116+
is.NoErr(err)
117+
}))
118+
defer srv.Close()
119+
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
120+
defer cancel()
121+
ctx = NewContext(ctx, srv.URL)
122+
123+
req := NewRequest("query {}")
124+
req.Var("username", "matryer")
125+
126+
// check variables
127+
is.True(req != nil)
128+
is.Equal(req.vars["username"], "matryer")
129+
130+
var resp struct {
131+
Value string
132+
}
133+
err := req.Run(ctx, &resp)
134+
is.NoErr(err)
135+
is.Equal(calls, 1)
136+
137+
is.Equal(resp.Value, "some data")
138+
139+
}
140+
141+
func TestFile(t *testing.T) {
142+
is := is.New(t)
143+
144+
var calls int
145+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
146+
calls++
147+
file, header, err := r.FormFile("file")
148+
is.NoErr(err)
149+
defer file.Close()
150+
is.Equal(header.Filename, "filename.txt")
151+
is.Equal(header.Size, int64(14))
152+
153+
b, err := ioutil.ReadAll(file)
154+
is.NoErr(err)
155+
is.Equal(string(b), `This is a file`)
156+
157+
_, err = io.WriteString(w, `{"data":{"value":"some data"}}`)
158+
is.NoErr(err)
159+
}))
160+
defer srv.Close()
161+
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
162+
defer cancel()
163+
ctx = NewContext(ctx, srv.URL)
164+
165+
f := strings.NewReader(`This is a file`)
166+
167+
req := NewRequest("query {}")
168+
req.File("filename.txt", f)
169+
170+
err := req.Run(ctx, nil)
171+
is.NoErr(err)
172+
173+
}

context.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package graphql
2+
3+
import "context"
4+
5+
// contextKey provides unique keys for context values.
6+
type contextKey string
7+
8+
// clientContextKey is the context value key for the Client.
9+
var clientContextKey = contextKey("graphql client context")
10+
11+
// fromContext gets the client from the specified
12+
// Context.
13+
func fromContext(ctx context.Context) *client {
14+
c, _ := ctx.Value(clientContextKey).(*client)
15+
return c
16+
}
17+
18+
// NewContext makes a new context.Context that enables requests.
19+
func NewContext(parent context.Context, endpoint string) context.Context {
20+
client := &client{
21+
endpoint: endpoint,
22+
}
23+
return context.WithValue(parent, clientContextKey, client)
24+
}

context_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package graphql
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/matryer/is"
8+
)
9+
10+
func TestNewContext(t *testing.T) {
11+
is := is.New(t)
12+
13+
testContextKey := contextKey("something")
14+
15+
ctx := context.Background()
16+
ctx = context.WithValue(ctx, testContextKey, true)
17+
18+
endpoint := "https://server.com/graphql"
19+
ctx = NewContext(ctx, endpoint)
20+
21+
vclient := fromContext(ctx)
22+
is.Equal(vclient.endpoint, endpoint)
23+
24+
vclient2 := fromContext(ctx)
25+
is.Equal(vclient, vclient2)
26+
27+
is.Equal(ctx.Value(testContextKey), true) // normal context stuff should work
28+
}

0 commit comments

Comments
 (0)