Skip to content

Commit

Permalink
feat: Add support for adding multiparts that can use JSON DSL #1642
Browse files Browse the repository at this point in the history
  • Loading branch information
rholshausen committed Apr 22, 2024
1 parent de80a5c commit 662da3c
Show file tree
Hide file tree
Showing 10 changed files with 260 additions and 9 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package au.com.dius.pact.consumer.junit5;

import au.com.dius.pact.consumer.MockServer;
import au.com.dius.pact.consumer.dsl.LambdaDsl;
import au.com.dius.pact.consumer.dsl.MultipartBuilder;
import au.com.dius.pact.consumer.dsl.PactBuilder;
import au.com.dius.pact.core.model.V4Pact;
import au.com.dius.pact.core.model.annotations.Pact;
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
import org.apache.hc.client5.http.fluent.Request;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.ContentType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.io.IOException;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;

@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "MultipartProvider")
public class MultipartRequestTest {
@Pact(consumer = "MultipartConsumer")
public V4Pact pact(PactBuilder builder) {
return builder
.expectsToReceiveHttpInteraction("multipart request", interactionBuilder ->
interactionBuilder
.withRequest(requestBuilder -> requestBuilder
.path("/path")
.method("POST")
.body(new MultipartBuilder()
.filePart("file-part", "RAT.JPG", getClass().getResourceAsStream("/RAT.JPG"), "image/jpeg")
.jsonPart("json-part", LambdaDsl.newJsonBody(body -> body
.stringMatcher("a", "\\w+", "B")
.integerType("c", 100)).build())
)
)
.willRespondWith(responseBuilder -> responseBuilder.status(201))
)
.toPact();
}

@Test
@PactTestFor
void testArticles(MockServer mockServer) throws IOException {
ClassicHttpResponse httpResponse = (ClassicHttpResponse) Request.post(mockServer.getUrl() + "/path")
.body(
MultipartEntityBuilder.create()
.addBinaryBody("file-part", getClass().getResourceAsStream("/RAT.JPG"), ContentType.IMAGE_JPEG, "RAT.JPG")
.addTextBody("json-part", "{\"a\": \"B\", \"c\": 1234}", ContentType.APPLICATION_JSON)
.build()
)
.execute()
.returnResponse();
assertThat(httpResponse.getCode(), is(equalTo(201)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,9 @@ public interface BodyBuilder {
* Constructs the body returning the contents as a byte array
*/
byte[] buildBody();

/**
* Returns any matchers that are required for headers
*/
default MatchingRuleCategory getHeaderMatchers() { return null; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,11 @@ abstract class HttpPartBuilder(private val part: IHttpPart) {
*/
open fun body(builder: BodyBuilder): HttpPartBuilder {
part.matchingRules.addCategory(builder.matchers)
val headerMatchers = builder.headerMatchers
if (headerMatchers != null) {
part.matchingRules.addCategory(headerMatchers)
}

part.generators.addGenerators(builder.generators)

val contentTypeHeader = part.contentTypeHeader()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package au.com.dius.pact.consumer.dsl

import au.com.dius.pact.consumer.Headers
import au.com.dius.pact.core.model.ContentType
import au.com.dius.pact.core.model.OptionalBody
import au.com.dius.pact.core.model.generators.Generators
import au.com.dius.pact.core.model.matchingrules.MatchingRuleCategory
import au.com.dius.pact.core.model.matchingrules.MatchingRules
import au.com.dius.pact.core.model.matchingrules.MatchingRulesImpl
import au.com.dius.pact.core.model.matchingrules.RegexMatcher
import org.apache.hc.client5.http.entity.mime.HttpMultipartMode
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder
import org.apache.hc.core5.http.HttpEntity
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.nio.charset.Charset

/**
* Builder class for constructing multipart/\* bodies.
*/
open class MultipartBuilder: BodyBuilder {
private val builder = MultipartEntityBuilder.create()
private var entity: HttpEntity? = null
val matchingRules: MatchingRules = MatchingRulesImpl()
private val generators = Generators()

init {
builder.setMode(HttpMultipartMode.EXTENDED)
}

override fun getMatchers(): MatchingRuleCategory {
build()
return matchingRules.rulesForCategory("body")
}

override fun getHeaderMatchers(): MatchingRuleCategory {
build()
return matchingRules.rulesForCategory("header")
}

override fun getGenerators(): Generators {
build()
return generators
}

override fun getContentType(): ContentType {
build()
return ContentType(entity!!.contentType)
}

private fun build() {
if (entity == null) {
entity = builder.build()
val headerRules = matchingRules.addCategory("header")
headerRules.addRule("Content-Type", RegexMatcher(Headers.MULTIPART_HEADER_REGEX, entity!!.contentType))
}
}

override fun buildBody(): ByteArray {
build()
val stream = ByteArrayOutputStream()
entity!!.writeTo(stream)
return stream.toByteArray()
}

/**
* Adds the contents of an input stream as a binary part with the given name and file name
*/
@JvmOverloads
fun filePart(
partName: String,
fileName: String? = null,
inputStream: InputStream,
contentType: String? = null
): MultipartBuilder {
val ct = if (contentType.isNullOrEmpty()) {
null
} else {
org.apache.hc.core5.http.ContentType.create(contentType)
}
builder.addBinaryBody(partName, inputStream.use { it.readAllBytes() }, ct, fileName)
return this
}

/**
* Adds the contents of a byte array as a binary part with the given name and file name
*/
@JvmOverloads
fun binaryPart(
partName: String,
fileName: String? = null,
bytes: ByteArray,
contentType: String? = null
): MultipartBuilder {
val ct = if (contentType.isNullOrEmpty()) {
null
} else {
org.apache.hc.core5.http.ContentType.create(contentType)
}
builder.addBinaryBody(partName, bytes, ct, fileName)
return this
}

/**
* Adds a JSON document as a part, using the standard Pact JSON DSL
*/
fun jsonPart(partName: String, part: DslPart): MultipartBuilder {
val parent = part.close()!!
matchingRules.addCategory(parent.matchers.copyWithUpdatedMatcherRootPrefix("\$.$partName"))
generators.addGenerators(parent.generators)
builder.addTextBody(partName, part.body.toString(), org.apache.hc.core5.http.ContentType.APPLICATION_JSON)
return this
}

/**
* Adds the contents of a string as a text part with the given name
*/
@JvmOverloads
fun textPart(
partName: String,
value: String,
contentType: String? = null
): MultipartBuilder {
val ct = if (contentType.isNullOrEmpty()) {
null
} else {
org.apache.hc.core5.http.ContentType.create(contentType)
}
builder.addTextBody(partName, value, ct)
return this
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ 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.v4.MessageContents
import au.com.dius.pact.core.support.Json.toJson
import au.com.dius.pact.core.support.Result
import au.com.dius.pact.core.support.Result.*
import au.com.dius.pact.core.support.deepMerge
import au.com.dius.pact.core.support.isNotEmpty
import au.com.dius.pact.core.support.json.JsonValue
import io.github.oshai.kotlinlogging.KLogging
import io.pact.plugins.jvm.core.CatalogueEntry
import io.pact.plugins.jvm.core.CatalogueEntryProviderType
import io.pact.plugins.jvm.core.CatalogueEntryType
Expand All @@ -38,10 +38,6 @@ import io.pact.plugins.jvm.core.DefaultPluginManager
import io.pact.plugins.jvm.core.PactPlugin
import io.pact.plugins.jvm.core.PactPluginEntryNotFoundException
import io.pact.plugins.jvm.core.PactPluginNotFoundException
import io.github.oshai.kotlinlogging.KLogging
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.exists

interface DslBuilder {
fun addPluginConfiguration(matcher: ContentMatcher, pactConfiguration: Map<String, JsonValue>)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -611,11 +611,15 @@ open class PactDslRequestWithPath : PactDslRequestBase {
}

/**
* Sets the body using the buidler
* Sets the body using the builder
* @param builder Body Builder
*/
fun body(builder: BodyBuilder): PactDslRequestWithPath {
requestMatchers.addCategory(builder.matchers)
val headerMatchers = builder.headerMatchers
if (headerMatchers != null) {
requestMatchers.addCategory(headerMatchers)
}
requestGenerators.addGenerators(builder.generators)
val contentType = builder.contentType
requestHeaders[CONTENT_TYPE] = listOf(contentType.toString())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,23 @@ open class PactDslResponse @JvmOverloads constructor(
return this
}

/**
* Sets the body using the builder
* @param builder Body Builder
*/
fun body(builder: BodyBuilder): PactDslResponse {
responseMatchers.addCategory(builder.matchers)
val headerMatchers = builder.headerMatchers
if (headerMatchers != null) {
responseMatchers.addCategory(headerMatchers)
}
responseGenerators.addGenerators(builder.generators)
val contentType = builder.contentType
responseHeaders[PactDslRequestBase.CONTENT_TYPE] = listOf(contentType.toString())
responseBody = body(builder.buildBody(), contentType)
return this
}

/**
* Response body as a binary data. It will match any expected bodies against the content type.
* @param example Example contents to use in the consumer test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ data class MatchingContext @JvmOverloads constructor(
it is MinMaxEqualsIgnoreOrderMatcher
}
}

/**
* Creates a new context with all rules that match the rootPath, with that path replaced with root
*/
fun extractPath(rootPath: String): MatchingContext {
return copy(matchers = matchers.updateKeys(rootPath, "$"),
allowUnexpectedKeys = allowUnexpectedKeys, pluginConfiguration = pluginConfiguration)
}
}

@Suppress("TooManyFunctions")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ 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
import au.com.dius.pact.core.support.isNotEmpty
import io.github.oshai.kotlinlogging.KLogging
import io.pact.plugins.jvm.core.InteractionContents
import java.util.Enumeration
import javax.mail.BodyPart
import javax.mail.Header
import javax.mail.internet.ContentDisposition
import javax.mail.internet.MimeMultipart
import javax.mail.internet.MimePart
import javax.mail.util.ByteArrayDataSource

class MultipartMessageContentMatcher : ContentMatcher {
Expand Down Expand Up @@ -54,7 +57,18 @@ class MultipartMessageContentMatcher : ContentMatcher {
val expectedPart = expectedMultipart.getBodyPart(i)
if (i < actualMultipart.count) {
val actualPart = actualMultipart.getBodyPart(i)
val path = "\$.$i"
var path = i.toString()
if (expectedPart is MimePart) {
val disposition = expectedPart.getHeader("Content-Disposition", null)
if (disposition != null) {
val cd = ContentDisposition(disposition)
val parameter = cd.getParameter("name")
if (parameter.isNotEmpty()) {
path = parameter
}
}
}

val headerResult = compareHeaders(path, expectedPart, actualPart, context)
logger.debug { "Comparing part $i: header mismatches ${headerResult.size}" }
val bodyMismatches = compareContents(path, expectedPart, actualPart, context)
Expand Down Expand Up @@ -87,7 +101,7 @@ class MultipartMessageContentMatcher : ContentMatcher {
val expected = bodyPartTpHttpPart(expectedMultipart)
val actual = bodyPartTpHttpPart(actualMultipart)
logger.debug { "Comparing multipart contents: ${expected.determineContentType()} -> ${actual.determineContentType()}" }
val result = Matching.matchBody(expected, actual, context)
val result = Matching.matchBody(expected, actual, context.extractPath("\$.$path"))
return result.bodyResults.flatMap { matchResult ->
matchResult.result.map {
it.copy(path = path + it.path.removePrefix("$"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,15 @@ data class MatchingRuleCategory @JvmOverloads constructor(
fun any(matchers: List<Class<out MatchingRule>>): Boolean {
return matchingRules.values.any { it.any(matchers) }
}

/**
* Creates a copy of the rules that start with the given prefix, re-keyed with the new root
*/
fun updateKeys(prefix: String, newRoot: String): MatchingRuleCategory {
return copy(matchingRules = matchingRules.filter {
it.key.startsWith(prefix)
}.mapKeys {
it.key.replace(prefix, newRoot)
}.toMutableMap())
}
}

0 comments on commit 662da3c

Please sign in to comment.