diff --git a/examples/v3/consumer_test.go b/examples/v3/consumer_test.go index d3b229e30..f757b8e4e 100644 --- a/examples/v3/consumer_test.go +++ b/examples/v3/consumer_test.go @@ -69,7 +69,13 @@ func TestConsumerV2(t *testing.T) { "dateTime": v3.Regex("2020-01-01", "[0-9\\-]+"), "name": s("FirstName"), "lastName": s("LastName"), - // "id": v3.I32(1), // Add this to demonstrate adding a v3 matcher failing the build (not at the type system level unfortunately) + "itemsMin": v3.ArrayMinLike("min", 3), + // Add any of these this to demonstrate adding a v3 matcher failing the build (not at the type system level unfortunately) + // "id": v3.Integer(1), + // "superstring": v3.Includes("foo"), + // "accountBalance": v3.Decimal(123.76), + // "itemsMinMax": v3.ArrayMinMaxLike(27, 3, 5), + // "equality": v3.Equality("a thing"), }, }) @@ -109,7 +115,8 @@ func TestConsumerV3(t *testing.T) { Path: v3.Regex("/foobar", `\/foo.*`), Headers: v3.MapMatcher{"Content-Type": s("application/json"), "Authorization": s("Bearer 1234")}, Body: v3.MapMatcher{ - "name": s("billy"), + "name": s("billy"), + "dateTime": v3.DateTimeGenerated("2020-02-02", "YYYY-MM-dd"), }, Query: v3.QueryMatcher{ "baz": []interface{}{ @@ -164,7 +171,7 @@ var test = func(config v3.MockServerConfig) error { RawQuery: "baz=bat&baz=foo&baz=something", // Default behaviour // RawQuery: "baz[]=bat&baz[]=foo&baz[]=something", // TODO: Rust v3 does not support this syntax }, - Body: ioutil.NopCloser(strings.NewReader(`{"name":"billy"}`)), + Body: ioutil.NopCloser(strings.NewReader(`{"name":"billy", "dateTime":"2020-02-02"}`)), Header: make(http.Header), } diff --git a/examples/v3/pacts/consumer-provider.json b/examples/v3/pacts/consumer-provider.json index d57477faf..f4d8ef434 100644 --- a/examples/v3/pacts/consumer-provider.json +++ b/examples/v3/pacts/consumer-provider.json @@ -15,8 +15,17 @@ ], "request": { "body": { + "dateTime": "2020-02-02", "name": "billy" }, + "generators": { + "body": { + "$.dateTime": { + "format": "YYYY-MM-dd", + "type": "DateTime" + } + } + }, "headers": { "Authorization": "Bearer 1234", "Content-Type": "application/json" diff --git a/v3/matcher.go b/v3/matcher.go index 1372883e2..448b8068b 100644 --- a/v3/matcher.go +++ b/v3/matcher.go @@ -1,16 +1,18 @@ package v3 -import "time" +import ( + "time" +) // Term Matcher regexes const ( - hexadecimal = `[0-9a-fA-F]+` - ipAddress = `(\d{1,3}\.)+\d{1,3}` - ipv6Address = `(\A([0-9a-f]{1,4}:){1,1}(:[0-9a-f]{1,4}){1,6}\Z)|(\A([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}\Z)|(\A([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}\Z)|(\A([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,3}\Z)|(\A([0-9a-f]{1,4}:){1,5}(:[0-9a-f]{1,4}){1,2}\Z)|(\A([0-9a-f]{1,4}:){1,6}(:[0-9a-f]{1,4}){1,1}\Z)|(\A(([0-9a-f]{1,4}:){1,7}|:):\Z)|(\A:(:[0-9a-f]{1,4}){1,7}\Z)|(\A((([0-9a-f]{1,4}:){6})(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})\Z)|(\A(([0-9a-f]{1,4}:){5}[0-9a-f]{1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})\Z)|(\A([0-9a-f]{1,4}:){5}:[0-9a-f]{1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,1}(:[0-9a-f]{1,4}){1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,3}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,2}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,1}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A(([0-9a-f]{1,4}:){1,5}|:):(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A:(:[0-9a-f]{1,4}){1,5}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)` - uuid = `[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` - timestamp = `^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$` - date = `^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))?)` - timeRegex = `^(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?$` + hexadecimalRegex = `[0-9a-fA-F]+` + ipAddressRegex = `(\d{1,3}\.)+\d{1,3}` + ipv6AddressRegex = `(\A([0-9a-f]{1,4}:){1,1}(:[0-9a-f]{1,4}){1,6}\Z)|(\A([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}\Z)|(\A([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}\Z)|(\A([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,3}\Z)|(\A([0-9a-f]{1,4}:){1,5}(:[0-9a-f]{1,4}){1,2}\Z)|(\A([0-9a-f]{1,4}:){1,6}(:[0-9a-f]{1,4}){1,1}\Z)|(\A(([0-9a-f]{1,4}:){1,7}|:):\Z)|(\A:(:[0-9a-f]{1,4}){1,7}\Z)|(\A((([0-9a-f]{1,4}:){6})(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})\Z)|(\A(([0-9a-f]{1,4}:){5}[0-9a-f]{1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})\Z)|(\A([0-9a-f]{1,4}:){5}:[0-9a-f]{1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,1}(:[0-9a-f]{1,4}){1,4}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,3}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,2}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,1}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A(([0-9a-f]{1,4}:){1,5}|:):(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)|(\A:(:[0-9a-f]{1,4}){1,5}:(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}\Z)` + uuidRegex = `[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` + timestampRegex = `^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$` + dateRegex = `^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))?)` + timeRegex = `^(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?$` ) var timeExample = time.Date(2000, 2, 1, 12, 30, 0, 0, time.UTC) @@ -58,4 +60,18 @@ const ( // includes matcher // https://github.com/pact-foundation/pact-specification/tree/version-3#add-an-include-matcher includesMatcher = "includesMatcher" + + // string generator + // https://github.com/pact-foundation/pact-specification/tree/version-3#introduce-example-generators + stringGeneratorMatcher = "stringGeneratorMatcher" +) + +// MatcherClass is used to differentiate the various matchers when serialising +type generatorType string + +// Matcher Types used to discriminate when serialising the rules +const ( + dateTimeGenerator generatorType = "DateTime" + dateGenerator = "Date" + timeGenerator = "Time" ) diff --git a/v3/matcher_v2.go b/v3/matcher_v2.go index 228b89f81..c7939a1ea 100644 --- a/v3/matcher_v2.go +++ b/v3/matcher_v2.go @@ -19,8 +19,6 @@ func (m eachLike) GetValue() interface{} { return m.Contents } -func (m eachLike) isMatcher() {} - func (m eachLike) Type() MatcherClass { return arrayMinLikeMatcher } @@ -44,8 +42,6 @@ func (m like) GetValue() interface{} { return m.Contents } -func (m like) isMatcher() {} - func (m like) Type() MatcherClass { return likeMatcher } @@ -64,8 +60,6 @@ func (m term) GetValue() interface{} { return m.Data.Generate } -func (m term) isMatcher() {} - func (m term) Type() MatcherClass { return regexMatcher } @@ -121,7 +115,7 @@ func Term(generate string, matcher string) MatcherV2 { // HexValue defines a matcher that accepts hexidecimal values. func HexValue() MatcherV2 { - return Regex("3F", hexadecimal) + return Regex("3F", hexadecimalRegex) } // Identifier defines a matcher that accepts number values. @@ -131,7 +125,7 @@ func Identifier() MatcherV2 { // IPAddress defines a matcher that accepts valid IPv4 addresses. func IPAddress() MatcherV2 { - return Regex("127.0.0.1", ipAddress) + return Regex("127.0.0.1", ipAddressRegex) } // IPv4Address matches valid IPv4 addresses. @@ -139,19 +133,19 @@ var IPv4Address = IPAddress // IPv6Address defines a matcher that accepts IP addresses. func IPv6Address() MatcherV2 { - return Regex("::ffff:192.0.2.128", ipAddress) + return Regex("::ffff:192.0.2.128", ipAddressRegex) } // Timestamp matches a pattern corresponding to the ISO_DATETIME_FORMAT, which // is "yyyy-MM-dd'T'HH:mm:ss". The current date and time is used as the eaxmple. func Timestamp() MatcherV2 { - return Regex(timeExample.Format(time.RFC3339), timestamp) + return Regex(timeExample.Format(time.RFC3339), timestampRegex) } // Date matches a pattern corresponding to the ISO_DATE_FORMAT, which // is "yyyy-MM-dd". The current date is used as the eaxmple. func Date() MatcherV2 { - return Regex(timeExample.Format("2006-01-02"), date) + return Regex(timeExample.Format("2006-01-02"), dateRegex) } // Time matches a pattern corresponding to the ISO_DATE_FORMAT, which @@ -162,7 +156,7 @@ func Time() MatcherV2 { // UUID defines a matcher that accepts UUIDs. Produces a v4 UUID as the example. func UUID() MatcherV2 { - return Regex("fc763eba-0905-41c5-a27f-3934ab26786c", uuid) + return Regex("fc763eba-0905-41c5-a27f-3934ab26786c", uuidRegex) } // Regex is a more appropriately named alias for the "Term" matcher @@ -173,10 +167,6 @@ var Regex = Term // We use the strategy outlined at http://www.jerf.org/iri/post/2917 // to create a "sum" or "union" type. type MatcherV2 interface { - // isMatcher is how we tell the compiler that strings - // and other types are the same / allowed - isMatcher() - // GetValue returns the raw generated value for the matcher // without any of the matching detail context GetValue() interface{} @@ -191,8 +181,6 @@ type MatcherV2 interface { // it allows plain strings to be matched type S string -func (s S) isMatcher() {} - // GetValue returns the raw generated value for the matcher // without any of the matching detail context func (s S) GetValue() interface{} { @@ -219,8 +207,6 @@ type StructMatcher map[string]MatcherV2 // type StructMatcher map[string]Matcher -func (m StructMatcher) isMatcher() {} - // GetValue returns the raw generated value for the matcher // without any of the matching detail context func (m StructMatcher) GetValue() interface{} { diff --git a/v3/matcher_v2_test.go b/v3/matcher_v2_test.go index e8abb8efe..6ca7a49f2 100644 --- a/v3/matcher_v2_test.go +++ b/v3/matcher_v2_test.go @@ -474,7 +474,7 @@ func TestMatcher_SugarMatchers(t *testing.T) { "UUID": { matcher: UUID(), testCase: func(v interface{}) (err error) { - match, err := regexp.MatchString(uuid, v.(string)) + match, err := regexp.MatchString(uuidRegex, v.(string)) if !match { err = fmt.Errorf("want string, got '%v'. Err: %v", v, err) diff --git a/v3/matcher_v3.go b/v3/matcher_v3.go index 4caaf0ef6..eb047f233 100644 --- a/v3/matcher_v3.go +++ b/v3/matcher_v3.go @@ -1,9 +1,5 @@ package v3 -type v3Matcher interface { - isV3Matcher() -} - // MatcherV3 denotes a V3 specific Matcher type MatcherV3 interface { MatcherV2 @@ -12,10 +8,13 @@ type MatcherV3 interface { isV3Matcher() } +type generator interface { + Generator() rule +} + // Integer defines a matcher that accepts any integer value. type Integer int -func (i Integer) isMatcher() {} func (i Integer) isV3Matcher() {} // GetValue returns the raw generated value for the matcher @@ -41,7 +40,6 @@ func (d Decimal) GetValue() interface{} { return float64(d) } -func (d Decimal) isMatcher() {} func (d Decimal) isV3Matcher() {} func (d Decimal) Type() MatcherClass { @@ -61,7 +59,6 @@ func (n Null) GetValue() interface{} { return float64(n) } -func (n Null) isMatcher() {} func (n Null) isV3Matcher() {} func (n Null) Type() MatcherClass { @@ -84,7 +81,6 @@ func (e equality) GetValue() interface{} { return e.contents } -func (e equality) isMatcher() {} func (e equality) isV3Matcher() {} func (e equality) Type() MatcherClass { @@ -112,7 +108,6 @@ type Includes string func (i Includes) GetValue() interface{} { return string(i) } -func (i Includes) isMatcher() {} func (i Includes) isV3Matcher() {} func (i Includes) Type() MatcherClass { return includesMatcher @@ -135,7 +130,6 @@ func (m minMaxLike) GetValue() interface{} { return m.Contents } -func (m minMaxLike) isMatcher() {} func (m minMaxLike) isV3Matcher() {} func (m minMaxLike) Type() MatcherClass { @@ -175,3 +169,57 @@ func ArrayMaxLike(content interface{}, max int) MatcherV3 { Max: max, } } + +type stringGenerator struct { + contents string + generator generatorType + format string +} + +func (s stringGenerator) GetValue() interface{} { + return s.contents +} + +func (s stringGenerator) isV3Matcher() {} + +func (s stringGenerator) Type() MatcherClass { + return stringGeneratorMatcher +} + +func (s stringGenerator) Generator() rule { + r := rule{ + "type": s.generator, + } + if s.format != "" { + r["format"] = s.format + } + + return r +} +func (s stringGenerator) MatchingRule() rule { + return nil +} + +func DateGenerated(example string, format string) MatcherV2 { + return stringGenerator{ + contents: example, + generator: dateGenerator, + format: format, + } +} + +func TimeGenerated(example string, format string) MatcherV2 { + return stringGenerator{ + contents: example, + generator: timeGenerator, + format: format, + } +} + +func DateTimeGenerated(example string, format string) MatcherV2 { + return stringGenerator{ + contents: example, + generator: dateTimeGenerator, + format: format, + } +} diff --git a/v3/pact_v3.go b/v3/pact_v3.go index 6936380fd..f7b3b2b45 100644 --- a/v3/pact_v3.go +++ b/v3/pact_v3.go @@ -12,6 +12,7 @@ type object map[string]interface{} // TODO: this is actually more typed than this // once we understand the model better, let's make it more type-safe type ruleSet map[string]matchers +type generators map[string]rule // type ruleValue map[string]interface{} type matcherLogic string @@ -38,7 +39,12 @@ type ruleV3 struct { } type matchingRuleV3 = ruleV3 -type generatorV3 = ruleV3 +type generatorV3 = struct { + Body generators `json:"body,omitempty"` + Headers generators `json:"headers,omitempty"` + Query generators `json:"query,omitempty"` + Path matchers `json:"path,omitempty"` +} type pactRequestV3 struct { Method string `json:"method"` @@ -94,11 +100,11 @@ func pactInteractionFromV3Interaction(interaction InteractionV3) pactInteraction Request: pactRequestV3{ Method: interaction.Request.Method, Generators: generatorV3{ - Body: make(ruleSet), - Headers: make(ruleSet), - Query: make(ruleSet), + Body: make(generators), + Headers: make(generators), + Query: make(generators), }, - MatchingRules: generatorV3{ + MatchingRules: matchingRuleV3{ Body: make(ruleSet), Headers: make(ruleSet), Query: make(ruleSet), @@ -107,9 +113,9 @@ func pactInteractionFromV3Interaction(interaction InteractionV3) pactInteraction Response: pactResponseV3{ Status: interaction.Response.Status, Generators: generatorV3{ - Body: make(ruleSet), - Headers: make(ruleSet), - Query: make(ruleSet), + Body: make(generators), + Headers: make(generators), + Query: make(generators), }, MatchingRules: matchingRuleV3{ Body: make(ruleSet), @@ -145,7 +151,7 @@ func (p *pactFileV3) generateV3PactFile() *pactFileV3 { } func recurseMapTypeV3(key string, value interface{}, body object, path string, - matchingRules ruleSet, generators ruleSet) (string, object, ruleSet, ruleSet) { + matchingRules ruleSet, generators generators) (string, object, ruleSet, generators) { mapped := reflect.ValueOf(value) entry := make(object) path = path + buildPath(key, "") @@ -174,8 +180,8 @@ func wrapMatchingRule(r rule) matchers { } } -func buildPart(value interface{}) (object, ruleSet, ruleSet) { - _, o, matchingRules, generators := buildPactPartV3("", value, make(object), "$", make(ruleSet), make(ruleSet)) +func buildPart(value interface{}) (object, ruleSet, generators) { + _, o, matchingRules, generators := buildPactPartV3("", value, make(object), "$", make(ruleSet), make(generators)) return o, matchingRules, generators } @@ -195,7 +201,7 @@ func buildPart(value interface{}) (object, ruleSet, ruleSet) { // // Returns path, body, matchingRules, generators func buildPactPartV3(key string, value interface{}, body object, path string, - matchingRules ruleSet, generators ruleSet) (string, object, ruleSet, ruleSet) { + matchingRules ruleSet, generators generators) (string, object, ruleSet, generators) { log.Println("[TRACE] generate pact => key:", key, ", body:", body, ", value:", value, ", path:", path) switch t := value.(type) { @@ -203,13 +209,17 @@ func buildPactPartV3(key string, value interface{}, body object, path string, case MatcherV3: switch t.Type() { - case decimalMatcher, integerMatcher, nullMatcher, equalityMatcher, includesMatcher: + case decimalMatcher, integerMatcher, nullMatcher, equalityMatcher, includesMatcher, stringGeneratorMatcher: log.Println("[TRACE] generate pact: decimal/integer/null matcher") builtPath := path + buildPath(key, "") body[key] = t.GetValue() log.Println("[TRACE] generate pact: decimal/integer/null matcher => ", builtPath) matchingRules[builtPath] = wrapMatchingRule(t.MatchingRule()) + if g, ok := t.(generator); ok { + generators[builtPath] = g.Generator() + } + case arrayMinMaxLikeMatcher: times := 1 m := t.(minMaxLike)