diff --git a/README.md b/README.md index db1c747..d654a1e 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,15 @@ Another flavour of JSON matching is to match only specific fields with [JSON Pat | $[0].dyn | "$dyn" | ``` +Response body can be matched with regular expression, named matches are treated as variables. + +```gherkin + And I should have "some-service" response with body, that matches regular expression + """ + "time"\s*:\s*"(?P\d{4}) + """ +``` + ```gherkin Status can be defined with either phrase or numeric code. diff --git a/_testdata/Dynamic.feature b/_testdata/Dynamic.feature index ebc0cf9..420560e 100644 --- a/_testdata/Dynamic.feature +++ b/_testdata/Dynamic.feature @@ -24,6 +24,13 @@ Feature: Dynamic data is used in steps "id":"$user_id" """ + And I should have response with body, that matches regular expression + """ + "name"\s*:\s*"(?P\w+) + """ + + And variable $first_name equals to "John" + # Creating an order for that user with $user_id. When I request HTTP endpoint with method "POST" and URI "/order/$user_id/?user_id=$user_id" And I request HTTP endpoint with header "X-UserId: $user_id" diff --git a/_testdata/LocalClient.feature b/_testdata/LocalClient.feature index 67ec124..5051d19 100644 --- a/_testdata/LocalClient.feature +++ b/_testdata/LocalClient.feature @@ -67,6 +67,13 @@ Feature: HTTP Service """ "status":"failed" """ + And I should have other responses with body, that matches regular expression + """ + "error"\s*:\s*"(?P[A-Za-z]+)" + """ + + And variable $errorMessage equals to "foo" + And I should have other responses with header "Content-Type: application/json" diff --git a/local_client.go b/local_client.go index da47c67..155b244 100644 --- a/local_client.go +++ b/local_client.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -216,6 +217,7 @@ func (l *LocalClient) RegisterSteps(s *godog.ScenarioContext) { s.Step(`^I should have(.*) response with body, that matches JSON from file$`, l.iShouldHaveResponseWithBodyThatMatchesJSONFromFile) s.Step(`^I should have(.*) response with body, that matches JSON$`, l.iShouldHaveResponseWithBodyThatMatchesJSON) s.Step(`^I should have(.*) response with body, that matches JSON paths$`, l.iShouldHaveResponseWithBodyThatMatchesJSONPaths) + s.Step(`^I should have(.*) response with body, that matches regular expression$`, l.iShouldHaveResponseWithBodyThatMatchesRegexp) s.Step(`^I should have(.*) other responses with status "([^"]*)"$`, l.iShouldHaveOtherResponsesWithStatus) s.Step(`^I should have(.*) other responses with header "([^"]*): ([^"]*)"$`, l.iShouldHaveOtherResponsesWithHeader) @@ -226,6 +228,7 @@ func (l *LocalClient) RegisterSteps(s *godog.ScenarioContext) { s.Step(`^I should have(.*) other responses with body, that matches JSON$`, l.iShouldHaveOtherResponsesWithBodyThatMatchesJSON) s.Step(`^I should have(.*) other responses with body, that matches JSON from file$`, l.iShouldHaveOtherResponsesWithBodyThatMatchesJSONFromFile) s.Step(`^I should have(.*) other responses with body, that matches JSON paths$`, l.iShouldHaveOtherResponsesWithBodyThatMatchesJSONPaths) + s.Step(`^I should have(.*) other responses with body, that matches regular expression$`, l.iShouldHaveOtherResponsesWithBodyThatMatchesRegexp) s.After(l.afterScenario) } @@ -767,6 +770,99 @@ func (l *LocalClient) iShouldHaveResponseWithBodyThatContains(ctx context.Contex }) } +func (l *LocalClient) iShouldHaveResponseWithBodyThatMatchesRegexp(ctx context.Context, service, bodyDoc string) (context.Context, error) { + ctx = l.VS.PrepareContext(ctx) + + return l.expectResponse(ctx, service, func(c *httpmock.Client) error { + return c.ExpectResponseBodyCallback(func(received []byte) error { + return l.matches(ctx, received, bodyDoc) + }) + }) +} + +func (l *LocalClient) matches(ctx context.Context, received []byte, pattern string) error { + capture, matched, err := l.extractNamedMatches(received, pattern) + if err != nil { + return err + } + + if !matched { + return augmentBodyErr(ctx, fmt.Errorf("%w %q in %q", errDoesNotContain, pattern, received)) + } + + _, vs := l.VS.Vars(ctx) + + for k, v := range capture { + k := "$" + k + + _, err := l.varCollected(vs, k, string(v)) + if err != nil { + return augmentBodyErr(ctx, err) + } + } + + return nil +} + +func (l *LocalClient) extractNamedMatches(data []byte, pattern string) (map[string][]byte, bool, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, false, err + } + + indices := re.FindSubmatchIndex(data) + if indices == nil { + return nil, false, nil // no full match + } + + names := re.SubexpNames() + result := make(map[string][]byte, len(names)-1) + + // i=0 → full match + // i≄1 → capture groups + for i, name := range names { + if i == 0 || name == "" { + continue // skip whole match and unnamed groups + } + + start, end := indices[i*2], indices[i*2+1] + if start == -1 { + result[name] = nil // optional group didn't match + + continue + } + + result[name] = data[start:end] // zero-copy []byte slice + } + + return result, true, nil +} + +func (l *LocalClient) varCollected(vs *shared.Vars, s string, v interface{}) (bool, error) { + if vs == nil || !vs.IsVar(s) { + return false, nil + } + + if n, ok := v.(json.Number); ok { + v = shared.DecodeJSONNumber(n) + } else if f, ok := v.(float64); ok && f == float64(int64(f)) { + v = int64(f) + } + + fv, found := vs.Get(s) + if !found { + vs.Set(s, v) + + return true, nil + } + + if fv != v { + return false, fmt.Errorf("unexpected variable %s value, expected %v, received %v", s, fv, v) + } + + return false, nil +} + func (l *LocalClient) iShouldHaveOtherResponsesWithBodyThatContains(ctx context.Context, service, bodyDoc string) (context.Context, error) { ctx = l.VS.PrepareContext(ctx) @@ -865,6 +961,16 @@ func (l *LocalClient) iShouldHaveOtherResponsesWithBodyThatMatchesJSONPaths(ctx }) } +func (l *LocalClient) iShouldHaveOtherResponsesWithBodyThatMatchesRegexp(ctx context.Context, service, bodyDoc string) (context.Context, error) { + ctx = l.VS.PrepareContext(ctx) + + return l.expectResponse(ctx, service, func(c *httpmock.Client) error { + return c.ExpectOtherResponsesBodyCallback(func(received []byte) error { + return l.matches(ctx, received, bodyDoc) + }) + }) +} + func (l *LocalClient) iShouldHaveOtherResponsesWithBodyThatMatchesJSONFromFile(ctx context.Context, service, filePath string) (context.Context, error) { ctx = l.VS.PrepareContext(ctx) diff --git a/local_client_test.go b/local_client_test.go index e1280f8..faa936b 100644 --- a/local_client_test.go +++ b/local_client_test.go @@ -10,7 +10,8 @@ import ( "github.com/bool64/httpmock" "github.com/cucumber/godog" - httpsteps "github.com/godogx/httpsteps" + "github.com/godogx/httpsteps" + "github.com/godogx/vars" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -26,6 +27,7 @@ func TestLocal_RegisterSteps(t *testing.T) { concurrencyLevel := 5 setExpectations(mock, concurrencyLevel) + vs := vars.Steps{} local := httpsteps.NewLocalClient(srvURL, func(client *httpmock.Client) { client.Headers = map[string]string{ "X-Foo": "bar", @@ -36,6 +38,7 @@ func TestLocal_RegisterSteps(t *testing.T) { suite := godog.TestSuite{ ScenarioInitializer: func(s *godog.ScenarioContext) { + vs.Register(s) local.RegisterSteps(s) }, Options: &godog.Options{ @@ -226,10 +229,12 @@ func TestLocal_RegisterSteps_dynamic(t *testing.T) { })) defer srv.Close() + vs := vars.Steps{} local := httpsteps.NewLocalClient(srv.URL) suite := godog.TestSuite{ ScenarioInitializer: func(s *godog.ScenarioContext) { + vs.Register(s) local.RegisterSteps(s) }, Options: &godog.Options{