Skip to content

Commit

Permalink
feat: add method to setup content type body matching in the consumer …
Browse files Browse the repository at this point in the history
…DSL #1623
  • Loading branch information
rholshausen committed Oct 26, 2022
1 parent c1f8486 commit 72f9193
Show file tree
Hide file tree
Showing 15 changed files with 341 additions and 28 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package au.com.dius.pact.consumer.junit5.xml

import au.com.dius.pact.consumer.MockServer
import au.com.dius.pact.consumer.dsl.PactDslWithProvider
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt
import au.com.dius.pact.consumer.junit5.PactTestFor
import au.com.dius.pact.core.model.PactSpecVersion
import au.com.dius.pact.core.model.RequestResponsePact
import au.com.dius.pact.core.model.annotations.Pact
import org.apache.hc.client5.http.fluent.Request
import org.apache.hc.core5.http.ContentType
import org.apache.hc.core5.http.HttpResponse
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith

@ExtendWith(PactConsumerTestExt)
@PactTestFor(providerName = 'XMLProvider', pactVersion = PactSpecVersion.V3)
class XMLContentTypePactTest {
def example = '<?xml version=\"1.0\" encoding=\"utf-8\"?><example>foo</example>'

@Pact(consumer = 'XMLConsumer2')
RequestResponsePact xmlMessage(PactDslWithProvider builder) {
builder
.uponReceiving('a POST request with an XML message')
.method('POST')
.path('/message')
.bodyMatchingContentType('application/xml', example)
.willRespondWith()
.status(200)
.bodyMatchingContentType('application/xml', example)
.toPact()
}

@Test

void testXMLPost(MockServer mockServer) {
HttpResponse httpResponse = Request.post("${mockServer.url}/message")
.bodyString(
'''<?xml version="1.0" encoding="UTF-8"?>
<Message type="Request">
<Head>
<Client name="WebCheck">
<Version>2.2.8.3</Version>
</Client>
<Server>
<Name>SrvCheck</Name>
<Version>3.0</Version>
</Server>
<Authentication>
<User>peter</User>
<Password>token_placeholder</Password>
</Authentication>
<Token>1234567323211242144</Token>
</Head>
<Body>
<Call method="getInfo" service="CheckRpcService">
<Param name="exportId">
<ExportId>123456789</ExportId>
</Param>
</Call>
</Body>
</Message>
''', ContentType.APPLICATION_XML
)
.execute().returnResponse()
assert httpResponse.code == 200
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,11 @@ data class UuidMatcher(override val value: String?) :
Matcher(value, RegexMatcher(Matchers.UUID_REGEX.toString(), value),
if (value == null) UuidGenerator() else null)

data class EqualsMatcher(override val value: Any?) : Matcher(value, au.com.dius.pact.core.model.matchingrules.EqualsMatcher)
data class EqualsMatcher(override val value: Any?) : Matcher(value,
au.com.dius.pact.core.model.matchingrules.EqualsMatcher)

data class IncludeMatcher(override val value: String) : Matcher(value, au.com.dius.pact.core.model.matchingrules.IncludeMatcher(value))
data class IncludeMatcher(override val value: String) : Matcher(value,
au.com.dius.pact.core.model.matchingrules.IncludeMatcher(value))

object NullMatcher : Matcher(null, au.com.dius.pact.core.model.matchingrules.NullMatcher)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,10 @@ open class PactBuilder(
return this
}

private fun setupMessageContents(contents: Any?, interaction: V4Interaction): List<Pair<MessageContents, InteractionMarkup>> {
private fun setupMessageContents(
contents: Any?,
interaction: V4Interaction
): List<Pair<MessageContents, InteractionMarkup>> {
logger.debug { "Explicit contents, will look for a content matcher" }
return when (contents) {
is Map<*, *> -> if (contents.containsKey("pact:content-type")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import au.com.dius.pact.core.model.generators.DateGenerator
import au.com.dius.pact.core.model.generators.DateTimeGenerator
import au.com.dius.pact.core.model.generators.Generators
import au.com.dius.pact.core.model.generators.TimeGenerator
import au.com.dius.pact.core.model.matchingrules.ContentTypeMatcher
import au.com.dius.pact.core.model.matchingrules.DateMatcher
import au.com.dius.pact.core.model.matchingrules.MatchingRules
import au.com.dius.pact.core.model.matchingrules.MatchingRulesImpl
Expand All @@ -23,6 +24,7 @@ import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder
import org.apache.hc.core5.http.ContentType
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.nio.charset.Charset
import java.util.Date

open class PactDslRequestBase(
Expand Down Expand Up @@ -130,6 +132,18 @@ open class PactDslRequestBase(
return this
}

/**
* Sets up a content type matcher to match any body of the given content type
*/
protected open fun bodyMatchingContentType(contentType: String, exampleContents: String): PactDslRequestBase {
val ct = au.com.dius.pact.core.model.ContentType(contentType)
val charset = ct.asCharset()
requestBody = body(exampleContents.toByteArray(charset), ct)
requestHeaders[CONTENT_TYPE] = listOf(contentType)
requestMatchers.addCategory("body").addRule("$", ContentTypeMatcher(contentType))
return this
}

protected val isContentTypeHeaderNotSet: Boolean
get() = requestHeaders.keys.none { key -> key.equals(CONTENT_TYPE, ignoreCase = true) }
protected val contentTypeHeader: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,13 @@ open class PactDslRequestWithPath : PactDslRequestBase {
return this
}

/**
* Sets up a content type matcher to match any body of the given content type
*/
public override fun bodyMatchingContentType(contentType: String, exampleContents: String): PactDslRequestWithPath {
return super.bodyMatchingContentType(contentType, exampleContents) as PactDslRequestWithPath
}

/**
* The path of the request
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,13 @@ open class PactDslRequestWithoutPath @JvmOverloads constructor(
return this
}

/**
* Sets up a content type matcher to match any body of the given content type
*/
public override fun bodyMatchingContentType(contentType: String, exampleContents: String): PactDslRequestWithoutPath {
return super.bodyMatchingContentType(contentType, exampleContents) as PactDslRequestWithoutPath
}

/**
* The path of the request
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,18 @@ open class PactDslResponse @JvmOverloads constructor(
return this
}

/**
* Sets up a content type matcher to match any body of the given content type
*/
fun bodyMatchingContentType(contentType: String, exampleContents: String): PactDslResponse {
val ct = au.com.dius.pact.core.model.ContentType(contentType)
val charset = ct.asCharset()
responseBody = body(exampleContents.toByteArray(charset), ct)
responseHeaders[PactDslRequestBase.CONTENT_TYPE] = listOf(contentType)
responseMatchers.addCategory("body").addRule("$", ContentTypeMatcher(contentType))
return this
}

protected val isContentTypeHeaderNotSet: Boolean
get() = responseHeaders.keys.none { key -> key.equals(CONTENT_TYPE, ignoreCase = true) }
protected val contentTypeHeader: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package au.com.dius.pact.consumer.dsl

import au.com.dius.pact.consumer.ConsumerPactBuilder
import au.com.dius.pact.core.model.OptionalBody
import au.com.dius.pact.core.model.PactSpecVersion
import au.com.dius.pact.core.model.generators.Generators
import au.com.dius.pact.core.model.matchingrules.MatchingRulesImpl
import au.com.dius.pact.core.model.matchingrules.RegexMatcher
Expand Down Expand Up @@ -196,4 +197,24 @@ class PactDslRequestWithPathSpec extends Specification {
then:
request.additionalMetadata == [test: 'value']
}
@Issue('#1623')
def 'supports setting a content type matcher'() {
given:
def request = ConsumerPactBuilder.consumer('spec')
.hasPactWith('provider')
.uponReceiving('a XML request')
.path("/path")
def example = '<?xml version=\"1.0\" encoding=\"utf-8\"?><example>foo</example>'
when:
def result = request.bodyMatchingContentType('application/xml', example)
then:
result.requestHeaders['Content-Type'] == ['application/xml']
result.requestBody.valueAsString() == example
result.requestMatchers.rulesForCategory('body').toMap(PactSpecVersion.V4) == [
'$': [matchers: [[match: 'contentType', value: 'application/xml']], combine: 'AND']
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package au.com.dius.pact.consumer.dsl

import au.com.dius.pact.consumer.ConsumerPactBuilder
import au.com.dius.pact.core.model.OptionalBody
import au.com.dius.pact.core.model.PactSpecVersion
import spock.lang.Issue
import spock.lang.Specification

Expand Down Expand Up @@ -58,4 +59,23 @@ class PactDslRequestWithoutPathSpec extends Specification {
then:
subject.additionalMetadata == [test: 'value']
}

@Issue('#1623')
def 'supports setting a content type matcher'() {
given:
def request = ConsumerPactBuilder.consumer('spec')
.hasPactWith('provider')
.uponReceiving('a XML request')
def example = '<?xml version=\"1.0\" encoding=\"utf-8\"?><example>foo</example>'

when:
def result = request.bodyMatchingContentType('application/xml', example)

then:
result.requestHeaders['Content-Type'] == ['application/xml']
result.requestBody.valueAsString() == example
result.requestMatchers.rulesForCategory('body').toMap(PactSpecVersion.V4) == [
'$': [matchers: [[match: 'contentType', value: 'application/xml']], combine: 'AND']
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,25 @@ class PactDslResponseSpec extends Specification {
v4Interaction.response.body.state == OptionalBody.State.EMPTY
v4Interaction.toMap(PactSpecVersion.V4).response == [status: 200, body: [content: '']]
}

@Issue('#1623')
def 'supports setting a content type matcher'() {
given:
def response = ConsumerPactBuilder.consumer('spec')
.hasPactWith('provider')
.uponReceiving('a XML request')
.path("/path")
.willRespondWith()
def example = '<?xml version=\"1.0\" encoding=\"utf-8\"?><example>foo</example>'

when:
def result = response.bodyMatchingContentType('application/xml', example)

then:
response.responseHeaders['Content-Type'] == ['application/xml']
result.responseBody.valueAsString() == example
result.responseMatchers.rulesForCategory('body').toMap(PactSpecVersion.V4) == [
'$': [matchers: [[match: 'contentType', value: 'application/xml']], combine: 'AND']
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ object Matchers : KLogging() {

@JvmStatic
@JvmOverloads
@Suppress("LongParameterList")
fun <M : Mismatch> domatch(
context: MatchingContext,
path: List<String>,
Expand All @@ -82,6 +83,7 @@ object Matchers : KLogging() {
/**
* Compares the expected and actual maps using the provided matching rule
*/
@Suppress("LongParameterList")
fun <T> compareMaps(
path: List<String>,
matcher: MatchingRule,
Expand Down Expand Up @@ -113,7 +115,7 @@ object Matchers : KLogging() {
return result
}

@Suppress("LongMethod")
@Suppress("LongMethod", "LongParameterList")
fun <T> compareLists(
path: List<String>,
matcher: MatchingRule,
Expand Down Expand Up @@ -193,6 +195,7 @@ object Matchers : KLogging() {
/**
* Compares any "extra" actual elements to expected
*/
@Suppress("LongParameterList")
private fun <T> compareActualElements(
path: List<String>,
actualIndex: Int,
Expand All @@ -212,6 +215,7 @@ object Matchers : KLogging() {
/**
* Compares every permutation of actual against expected.
*/
@Suppress("LongParameterList")
fun <T> compareListContentUnordered(
expectedList: List<T>,
actualList: List<T>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,36 +221,46 @@ object Matching : KLogging() {
if (expected.equals(actual, ignoreCase = true)) null
else MethodMismatch(expected, actual)

@Suppress("ComplexMethod")
fun matchBody(expected: HttpPart, actual: HttpPart, context: MatchingContext): BodyMatchResult {
logger.debug { "matchBody: context=$context" }

val expectedContentType = expected.determineContentType()
val actualContentType = actual.determineContentType()
return if (expectedContentType.getBaseType() == actualContentType.getBaseType()) {
val matcher = MatchingConfig.lookupContentMatcher(actualContentType.getBaseType())
if (matcher != null) {
logger.debug { "Found a matcher for $actualContentType -> $matcher" }
matcher.matchBody(expected.body, actual.body, context)
} else {
logger.debug { "No matcher for $actualContentType, using equality" }
when {
expected.body.isMissing() -> BodyMatchResult(null, emptyList())
expected.body.isNull() && actual.body.isPresent() -> BodyMatchResult(null,
listOf(BodyItemMatchResult("$", listOf(BodyMismatch(null, actual.body.unwrap(),
"Expected an empty body but received '${actual.body.unwrap()}'")))))
expected.body.isNull() -> BodyMatchResult(null, emptyList())
actual.body.isMissing() -> BodyMatchResult(null,
listOf(BodyItemMatchResult("$", listOf(BodyMismatch(expected.body.unwrap(), null,
"Expected body '${expected.body.unwrap()}' but was missing")))))
else -> matchBodyContents(expected, actual)
val rootMatcher = expected.matchingRules.rulesForCategory("body").matchingRules["$"]

return when {
rootMatcher != null && rootMatcher.canMatch(expectedContentType) -> BodyMatchResult(null,
listOf(BodyItemMatchResult("$", domatch(rootMatcher, listOf("$"), expected.body.unwrap(),
actual.body.unwrap(), BodyMismatchFactory))))
expectedContentType.getBaseType() == actualContentType.getBaseType() -> {
val matcher = MatchingConfig.lookupContentMatcher(actualContentType.getBaseType())
if (matcher != null) {
logger.debug { "Found a matcher for $actualContentType -> $matcher" }
matcher.matchBody(expected.body, actual.body, context)
} else {
logger.debug { "No matcher for $actualContentType, using equality" }
when {
expected.body.isMissing() -> BodyMatchResult(null, emptyList())
expected.body.isNull() && actual.body.isPresent() -> BodyMatchResult(null,
listOf(BodyItemMatchResult("$", listOf(BodyMismatch(null, actual.body.unwrap(),
"Expected an empty body but received '${actual.body.unwrap()}'")))))
expected.body.isNull() -> BodyMatchResult(null, emptyList())
actual.body.isMissing() -> BodyMatchResult(null,
listOf(BodyItemMatchResult("$", listOf(BodyMismatch(expected.body.unwrap(), null,
"Expected body '${expected.body.unwrap()}' but was missing")))))
else -> matchBodyContents(expected, actual)
}
}
}
} else {
if (expected.body.isMissing() || expected.body.isNull() || expected.body.isEmpty())
BodyMatchResult(null, emptyList())
else
BodyMatchResult(
BodyTypeMismatch(expectedContentType.getBaseType(), actualContentType.getBaseType()),
emptyList())
else -> {
if (expected.body.isMissing() || expected.body.isNull() || expected.body.isEmpty())
BodyMatchResult(null, emptyList())
else
BodyMatchResult(
BodyTypeMismatch(expectedContentType.getBaseType(), actualContentType.getBaseType()),
emptyList())
}
}
}

Expand Down
Loading

0 comments on commit 72f9193

Please sign in to comment.