Skip to content

Commit 929aee5

Browse files
mefellowsYOU54F
authored andcommitted
feat: support graphql in v2 interface
1 parent 6b525b4 commit 929aee5

File tree

5 files changed

+305
-0
lines changed

5 files changed

+305
-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
@@ -4,8 +4,10 @@ go 1.20
44

55
require (
66
github.com/golang/protobuf v1.5.4
7+
github.com/graph-gophers/graphql-go v1.5.0
78
github.com/hashicorp/go-version v1.7.0
89
github.com/hashicorp/logutils v1.0.0
10+
github.com/hasura/go-graphql-client v0.12.1
911
github.com/linkedin/goavro/v2 v2.13.0
1012
github.com/spf13/afero v1.11.0
1113
github.com/spf13/cobra v1.8.1
@@ -18,6 +20,7 @@ require (
1820
require (
1921
github.com/davecgh/go-spew v1.1.1 // indirect
2022
github.com/golang/snappy v0.0.4 // indirect
23+
github.com/google/uuid v1.6.0 // indirect
2124
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2225
github.com/kr/pretty v0.3.0 // indirect
2326
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -29,4 +32,5 @@ require (
2932
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect
3033
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
3134
gopkg.in/yaml.v3 v3.0.1 // indirect
35+
nhooyr.io/websocket v1.8.10 // indirect
3236
)

go.sum

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,26 @@ 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.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
710
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
811
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
912
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
1013
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
14+
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
1115
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
16+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
17+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
18+
github.com/graph-gophers/graphql-go v1.5.0 h1:fDqblo50TEpD0LY7RXk/LFVYEVqo3+tXMNMPSVXA1yc=
19+
github.com/graph-gophers/graphql-go v1.5.0/go.mod h1:YtmJZDLbF1YYNrlNAuiO5zAStUWc3XZT07iGsVqe1Os=
1220
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
1321
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
1422
github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=
1523
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
24+
github.com/hasura/go-graphql-client v0.12.1 h1:tL+BCoyubkYYyaQ+tJz+oPe/pSxYwOJHwe5SSqqi6WI=
25+
github.com/hasura/go-graphql-client v0.12.1/go.mod h1:F4N4kR6vY8amio3gEu3tjSZr8GPOXJr3zj72DKixfLE=
1626
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
1727
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
1828
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -25,6 +35,7 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
2535
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
2636
github.com/linkedin/goavro/v2 v2.13.0 h1:L8eI8GcuciwUkt41Ej62joSZS4kKaYIUdze+6for9NU=
2737
github.com/linkedin/goavro/v2 v2.13.0/go.mod h1:KXx+erlq+RPlGSPmLF7xGo6SAbh8sCQ53x064+ioxhk=
38+
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
2839
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
2940
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
3041
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
@@ -39,16 +50,20 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
3950
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
4051
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
4152
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
53+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
4254
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
4355
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
4456
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
4557
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
58+
go.opentelemetry.io/otel v1.6.3/go.mod h1:7BgNga5fNlF/iZjG06hM3yofffp0ofKCDwSXx1GC4dI=
59+
go.opentelemetry.io/otel/trace v1.6.3/go.mod h1:GNJQusJlUgZl9/TQBPKU/Y/ty+0iVB5fjhKeJGZPGFs=
4660
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
4761
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
4862
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
4963
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
5064
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
5165
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
66+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
5267
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc=
5368
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
5469
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
@@ -65,3 +80,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
6580
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
6681
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
6782
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
83+
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
84+
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=

0 commit comments

Comments
 (0)