Skip to content

Commit

Permalink
feat(hooks): add RequestFilter and StateHandlers to DSL
Browse files Browse the repository at this point in the history
Adds the ability to intercept requests to/from the provider
in order to deal with more complex scenarios (such as testing
OAuth tokens with time-bound scopes).

Additionally, removes the need for a "setup" endpoint, in favour
of passing functions keyed by the states they setup.

- New fields to DSL
- Deprecate the ProviderStatesSetupURL
- Add new proxy package, containing the reverse proxy and
  middleware types
- Update test coverage for existing types
  • Loading branch information
mefellows committed Feb 26, 2019
1 parent 44be561 commit f5678c7
Show file tree
Hide file tree
Showing 24 changed files with 739 additions and 58 deletions.
16 changes: 16 additions & 0 deletions client/message_service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package client

import "testing"

func TestMessageService_NewService(t *testing.T) {
s := &MessageService{}
svc := s.NewService([]string{"--foo"})

if svc == nil {
t.Fatalf("Expected a non-nil object but got nil")
}

if s.Args[0] != "--foo" {
t.Fatalf("Expected '--foo' argument to be passed")
}
}
2 changes: 1 addition & 1 deletion dsl/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ var (
pactURLPattern = "%s/pacts/provider/%s/latest"
pactURLPatternWithTag = "%s/pacts/provider/%s/latest/%s"

// ErrNoConsumers is returned when no consumer are not found for a provider.
// ErrNoConsumers is returned when no consumer are found for a provider
ErrNoConsumers = errors.New("no consumers found")

// ErrUnauthorized represents a Forbidden (403).
Expand Down
12 changes: 6 additions & 6 deletions dsl/broker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,15 +178,15 @@ func setupMockBroker(auth bool) *httptest.Server {
// curl --user pactuser:pact -H "accept: application/hal+json" "http://pact.onegeek.com.au/pacts/provider/bobby/latest"
mux.HandleFunc("/pacts/provider/bobby/latest", authFunc(func(w http.ResponseWriter, req *http.Request) {
log.Println("[DEBUG] get pacts for provider 'bobby'")
fmt.Fprintf(w, `{"_links":{"self":{"href":"%s/pacts/provider/bobby/latest","title":"Latest pact versions for the provider bobby"},"provider":{"href":"%s/pacticipants/bobby","title":"bobby"},"pb:pacts":[{"href":"%s/pacts/provider/bobby/consumer/jessica/version/2.0.0","title":"Pact between jessica (v2.0.0) and bobby","name":"jessica"},{"href":"%s/pacts/provider/bobby/consumer/billy/version/1.0.0","title":"Pact between billy (v1.0.0) and bobby","name":"billy"}],"pacts":[{"href":"%s/pacts/provider/bobby/consumer/jessica/version/2.0.0","title":"OLD Pact between jessica (v2.0.0) and bobby","name":"jessica"},{"href":"%s/pacts/provider/bobby/consumer/billy/version/1.0.0","title":"OLD Pact between billy (v1.0.0) and bobby","name":"billy"}]}}`, server.URL, server.URL, server.URL, server.URL, server.URL, server.URL)
fmt.Fprintf(w, `{"_links":{"self":{"href":"%s/pacts/provider/bobby/latest","title":"Latest pact versions for the provider bobby"},"provider":{"href":"%s/pacticipants/bobby","title":"bobby"},"pb:pacts":[{"href":"%s/pacts/provider/bobby/consumer/jessica/version/2.0.0","title":"Pact between jessica (v2.0.0) and bobby","name":"jessica"},{"href":"%s/pacts/provider/loginprovider/consumer/jmarie/version/1.0.0","title":"Pact between billy (v1.0.0) and bobby","name":"billy"}],"pacts":[{"href":"%s/pacts/provider/bobby/consumer/jessica/version/2.0.0","title":"OLD Pact between jessica (v2.0.0) and bobby","name":"jessica"},{"href":"%s/pacts/provider/loginprovider/consumer/jmarie/version/1.0.0","title":"OLD Pact between billy (v1.0.0) and bobby","name":"billy"}]}}`, server.URL, server.URL, server.URL, server.URL, server.URL, server.URL)
w.Header().Add("Content-Type", "application/hal+json")
}))

// Find 'bobby' consumers for tag 'prod'
// curl --user pactuser:pact -H "accept: application/hal+json" "http://pact.onegeek.com.au/pacts/provider/bobby/latest/sit4"
mux.Handle("/pacts/provider/bobby/latest/prod", authFunc(func(w http.ResponseWriter, req *http.Request) {
log.Println("[DEBUG] get all pacts for provider 'bobby' where the tag 'prod' exists")
fmt.Fprintf(w, `{"_links":{"self":{"href":"%s/pacts/provider/bobby/latest/dev","title":"Latest pact versions for the provider bobby with tag 'dev'"},"provider":{"href":"%s/pacticipants/bobby","title":"bobby"},"pb:pacts":[{"href":"%s/pacts/provider/bobby/consumer/billy/version/1.0.0","title":"Pact between billy (v1.0.0) and bobby","name":"billy"}],"pacts":[{"href":"%s/pacts/provider/bobby/consumer/billy/version/1.0.0","title":"OLD Pact between billy (v1.0.0) and bobby","name":"billy"}]}}`, server.URL, server.URL, server.URL, server.URL)
fmt.Fprintf(w, `{"_links":{"self":{"href":"%s/pacts/provider/bobby/latest/dev","title":"Latest pact versions for the provider bobby with tag 'dev'"},"provider":{"href":"%s/pacticipants/bobby","title":"bobby"},"pb:pacts":[{"href":"%s/pacts/provider/loginprovider/consumer/jmarie/version/1.0.0","title":"Pact between billy (v1.0.0) and bobby","name":"billy"}],"pacts":[{"href":"%s/pacts/provider/loginprovider/consumer/jmarie/version/1.0.0","title":"OLD Pact between billy (v1.0.0) and bobby","name":"billy"}]}}`, server.URL, server.URL, server.URL, server.URL)
w.Header().Add("Content-Type", "application/hal+json")
}))

Expand All @@ -209,15 +209,15 @@ func setupMockBroker(auth bool) *httptest.Server {
// curl --user pactuser:pact -H "accept: application/hal+json" "http://pact.onegeek.com.au/pacts/provider/bobby/latest/sit4"
mux.Handle("/pacts/provider/bobby/latest/dev", authFunc(func(w http.ResponseWriter, req *http.Request) {
log.Println("[DEBUG] get all pacts for provider 'bobby' where the tag 'dev' exists")
fmt.Fprintf(w, `{"_links":{"self":{"href":"%s/pacts/provider/bobby/latest/dev","title":"Latest pact versions for the provider bobby with tag 'dev'"},"provider":{"href":"%s/pacticipants/bobby","title":"bobby"},"pb:pacts":[{"href":"%s/pacts/provider/bobby/consumer/billy/version/1.0.1","title":"Pact between billy (v1.0.1) and bobby","name":"billy"}],"pacts":[{"href":"%s/pacts/provider/bobby/consumer/billy/version/1.0.1","title":"OLD Pact between billy (v1.0.1) and bobby","name":"billy"}]}}`, server.URL, server.URL, server.URL, server.URL)
fmt.Fprintf(w, `{"_links":{"self":{"href":"%s/pacts/provider/bobby/latest/dev","title":"Latest pact versions for the provider bobby with tag 'dev'"},"provider":{"href":"%s/pacticipants/bobby","title":"bobby"},"pb:pacts":[{"href":"%s/pacts/provider/loginprovider/consumer/jmarie/version/1.0.1","title":"Pact between billy (v1.0.1) and bobby","name":"billy"}],"pacts":[{"href":"%s/pacts/provider/loginprovider/consumer/jmarie/version/1.0.1","title":"OLD Pact between billy (v1.0.1) and bobby","name":"billy"}]}}`, server.URL, server.URL, server.URL, server.URL)
w.Header().Add("Content-Type", "application/hal+json")
}))

// Actual Consumer Pact
// curl -v --user pactuser:pact -H "accept: application/json" http://pact.onegeek.com.au/pacts/provider/bobby/consumer/billy/version/1.0.0
mux.Handle("/pacts/provider/bobby/consumer/billy/version/", authFunc(func(w http.ResponseWriter, req *http.Request) {
// curl -v --user pactuser:pact -H "accept: application/json" http://pact.onegeek.com.au/pacts/provider/loginprovider/consumer/jmarie/version/1.0.0
mux.Handle("/pacts/provider/loginprovider/consumer/jmarie/version/", authFunc(func(w http.ResponseWriter, req *http.Request) {
log.Println("[DEBUG] get all pacts for provider 'bobby' where any tag exists")
fmt.Fprintf(w, `{"consumer":{"name":"billy"},"provider":{"name":"bobby"},"interactions":[{"description":"Some name for the test","provider_state":"Some state","request":{"method":"GET","path":"/foobar"},"response":{"status":200,"headers":{"Content-Type":"application/json"}}},{"description":"Some name for the test","provider_state":"Some state2","request":{"method":"GET","path":"/bazbat"},"response":{"status":200,"headers":{},"body":[[{"colour":"red","size":10,"tag":[["jumper","shirt"],["jumper","shirt"]]}]],"matchingRules":{"$.body":{"min":1},"$.body[*].*":{"match":"type"},"$.body[*]":{"min":1},"$.body[*][*].*":{"match":"type"},"$.body[*][*].colour":{"match":"regex","regex":"red|green|blue"},"$.body[*][*].size":{"match":"type"},"$.body[*][*].tag":{"min":2},"$.body[*][*].tag[*].*":{"match":"type"},"$.body[*][*].tag[*][0]":{"match":"type"},"$.body[*][*].tag[*][1]":{"match":"type"}}}}],"metadata":{"pactSpecificationVersion":"2.0.0"},"updatedAt":"2016-06-11T13:11:33+00:00","createdAt":"2016-06-09T12:46:42+00:00","_links":{"self":{"title":"Pact","name":"Pact between billy (v1.0.0) and bobby","href":"%s/pacts/provider/bobby/consumer/billy/version/1.0.0"},"pb:consumer":{"title":"Consumer","name":"billy","href":"%s/pacticipants/billy"},"pb:provider":{"title":"Provider","name":"bobby","href":"%s/pacticipants/bobby"},"pb:latest-pact-version":{"title":"Pact","name":"Latest version of this pact","href":"%s/pacts/provider/bobby/consumer/billy/latest"},"pb:previous-distinct":{"title":"Pact","name":"Previous distinct version of this pact","href":"%s/pacts/provider/bobby/consumer/billy/version/1.0.0/previous-distinct"},"pb:diff-previous-distinct":{"title":"Diff","name":"Diff with previous distinct version of this pact","href":"%s/pacts/provider/bobby/consumer/billy/version/1.0.0/diff/previous-distinct"},"pb:pact-webhooks":{"title":"Webhooks for the pact between billy and bobby","href":"%s/webhooks/provider/bobby/consumer/billy"},"pb:tag-prod-version":{"title":"Tag this version as 'production'","href":"%s/pacticipants/billy/versions/1.0.0/tags/prod"},"pb:tag-version":{"title":"Tag version","href":"%s/pacticipants/billy/versions/1.0.0/tags/{tag}"},"curies":[{"name":"pb","href":"%s/doc/{rel}","templated":true}]}}`, server.URL, server.URL, server.URL, server.URL, server.URL, server.URL, server.URL, server.URL, server.URL, server.URL)
fmt.Fprintf(w, `{"consumer":{"name":"billy"},"provider":{"name":"bobby"},"interactions":[{"description":"Some name for the test","provider_state":"Some state","request":{"method":"GET","path":"/foobar"},"response":{"status":200,"headers":{"Content-Type":"application/json"}}},{"description":"Some name for the test","provider_state":"Some state2","request":{"method":"GET","path":"/bazbat"},"response":{"status":200,"headers":{},"body":[[{"colour":"red","size":10,"tag":[["jumper","shirt"],["jumper","shirt"]]}]],"matchingRules":{"$.body":{"min":1},"$.body[*].*":{"match":"type"},"$.body[*]":{"min":1},"$.body[*][*].*":{"match":"type"},"$.body[*][*].colour":{"match":"regex","regex":"red|green|blue"},"$.body[*][*].size":{"match":"type"},"$.body[*][*].tag":{"min":2},"$.body[*][*].tag[*].*":{"match":"type"},"$.body[*][*].tag[*][0]":{"match":"type"},"$.body[*][*].tag[*][1]":{"match":"type"}}}}],"metadata":{"pactSpecificationVersion":"2.0.0"},"updatedAt":"2016-06-11T13:11:33+00:00","createdAt":"2016-06-09T12:46:42+00:00","_links":{"self":{"title":"Pact","name":"Pact between billy (v1.0.0) and bobby","href":"%s/pacts/provider/loginprovider/consumer/jmarie/version/1.0.0"},"pb:consumer":{"title":"Consumer","name":"billy","href":"%s/pacticipants/billy"},"pb:provider":{"title":"Provider","name":"bobby","href":"%s/pacticipants/bobby"},"pb:latest-pact-version":{"title":"Pact","name":"Latest version of this pact","href":"%s/pacts/provider/loginprovider/consumer/jmarie/latest"},"pb:previous-distinct":{"title":"Pact","name":"Previous distinct version of this pact","href":"%s/pacts/provider/loginprovider/consumer/jmarie/version/1.0.0/previous-distinct"},"pb:diff-previous-distinct":{"title":"Diff","name":"Diff with previous distinct version of this pact","href":"%s/pacts/provider/loginprovider/consumer/jmarie/version/1.0.0/diff/previous-distinct"},"pb:pact-webhooks":{"title":"Webhooks for the pact between billy and bobby","href":"%s/webhooks/provider/bobby/consumer/billy"},"pb:tag-prod-version":{"title":"Tag this version as 'production'","href":"%s/pacticipants/billy/versions/1.0.0/tags/prod"},"pb:tag-version":{"title":"Tag version","href":"%s/pacticipants/billy/versions/1.0.0/tags/{tag}"},"curies":[{"name":"pb","href":"%s/doc/{rel}","templated":true}]}}`, server.URL, server.URL, server.URL, server.URL, server.URL, server.URL, server.URL, server.URL, server.URL, server.URL)
w.Header().Add("Content-Type", "application/hal+json")
}))

Expand Down
105 changes: 101 additions & 4 deletions dsl/pact.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
Expand All @@ -20,6 +21,7 @@ import (

"github.com/hashicorp/logutils"
"github.com/pact-foundation/pact-go/install"
"github.com/pact-foundation/pact-go/proxy"
"github.com/pact-foundation/pact-go/types"
"github.com/pact-foundation/pact-go/utils"
)
Expand Down Expand Up @@ -299,6 +301,7 @@ func (p *Pact) WritePact() error {
// a running Provider API, providing raw response from the Verification process.
func (p *Pact) VerifyProviderRaw(request types.VerifyRequest) (types.ProviderVerifierResponse, error) {
p.Setup(false)
var res types.ProviderVerifierResponse

// If we provide a Broker, we go to it to find consumers
if request.BrokerURL != "" {
Expand All @@ -309,9 +312,64 @@ func (p *Pact) VerifyProviderRaw(request types.VerifyRequest) (types.ProviderVer
}
}

u, err := url.Parse(request.ProviderBaseURL)

if err != nil {
return res, err
}

m := []proxy.Middleware{
stateHandlerMiddleware(request.StateHandlers),
}

if request.RequestFilter != nil {
m = append(m, request.RequestFilter)
}

// Configure HTTP Verification Proxy
opts := proxy.Options{
TargetAddress: fmt.Sprintf("%s:%s", u.Hostname(), u.Port()),
TargetScheme: u.Scheme,
Middleware: m,
}

// Starts the message wrapper API with hooks back to the state handlers
// This maps the 'description' field of a message pact, to a function handler
// that will implement the message producer. This function must return an object and optionally
// and error. The object will be marshalled to JSON for comparison.
port, err := proxy.HTTPReverseProxy(opts)

// Backwards compatibility, setup old provider states URL if given
// Otherwise point to proxy
setupURL := request.ProviderStatesSetupURL
if request.ProviderStatesSetupURL == "" {
setupURL = fmt.Sprintf("%s://localhost:%d/__setup", u.Scheme, port)
}

// Construct verifier request
verificationRequest := types.VerifyRequest{
ProviderBaseURL: fmt.Sprintf("%s://localhost:%d", u.Scheme, port), //
PactURLs: request.PactURLs,
BrokerURL: request.BrokerURL,
Tags: request.Tags,
BrokerUsername: request.BrokerUsername,
BrokerPassword: request.BrokerPassword,
PublishVerificationResults: request.PublishVerificationResults,
ProviderVersion: request.ProviderVersion,
ProviderStatesSetupURL: setupURL,
}

portErr := waitForPort(port, "tcp", "localhost", p.ClientTimeout,
fmt.Sprintf(`Timed out waiting for http verification proxy on port %d - check for errors`, port))

if portErr != nil {
log.Fatal("Error:", err)
return res, portErr
}

log.Println("[DEBUG] pact provider verification")

return p.pactClient.VerifyProvider(request)
return p.pactClient.VerifyProvider(verificationRequest)
}

// VerifyProvider accepts an instance of `*testing.T`
Expand Down Expand Up @@ -343,7 +401,46 @@ var checkCliCompatibility = func() {
}
}

var messageHandler = func(messageHandlers MessageHandlers, stateHandlers StateHandlers) http.HandlerFunc {
// statehandler accepts a state object from the verifier and executes
// any state handlers associated with the provider.
// It will not execute further middleware if it is the designted "state" request
// TODO: path = /__setup and header must be "X-..." ?
// TODO: make configuration by the pact DSL
func stateHandlerMiddleware(stateHandlers types.StateHandlers) proxy.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.RequestURI == "/__setup" {
var s *types.ProviderState
decoder := json.NewDecoder(r.Body)
decoder.Decode(&s)

// Setup any provider state
for _, state := range s.States {
sf, stateFound := stateHandlers[state]

if !stateFound {
log.Printf("[WARN] state handler not found for state: %v", state)
} else {
// Execute state handler
if err := sf(); err != nil {
log.Printf("[WARN] state handler for '%v' return error: %v", state, err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
}

w.WriteHeader(http.StatusOK)
return
}

log.Println("[DEBUG] skipping state handler for request", r.RequestURI)
next.ServeHTTP(w, r)
})
}
}

var messageVerificationHandler = func(messageHandlers MessageHandlers, stateHandlers StateHandlers) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")

Expand Down Expand Up @@ -464,7 +561,7 @@ func (p *Pact) VerifyMessageProviderRaw(request VerifyMessageRequest) (types.Pro
ProviderVersion: request.ProviderVersion,
}

mux.HandleFunc("/", messageHandler(request.MessageHandlers, request.StateHandlers))
mux.HandleFunc("/", messageVerificationHandler(request.MessageHandlers, request.StateHandlers))

ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
Expand All @@ -476,7 +573,7 @@ func (p *Pact) VerifyMessageProviderRaw(request VerifyMessageRequest) (types.Pro
go http.Serve(ln, mux)

portErr := waitForPort(port, "tcp", "localhost", p.ClientTimeout,
fmt.Sprintf(`Timed out waiting for Daemon on port %d - are you sure it's running?`, port))
fmt.Sprintf(`Timed out waiting for pact proxy on port %d - check for errors`, port))

if portErr != nil {
log.Fatal("Error:", err)
Expand Down
30 changes: 30 additions & 0 deletions dsl/verify_mesage_request_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package dsl

import "testing"

func TestVerifyMessageRequest_Valid(t *testing.T) {
r := VerifyMessageRequest{
PactURLs: []string{
"http://localhost:1234",
},
BrokerPassword: "user",
BrokerUsername: "pass",
ProviderVersion: "1.0.0",
PublishVerificationResults: true,
}

err := r.Validate()

if err != nil {
t.Fatal("want nil, got err: ", err)
}
}
func TestVerifyMessageRequest_Invalid(t *testing.T) {
r := VerifyMessageRequest{}

err := r.Validate()

if err == nil {
t.Fatal("want error, got nil")
}
}
49 changes: 36 additions & 13 deletions examples/consumer/goconsumer/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,12 @@ import (
ex "github.com/pact-foundation/pact-go/examples/types"
)

// User is a representation of a User. Dah.
// type User struct {
// Name string `json:"name" pact:"example=Jean-Marie de La Beaujardière😀😍"`
// username string `pact:"example=`
// password string

// Tags []string `json:"tags" pact:"min=2"`
// Date string `json:"date" pact:"example=2000-01-01,regex=^\\d{4}-\\d{2}-\\d{2}$"`
// }

// Client is a UI for the User Service.
type Client struct {
user *ex.User
Host string
err error
user *ex.User
Host string
err error
token string
}

// Marshalling format for Users.
Expand All @@ -41,6 +32,38 @@ type templateData struct {
var loginTemplatePath = "login.html"
var templates = template.Must(template.ParseFiles(loginTemplatePath))

// getUser finds a user
func (c *Client) getUser(id string) (*ex.User, error) {

u := fmt.Sprintf("%s/users/%s", c.Host, id)
req, err := http.NewRequest("GET", u, nil)

// NOTE: by default, request bodies are expected to be sent with a Content-Type
// of application/json. If you don't explicitly set the content-type, you
// will get a mismatch during Verification.
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", c.token)

res, err := http.DefaultClient.Do(req)

if res.StatusCode != 200 || err != nil {
return nil, fmt.Errorf("get user failed")
}

data, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}

var response ex.User
err = json.Unmarshal(data, &response)
if err != nil {
return nil, err
}

return &response, err
}

// Login handles the login API call to the User Service.
func (c *Client) login(username string, password string) (*ex.User, error) {
loginRequest := fmt.Sprintf(`
Expand Down
Loading

0 comments on commit f5678c7

Please sign in to comment.