Skip to content

Commit

Permalink
refactor: move content detection to OptionalBody and ContentType classes
Browse files Browse the repository at this point in the history
  • Loading branch information
Ronald Holshausen committed Jun 13, 2020
1 parent 5bf94d7 commit b8ee6cd
Show file tree
Hide file tree
Showing 28 changed files with 299 additions and 170 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,17 @@ class PactBuilder extends BaseBuilder {
Map query = setupQueryParameters(requestData[i].query ?: [:], requestMatchers, requestGenerators)
Map responseHeaders = setupHeaders(responseData[i].headers ?: [:], responseMatchers, responseGenerators)
String path = setupPath(requestData[i].path ?: '/', requestMatchers, requestGenerators)
def requestBody = requestData[i].body instanceof String ? requestData[i].body.bytes : requestData[i].body
def responseBody = responseData[i].body instanceof String ? responseData[i].body.bytes : responseData[i].body
interactions << new RequestResponseInteraction(
requestDescription,
providerStates,
new Request(requestData[i].method ?: 'get', path, query, headers,
requestData[i].containsKey(BODY) ? OptionalBody.body(requestData[i].body.bytes, contentType(headers)) :
requestData[i].containsKey(BODY) ? OptionalBody.body(requestBody, contentType(headers)) :
OptionalBody.missing(),
requestMatchers, requestGenerators),
new Response(responseData[i].status ?: 200, responseHeaders,
responseData[i].containsKey(BODY) ? OptionalBody.body(responseData[i].body.bytes,
responseData[i].containsKey(BODY) ? OptionalBody.body(responseBody,
contentType(responseHeaders)) : OptionalBody.missing(),
responseMatchers, responseGenerators), null
)
Expand Down Expand Up @@ -402,16 +404,16 @@ class PactBuilder extends BaseBuilder {
.setMode(HttpMultipartMode.BROWSER_COMPATIBLE)
.addBinaryBody(partName, data, ContentType.create(fileContentType), fileName)
.build()
def os = new ByteArrayOutputStream()
ByteArrayOutputStream os = new ByteArrayOutputStream()
multipart.writeTo(os)
if (requestState) {
requestData.last().body = os.toString()
requestData.last().body = os.toByteArray()
requestData.last().headers = requestData.last().headers ?: [:]
requestData.last().headers[CONTENT_TYPE] = multipart.contentType.value
Category category = requestData.last().matchers.addCategory(HEADER)
category.addRule(CONTENT_TYPE, new RegexMatcher(Headers.MULTIPART_HEADER_REGEX, multipart.contentType.value))
} else {
responseData.last().body = os.toString()
responseData.last().body = os.toByteArray()
responseData.last().headers = responseData.last().headers ?: [:]
responseData.last().headers[CONTENT_TYPE] = multipart.contentType.value
Category category = responseData.last().matchers.addCategory(HEADER)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package au.com.dius.pact.consumer.junit5

import au.com.dius.pact.consumer.MockServer
import au.com.dius.pact.consumer.dsl.PactDslJsonBody
import au.com.dius.pact.consumer.dsl.PactDslWithProvider
import au.com.dius.pact.core.model.RequestResponsePact
import au.com.dius.pact.core.model.annotations.Pact
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 org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith

@ExtendWith(PactConsumerTestExt)
@PactTestFor(providerName = 'ProviderThatAcceptsImages')
class PostImageBodyTest {
@Pact(consumer = 'Consumer')
RequestResponsePact pact(PactDslWithProvider builder) {
PostImageBodyTest.getResourceAsStream('/ron.jpg').withCloseable { stream ->
builder
.uponReceiving('a request with an image')
.method('POST')
.path('/images')
.withFileUpload('photo', 'ron.jpg', 'image/jpeg', stream.bytes)
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.integerType('version', 1)
.integerType('status', 0)
.stringValue('errorMessage', '')
.array('issues').closeArray())
.toPact()
}
}

@Test
void testFiles(MockServer mockServer) {
CloseableHttpClient httpclient = HttpClients.createDefault()
def result = httpclient.withCloseable {
PostImageBodyTest.getResourceAsStream('/RAT.JPG').withCloseable { stream ->
def data = MultipartEntityBuilder.create()
.setMode(HttpMultipartMode.BROWSER_COMPATIBLE)
.addBinaryBody('photo', stream, ContentType.create('image/jpeg'), 'ron.jpg')
.build()
def request = RequestBuilder
.post(mockServer.url + '/images')
.setEntity(data)
.build()
httpclient.execute(request)
}
}
assert result.statusLine.statusCode == 200
}
}
Binary file added consumer/junit5/src/test/resources/RAT.JPG
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added consumer/junit5/src/test/resources/ron.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ protected void setupFileUpload(String partName, String fileName, String fileCont
.setMode(HttpMultipartMode.BROWSER_COMPATIBLE)
.addBinaryBody(partName, data, ContentType.create(fileContentType), fileName)
.build();
OutputStream os = new ByteArrayOutputStream();
ByteArrayOutputStream os = new ByteArrayOutputStream();
multipart.writeTo(os);

requestBody = OptionalBody.body(os.toString().getBytes(),
requestBody = OptionalBody.body(os.toByteArray(),
new au.com.dius.pact.core.model.ContentType(multipart.getContentType().getValue()));
requestMatchers.addCategory("header").addRule(CONTENT_TYPE, new RegexMatcher(MULTIPART_HEADER_REGEX,
multipart.getContentType().getValue()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ class KTorMockServer(
val body = if (bodyContents.isEmpty()) {
OptionalBody.empty()
} else {
OptionalBody.body(bodyContents, ContentType(headers["Content-Type"] ?: ContentType.JSON.contentType))
OptionalBody.body(bodyContents, ContentType.fromString(headers["Content-Type"]).or(ContentType.JSON))
}
return Request(call.request.httpMethod.value, call.request.path(),
call.request.queryParameters.entries().associate { it.toPair() }.toMutableMap(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ class MultipartMessageBodyMatcher : BodyMatcher {
null, "Expected a multipart body but was missing"))
expected.isEmpty() && actual.isEmpty() -> emptyList()
else -> {
val expectedMultipart = parseMultipart(expected.valueAsString(), expected.contentType.contentType!!)
val actualMultipart = parseMultipart(actual.valueAsString(), actual.contentType.contentType!!)
val expectedMultipart = parseMultipart(expected.valueAsString(), expected.contentType.toString())
val actualMultipart = parseMultipart(actual.valueAsString(), actual.contentType.toString())
compareHeaders(expectedMultipart, actualMultipart) + compareContents(expectedMultipart, actualMultipart)
}
}
Expand Down
1 change: 1 addition & 0 deletions core/model/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies {
compile "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion"
implementation "org.slf4j:slf4j-api:${project.slf4jVersion}"
api "com.google.code.gson:gson:${project.gsonVersion}"
implementation 'org.apache.tika:tika-core:1.24.1'

testCompile "ch.qos.logback:logback-classic:${project.logbackVersion}"
testCompile "io.github.http-builder-ng:http-builder-ng-apache:${project.httpBuilderVersion}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ abstract class BaseRequest : HttpPart() {
* @param contents File contents
*/
fun withMultipartFileUpload(partName: String, filename: String, contentType: ContentType, contents: String) =
withMultipartFileUpload(partName, filename, contentType.contentType!!, contents)
withMultipartFileUpload(partName, filename, contentType.toString(), contents)

/**
* Sets up the request as a multipart file upload
Expand Down
Original file line number Diff line number Diff line change
@@ -1,43 +1,72 @@
package au.com.dius.pact.core.model

import au.com.dius.pact.core.support.isNotEmpty
import mu.KLogging
import org.apache.tika.mime.MediaType
import java.nio.charset.Charset

private val jsonRegex = Regex("application\\/.*json")
private val xmlRegex = Regex("application\\/.*xml")
private val jsonRegex = Regex(".*json")
private val xmlRegex = Regex(".*xml")

data class ContentType(val contentType: String?) {
data class ContentType(val contentType: MediaType?) {

@Suppress("TooGenericExceptionCaught")
private val parsedContentType: org.apache.http.entity.ContentType? = try {
if (contentType.isNullOrEmpty()) {
null
} else {
org.apache.http.entity.ContentType.parse(contentType)
}
} catch (e: Exception) {
logger.debug { "Failed to parse content type '$contentType'" }
null
}
constructor(contentType: String) : this(MediaType.parse(contentType))

fun isJson(): Boolean = if (contentType != null) jsonRegex.matches(contentType.subtype.toLowerCase()) else false

fun isJson(): Boolean = if (contentType != null) jsonRegex.matches(contentType.toLowerCase()) else false
fun isXml(): Boolean = if (contentType != null) xmlRegex.matches(contentType.subtype.toLowerCase()) else false

fun isXml(): Boolean = if (contentType != null) xmlRegex.matches(contentType.toLowerCase()) else false
fun isOctetStream(): Boolean = if (contentType != null)
contentType.baseType.toString() == "application/octet-stream"
else false

override fun toString() = contentType.toString()

fun asCharset(): Charset = parsedContentType?.charset ?: Charset.defaultCharset()
fun asString() = contentType?.toString()

fun asCharset(): Charset {
return if (contentType != null && contentType.hasParameters()) {
val cs = contentType.parameters["charset"]
if (cs.isNotEmpty()) {
Charset.forName(cs)
} else {
Charset.defaultCharset()
}
} else {
Charset.defaultCharset()
}
}

fun or(other: ContentType) = if (contentType == null) {
other
} else {
this
}

fun asMimeType() = parsedContentType?.mimeType ?: contentType
fun getBaseType() = contentType?.baseType?.toString()

companion object : KLogging() {
@JvmStatic
val UNKNOWN = ContentType("")
fun fromString(contentType: String?) = if (contentType.isNullOrEmpty()) {
UNKNOWN
} else {
ContentType(contentType)
}

val XMLREGEXP = """^\s*<\?xml\s*version.*""".toRegex()
val HTMLREGEXP = """^\s*(<!DOCTYPE)|(<HTML>).*""".toRegex()
val JSONREGEXP = """^\s*(true|false|null|[0-9]+|"\w*|\{\s*(}|"\w+)|\[\s*).*""".toRegex()
val XMLREGEXP2 = """^\s*<\w+\s*(:\w+=[\"”][^\"”]+[\"”])?.*""".toRegex()

@JvmStatic
val UNKNOWN = ContentType(null)
@JvmStatic
val TEXT_PLAIN = ContentType("text/plain")
@JvmStatic
val HTML = ContentType("text/html")
@JvmStatic
val JSON = ContentType("application/json")
@JvmStatic
val XML = ContentType("application/xml")
}
}
40 changes: 12 additions & 28 deletions core/model/src/main/kotlin/au/com/dius/pact/core/model/HttpPart.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package au.com.dius.pact.core.model

import au.com.dius.pact.core.model.matchingrules.MatchingRules
import au.com.dius.pact.core.support.isNotEmpty
import mu.KLogging
import java.nio.charset.Charset

Expand All @@ -14,14 +15,11 @@ abstract class HttpPart {
abstract var matchingRules: MatchingRules

fun contentType(): String? = contentTypeHeader()?.split(Regex("\\s*;\\s*"))?.first()
?: body.contentType.asString()

fun contentTypeHeader(): String? {
val contentTypeKey = headers.keys.find { CONTENT_TYPE.equals(it, ignoreCase = true) }
return if (contentTypeKey.isNullOrEmpty()) {
detectContentType()
} else {
headers[contentTypeKey]?.first()
}
return headers[contentTypeKey]?.first()
}

fun jsonBody(): Boolean {
Expand All @@ -34,41 +32,27 @@ abstract class HttpPart {
return contentType?.matches(Regex("application\\/.*xml")) ?: false
}

fun detectContentType(): String? = when {
body.isPresent() -> {
val s = body.value!!.take(32).map {
if (it == '\n'.toByte()) ' ' else it.toChar()
}.joinToString("")
when {
s.matches(XMLREGEXP) -> "application/xml"
s.toUpperCase().matches(HTMLREGEXP) -> "text/html"
s.matches(JSONREGEXP) -> "application/json"
s.matches(XMLREGEXP2) -> "application/xml"
else -> "text/plain"
}
}
else -> null
}

fun setDefaultContentType(contentType: String) {
if (!headers.containsKey(CONTENT_TYPE)) {
if (headers.keys.find { it.equals(CONTENT_TYPE, ignoreCase = true) } == null) {
headers[CONTENT_TYPE] = listOf(contentType)
}
}

fun charset(): Charset? {
return when {
body.isPresent() -> body.contentType.asCharset()
else -> ContentType(contentTypeHeader()).asCharset()
else -> {
val contentType = contentTypeHeader()
if (contentType.isNotEmpty()) {
ContentType(contentType!!).asCharset()
} else {
null
}
}
}
}

companion object : KLogging() {
private const val CONTENT_TYPE = "Content-Type"

val XMLREGEXP = """^\s*<\?xml\s*version.*""".toRegex()
val HTMLREGEXP = """^\s*(<!DOCTYPE)|(<HTML>).*""".toRegex()
val JSONREGEXP = """^\s*(true|false|null|[0-9]+|"\w*|\{\s*(}|"\w+)|\[\s*).*""".toRegex()
val XMLREGEXP2 = """^\s*<\w+\s*(:\w+=[\"”][^\"”]+[\"”])?.*""".toRegex()
}
}
Loading

0 comments on commit b8ee6cd

Please sign in to comment.