Skip to content

Commit

Permalink
feat: graphql interface
Browse files Browse the repository at this point in the history
  • Loading branch information
mefellows committed Jun 27, 2022
1 parent 81dccce commit dcd8e0e
Show file tree
Hide file tree
Showing 1,065 changed files with 177,057 additions and 162,611 deletions.
139 changes: 139 additions & 0 deletions dsl/graphql/interaction.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package graphql

import (
"fmt"
"regexp"

"github.com/pact-foundation/pact-go/dsl"
)

// 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 dsl.MapMatcher

// Path to GraphQL endpoint
Path dsl.Matcher

// HTTP Query String
QueryString dsl.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 dsl.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 = dsl.MapMatcher{"content-type": dsl.String("application/json")}

func (r *Query) setHeader(headerName string, value dsl.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) *dsl.Request {
if request.Headers == nil {
request.Headers = defaultHeaders
}

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

}

type graphQLQueryBody struct {
Operation string `json:"operationName,omitempty"`
Query dsl.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))
}
138 changes: 138 additions & 0 deletions examples/graphql/consumer/graphql_consumer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package consumer

import (
"context"
"fmt"
"log"
"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/dsl"
g "github.com/pact-foundation/pact-go/dsl/graphql"
"github.com/stretchr/testify/assert"
)

func TestGraphQLConsumer(t *testing.T) {
// Create Pact connecting to local Daemon
pact := &dsl.Pact{
Consumer: "GraphQLConsumer",
Provider: "GraphQLProvider",
Host: "localhost",
}
defer pact.Teardown()

// Set up our expected interactions.
pact.
AddInteraction().
Given("User foo exists").
UponReceiving("A request to get foo").
WithRequest(*g.Interaction(g.Query{
Method: "POST",
Path: dsl.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",
},
})).
WillRespondWith(dsl.Response{
Status: 200,
Headers: dsl.MapMatcher{"Content-Type": dsl.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",
},
},
},
},
}})

// assert on the response
var test = func() error {
res, err := executeQuery(fmt.Sprintf("http://localhost:%d", pact.Server.Port))

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

return nil
}

// Verify
if err := pact.Verify(test); err != nil {
log.Fatalf("Error on Verify: %v", 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)"`
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"consumer": {
"name": "GraphQLConsumer"
},
"provider": {
"name": "GraphQLProvider"
},
"interactions": [
{
"description": "A request to get foo",
"providerState": "User foo exists",
"request": {
"method": "POST",
"path": "/query",
"headers": {
"content-type": "application/json"
},
"body": {
"query": "query ($characterID:ID!){\n\t\t\t\thero {\n\t\t\t\t\tid,\n\t\t\t\t\tname\n\t\t\t\t},\n\t\t\t\tcharacter(id: $characterID)\n\t\t\t\t{\n\t\t\t\t\tname,\n\t\t\t\t\tfriends{\n\t\t\t\t\t\tname,\n\t\t\t\t\t\t__typename\n\t\t\t\t\t},\n\t\t\t\t\tappearsIn\n\t\t\t\t}\n\t\t\t}",
"variables": {
"characterID": "1003"
}
},
"matchingRules": {
"$.body.query": {
"match": "regex",
"regex": "query\\s*\\(\\$characterID:ID!\\)\\{\\s*hero\\s*\\{\\s*id,\\s*name\\s*\\},\\s*character\\(id:\\s*\\$characterID\\)\\s*\\{\\s*name,\\s*friends\\{\\s*name,\\s*__typename\\s*\\},\\s*appearsIn\\s*\\}\\s*\\}"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": {
"data": {
"Hero": {
"ID": "1003",
"Name": "Darth Vader"
},
"character": {
"Name": "Darth Vader",
"Friends": [
{
"Name": "Wilhuff Tarkin",
"__typename": "friends"
}
],
"AppearsIn": [
"EMPIRE"
]
}
}
}
}
}
],
"metadata": {
"pactSpecification": {
"version": "2.0.0"
}
}
}
53 changes: 53 additions & 0 deletions examples/graphql/consumer/pacts/myconsumer-myprovider.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"consumer": {
"name": "MyConsumer"
},
"provider": {
"name": "MyProvider"
},
"interactions": [
{
"description": "A request to get foo",
"providerState": "User foo exists",
"request": {
"method": "POST",
"path": "/query",
"body": {
"query": "query ($characterID:ID!){\n\t\t\t\thero {\n\t\t\t\t\tid,\n\t\t\t\t\tname\n\t\t\t\t},\n\t\t\t\tcharacter(id: $characterID)\n\t\t\t\t{\n\t\t\t\t\tname,\n\t\t\t\t\tfriends{\n\t\t\t\t\t\tname,\n\t\t\t\t\t\t__typename\n\t\t\t\t\t},\n\t\t\t\t\tappearsIn\n\t\t\t\t}\n\t\t\t}",
"variables": {
"characterID": "1003"
}
},
"matchingRules": {
"$.body.query": {
"match": "regex",
"regex": "query\\s*\\(\\$characterID:ID!\\)\\{\\s*hero\\s*\\{\\s*id,\\s*name\\s*\\},\\s*character\\(id:\\s*\\$characterID\\)\\s*\\{\\s*name,\\s*friends\\{\\s*name,\\s*__typename\\s*\\},\\s*appearsIn\\s*\\}\\s*\\}"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": {
"lastName": "sampson",
"name": "billy"
},
"matchingRules": {
"$.body.lastName": {
"match": "type"
},
"$.body.name": {
"match": "type"
}
}
}
}
],
"metadata": {
"pactSpecification": {
"version": "2.0.0"
}
}
}
Loading

0 comments on commit dcd8e0e

Please sign in to comment.