From 662da3ccf2da5b50b23a2a269ae787b69afc0b3c Mon Sep 17 00:00:00 2001 From: Ronald Holshausen Date: Tue, 22 Aug 2023 11:50:21 +1000 Subject: [PATCH] feat: Add support for adding multiparts that can use JSON DSL #1642 --- .../consumer/junit5/MultipartRequestTest.java | 59 ++++++++ .../dius/pact/consumer/dsl/BodyBuilder.java | 5 + .../dius/pact/consumer/dsl/HttpPartBuilder.kt | 5 + .../pact/consumer/dsl/MultipartBuilder.kt | 132 ++++++++++++++++++ .../com/dius/pact/consumer/dsl/PactBuilder.kt | 6 +- .../consumer/dsl/PactDslRequestWithPath.kt | 6 +- .../dius/pact/consumer/dsl/PactDslResponse.kt | 17 +++ .../com/dius/pact/core/matchers/Matching.kt | 8 ++ .../MultipartMessageContentMatcher.kt | 20 ++- .../matchingrules/MatchingRuleCategory.kt | 11 ++ 10 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 consumer/junit5/src/test/java/au/com/dius/pact/consumer/junit5/MultipartRequestTest.java create mode 100644 consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/MultipartBuilder.kt diff --git a/consumer/junit5/src/test/java/au/com/dius/pact/consumer/junit5/MultipartRequestTest.java b/consumer/junit5/src/test/java/au/com/dius/pact/consumer/junit5/MultipartRequestTest.java new file mode 100644 index 0000000000..8bb70eb7cd --- /dev/null +++ b/consumer/junit5/src/test/java/au/com/dius/pact/consumer/junit5/MultipartRequestTest.java @@ -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))); + } +} diff --git a/consumer/src/main/java/au/com/dius/pact/consumer/dsl/BodyBuilder.java b/consumer/src/main/java/au/com/dius/pact/consumer/dsl/BodyBuilder.java index 1bee169e31..eae09f5263 100644 --- a/consumer/src/main/java/au/com/dius/pact/consumer/dsl/BodyBuilder.java +++ b/consumer/src/main/java/au/com/dius/pact/consumer/dsl/BodyBuilder.java @@ -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; } } diff --git a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/HttpPartBuilder.kt b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/HttpPartBuilder.kt index 496db518ce..c27ab27182 100644 --- a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/HttpPartBuilder.kt +++ b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/HttpPartBuilder.kt @@ -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() diff --git a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/MultipartBuilder.kt b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/MultipartBuilder.kt new file mode 100644 index 0000000000..38a3eeae46 --- /dev/null +++ b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/MultipartBuilder.kt @@ -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 + } +} diff --git a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactBuilder.kt b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactBuilder.kt index efb2ea500f..315878273a 100644 --- a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactBuilder.kt +++ b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactBuilder.kt @@ -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 @@ -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) diff --git a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactDslRequestWithPath.kt b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactDslRequestWithPath.kt index bf702bdbcb..f99e23a30d 100644 --- a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactDslRequestWithPath.kt +++ b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactDslRequestWithPath.kt @@ -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()) diff --git a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactDslResponse.kt b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactDslResponse.kt index 56778c8b74..3b2b05b43c 100644 --- a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactDslResponse.kt +++ b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactDslResponse.kt @@ -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 diff --git a/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/Matching.kt b/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/Matching.kt index 51067e641f..89c8112ba2 100644 --- a/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/Matching.kt +++ b/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/Matching.kt @@ -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") diff --git a/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/MultipartMessageContentMatcher.kt b/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/MultipartMessageContentMatcher.kt index a1442671ee..21f873c155 100755 --- a/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/MultipartMessageContentMatcher.kt +++ b/core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/MultipartMessageContentMatcher.kt @@ -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 { @@ -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) @@ -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("$")) diff --git a/core/model/src/main/kotlin/au/com/dius/pact/core/model/matchingrules/MatchingRuleCategory.kt b/core/model/src/main/kotlin/au/com/dius/pact/core/model/matchingrules/MatchingRuleCategory.kt index 9a06dc1b0c..478bc3ec46 100644 --- a/core/model/src/main/kotlin/au/com/dius/pact/core/model/matchingrules/MatchingRuleCategory.kt +++ b/core/model/src/main/kotlin/au/com/dius/pact/core/model/matchingrules/MatchingRuleCategory.kt @@ -227,4 +227,15 @@ data class MatchingRuleCategory @JvmOverloads constructor( fun any(matchers: List>): 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()) + } }