Skip to content

Commit

Permalink
feat: support graphql in v2 interface
Browse files Browse the repository at this point in the history
  • Loading branch information
mefellows committed Apr 22, 2024
1 parent b3b85cc commit 2849d82
Show file tree
Hide file tree
Showing 5 changed files with 304 additions and 0 deletions.
140 changes: 140 additions & 0 deletions consumer/graphql/interaction.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package graphql

import (
"fmt"
"regexp"

"github.com/pact-foundation/pact-go/v2/consumer"
"github.com/pact-foundation/pact-go/v2/matchers"
)

// Variables represents values to be substituted into the query
type Variables map[string]interface{}

// Query is the main implementation of the Pact interface.
type Query struct {
// HTTP Headers
Headers matchers.MapMatcher

// Path to GraphQL endpoint
Path matchers.Matcher

// HTTP Query String
QueryString matchers.MapMatcher

// GraphQL Query
Query string

// GraphQL Variables
Variables Variables

// GraphQL Operation
Operation string

// GraphQL method (usually POST, but can be get with a query string)
// NOTE: for query string users, the standard HTTP interaction should suffice
Method string

// Supports graphql extensions such as https://www.apollographql.com/docs/apollo-server/performance/apq/
Extensions Extensions
}
type Extensions map[string]interface{}

// Specify the operation (if any)
func (r *Query) WithOperation(operation string) *Query {
r.Operation = operation

return r
}

// WithContentType overrides the default content-type (application/json)
// for the GraphQL Query
func (r *Query) WithContentType(contentType matchers.Matcher) *Query {
r.setHeader("content-type", contentType)

return r
}

// Specify the method (defaults to POST)
func (r *Query) WithMethod(method string) *Query {
r.Method = method

return r
}

// Given specifies a provider state. Optional.
func (r *Query) WithQuery(query string) *Query {
r.Query = query

return r
}

// Given specifies a provider state. Optional.
func (r *Query) WithVariables(variables Variables) *Query {
r.Variables = variables

return r
}

// Set the query extensions
func (r *Query) WithExtensions(extensions Extensions) *Query {
r.Extensions = extensions

return r
}

var defaultHeaders = matchers.MapMatcher{"content-type": matchers.String("application/json")}

func (r *Query) setHeader(headerName string, value matchers.Matcher) *Query {
if r.Headers == nil {
r.Headers = defaultHeaders
}

r.Headers[headerName] = value

return r
}

// Construct a Pact HTTP request for a GraphQL interaction
func Interaction(request Query) *consumer.Request {
if request.Headers == nil {
request.Headers = defaultHeaders
}

return &consumer.Request{
Method: request.Method,
Path: request.Path,
Query: request.QueryString,
Body: graphQLQueryBody{
Operation: request.Operation,
Query: matchers.Regex(request.Query, escapeGraphQlQuery(request.Query)),
Variables: request.Variables,
},
Headers: request.Headers,
}

}

type graphQLQueryBody struct {
Operation string `json:"operationName,omitempty"`
Query matchers.Matcher `json:"query"`
Variables Variables `json:"variables,omitempty"`
}

func escapeSpace(s string) string {
r := regexp.MustCompile(`\s+`)
return r.ReplaceAllString(s, `\s*`)
}

func escapeRegexChars(s string) string {
r := regexp.MustCompile(`(?m)[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]`)

f := func(s string) string {
return fmt.Sprintf(`\%s`, s)
}
return r.ReplaceAllStringFunc(s, f)
}

func escapeGraphQlQuery(s string) string {
return escapeSpace(escapeRegexChars(s))
}
9 changes: 9 additions & 0 deletions consumer/graphql/response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package graphql

// GraphQLRseponse models the GraphQL Response format.
// See also http://spec.graphql.org/October2021/#sec-Response-Format
type Response struct {
Data interface{} `json:"data,omitempty"`
Errors []interface{} `json:"errors,omitempty"`
Extensions map[string]interface{} `json:"extensions,omitempty"`
}
135 changes: 135 additions & 0 deletions examples/graphql/graphql_consumer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//go:build consumer
// +build consumer

package graphql

