Skip to content

Commit

Permalink
Implemented matching of multipart uploads #123
Browse files Browse the repository at this point in the history
  • Loading branch information
Ronald Holshausen committed Nov 29, 2017
1 parent 1fe85e4 commit 5180041
Show file tree
Hide file tree
Showing 7 changed files with 279 additions and 2 deletions.
Expand Up @@ -18,7 +18,11 @@ import au.com.dius.pact.model.Response
import au.com.dius.pact.model.generators.Generators
import au.com.dius.pact.model.matchingrules.MatchingRules
import au.com.dius.pact.model.matchingrules.MatchingRulesImpl
import au.com.dius.pact.model.matchingrules.RegexMatcher
import groovy.json.JsonBuilder
import org.apache.http.entity.ContentType
import org.apache.http.entity.mime.HttpMultipartMode
import org.apache.http.entity.mime.MultipartEntityBuilder
import scala.collection.JavaConverters$

import java.util.regex.Pattern
Expand All @@ -35,6 +39,8 @@ class PactBuilder extends BaseBuilder {
private static final String JSON = 'application/json'
private static final String BODY = 'body'
private static final String LOCALHOST = 'localhost'
public static final String HEADER = 'header'
public static final String MULTIPART_HEADER_REGEX = 'multipart/form-data;\\s*boundary=.*'

Consumer consumer
Provider provider
Expand Down Expand Up @@ -133,7 +139,7 @@ class PactBuilder extends BaseBuilder {

private static Map setupHeaders(Map headers, MatchingRules matchers) {
headers.collectEntries { key, value ->
def header = 'header'
def header = HEADER
if (value instanceof Matcher) {
matchers.addCategory(header).addRule(key, value.matcher)
[key, value.value]
Expand Down Expand Up @@ -402,4 +408,33 @@ class PactBuilder extends BaseBuilder {
throw new PactFailedException(result)
}
}

/**
* Sets up a file upload request. This will add the correct content type header to the request
* @param partName This is the name of the part in the multipart body.
* @param fileName This is the name of the file that was uploaded
* @param fileContentType This is the content type of the uploaded file
* @param data This is the actual file contents
*/
void withFileUpload(String partName, String fileName, String fileContentType, byte[] data) {
def multipart = MultipartEntityBuilder.create()
.setMode(HttpMultipartMode.BROWSER_COMPATIBLE)
.addBinaryBody(partName, data, ContentType.create(fileContentType), fileName)
.build()
def os = new ByteArrayOutputStream()
multipart.writeTo(os)
if (requestState) {
requestData.last().body = os.toString()
requestData.last().headers = requestData.last().headers ?: [:]
requestData.last().headers[CONTENT_TYPE] = multipart.contentType.value
au.com.dius.pact.model.matchingrules.Category category = requestData.last().matchers.addCategory(HEADER)
category.addRule(CONTENT_TYPE, new RegexMatcher(MULTIPART_HEADER_REGEX, multipart.contentType.value))
} else {
responseData.last().body = os.toString()
responseData.last().headers = responseData.last().headers ?: [:]
responseData.last().headers[CONTENT_TYPE] = multipart.contentType.value
au.com.dius.pact.model.matchingrules.Category category = responseData.last().matchers.addCategory(HEADER)
category.addRule(CONTENT_TYPE, new RegexMatcher(MULTIPART_HEADER_REGEX, multipart.contentType.value))
}
}
}
@@ -0,0 +1,48 @@
package au.com.dius.pact.consumer.groovy

import au.com.dius.pact.consumer.MockServer
import au.com.dius.pact.consumer.PactVerificationResult
import org.apache.http.client.methods.RequestBuilder
import org.apache.http.entity.ContentType
import org.apache.http.entity.mime.HttpMultipartMode
import org.apache.http.entity.mime.MultipartEntityBuilder
import org.apache.http.impl.client.CloseableHttpClient
import org.apache.http.impl.client.HttpClients
import spock.lang.Specification

class ExampleFileUploadSpec extends Specification {

def 'handles bodies from form posts'() {
given:
def service = new PactBuilder()
service {
serviceConsumer 'Consumer'
hasPactWith 'File Service'
uponReceiving('a multipart file POST')
withAttributes(path: '/upload', method: 'post')
withFileUpload('file', 'data.csv', 'text/csv', '1,2,3,4\n5,6,7,8'.bytes)
willRespondWith(status: 201, body: 'file uploaded ok', headers: ['Content-Type': 'text/plain'])
}

when:
def result = service.runTest { MockServer mockServer ->
CloseableHttpClient httpclient = HttpClients.createDefault()
httpclient.withCloseable {
def data = MultipartEntityBuilder.create()
.setMode(HttpMultipartMode.BROWSER_COMPATIBLE)
.addBinaryBody('file', '1,2,3,4\n5,6,7,8'.bytes, ContentType.create('text/csv'), 'data.csv')
.build()
def request = RequestBuilder
.post(mockServer.url + '/upload')
.setEntity(data)
.build()
println('Executing request ' + request.requestLine)
httpclient.execute(request)
}
}

then:
result == PactVerificationResult.Ok.INSTANCE
}

}
Expand Up @@ -6,7 +6,8 @@ object MatchingConfig {
"application/.*json" to "au.com.dius.pact.matchers.JsonBodyMatcher",
"application/json-rpc" to "au.com.dius.pact.matchers.JsonBodyMatcher",
"application/jsonrequest" to "au.com.dius.pact.matchers.JsonBodyMatcher",
"text/plain" to "au.com.dius.pact.matchers.PlainTextBodyMatcher"
"text/plain" to "au.com.dius.pact.matchers.PlainTextBodyMatcher",
"multipart/form-data" to "au.com.dius.pact.matchers.MultipartFormBodyMatcher"
)

@JvmStatic
Expand Down
@@ -0,0 +1,69 @@
package au.com.dius.pact.matchers

import au.com.dius.pact.model.HttpPart
import au.com.dius.pact.model.isEmpty
import au.com.dius.pact.model.isMissing
import au.com.dius.pact.model.isNotPresent
import au.com.dius.pact.model.isPresent
import au.com.dius.pact.model.orElse
import java.util.Enumeration
import javax.mail.BodyPart
import javax.mail.Header
import javax.mail.internet.MimeMultipart
import javax.mail.util.ByteArrayDataSource

class MultipartFormBodyMatcher : BodyMatcher {

override fun matchBody(expected: HttpPart, actual: HttpPart, allowUnexpectedKeys: Boolean): List<BodyMismatch> {
val expectedBody = expected.body
val actualBody = actual.body
return when {
expectedBody.isMissing() -> emptyList()
expectedBody.isPresent() && actualBody.isNotPresent() -> listOf(BodyMismatch(expectedBody.orElse(""),
null, "Expected a multipart body but was missing"))
expectedBody.isEmpty() && actualBody.isEmpty() -> emptyList()
else -> {
val expectedMultipart = parseMultipart(expectedBody.orElse(""), expected.contentTypeHeader().orEmpty())
val actualMultipart = parseMultipart(actualBody.orElse(""), actual.contentTypeHeader().orEmpty())
compareHeaders(expectedMultipart, actualMultipart) + compareContents(expectedMultipart, actualMultipart)
}
}
}

private fun compareContents(expectedMultipart: BodyPart, actualMultipart: BodyPart): List<BodyMismatch> {
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(BodyMismatch(expectedContents,
actualContents, "Expected no contents, but received ${actualContents.toByteArray().size} bytes of content"))
else -> listOf(BodyMismatch(expectedContents,
actualContents, "Expected content with the multipart, but received no bytes of content"))
}
}

private fun compareHeaders(expectedMultipart: BodyPart, actualMultipart: BodyPart): 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(BodyMismatch(it.toString(), null,
"Expected a multipart header '${it.name}' with value '${it.value}', but was '$actualValue'"))
}
} else {
mismatches.add(BodyMismatch(it.toString(), null, "Expected a multipart header '${it.name}', but was missing"))
}
}

