Skip to content

Commit 2849d82

Browse files
committed
feat: support graphql in v2 interface
1 parent b3b85cc commit 2849d82

File tree

5 files changed

+304
-0
lines changed

5 files changed

+304
-0
lines changed

consumer/graphql/interaction.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package graphql
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
7+
"github.com/pact-foundation/pact-go/v2/consumer"
8+
"github.com/pact-foundation/pact-go/v2/matchers"
9+
)
10+
11+
// Variables represents values to be substituted into the query
12+
type Variables map[string]interface{}
13+
14+
// Query is the main implementation of the Pact interface.
15+
type Query struct {
16+
// HTTP Headers
17+
Headers matchers.MapMatcher
18+
19+
// Path to GraphQL endpoint
20+
Path matchers.Matcher
21+
22+
// HTTP Query String
23+
QueryString matchers.MapMatcher
24+
25+
// GraphQL Query
26+
Query string
27+
28+
// GraphQL Variables
29+
Variables Variables
30+
31+
// GraphQL Operation
32+
Operation string
33+
34+
// GraphQL method (usually POST, but can be get with a query string)
35+
// NOTE: for query string users, the standard HTTP interaction should suffice
36+
Method string
37+
38+
// Supports graphql extensions such as https://www.apollographql.com/docs/apollo-server/performance/apq/
39+
Extensions Extensions
40+
}
41+
type Extensions map[string]interface{}
42+
43+
// Specify the operation (if any)
44+
func (r *Query) WithOperation(operation string) *Query {
45+
r.Operation = operation
46+
47+
return r
48+
}
49+
50+
// WithContentType overrides the default content-type (application/json)
51+
// for the GraphQL Query
52+
func (r *Query) WithContentType(contentType matchers.Matcher) *Query {
53+
r.setHeader("content-type", contentType)
54+
55+
return r
56+
}
57+
58+
// Specify the method (defaults to POST)
59+
func (r *Query) WithMethod(method string) *Query {
60+
r.Method = method
61+
62+
return r
63+
}
64+
65+
// Given specifies a provider state. Optional.
66+
func (r *Query) WithQuery(query string) *Query {
67+
r.Query = query
68+
69+
return r
70+
}
71+
72+
// Given specifies a provider state. Optional.
73+
func (r *Query) WithVariables(variables Variables) *Query {
74+
r.Variables = variables
75+
76+
return r
77+
}
78+
79+
// Set the query extensions
80+
func (r *Query) WithExtensions(extensions Extensions) *Query {
81+
r.Extensions = extensions
82+
83+
return r
84+
}
85+
86+
var defaultHeaders = matchers.MapMatcher{"content-type": matchers.String("application/json")}
87+
88+
func (r *Query) setHeader(headerName string, value matchers.Matcher) *Query {
89+
if r.Headers == nil {
90+
r.Headers = defaultHeaders
91+
}
92+
93+
r.Headers[headerName] = value
94+
95+
return r
96+
}
97+
98+
// Construct a Pact HTTP request for a GraphQL interaction
99+
func Interaction(request Query) *consumer.Request {
100+
if request.Headers == nil {
101+
request.Headers = defaultHeaders
102+
}
103+
104+
return &consumer.Request{
105+
Method: request.Method,
106+
Path: request.Path,
107+
Query: request.QueryString,
108+
Body: graphQLQueryBody{
109+
Operation: request.Operation,
110+
Query: matchers.Regex(request.Query, escapeGraphQlQuery(request.Query)),
111+
Variables: request.Variables,
112+
},
113+
Headers: request.Headers,
114+
}
115+
116+
}
117+
118+
type graphQLQueryBody struct {
119+
Operation string `json:"operationName,omitempty"`
120+
Query matchers.Matcher `json:"query"`
121+
Variables Variables `json:"variables,omitempty"`
122+
}
123+
124+
func escapeSpace(s string) string {
125+
r := regexp.MustCompile(`\s+`)
126+
return r.ReplaceAllString(s, `\s*`)
127+
}
128+
129+
func escapeRegexChars(s string) string {
130+
r := regexp.MustCompile(`(?m)[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]`)
131+
132+
f := func(s string) string {
133+
return fmt.Sprintf(`\%s`, s)
134+
}
135+
return r.ReplaceAllStringFunc(s, f)
136+
}
137+
138+
func escapeGraphQlQuery(s string) string {
139+
return escapeSpace(escapeRegexChars(s))
140+
}