import (
"context"
"fmt"
"net/http"
"testing"

graphqlserver "github.com/graph-gophers/graphql-go"
"github.com/graph-gophers/graphql-go/example/starwars"
"github.com/graph-gophers/graphql-go/relay"
graphql "github.com/hasura/go-graphql-client"
"github.com/pact-foundation/pact-go/v2/consumer"
g "github.com/pact-foundation/pact-go/v2/consumer/graphql"
"github.com/pact-foundation/pact-go/v2/matchers"
"github.com/stretchr/testify/assert"
)

func TestGraphQLConsumer(t *testing.T) {
// Create Pact connecting to local Daemon
pact, err := consumer.NewV4Pact(consumer.MockHTTPProviderConfig{
Consumer: "GraphQLConsumer",
Provider: "GraphQLProvider",
})
assert.NoError(t, err)

// Set up our expected interactions.
err = pact.
AddInteraction().
Given("User foo exists").
UponReceiving("A request to get foo").
WithCompleteRequest(*g.Interaction(g.Query{
Method: "POST",
Path: matchers.String("/query"),
Query: `query ($characterID:ID!){
hero {
id,
name
},
character(id: $characterID)
{
name,
friends{
name,
__typename
},
appearsIn
}
}`,
// Operation: "SomeOperation", // if needed
Variables: g.Variables{
"characterID": "1003",
},
})).
WithCompleteResponse(consumer.Response{
Status: 200,
Headers: matchers.MapMatcher{"Content-Type": matchers.String("application/json")},
Body: g.Response{
Data: heroQuery{
Hero: hero{
ID: graphql.ID("1003"),
Name: "Darth Vader",
},
Character: character{
Name: "Darth Vader",
AppearsIn: []graphql.String{
"EMPIRE",
},
Friends: []friend{
{
Name: "Wilhuff Tarkin",
Typename: "friends",
},
},
},
},
}}).
ExecuteTest(t, func(s consumer.MockServerConfig) error {
res, err := executeQuery(fmt.Sprintf("http://%s:%d", s.Host, s.Port))

fmt.Println(res)
assert.NoError(t, err)
assert.NotNil(t, res.Hero.ID)

return nil
})

assert.NoError(t, err)
}

func executeQuery(baseURL string) (heroQuery, error) {
var q heroQuery

// Set up a GraphQL server.
schema, err := graphqlserver.ParseSchema(starwars.Schema, &starwars.Resolver{})
if err != nil {
return q, err
}
mux := http.NewServeMux()
mux.Handle("/query", &relay.Handler{Schema: schema})

client := graphql.NewClient(fmt.Sprintf("%s/query", baseURL), nil)

variables := map[string]interface{}{
"characterID": graphql.ID("1003"),
}
err = client.Query(context.Background(), &q, variables)
if err != nil {
return q, err
}

return q, nil
}

type hero struct {
ID graphql.ID `json:"ID"`
Name graphql.String `json:"Name"`
}
type friend struct {
Name graphql.String `json:"Name"`
Typename graphql.String `json:"__typename" graphql:"__typename"`
}
type character struct {
Name graphql.String `json:"Name"`
Friends []friend `json:"Friends"`
AppearsIn []graphql.String `json:"AppearsIn"`
}

type heroQuery struct {
Hero hero `json:"Hero"`
Character character `json:"character" graphql:"character(id: $characterID)"`
}
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/graph-gophers/graphql-go v1.5.0 // indirect
github.com/hasura/go-graphql-client v0.12.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
Expand All @@ -30,4 +33,5 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
nhooyr.io/websocket v1.8.10 // indirect
)
16 changes: 16 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,29 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/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/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hasura/go-graphql-client v0.12.1 h1:tL+BCoyubkYYyaQ+tJz+oPe/pSxYwOJHwe5SSqqi6WI=
github.com/hasura/go-graphql-client v0.12.1/go.mod h1:F4N4kR6vY8amio3gEu3tjSZr8GPOXJr3zj72DKixfLE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
Expand All @@ -28,6 +38,7 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/linkedin/goavro/v2 v2.12.0 h1:rIQQSj8jdAUlKQh6DttK8wCRv4t4QO09g1C4aBWXslg=
github.com/linkedin/goavro/v2 v2.12.0/go.mod h1:KXx+erlq+RPlGSPmLF7xGo6SAbh8sCQ53x064+ioxhk=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
Expand All @@ -42,10 +53,13 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
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=
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
Expand All @@ -71,3 +85,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=

0 comments on commit 2849d82

Please sign in to comment.