diff --git a/compatibility-suite/src/test/groovy/steps/v4/Generators.groovy b/compatibility-suite/src/test/groovy/steps/v4/Generators.groovy new file mode 100644 index 000000000..682317441 --- /dev/null +++ b/compatibility-suite/src/test/groovy/steps/v4/Generators.groovy @@ -0,0 +1,176 @@ +package steps.v4 + +import au.com.dius.pact.core.model.HttpRequest +import au.com.dius.pact.core.model.IRequest +import au.com.dius.pact.core.model.JsonUtils +import au.com.dius.pact.core.model.generators.GeneratorTestMode +import au.com.dius.pact.core.support.json.JsonParser +import au.com.dius.pact.core.support.json.JsonValue +import io.cucumber.datatable.DataTable +import io.cucumber.java.en.Given +import io.cucumber.java.en.Then +import io.cucumber.java.en.When + +import static steps.shared.SharedSteps.configureBody +import static steps.shared.SharedSteps.determineContentType + +@SuppressWarnings('SpaceAfterOpeningBrace') +class Generators { + HttpRequest request + IRequest generatedRequest + Map context = [:] + GeneratorTestMode testMode = GeneratorTestMode.Provider + JsonValue originalJson + JsonValue generatedJson + + @Given('a request configured with the following generators:') + void a_request_configured_with_the_following_generators(DataTable dataTable) { + request = new HttpRequest('GET', '/path/one') + def entry = dataTable.entries().first() + if (entry['body']) { + def part = configureBody(entry['body'], determineContentType(entry['body'], request.contentTypeHeader())) + request.body = part.body + request.headers.putAll(part.headers) + } + if (entry['generators']) { + JsonValue json + if (entry['generators'].startsWith('JSON:')) { + json = JsonParser.INSTANCE.parseString(entry['generators'][5..-1]) + } else { + File contents = new File("pact-compatibility-suite/fixtures/${entry['generators']}") + contents.withInputStream { + json = JsonParser.INSTANCE.parseStream(it) + } + } + request.generators.categories.putAll(au.com.dius.pact.core.model.generators.Generators.fromJson(json).categories) + } + } + + @Given('the generator test mode is set as {string}') + void the_generator_test_mode_is_set_as(String mode) { + testMode = mode == 'Consumer' ? GeneratorTestMode.Consumer : GeneratorTestMode.Provider + } + + @When('the request is prepared for use') + void the_request_prepared_for_use() { + generatedRequest = request.generatedRequest(context, testMode) + originalJson = request.body.present ? JsonParser.INSTANCE.parseString(request.body.valueAsString()) : null + generatedJson = generatedRequest.body.present ? + JsonParser.INSTANCE.parseString(generatedRequest.body.valueAsString()) : null + } + + @When('the request is prepared for use with a {string} context:') + void the_request_is_prepared_for_use_with_a_context(String type, DataTable dataTable) { + context[type] = JsonParser.parseString(dataTable.values().first()).asObject().entries + generatedRequest = request.generatedRequest(context, testMode) + originalJson = request.body.present ? JsonParser.INSTANCE.parseString(request.body.valueAsString()) : null + generatedJson = generatedRequest.body.present ? + JsonParser.INSTANCE.parseString(generatedRequest.body.valueAsString()) : null + } + + @Then('the body value for {string} will have been replaced with a(n) {string}') + void the_body_value_for_will_have_been_replaced_with_a_value(String path, String type) { + def originalElement = JsonUtils.INSTANCE.fetchPath(originalJson, path) + def element = JsonUtils.INSTANCE.fetchPath(generatedJson, path) + assert originalElement != element + matchTypeOfElement(type, element) + } + + static void matchTypeOfElement(String type, JsonValue element) { + switch (type) { + case 'integer' -> { + assert element.type() == 'Integer' + assert element.toString() ==~ /\d+/ + } + case 'decimal number' -> { + assert element.type() == 'Decimal' + assert element.toString() ==~ /\d+\.\d+/ + } + case 'hexadecimal number' -> { + assert element.type() == 'String' + assert element.toString() ==~ /[a-fA-F0-9]+/ + } + case 'random string' -> { + assert element.type() == 'String' + } + case 'string from the regex' -> { + assert element.type() == 'String' + assert element.toString() ==~ /\d{1,8}/ + } + case 'date' -> { + assert element.type() == 'String' + assert element.toString() ==~ /\d{4}-\d{2}-\d{2}/ + } + case 'time' -> { + assert element.type() == 'String' + assert element.toString() ==~ /\d{2}:\d{2}:\d{2}/ + } + case 'date-time' -> { + assert element.type() == 'String' + assert element.toString() ==~ /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{1,9}/ + } + case 'UUID' -> { + assert element.type() == 'String' + UUID.fromString(element.toString()) + } + case 'simple UUID' -> { + assert element.type() == 'String' + assert element.toString() ==~ /[0-9a-zA-Z]{32}/ + } + case 'lower-case-hyphenated UUID' -> { + assert element.type() == 'String' + UUID.fromString(element.toString()) + } + case 'upper-case-hyphenated UUID' -> { + assert element.type() == 'String' + UUID.fromString(element.toString()) + } + case 'URN UUID' -> { + assert element.type() == 'String' + assert element.toString().startsWith('urn:uuid:') + UUID.fromString(element.toString().substring('urn:uuid:'.length())) + } + case 'boolean' -> { + assert element.type() == 'Boolean' + } + default -> throw new AssertionError("Invalid type: $type") + } + } + + @Then('the body value for {string} will have been replaced with {string}') + void the_body_value_for_will_have_been_replaced_with_value(String path, String value) { + def originalElement = JsonUtils.INSTANCE.fetchPath(originalJson, path) + def element = JsonUtils.INSTANCE.fetchPath(generatedJson, path) + assert originalElement != element + assert element.type() == 'String' + assert element.toString() == value + } + + @Then('the request {string} will be set as {string}') + void the_request_will_be_set_as(String part, String value) { + switch (part) { + case 'path' -> { + assert generatedRequest.path == value + } + default -> throw new AssertionError("Invalid HTTP part: $part") + } + } + + @Then('the request {string} will match {string}') + void the_request_will_match(String part, String regex) { + switch (part) { + case 'path' -> { + assert generatedRequest.path ==~ regex + } + case ~/^header.*/ -> { + def header = (part =~ /\[(.*)]/)[0][1] + assert generatedRequest.headers[header].every { it ==~ regex } + } + case ~/^queryParameter.*/ -> { + def name = (part =~ /\[(.*)]/)[0][1] + assert generatedRequest.query[name].every { it ==~ regex } + } + default -> throw new AssertionError("Invalid HTTP part: $part") + } + } +} diff --git a/compatibility-suite/src/test/groovy/steps/v4/HttpMatching.groovy b/compatibility-suite/src/test/groovy/steps/v4/HttpMatching.groovy index 9bcf7ee9c..1e05e9b72 100644 --- a/compatibility-suite/src/test/groovy/steps/v4/HttpMatching.groovy +++ b/compatibility-suite/src/test/groovy/steps/v4/HttpMatching.groovy @@ -87,7 +87,12 @@ class HttpMatching { expectedRequest = new HttpRequest() def entry = dataTable.entries().first() if (entry['body']) { - def part = configureBody(entry['body'], determineContentType(entry['body'], expectedRequest.contentTypeHeader())) + def part + if (entry['content type']) { + part = configureBody(entry['body'], entry['content type']) + } else { + part = configureBody(entry['body'], determineContentType(entry['body'], expectedRequest.contentTypeHeader())) + } expectedRequest.body = part.body expectedRequest.headers.putAll(part.headers) } @@ -111,8 +116,13 @@ class HttpMatching { receivedRequests << new HttpRequest() def entry = dataTable.entries().first() if (entry['body']) { - def part = configureBody(entry['body'], determineContentType(entry['body'], - receivedRequests[0].contentTypeHeader())) + def part + if (entry['content type']) { + part = configureBody(entry['body'], entry['content type']) + } else { + part = configureBody(entry['body'], determineContentType(entry['body'], + receivedRequests[0].contentTypeHeader())) + } receivedRequests[0].body = part.body receivedRequests[0].headers.putAll(part.headers) } diff --git a/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/JsonContentMatcher.kt b/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/JsonContentMatcher.kt index 1121cb468..c0bc99e30 100644 --- a/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/JsonContentMatcher.kt +++ b/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/JsonContentMatcher.kt @@ -144,9 +144,10 @@ object JsonContentMatcher : ContentMatcher, KLogging() { val expectedEntries = expectedValues.entries val actualEntries = actualValues.entries if (context.matcherDefined(path)) { + logger.debug { "compareMaps: matcher defined for path $path" } for (matcher in context.selectBestMatcher(path).rules) { result.addAll(Matchers.compareMaps(path, matcher, expectedEntries, actualEntries, context, generateDiff) { - p, expected, actual -> compare(p, expected ?: JsonValue.Null, actual ?: JsonValue.Null, context) + p, expected, actual, ctx -> compare(p, expected ?: JsonValue.Null, actual ?: JsonValue.Null, ctx) }) } } else { diff --git a/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/MatcherExecutor.kt b/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/MatcherExecutor.kt index 0f63118db..f43644a26 100755 --- a/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/MatcherExecutor.kt +++ b/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/MatcherExecutor.kt @@ -141,7 +141,7 @@ fun domatch( mismatchFn: MismatchFactory, cascaded: Boolean ): List { - logger.debug { "Matching value at $path with $matcher" } + logger.debug { "Matching value $actual at $path with $matcher" } return when (matcher) { is RegexMatcher -> matchRegex(matcher.regex, path, expected, actual, mismatchFn) is TypeMatcher -> matchType(path, expected, actual, mismatchFn, true) diff --git a/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/Matchers.kt b/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/Matchers.kt index 895bb2d53..f4749b7df 100755 --- a/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/Matchers.kt +++ b/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/Matchers.kt @@ -84,15 +84,47 @@ object Matchers : KLogging() { actualEntries: Map, context: MatchingContext, generateDiff: () -> String, - callback: (List, T?, T?) -> List + callback: (List, T?, T?, MatchingContext) -> List ): List { val result = mutableListOf() if (matcher is ValuesMatcher || matcher is EachValueMatcher) { + logger.debug { "Matcher is ValuesMatcher or EachValueMatcher, checking just the values" } + val subContext = if (matcher is EachValueMatcher) { + val associatedRules = matcher.definition.rules.mapNotNull { + when (it) { + is Either.A -> it.value + is Either.B -> { + result.add( + BodyItemMatchResult( + constructPath(path), + listOf( + BodyMismatch( + expectedEntries, actualEntries, + "Found an un-resolved reference ${it.value.name}", constructPath(path), generateDiff() + ) + ) + ) + ) + null + } + } + } + val matcherPath = constructPath(path) + ".*" + MatchingContext( + MatchingRuleCategory("body", mutableMapOf( + matcherPath to MatchingRuleGroup(associatedRules.toMutableList()) + )), + context.allowUnexpectedKeys, + context.pluginConfiguration + ) + } else { + context + } actualEntries.entries.forEach { (key, value) -> if (expectedEntries.containsKey(key)) { - result.addAll(callback(path + key, expectedEntries[key]!!, value)) + result.addAll(callback(path + key, expectedEntries[key]!!, value, subContext)) } else { - result.addAll(callback(path + key, expectedEntries.values.firstOrNull(), value)) + result.addAll(callback(path + key, expectedEntries.values.firstOrNull(), value, subContext)) } } } else { @@ -100,7 +132,7 @@ object Matchers : KLogging() { if (matcher !is EachKeyMatcher) { expectedEntries.entries.forEach { (key, value) -> if (actualEntries.containsKey(key)) { - result.addAll(callback(path + key, value, actualEntries[key])) + result.addAll(callback(path + key, value, actualEntries[key], context)) } } } diff --git a/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/Matching.kt b/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/Matching.kt index 40924b737..51067e641 100644 --- a/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/Matching.kt +++ b/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/Matching.kt @@ -84,7 +84,7 @@ data class MatchingContext @JvmOverloads constructor( val result = mutableListOf() - if (!directMatcherDefined(path, listOf(EachValueMatcher::class.java, ValuesMatcher::class.java))) { + if (!directMatcherDefined(path, listOf(EachKeyMatcher::class.java, EachValueMatcher::class.java, ValuesMatcher::class.java))) { if (allowUnexpectedKeys && missingKeys.isNotEmpty()) { result.add( BodyItemMatchResult( @@ -226,7 +226,10 @@ object Matching : KLogging() { listOf(BodyItemMatchResult("$", domatch(rootMatcher, listOf("$"), expected.body.orEmpty(), actual.body.orEmpty(), BodyMismatchFactory)))) expectedContentType.getBaseType() == actualContentType.getBaseType() -> { - val matcher = MatchingConfig.lookupContentMatcher(actualContentType.getBaseType()) + var matcher = MatchingConfig.lookupContentMatcher(actualContentType.getBaseType()) + if (matcher == null) { + matcher = MatchingConfig.lookupContentMatcher(actualContentType.getSupertype().toString()) + } if (matcher != null) { logger.debug { "Found a matcher for $actualContentType -> $matcher" } matcher.matchBody(expected.body, actual.body, context) diff --git a/core/model/src/test/groovy/au/com/dius/pact/core/model/ContentTypeSpec.groovy b/core/model/src/test/groovy/au/com/dius/pact/core/model/ContentTypeSpec.groovy index 2fc87db34..2f368b28f 100644 --- a/core/model/src/test/groovy/au/com/dius/pact/core/model/ContentTypeSpec.groovy +++ b/core/model/src/test/groovy/au/com/dius/pact/core/model/ContentTypeSpec.groovy @@ -35,6 +35,7 @@ class ContentTypeSpec extends Specification { 'application/x-thrift' || true 'application/x-other' || false 'application/graphql' || true + 'application/vnd.siren+json' || true contentType = new ContentType(value) } @@ -113,6 +114,7 @@ class ContentTypeSpec extends Specification { 'application/json' || false 'application/hal+json' || false 'application/HAL+JSON' || false + 'application/vnd.siren+json' || false 'application/xml' || false 'application/atom+xml' || false 'application/octet-stream' || true @@ -142,6 +144,7 @@ class ContentTypeSpec extends Specification { 'application/json' || 'application/javascript' 'application/hal+json' || 'application/json' 'application/HAL+JSON' || 'application/json' + 'application/vnd.siren+json' || 'application/json' 'application/xml' || 'text/plain' 'application/atom+xml' || 'application/xml' 'application/octet-stream' || null