From 929aee57a8cfd046bafef4aa09361eff05cf2fbe Mon Sep 17 00:00:00 2001 From: Matt Fellows <53900+mefellows@users.noreply.github.com> Date: Mon, 22 Apr 2024 21:59:23 +1000 Subject: [PATCH] feat: support graphql in v2 interface --- consumer/graphql/interaction.go | 140 ++++++++++++++++++++++ consumer/graphql/response.go | 9 ++ examples/graphql/graphql_consumer_test.go | 135 +++++++++++++++++++++ go.mod | 4 + go.sum | 17 +++ 5 files changed, 305 insertions(+) create mode 100644 consumer/graphql/interaction.go create mode 100644 consumer/graphql/response.go create mode 100644 examples/graphql/graphql_consumer_test.go diff --git a/consumer/graphql/interaction.go b/consumer/graphql/interaction.go new file mode 100644 index 000000000..78326c7c2 --- /dev/null +++ b/consumer/graphql/interaction.go @@ -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)) +} diff --git a/consumer/graphql/response.go b/consumer/graphql/response.go new file mode 100644 index 000000000..0179bab5a --- /dev/null +++ b/consumer/graphql/response.go @@ -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"` +} diff --git a/examples/graphql/graphql_consumer_test.go b/examples/graphql/graphql_consumer_test.go new file mode 100644 index 000000000..48fabe171 --- /dev/null +++ b/examples/graphql/graphql_consumer_test.go @@ -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)"` +} diff --git a/go.mod b/go.mod index 801a93261..9054553d8 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,10 @@ go 1.20 require ( github.com/golang/protobuf v1.5.4 + github.com/graph-gophers/graphql-go v1.5.0 github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/logutils v1.0.0 + github.com/hasura/go-graphql-client v0.12.1 github.com/linkedin/goavro/v2 v2.13.0 github.com/spf13/afero v1.11.0 github.com/spf13/cobra v1.8.1 @@ -18,6 +20,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang/snappy v0.0.4 // indirect + github.com/google/uuid v1.6.0 // 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 @@ -29,4 +32,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // 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 ) diff --git a/go.sum b/go.sum index f08b5b123..e907e0616 100644 --- a/go.sum +++ b/go.sum @@ -3,16 +3,26 @@ 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.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +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.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.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= @@ -25,6 +35,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.13.0 h1:L8eI8GcuciwUkt41Ej62joSZS4kKaYIUdze+6for9NU= github.com/linkedin/goavro/v2 v2.13.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= @@ -39,16 +50,20 @@ 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.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= 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= 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=