return mismatches
}

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

}
@@ -0,0 +1,103 @@
package au.com.dius.pact.matchers

import au.com.dius.pact.model.OptionalBody
import au.com.dius.pact.model.Request
import spock.lang.Specification

class MultipartFormBodyMatcherSpec extends Specification {

private MultipartFormBodyMatcher matcher
private expected, actual

def setup() {
matcher = new MultipartFormBodyMatcher()
expected = { body -> new Request('', '', null, ['Content-Type': 'multipart/form-data; boundary=XXX'], body) }
actual = { body -> new Request('', '', null, ['Content-Type': 'multipart/form-data; boundary=XXX'], body) }
}

def 'return no mismatches - when comparing empty bodies'() {
expect:
matcher.matchBody(expected(expectedBody), actual(actualBody), true).empty

where:

actualBody = OptionalBody.empty()
expectedBody = OptionalBody.empty()
}

def 'return no mismatches - when comparing a missing body to anything'() {
expect:
matcher.matchBody(expected(expectedBody), actual(actualBody), true).empty

where:

actualBody = OptionalBody.body('"Blah"')
expectedBody = OptionalBody.missing()
}

def 'returns a mismatch - when comparing anything to an empty body'() {
expect:
matcher.matchBody(expected(expectedBody), actual(actualBody), true)*.mismatch == [
'Expected a multipart body but was missing'
]

where:

actualBody = OptionalBody.body('')
expectedBody = OptionalBody.body('"Blah"')
}

def 'returns a mismatch - when the actual body is missing a header'() {
expect:
matcher.matchBody(expected(expectedBody), actual(actualBody), true)*.mismatch == [
'Expected a multipart header \'Test\', but was missing'
]

where:

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

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

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')
}

def 'returns a mismatch - when the actual body is empty'() {
expect:
matcher.matchBody(expected(expectedBody), actual(actualBody), true)*.mismatch == [
'Expected content with the multipart, but received no bytes of content'
]

where:

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

@SuppressWarnings('ParameterCount')
OptionalBody multipart(disposition, name, filename, contentType, headers, body) {
OptionalBody.body(

"""--XXX
|Content-Disposition: $disposition; name=\"$name\"; filename=\"$filename\"
|Content-Type: $contentType
|$headers
|
|$body
|--XXX
""".stripMargin()
)
}

}
Expand Up @@ -48,6 +48,10 @@ data class OptionalBody(val state: State, val value: String? = null) {
return state == State.PRESENT
}

fun isNotPresent(): Boolean {
return state != State.PRESENT
}

fun orElse(defaultValue: String) : String {
return if (state == State.EMPTY || state == State.PRESENT) {
this.value!!
Expand All @@ -73,4 +77,6 @@ fun OptionalBody?.isNull() = this == null || this.isNull()

fun OptionalBody?.isPresent() = this != null && this.isPresent()

fun OptionalBody?.isNotPresent() = this == null || this.isNotPresent()

fun OptionalBody?.orElse(defaultValue: String) = this?.orElse(defaultValue) ?: defaultValue
Expand Up @@ -61,6 +61,21 @@ class OptionalBodySpec extends Specification {
OptionalBody.body('a') | true
}

@Unroll
def 'returns the appropriate state for not present'() {
expect:
body.notPresent == value

where:
body | value
OptionalBody.missing() | true
OptionalBody.empty() | true
OptionalBody.nullBody() | true
OptionalBody.body('') | true
OptionalBody.body(null) | true
OptionalBody.body('a') | false
}

@Unroll
def 'returns the appropriate value for orElse'() {
expect:
Expand Down

0 comments on commit 5180041

Please sign in to comment.