Skip to content

Commit

Permalink
feat(compatibility-suite): Implemented scenarios related to multipart…
Browse files Browse the repository at this point in the history
… bodies
  • Loading branch information
rholshausen committed Jun 30, 2023
1 parent 20e3cc8 commit 27498a1
Show file tree
Hide file tree
Showing 5 changed files with 128 additions and 58 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ class PostImageBodyTest {
void testFiles(MockServer mockServer) {
CloseableHttpClient httpclient = HttpClients.createDefault()
def result = httpclient.withCloseable {
PostImageBodyTest.getResourceAsStream('/RAT.JPG').withCloseable { stream ->
PostImageBodyTest.getResourceAsStream('/ron.jpg').withCloseable { stream ->
def data = MultipartEntityBuilder.create()
.setMode(HttpMultipartMode.BROWSER_COMPATIBLE)
.addBinaryBody('photo', stream, ContentType.create('image/jpeg'), 'ron.jpg')
.addBinaryBody('text', 'some text stuff'.bytes, ContentType.create('text/plain'), 'ron.txt')
.addBinaryBody('text', 'hello world!'.bytes, ContentType.create('text/plain'), 'ron.txt')
.build()
def request = RequestBuilder
.post(mockServer.url + '/images')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package au.com.dius.pact.core.matchers

import au.com.dius.pact.core.model.HttpPart
import au.com.dius.pact.core.model.IHttpPart
import au.com.dius.pact.core.model.IRequest
import au.com.dius.pact.core.model.PathToken
import au.com.dius.pact.core.model.constructPath
Expand Down Expand Up @@ -213,7 +214,7 @@ object Matching : KLogging() {
else MethodMismatch(expected, actual)

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

val expectedContentType = expected.determineContentType()
Expand Down Expand Up @@ -255,7 +256,7 @@ object Matching : KLogging() {
}
}

fun matchBodyContents(expected: HttpPart, actual: HttpPart): BodyMatchResult {
fun matchBodyContents(expected: IHttpPart, actual: IHttpPart): BodyMatchResult {
val matcher = expected.matchingRules.rulesForCategory("body").matchingRules["$"]
val contentType = expected.determineContentType()
return when {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,18 @@ object MatchingConfig {
"text/xml" to "au.com.dius.pact.core.matchers.XmlContentMatcher",
".*json.*" to "au.com.dius.pact.core.matchers.JsonContentMatcher",
"text/plain" to "au.com.dius.pact.core.matchers.PlainTextContentMatcher",
"multipart/form-data" to "au.com.dius.pact.core.matchers.MultipartMessageContentMatcher",
"multipart/mixed" to "au.com.dius.pact.core.matchers.MultipartMessageContentMatcher",
"multipart/.*" to "au.com.dius.pact.core.matchers.MultipartMessageContentMatcher",
"application/x-www-form-urlencoded" to "au.com.dius.pact.core.matchers.FormPostContentMatcher"
)

@JvmStatic
fun lookupContentMatcher(contentType: String?): ContentMatcher? {
return if (contentType != null) {
val contentType1 = ContentType(contentType)
val contentMatcher = CatalogueManager.findContentMatcher(contentType1)
val ct = ContentType(contentType)
val contentMatcher = CatalogueManager.findContentMatcher(ct)
if (contentMatcher != null) {
if (!contentMatcher.isCore) {
PluginContentMatcher(contentMatcher, contentType1)
PluginContentMatcher(contentMatcher, ct)
} else {
coreContentMatcher(contentType)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package au.com.dius.pact.core.matchers

import au.com.dius.pact.core.model.ContentType
import au.com.dius.pact.core.model.HttpRequest
import au.com.dius.pact.core.model.IHttpPart
import au.com.dius.pact.core.model.OptionalBody
import au.com.dius.pact.core.support.Result
import io.pact.plugins.jvm.core.InteractionContents
Expand All @@ -25,14 +27,45 @@ class MultipartMessageContentMatcher : ContentMatcher {
null, "Expected a multipart body but was missing")))))
expected.isEmpty() && actual.isEmpty() -> BodyMatchResult(null, emptyList())
else -> {
val expectedMultipart = parseMultipart(expected.valueAsString(), expected.contentType.toString())
val actualMultipart = parseMultipart(actual.valueAsString(), actual.contentType.toString())
BodyMatchResult(null, compareHeaders(expectedMultipart, actualMultipart, context) +
compareContents(expectedMultipart, actualMultipart, context))
val expectedMultipart = MimeMultipart(ByteArrayDataSource(expected.orEmpty(), expected.contentType.toString()))
val actualMultipart = MimeMultipart(ByteArrayDataSource(actual.orEmpty(), actual.contentType.toString()))
BodyMatchResult(null, compareParts(expectedMultipart, actualMultipart, context))
}
}
}

private fun compareParts(
expectedMultipart: MimeMultipart,
actualMultipart: MimeMultipart,
context: MatchingContext
): List<BodyItemMatchResult> {
val matchResults = mutableListOf<BodyItemMatchResult>()

logger.debug { "Comparing multiparts: expected has ${expectedMultipart.count} part(s), " +
"actual has ${actualMultipart.count} part(s)" }

if (expectedMultipart.count != actualMultipart.count) {
matchResults.add(BodyItemMatchResult("$", listOf(BodyMismatch(expectedMultipart.count, actualMultipart.count,
"Expected a multipart message with ${expectedMultipart.count} part(s), " +
"but received one with ${actualMultipart.count} part(s)"))))
}

for (i in 0 until expectedMultipart.count) {
val expectedPart = expectedMultipart.getBodyPart(i)
if (i < actualMultipart.count) {
val actualPart = actualMultipart.getBodyPart(i)
val path = "\$.$i"
val headerResult = compareHeaders(path, expectedPart, actualPart, context)
logger.debug { "Comparing part $i: header mismatches ${headerResult.size}" }
val bodyMismatches = compareContents(path, expectedPart, actualPart, context)
logger.debug { "Comparing part $i: content mismatches ${bodyMismatches.size}" }
matchResults.add(BodyItemMatchResult(path, headerResult + bodyMismatches))
}
}

return matchResults
}

override fun setupBodyFromConfig(
bodyConfig: Map<String, Any?>
): Result<List<InteractionContents>, String> {
Expand All @@ -46,54 +79,55 @@ class MultipartMessageContentMatcher : ContentMatcher {

@Suppress("UnusedPrivateMember")
private fun compareContents(
path: String,
expectedMultipart: BodyPart,
actualMultipart: BodyPart,
context: MatchingContext
): List<BodyItemMatchResult> {
val expectedContents = expectedMultipart.content.toString().trim()
val actualContents = actualMultipart.content.toString().trim()
return when {
expectedContents.isEmpty() && actualContents.isEmpty() -> emptyList()
expectedContents.isNotEmpty() && actualContents.isNotEmpty() -> emptyList()
expectedContents.isEmpty() && actualContents.isNotEmpty() -> listOf(BodyItemMatchResult("$",
listOf(BodyMismatch(expectedContents, actualContents,
"Expected no contents, but received ${actualContents.toByteArray().size} bytes of content"))))
else -> listOf(BodyItemMatchResult("$", listOf(BodyMismatch(expectedContents,
actualContents, "Expected content with the multipart, but received no bytes of content"))))
): List<BodyMismatch> {
val expected = bodyPartTpHttpPart(expectedMultipart)
val actual = bodyPartTpHttpPart(actualMultipart)
logger.debug { "Comparing multipart contents: ${expected.determineContentType()} -> ${actual.determineContentType()}" }
val result = Matching.matchBody(expected, actual, context)
return result.bodyResults.flatMap { matchResult ->
matchResult.result.map {
it.copy(path = path + it.path.removePrefix("$"))
}
}
}

private fun bodyPartTpHttpPart(multipart: BodyPart): IHttpPart {
return HttpRequest(headers = mutableMapOf("content-type" to listOf(multipart.contentType)),
body = OptionalBody.body(multipart.inputStream.readAllBytes(), ContentType(multipart.contentType)))
}

private fun compareHeaders(
path: String,
expectedMultipart: BodyPart,
actualMultipart: BodyPart,
context: MatchingContext
): List<BodyItemMatchResult> {
val mismatches = mutableListOf<BodyItemMatchResult>()
): List<BodyMismatch> {
val mismatches = mutableListOf<BodyMismatch>()
(expectedMultipart.allHeaders as Enumeration<Header>).asSequence().forEach {
val header = actualMultipart.getHeader(it.name)
if (header != null) {
val actualValue = header.joinToString(separator = ", ")
if (actualValue != it.value) {
mismatches.add(BodyItemMatchResult(it.name, listOf(BodyMismatch(it.toString(), null,
"Expected a multipart header '${it.name}' with value '${it.value}', but was '$actualValue'"))))
mismatches.add(BodyMismatch(it.toString(), null,
"Expected a multipart header '${it.name}' with value '${it.value}', but was '$actualValue'",
path + "." + it.name))
}
} else {
if (it.name.equals("Content-Type", ignoreCase = true)) {
logger.debug { "Ignoring missing Content-Type header" }
} else {
mismatches.add(BodyItemMatchResult(it.name, listOf(BodyMismatch(it.toString(), null,
"Expected a multipart header '${it.name}', but was missing"))))
mismatches.add(BodyMismatch(it.toString(), null,
"Expected a multipart header '${it.name}', but was missing", path + "." + it.name))
}
}
}

return mismatches
}

private fun parseMultipart(body: String, contentType: String): BodyPart {
val multipart = MimeMultipart(ByteArrayDataSource(body, contentType))
return multipart.getBodyPart(0)
}

companion object : KLogging()
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package au.com.dius.pact.core.matchers
import au.com.dius.pact.core.model.ContentType
import au.com.dius.pact.core.model.OptionalBody
import au.com.dius.pact.core.model.matchingrules.MatchingRuleCategory
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder
import spock.lang.Specification

@SuppressWarnings('ThrowRuntimeException')
class MultipartMessageContentMatcherSpec extends Specification {

private MultipartMessageContentMatcher matcher
Expand Down Expand Up @@ -47,73 +49,107 @@ class MultipartMessageContentMatcherSpec extends Specification {
expectedBody = OptionalBody.body('"Blah"'.bytes)
}

def 'returns a mismatch - when the actual body is missing a header'() {
def 'Ignores missing content type header, which is optional'() {
expect:
matcher.matchBody(expectedBody, actualBody, context).mismatches.empty

where:

actualBody = multipartFormData('form-data', 'file', '476.csv', null, '', '1234')
expectedBody = multipartFormData('form-data', 'file', '476.csv', 'text/plain', '', '1234')
}

def 'returns a mismatch - when the headers do not match'() {
expect:
matcher.matchBody(expectedBody, actualBody, context).mismatches*.mismatch == [
'Expected a multipart header \'Test\', but was missing'
'Expected a multipart header \'Content-Type\' with value \'text/html\', but was \'text/plain\''
]

where:

actualBody = multipart('form-data', 'file', '476.csv', 'text/plain', '', '1234')
expectedBody = multipart('form-data', 'file', '476.csv', 'text/plain', 'Test: true\n', '1234')
actualBody = multipartFile('file', '476.csv', 'text/plain', '1234')
expectedBody = multipartFile('file', '476.csv', 'text/html', '1234')
}

def 'Ignores missing content type header, which is optional'() {
def 'returns a mismatch - when the actual body is empty'() {
expect:
matcher.matchBody(expectedBody, actualBody, context).mismatches.empty
matcher.matchBody(expectedBody, actualBody, context).mismatches*.mismatch == [
'Expected body \'1234\' to match \'\' using equality but did not match'
]

where:

actualBody = multipart('form-data', 'file', '476.csv', null, '', '1234')
expectedBody = multipart('form-data', 'file', '476.csv', 'text/plain', '', '1234')
actualBody = multipartFile('file', '476.csv', 'text/plain', '')
expectedBody = multipartFile('file', '476.csv', 'text/plain', '1234')
}

def 'returns a mismatch - when the headers do not match'() {
def 'returns a mismatch - when the number of parts is different'() {
expect:
matcher.matchBody(expectedBody, actualBody, context).mismatches*.mismatch == [
'Expected a multipart header \'Content-Type\' with value \'text/html\', but was \'text/plain\''
'Expected a multipart message with 1 part(s), but received one with 2 part(s)'
]

where:

actualBody = multipart('form-data', 'file', '476.csv', 'text/plain', 'Test: true\n', '1234')
expectedBody = multipart('form-data', 'file', '476.csv', 'text/html', 'Test: true\n', '1234')
actualBody = multipart('text/plain', 'This is some text', 'text/plain', 'this is some more text')
expectedBody = multipart('text/plain', 'This is some text')
}

def 'returns a mismatch - when the actual body is empty'() {
def 'returns a mismatch - when the parts have different content'() {
expect:
matcher.matchBody(expectedBody, actualBody, context).mismatches*.mismatch == [
'Expected content with the multipart, but received no bytes of content'
'Expected \'This is some other text\' (String) but received \'This is some text\' (String)'
]

where:

actualBody = multipart('form-data', 'file', '476.csv', 'text/plain', '',
'')
expectedBody = multipart('form-data', 'file', '476.csv', 'text/plain', '',
'1234')
actualBody = multipart('application/json', '{"text": "This is some text"}')
expectedBody = multipart('application/json', '{"text": "This is some other text"}')
}

@SuppressWarnings('ParameterCount')
OptionalBody multipartFile(String name, String filename, String contentType, String body) {
def builder = MultipartEntityBuilder.create()
def type = contentType ? org.apache.hc.core5.http.ContentType.parse(contentType) : null
builder.addBinaryBody(name, body.bytes, type, filename)

def entity = builder.build()
OptionalBody.body(entity.content.bytes, new ContentType(entity.contentType))
}

OptionalBody multipart(String... partData) {
if (partData.length % 2 != 0) {
throw new RuntimeException('multipart requires pairs')
}

def builder = MultipartEntityBuilder.create()
partData.collate(2).eachWithIndex { pair, index ->
builder.addTextBody("part-$index", pair[1],
org.apache.hc.core5.http.ContentType.parse(pair[0]))
}

def entity = builder.build()
OptionalBody.body(entity.content.bytes, new ContentType(entity.contentType))
}

@SuppressWarnings('ParameterCount')
OptionalBody multipart(disposition, name, filename, contentType, headers, body) {
OptionalBody multipartFormData(disposition, name, filename, contentType, headers, body) {
def contentTypeLine = ''
def headersLine = ''
if (contentType) {
contentTypeLine = "Content-Type: $contentType"
contentTypeLine = "Content-Type: $contentType\n"
if (headers) {
headersLine = "$contentTypeLine\n$headers"
headersLine = "$contentTypeLine\n$headers\n"
} else {
headersLine = contentTypeLine
}
} else if (headers) {
headersLine = headers
headersLine = headers ?: '\n'
}
OptionalBody.body(
"""--XXX
|Content-Disposition: $disposition; name=\"$name\"; filename=\"$filename\"
|$headersLine
|
|$body
|--XXX
""".stripMargin().bytes, new ContentType('multipart/form-data; boundary=XXX')
Expand Down

0 comments on commit 27498a1

Please sign in to comment.