Skip to content

Commit b1806ab

Browse files
committedAug 22, 2023
feat: Add support for adding multiparts that can use JSON DSL #1642
1 parent f98f1ad commit b1806ab

File tree

10 files changed

+260
-9
lines changed

10 files changed

+260
-9
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package au.com.dius.pact.consumer.junit5;
2+
3+
import au.com.dius.pact.consumer.MockServer;
4+
import au.com.dius.pact.consumer.dsl.LambdaDsl;
5+
import au.com.dius.pact.consumer.dsl.MultipartBuilder;
6+
import au.com.dius.pact.consumer.dsl.PactBuilder;
7+
import au.com.dius.pact.core.model.V4Pact;
8+
import au.com.dius.pact.core.model.annotations.Pact;
9+
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
10+
import org.apache.hc.client5.http.fluent.Request;
11+
import org.apache.hc.core5.http.ClassicHttpResponse;
12+
import org.apache.hc.core5.http.ContentType;
13+
import org.junit.jupiter.api.Test;
14+
import org.junit.jupiter.api.extension.ExtendWith;
15+
16+
import java.io.IOException;
17+
18+
import static org.hamcrest.MatcherAssert.assertThat;
19+
import static org.hamcrest.Matchers.equalTo;
20+
import static org.hamcrest.Matchers.is;
21+
22+
@ExtendWith(PactConsumerTestExt.class)
23+
@PactTestFor(providerName = "MultipartProvider")
24+
public class MultipartRequestTest {
25+
@Pact(consumer = "MultipartConsumer")
26+
public V4Pact pact(PactBuilder builder) {
27+
return builder
28+
.expectsToReceiveHttpInteraction("multipart request", interactionBuilder ->
29+
interactionBuilder
30+
.withRequest(requestBuilder -> requestBuilder
31+
.path("/path")
32+
.method("POST")
33+
.body(new MultipartBuilder()
34+
.filePart("file-part", "RAT.JPG", getClass().getResourceAsStream("/RAT.JPG"), "image/jpeg")
35+
.jsonPart("json-part", LambdaDsl.newJsonBody(body -> body
36+
.stringMatcher("a", "\\w+", "B")
37+
.integerType("c", 100)).build())
38+
)
39+
)
40+
.willRespondWith(responseBuilder -> responseBuilder.status(201))
41+
)
42+
.toPact();
43+
}
44+
45+
@Test
46+
@PactTestFor
47+
void testArticles(MockServer mockServer) throws IOException {
48+
ClassicHttpResponse httpResponse = (ClassicHttpResponse) Request.post(mockServer.getUrl() + "/path")
49+
.body(
50+
MultipartEntityBuilder.create()
51+
.addBinaryBody("file-part", getClass().getResourceAsStream("/RAT.JPG"), ContentType.IMAGE_JPEG, "RAT.JPG")
52+
.addTextBody("json-part", "{\"a\": \"B\", \"c\": 1234}", ContentType.APPLICATION_JSON)
53+
.build()
54+
)
55+
.execute()
56+
.returnResponse();
57+
assertThat(httpResponse.getCode(), is(equalTo(201)));
58+
}
59+
}

‎consumer/src/main/java/au/com/dius/pact/consumer/dsl/BodyBuilder.java

+5
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,9 @@ public interface BodyBuilder {
2727
* Constructs the body returning the contents as a byte array
2828
*/
2929
byte[] buildBody();
30+
31+
/**
32+
* Returns any matchers that are required for headers
33+
*/
34+
default MatchingRuleCategory getHeaderMatchers() { return null; }
3035
}

‎consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/HttpPartBuilder.kt