consumer/graphql/response.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package graphql
2+
3+
// GraphQLRseponse models the GraphQL Response format.
4+
// See also http://spec.graphql.org/October2021/#sec-Response-Format
5+
type Response struct {
6+
Data interface{} `json:"data,omitempty"`
7+
Errors []interface{} `json:"errors,omitempty"`
8+
Extensions map[string]interface{} `json:"extensions,omitempty"`
9+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//go:build consumer
2+
// +build consumer
3+
4+
package graphql
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"net/http"
10+
"testing"
11+
12+
graphqlserver "github.com/graph-gophers/graphql-go"
13+
"github.com/graph-gophers/graphql-go/example/starwars"
14+
"github.com/graph-gophers/graphql-go/relay"
15+
graphql "github.com/hasura/go-graphql-client"
16+
"github.com/pact-foundation/pact-go/v2/consumer"
17+
g "github.com/pact-foundation/pact-go/v2/consumer/graphql"
18+
"github.com/pact-foundation/pact-go/v2/matchers"
19+
"github.com/stretchr/testify/assert"
20+
)
21+
22+
func TestGraphQLConsumer(t *testing.T) {
23+
// Create Pact connecting to local Daemon
24+
pact, err := consumer.NewV4Pact(consumer.MockHTTPProviderConfig{
25+
Consumer: "GraphQLConsumer",
26+
Provider: "GraphQLProvider",
27+
})
28+
assert.NoError(t, err)
29+
30+
// Set up our expected interactions.
31+
err = pact.
32+
AddInteraction().
33+
Given("User foo exists").
34+
UponReceiving("A request to get foo").
35+
WithCompleteRequest(*g.Interaction(g.Query{
36+
Method: "POST",
37+
Path: matchers.String("/query"),
38+
Query: `query ($characterID:ID!){
39+
hero {
40+
id,
41+
name
42+
},
43+
character(id: $characterID)
44+
{
45+
name,
46+
friends{
47+
name,
48+
__typename
49+
},
50+
appearsIn
51+
}
52+
}`,
53+
// Operation: "SomeOperation", // if needed
54+
Variables: g.Variables{
55+
"characterID": "1003",
56+
},
57+
})).
58+
WithCompleteResponse(consumer.Response{
59+
Status: 200,
60+
Headers: matchers.MapMatcher{"Content-Type": matchers.String("application/json")},
61+
Body: g.Response{
62+
Data: heroQuery{
63+
Hero: hero{
64+
ID: graphql.ID("1003"),
65+
Name: "Darth Vader",
66+
},
67+
Character: character{
68+
Name: "Darth Vader",
69+
AppearsIn: []graphql.String{
70+
"EMPIRE",
71+
},
72+
Friends: []friend{
73+
{
74+
Name: "Wilhuff Tarkin",
75+
Typename: "friends",
76+
},
77+
},
78+
},
79+
},
80+
}}).
81+
ExecuteTest(t, func(s consumer.MockServerConfig) error {
82+
res, err := executeQuery(fmt.Sprintf("http://%s:%d", s.Host, s.Port))
83+
84+
fmt.Println(res)
85+
assert.NoError(t, err)
86+
assert.NotNil(t, res.Hero.ID)
87+
88+
return nil
89+
})
90+
91+
assert.NoError(t, err)
92+
}
93+
94+
func executeQuery(baseURL string) (heroQuery, error) {
95+
var q heroQuery
96+
97+
// Set up a GraphQL server.
98+
schema, err := graphqlserver.ParseSchema(starwars.Schema, &starwars.Resolver{})
99+
if err != nil {
100+
return q, err
101+
}
102+
mux := http.NewServeMux()
103+
mux.Handle("/query", &relay.Handler{Schema: schema})
104+
105+
client := graphql.NewClient(fmt.Sprintf("%s/query", baseURL), nil)
106+
107+
variables := map[string]interface{}{
108+
"characterID": graphql.ID("1003"),
109+
}
110+
err = client.Query(context.Background(), &q, variables)
111+
if err != nil {
112+
return q, err
113+
}
114+
115+
return q, nil
116+
}
117+
118+
type hero struct {
119+
ID graphql.ID `json:"ID"`
120+
Name graphql.String `json:"Name"`
121+
}
122+
type friend struct {
123+
Name graphql.String `json:"Name"`
124+
Typename graphql.String `json:"__typename" graphql:"__typename"`
125+
}
126+
type character struct {
127+
Name graphql.String `json:"Name"`
128+
Friends []friend `json:"Friends"`
129+
AppearsIn []graphql.String `json:"AppearsIn"`
130+
}
131+
132+
type heroQuery struct {
133+
Hero hero `json:"Hero"`
134+
Character character `json:"character" graphql:"character(id: $characterID)"`
135+
}

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ require (
1919
github.com/davecgh/go-spew v1.1.1 // indirect
2020
github.com/golang/snappy v0.0.4 // indirect
2121
github.com/google/go-cmp v0.6.0 // indirect
22+
github.com/google/uuid v1.6.0 // indirect
23+
github.com/graph-gophers/graphql-go v1.5.0 // indirect
24+
github.com/hasura/go-graphql-client v0.12.1 // indirect
2225
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2326
github.com/kr/pretty v0.3.0 // indirect
2427
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -30,4 +33,5 @@ require (
3033
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect
3134
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
3235
gopkg.in/yaml.v3 v3.0.1 // indirect
36+
nhooyr.io/websocket v1.8.10 // indirect
3337
)

go.sum

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,29 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
33
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
44
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
55
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6+
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
7+
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
8+
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
69
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
710
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
811
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
912
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
1013
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
1114
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
1215
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
16+
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
1317
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
1418
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
19+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
20+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
21+
github.com/graph-gophers/graphql-go v1.5.0 h1:fDqblo50TEpD0LY7RXk/LFVYEVqo3+tXMNMPSVXA1yc=
22+
github.com/graph-gophers/graphql-go v1.5.0/go.mod h1:YtmJZDLbF1YYNrlNAuiO5zAStUWc3XZT07iGsVqe1Os=
1523
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
1624
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
1725
github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=
1826
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
27+
github.com/hasura/go-graphql-client v0.12.1 h1:tL+BCoyubkYYyaQ+tJz+oPe/pSxYwOJHwe5SSqqi6WI=
28+
github.com/hasura/go-graphql-client v0.12.1/go.mod h1:F4N4kR6vY8amio3gEu3tjSZr8GPOXJr3zj72DKixfLE=
1929
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
2030
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
2131
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -28,6 +38,7 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
2838
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
2939
github.com/linkedin/goavro/v2 v2.12.0 h1:rIQQSj8jdAUlKQh6DttK8wCRv4t4QO09g1C4aBWXslg=
3040
github.com/linkedin/goavro/v2 v2.12.0/go.mod h1:KXx+erlq+RPlGSPmLF7xGo6SAbh8sCQ53x064+ioxhk=
41+
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
3142
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
3243
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
3344
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
@@ -42,10 +53,13 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
4253
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
4354
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
4455
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
56+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
4557
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
4658
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
4759
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
4860
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
61+
go.opentelemetry.io/otel v1.6.3/go.mod h1:7BgNga5fNlF/iZjG06hM3yofffp0ofKCDwSXx1GC4dI=
62+
go.opentelemetry.io/otel/trace v1.6.3/go.mod h1:GNJQusJlUgZl9/TQBPKU/Y/ty+0iVB5fjhKeJGZPGFs=
4963
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
5064
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
5165
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
@@ -71,3 +85,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
7185
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
7286
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
7387
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
88+
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
89+
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=

0 commit comments

Comments
 (0)