+5
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,11 @@ abstract class HttpPartBuilder(private val part: IHttpPart) {
232232
*/
233233
open fun body(builder: BodyBuilder): HttpPartBuilder {
234234
part.matchingRules.addCategory(builder.matchers)
235+
val headerMatchers = builder.headerMatchers
236+
if (headerMatchers != null) {
237+
part.matchingRules.addCategory(headerMatchers)
238+
}
239+
235240
part.generators.addGenerators(builder.generators)
236241

237242
val contentTypeHeader = part.contentTypeHeader()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package au.com.dius.pact.consumer.dsl
2+
3+
import au.com.dius.pact.consumer.Headers
4+
import au.com.dius.pact.core.model.ContentType
5+
import au.com.dius.pact.core.model.OptionalBody
6+
import au.com.dius.pact.core.model.generators.Generators
7+
import au.com.dius.pact.core.model.matchingrules.MatchingRuleCategory
8+
import au.com.dius.pact.core.model.matchingrules.MatchingRules
9+
import au.com.dius.pact.core.model.matchingrules.MatchingRulesImpl
10+
import au.com.dius.pact.core.model.matchingrules.RegexMatcher
11+
import org.apache.hc.client5.http.entity.mime.HttpMultipartMode
12+
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder
13+
import org.apache.hc.core5.http.HttpEntity
14+
import java.io.ByteArrayOutputStream
15+
import java.io.InputStream
16+
import java.nio.charset.Charset
17+
18+
/**
19+
* Builder class for constructing multipart/\* bodies.
20+
*/
21+
open class MultipartBuilder: BodyBuilder {
22+
private val builder = MultipartEntityBuilder.create()
23+
private var entity: HttpEntity? = null
24+
val matchingRules: MatchingRules = MatchingRulesImpl()
25+
private val generators = Generators()
26+
27+
init {
28+
builder.setMode(HttpMultipartMode.EXTENDED)
29+
}
30+
31+
override fun getMatchers(): MatchingRuleCategory {
32+
build()
33+
return matchingRules.rulesForCategory("body")
34+
}
35+
36+
override fun getHeaderMatchers(): MatchingRuleCategory {
37+
build()
38+
return matchingRules.rulesForCategory("header")
39+
}
40+
41+
override fun getGenerators(): Generators {
42+
build()
43+
return generators
44+
}
45+
46+
override fun getContentType(): ContentType {
47+
build()
48+
return ContentType(entity!!.contentType)
49+
}
50+
51+
private fun build() {
52+
if (entity == null) {
53+
entity = builder.build()
54+
val headerRules = matchingRules.addCategory("header")
55+
headerRules.addRule("Content-Type", RegexMatcher(Headers.MULTIPART_HEADER_REGEX, entity!!.contentType))
56+
}
57+
}
58+
59+
override fun buildBody(): ByteArray {
60+
build()
61+
val stream = ByteArrayOutputStream()
62+
entity!!.writeTo(stream)
63+
return stream.toByteArray()
64+
}
65+
66+
/**
67+
* Adds the contents of an input stream as a binary part with the given name and file name
68+
*/
69+
@JvmOverloads
70+
fun filePart(
71+
partName: String,
72+
fileName: String? = null,
73+
inputStream: InputStream,
74+
contentType: String? = null
75+
): MultipartBuilder {
76+
val ct = if (contentType.isNullOrEmpty()) {
77+
null
78+
} else {
79+
org.apache.hc.core5.http.ContentType.create(contentType)
80+
}
81+
builder.addBinaryBody(partName, inputStream.use { it.readAllBytes() }, ct, fileName)
82+
return this
83+
}
84+
85+
/**
86+
* Adds the contents of a byte array as a binary part with the given name and file name
87+
*/
88+
@JvmOverloads
89+
fun binaryPart(
90+
partName: String,
91+
fileName: String? = null,
92+
bytes: ByteArray,
93+
contentType: String? = null
94+
): MultipartBuilder {
95+
val ct = if (contentType.isNullOrEmpty()) {
96+
null
97+
} else {
98+
org.apache.hc.core5.http.ContentType.create(contentType)
99+
}
100+
builder.addBinaryBody(partName, bytes, ct, fileName)
101+
return this
102+
}
103+
104+
/**
105+
* Adds a JSON document as a part, using the standard Pact JSON DSL
106+
*/
107+
fun jsonPart(partName: String, part: DslPart): MultipartBuilder {
108+
val parent = part.close()!!
109+
matchingRules.addCategory(parent.matchers.copyWithUpdatedMatcherRootPrefix("\$.$partName"))
110+
generators.addGenerators(parent.generators)
111+
builder.addTextBody(partName, part.body.toString(), org.apache.hc.core5.http.ContentType.APPLICATION_JSON)
112+
return this
113+
}
114+
115+
/**
116+
* Adds the contents of a string as a text part with the given name
117+
*/
118+
@JvmOverloads
119+
fun textPart(
120+
partName: String,
121+
value: String,
122+
contentType: String? = null
123+
): MultipartBuilder {
124+
val ct = if (contentType.isNullOrEmpty()) {
125+
null
126+
} else {
127+
org.apache.hc.core5.http.ContentType.create(contentType)
128+
}
129+
builder.addTextBody(partName, value, ct)
130+
return this
131+
}
132+
}

‎consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactBuilder.kt

+1-5
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ import au.com.dius.pact.core.model.generators.Generators
2424
import au.com.dius.pact.core.model.matchingrules.MatchingRulesImpl
2525
import au.com.dius.pact.core.model.v4.MessageContents
2626
import au.com.dius.pact.core.support.Json.toJson
27-
import au.com.dius.pact.core.support.Result
2827
import au.com.dius.pact.core.support.Result.*
2928
import au.com.dius.pact.core.support.deepMerge
3029
import au.com.dius.pact.core.support.isNotEmpty
3130
import au.com.dius.pact.core.support.json.JsonValue
31+
import io.github.oshai.kotlinlogging.KLogging
3232
import io.pact.plugins.jvm.core.CatalogueEntry
3333
import io.pact.plugins.jvm.core.CatalogueEntryProviderType
3434
import io.pact.plugins.jvm.core.CatalogueEntryType
@@ -38,10 +38,6 @@ import io.pact.plugins.jvm.core.DefaultPluginManager
3838
import io.pact.plugins.jvm.core.PactPlugin
3939
import io.pact.plugins.jvm.core.PactPluginEntryNotFoundException
4040
import io.pact.plugins.jvm.core.PactPluginNotFoundException
41-
import io.github.oshai.kotlinlogging.KLogging
42-
import java.nio.file.Path
43-
import java.nio.file.Paths
44-
import kotlin.io.path.exists
4541

4642
interface DslBuilder {
4743
fun addPluginConfiguration(matcher: ContentMatcher, pactConfiguration: Map<String, JsonValue>)

‎consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactDslRequestWithPath.kt

+5-1
Original file line numberDiff line numberDiff line change
@@ -611,11 +611,15 @@ open class PactDslRequestWithPath : PactDslRequestBase {
611611
}
612612

613613
/**
614-
* Sets the body using the buidler
614+
* Sets the body using the builder
615615
* @param builder Body Builder
616616
*/
617617
fun body(builder: BodyBuilder): PactDslRequestWithPath {
618618
requestMatchers.addCategory(builder.matchers)
619+
val headerMatchers = builder.headerMatchers
620+
if (headerMatchers != null) {
621+
requestMatchers.addCategory(headerMatchers)
622+
}
619623
requestGenerators.addGenerators(builder.generators)
620624
val contentType = builder.contentType
621625
requestHeaders[CONTENT_TYPE] = listOf(contentType.toString())

‎consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/PactDslResponse.kt

+17
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,23 @@ open class PactDslResponse @JvmOverloads constructor(
260260
return this
261261
}
262262

263+
/**
264+
* Sets the body using the builder
265+
* @param builder Body Builder
266+
*/
267+
fun body(builder: BodyBuilder): PactDslResponse {
268+
responseMatchers.addCategory(builder.matchers)
269+
val headerMatchers = builder.headerMatchers
270+
if (headerMatchers != null) {
271+
responseMatchers.addCategory(headerMatchers)
272+
}
273+
responseGenerators.addGenerators(builder.generators)
274+
val contentType = builder.contentType
275+
responseHeaders[PactDslRequestBase.CONTENT_TYPE] = listOf(contentType.toString())
276+
responseBody = body(builder.buildBody(), contentType)
277+
return this
278+
}
279+
263280
/**
264281
* Response body as a binary data. It will match any expected bodies against the content type.
265282
* @param example Example contents to use in the consumer test

‎core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/Matching.kt

+8
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,14 @@ data class MatchingContext @JvmOverloads constructor(
161161
it is MinMaxEqualsIgnoreOrderMatcher
162162
}
163163
}
164+
165+
/**
166+
* Creates a new context with all rules that match the rootPath, with that path replaced with root
167+
*/
168+
fun extractPath(rootPath: String): MatchingContext {
169+
return copy(matchers = matchers.updateKeys(rootPath, "$"),
170+
allowUnexpectedKeys = allowUnexpectedKeys, pluginConfiguration = pluginConfiguration)
171+
}
164172
}
165173

166174
@Suppress("TooManyFunctions")

‎core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/MultipartMessageContentMatcher.kt

+17-3
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@ import au.com.dius.pact.core.model.HttpRequest
55
import au.com.dius.pact.core.model.IHttpPart
66
import au.com.dius.pact.core.model.OptionalBody
77
import au.com.dius.pact.core.support.Result
8-
import io.pact.plugins.jvm.core.InteractionContents
8+
import au.com.dius.pact.core.support.isNotEmpty
99
import io.github.oshai.kotlinlogging.KLogging
10+
import io.pact.plugins.jvm.core.InteractionContents
1011
import java.util.Enumeration
1112
import javax.mail.BodyPart
1213
import javax.mail.Header
14+
import javax.mail.internet.ContentDisposition
1315
import javax.mail.internet.MimeMultipart
16+
import javax.mail.internet.MimePart
1417
import javax.mail.util.ByteArrayDataSource
1518

1619
class MultipartMessageContentMatcher : ContentMatcher {
@@ -54,7 +57,18 @@ class MultipartMessageContentMatcher : ContentMatcher {
5457
val expectedPart = expectedMultipart.getBodyPart(i)
5558
if (i < actualMultipart.count) {
5659
val actualPart = actualMultipart.getBodyPart(i)
57-
val path = "\$.$i"
60+
var path = i.toString()
61+
if (expectedPart is MimePart) {
62+
val disposition = expectedPart.getHeader("Content-Disposition", null)
63+
if (disposition != null) {
64+
val cd = ContentDisposition(disposition)
65+
val parameter = cd.getParameter("name")
66+
if (parameter.isNotEmpty()) {
67+
path = parameter
68+
}
69+
}
70+
}
71+
5872
val headerResult = compareHeaders(path, expectedPart, actualPart, context)
5973
logger.debug { "Comparing part $i: header mismatches ${headerResult.size}" }
6074
val bodyMismatches = compareContents(path, expectedPart, actualPart, context)
@@ -87,7 +101,7 @@ class MultipartMessageContentMatcher : ContentMatcher {
87101
val expected = bodyPartTpHttpPart(expectedMultipart)
88102
val actual = bodyPartTpHttpPart(actualMultipart)
89103
logger.debug { "Comparing multipart contents: ${expected.determineContentType()} -> ${actual.determineContentType()}" }
90-
val result = Matching.matchBody(expected, actual, context)
104+
val result = Matching.matchBody(expected, actual, context.extractPath("\$.$path"))
91105
return result.bodyResults.flatMap { matchResult ->
92106
matchResult.result.map {
93107
it.copy(path = path + it.path.removePrefix("$"))

‎core/model/src/main/kotlin/au/com/dius/pact/core/model/matchingrules/MatchingRuleCategory.kt

+11
Original file line numberDiff line numberDiff line change
@@ -227,4 +227,15 @@ data class MatchingRuleCategory @JvmOverloads constructor(
227227
fun any(matchers: List<Class<out MatchingRule>>): Boolean {
228228
return matchingRules.values.any { it.any(matchers) }
229229
}
230+
231+
/**
232+
* Creates a copy of the rules that start with the given prefix, re-keyed with the new root
233+
*/
234+
fun updateKeys(prefix: String, newRoot: String): MatchingRuleCategory {
235+
return copy(matchingRules = matchingRules.filter {
236+
it.key.startsWith(prefix)
237+
}.mapKeys {
238+
it.key.replace(prefix, newRoot)
239+
}.toMutableMap())
240+
}
230241
}

0 commit comments

Comments
 (0)
Please sign in to comment.