Skip to content

Commit e3950d4

Browse files
committedSep 21, 2023
feat: Update the new builder DSL to allow setting contents as byte arrays #600
1 parent 90d6cc4 commit e3950d4

File tree

9 files changed

+411
-7
lines changed

9 files changed

+411
-7
lines changed
 

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

+29
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,35 @@ abstract class HttpPartBuilder(private val part: IHttpPart) {
205205
return this
206206
}
207207

208+
/**
209+
* Sets the body of the HTTP part as a byte array. If the content type is not already set, will default to
210+
* application/octet-stream.
211+
*/
212+
open fun body(body: ByteArray) = body(body, null)
213+
214+
/**
215+
* Sets the body of the HTTP part as a string value. If the content type is not provided or already set, will
216+
* default to application/octet-stream.
217+
*/
218+
open fun body(body: ByteArray, contentTypeString: String?): HttpPartBuilder {
219+
val contentTypeHeader = part.contentTypeHeader()
220+
val contentType = if (!contentTypeString.isNullOrEmpty()) {
221+
ContentType.fromString(contentTypeString)
222+
} else if (contentTypeHeader != null) {
223+
ContentType.fromString(contentTypeHeader)
224+
} else {
225+
ContentType.OCTET_STEAM
226+
}
227+
228+
part.body = OptionalBody.body(body, contentType)
229+
230+
if (contentTypeHeader == null || contentTypeString.isNotEmpty()) {
231+
part.headers["content-type"] = listOf(contentType.toString())
232+
}
233+
234+
return this
235+
}
236+
208237
/**
209238
* Sets the body, content type and matching rules from a DslPart
210239
*/

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

+8
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ open class HttpRequestBuilder(private val request: HttpRequest): HttpPartBuilder
7373
return super.body(body, contentTypeString) as HttpRequestBuilder
7474
}
7575

76+
override fun body(body: ByteArray): HttpRequestBuilder {
77+
return super.body(body) as HttpRequestBuilder
78+
}
79+
80+
override fun body(body: ByteArray, contentTypeString: String?): HttpRequestBuilder {
81+
return super.body(body, contentTypeString) as HttpRequestBuilder
82+
}
83+
7684
override fun body(dslPart: DslPart): HttpRequestBuilder {
7785
return super.body(dslPart) as HttpRequestBuilder
7886
}

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

+8
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,14 @@ open class HttpResponseBuilder(private val response: HttpResponse): HttpPartBuil
151151
return super.body(body, contentTypeString) as HttpResponseBuilder
152152
}
153153

154+
override fun body(body: ByteArray): HttpResponseBuilder {
155+
return super.body(body) as HttpResponseBuilder
156+
}
157+
158+
override fun body(body: ByteArray, contentTypeString: String?): HttpResponseBuilder {
159+
return super.body(body, contentTypeString) as HttpResponseBuilder
160+
}
161+
154162
override fun body(dslPart: DslPart): HttpResponseBuilder {
155163
return super.body(dslPart) as HttpResponseBuilder
156164
}

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

+55-5
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,26 @@ import au.com.dius.pact.consumer.xml.PactXmlBuilder
44
import au.com.dius.pact.core.model.ContentType
55
import au.com.dius.pact.core.model.OptionalBody
66
import au.com.dius.pact.core.model.generators.Category
7+
import au.com.dius.pact.core.model.matchingrules.ContentTypeMatcher
8+
import au.com.dius.pact.core.model.messaging.Message
79
import au.com.dius.pact.core.model.v4.MessageContents
10+
import au.com.dius.pact.core.support.isNotEmpty
811

912
/**
1013
* DSL builder for the message contents part of a V4 message
1114
*/
1215
class MessageContentsBuilder(var contents: MessageContents) {
16+
fun build() = contents
17+
1318
/**
1419
* Adds the expected metadata to the message contents
1520
*/
1621
fun withMetadata(metadata: Map<String, Any>): MessageContentsBuilder {
1722
contents = contents.copy(metadata = metadata.mapValues { (key, value) ->
1823
if (value is Matcher) {
19-
contents.matchingRules.addCategory("metadata").addRule(key, value.matcher!!)
24+
if (value.matcher != null) {
25+
contents.matchingRules.addCategory("metadata").addRule(key, value.matcher!!)
26+
}
2027
if (value.generator != null) {
2128
contents.generators.addGenerator(Category.METADATA, key, value.generator!!)
2229
}
@@ -98,14 +105,57 @@ class MessageContentsBuilder(var contents: MessageContents) {
98105
}
99106

100107
/**
101-
* Adds the string as the message contents with the given content type
108+
* Adds the string as the message contents with the given content type. If the content type is not supplied,
109+
* it will try to detect it otherwise will default to plain text.
102110
*/
103-
fun withContent(payload: String, contentType: String): MessageContentsBuilder {
104-
val ct = ContentType(contentType)
111+
@JvmOverloads
112+
fun withContent(payload: String, contentType: String? = null): MessageContentsBuilder {
113+
val contentTypeMetadata = Message.contentType(contents.metadata)
114+
val ct = if (contentType.isNotEmpty()) {
115+
ContentType.fromString(contentType)
116+
} else if (contentTypeMetadata.contentType != null) {
117+
contentTypeMetadata
118+
} else {
119+
OptionalBody.detectContentTypeInByteArray(payload.toByteArray()) ?: ContentType.TEXT_PLAIN
120+
}
105121
contents = contents.copy(
106122
contents = OptionalBody.body(payload.toByteArray(ct.asCharset()), ct),
107-
metadata = (contents.metadata + Pair("contentType", contentType)).toMutableMap()
123+
metadata = (contents.metadata + Pair("contentType", ct.toString())).toMutableMap()
124+
)
125+
return this
126+
}
127+
128+
/**
129+
* Sets the contents of the message as a byte array. If the content type is not provided or already set, will
130+
* default to application/octet-stream.
131+
*/
132+
@JvmOverloads
133+
fun withContent(payload: ByteArray, contentType: String? = null): MessageContentsBuilder {
134+
val contentTypeMetadata = Message.contentType(contents.metadata)
135+
val ct = if (contentType.isNotEmpty()) {
136+
ContentType.fromString(contentType)
137+
} else if (contentTypeMetadata.contentType != null) {
138+
contentTypeMetadata
139+
} else {
140+
ContentType.OCTET_STEAM
141+
}
142+
143+
contents = contents.copy(
144+
contents = OptionalBody.body(payload, ct),
145+
metadata = (contents.metadata + Pair("contentType", ct.toString())).toMutableMap()
108146
)
147+
148+
return this
149+
}
150+
151+
/**
152+
* Sets up a content type matcher to match any payload of the given content type
153+
*/
154+
fun withContentsMatchingContentType(contentType: String, exampleContents: ByteArray): MessageContentsBuilder {
155+
val ct = ContentType(contentType)
156+
contents.contents = OptionalBody.body(exampleContents, ct)
157+
contents.metadata["contentType"] = contentType
158+
contents.matchingRules.addCategory("body").addRule("$", ContentTypeMatcher(contentType))
109159
return this
110160
}
111161
}

‎consumer/src/test/groovy/au/com/dius/pact/consumer/dsl/HttpRequestBuilderSpec.groovy

+41-1
Original file line numberDiff line numberDiff line change
@@ -232,12 +232,14 @@ class HttpRequestBuilderSpec extends Specification {
232232
}
233233

234234
def 'supports setting up a content type matcher on the body'() {
235-
when:
235+
given:
236236
def gif1px = [
237237
0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377,
238238
0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104,
239239
0001, 0000, 0073
240240
] as byte[]
241+
242+
when:
241243
def request = builder
242244
.bodyMatchingContentType('image/gif', gif1px)
243245
.build()
@@ -253,6 +255,44 @@ class HttpRequestBuilderSpec extends Specification {
253255
)
254256
}
255257

258+
def 'allows setting the body of the request as a byte array'() {
259+
given:
260+
def gif1px = [
261+
0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377,
262+
0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104,
263+
0001, 0000, 0073
264+
] as byte[]
265+
266+
when:
267+
def request = builder
268+
.body(gif1px)
269+
.build()
270+
271+
then:
272+
request.body.unwrap() == gif1px
273+
request.body.contentType.toString() == 'application/octet-stream'
274+
request.headers['content-type'] == ['application/octet-stream']
275+
}
276+
277+
def 'allows setting the body of the request as a a byte array with a content type'() {
278+
given:
279+
def gif1px = [
280+
0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377,
281+
0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104,
282+
0001, 0000, 0073
283+
] as byte[]
284+
285+
when:
286+
def request = builder
287+
.body(gif1px, 'image/gif')
288+
.build()
289+
290+
then:
291+
request.body.unwrap() == gif1px
292+
request.body.contentType.toString() == 'image/gif'
293+
request.headers['content-type'] == ['image/gif']
294+
}
295+
256296
def 'allows adding query parameters to the request'() {
257297
when:
258298
def request = builder

‎consumer/src/test/groovy/au/com/dius/pact/consumer/dsl/HttpResponseBuilderSpec.groovy

+38
Original file line numberDiff line numberDiff line change
@@ -275,4 +275,42 @@ class HttpResponseBuilderSpec extends Specification {
275275
]
276276
)
277277
}
278+
279+
def 'allows setting the body of the response as a byte array'() {
280+
given:
281+
def gif1px = [
282+
0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377,
283+
0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104,
284+
0001, 0000, 0073
285+
] as byte[]
286+
287+
when:
288+
def response = builder
289+
.body(gif1px)
290+
.build()
291+
292+
then:
293+
response.body.unwrap() == gif1px
294+
response.body.contentType.toString() == 'application/octet-stream'
295+
response.headers['content-type'] == ['application/octet-stream']
296+
}
297+
298+
def 'allows setting the body of the response as a a byte array with a content type'() {
299+
given:
300+
def gif1px = [
301+
0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377,
302+
0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104,
303+
0001, 0000, 0073
304+
] as byte[]
305+
306+
when:
307+
def response = builder
308+
.body(gif1px, 'image/gif')
309+
.build()
310+
311+
then:
312+
response.body.unwrap() == gif1px
313+
response.body.contentType.toString() == 'image/gif'
314+
response.headers['content-type'] == ['image/gif']
315+
}
278316
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package au.com.dius.pact.consumer.dsl
2+
3+
import au.com.dius.pact.consumer.xml.PactXmlBuilder
4+
import au.com.dius.pact.core.model.generators.Category
5+
import au.com.dius.pact.core.model.generators.ProviderStateGenerator
6+
import au.com.dius.pact.core.model.matchingrules.ContentTypeMatcher
7+
import au.com.dius.pact.core.model.matchingrules.MatchingRuleCategory
8+
import au.com.dius.pact.core.model.matchingrules.MatchingRuleGroup
9+
import au.com.dius.pact.core.model.matchingrules.RegexMatcher
10+
import au.com.dius.pact.core.model.v4.MessageContents
11+
import spock.lang.Specification
12+
13+
import static au.com.dius.pact.consumer.dsl.Matchers.fromProviderState
14+
import static au.com.dius.pact.consumer.dsl.Matchers.regexp
15+
16+
class MessageContentsBuilderSpec extends Specification {
17+
18+
MessageContentsBuilder builder
19+
20+
def setup() {
21+
builder = new MessageContentsBuilder(new MessageContents())
22+
}
23+
24+
def 'allows adding metadata to the message'() {
25+
when:
26+
def message = builder
27+
.withMetadata([x: 'y', y: ['a', 'b', 'c']])
28+
.build()
29+
30+
then:
31+
message.metadata == [
32+
'x': 'y',
33+
'y': ['a', 'b', 'c']
34+
]
35+
}
36+
37+
def 'allows using matching rules with the metadata'() {
38+
when:
39+
def message = builder
40+
.withMetadata([x: regexp('\\d+', '111')])
41+
.build()
42+
43+
then:
44+
message.metadata == [
45+
'x': '111'
46+
]
47+
message.matchingRules.rulesForCategory('metadata') == new MatchingRuleCategory('metadata',
48+
[
49+
x: new MatchingRuleGroup([new RegexMatcher('\\d+', '111')])
50+
]
51+
)
52+
}
53+
54+
def 'supports setting metadata values from provider states'() {
55+
when:
56+
def message = builder
57+
.withMetadata(['A': fromProviderState('$a', '111')])
58+
.build()
59+
60+
then:
61+
message.metadata == [
62+
'A': '111'
63+
]
64+
message.matchingRules.rulesForCategory('metadata') == new MatchingRuleCategory('metadata', [:])
65+
message.generators.categoryFor(Category.METADATA) == [A: new ProviderStateGenerator('$a')]
66+
}
67+
68+
def 'allows setting the contents of the message as a string value'() {
69+
when:
70+
def message = builder
71+
.withContent('This is some text')
72+
.build()
73+
74+
then:
75+
message.contents.valueAsString() == 'This is some text'
76+
message.contents.contentType.toString() == 'text/plain; charset=ISO-8859-1'
77+
message.metadata['contentType'] == 'text/plain; charset=ISO-8859-1'
78+
}
79+
80+
def 'allows setting the contents of the message as a string value with a given content type'() {
81+
when:
82+
def message = builder
83+
.withContent('This is some text', 'text/test-special')
84+
.build()
85+
86+
then:
87+
message.contents.valueAsString() == 'This is some text'
88+
message.contents.contentType.toString() == 'text/test-special'
89+
message.metadata['contentType'] == 'text/test-special'
90+
}
91+
92+
def 'when setting the body, tries to detect the content type from the body contents'() {
93+
when:
94+
def message = builder
95+
.withContent('{"value": "This is some text"}')
96+
.build()
97+
98+
then:
99+
message.contents.valueAsString() == '{"value": "This is some text"}'
100+
message.contents.contentType.toString() == 'application/json'
101+
message.metadata['contentType'] == 'application/json'
102+
}
103+
104+
def 'when setting the body, uses any existing content type metadata value'() {
105+
when:
106+
def message = builder
107+
.withMetadata(['contentType': 'text/plain'])
108+
.withContent('{"value": "This is some text"}')
109+
.build()
110+
111+
then:
112+
message.contents.valueAsString() == '{"value": "This is some text"}'
113+
message.contents.contentType.toString() == 'text/plain'
114+
message.metadata['contentType'] == 'text/plain'
115+
}
116+
117+
def 'when setting the body, overrides any existing content type header if the content type is given'() {
118+
when:
119+
def message = builder
120+
.withMetadata(['contentType': 'text/plain'])
121+
.withContent('{"value": "This is some text"}', 'application/json')
122+
.build()
123+
124+
then:
125+
message.contents.valueAsString() == '{"value": "This is some text"}'
126+
message.contents.contentType.toString() == 'application/json'
127+
message.metadata['contentType'] == 'application/json'
128+
}
129+
130+
def 'supports setting the body from a DSLPart object'() {
131+
when:
132+
def message = builder
133+
.withContent(new PactDslJsonBody().stringType('value', 'This is some text'))
134+
.build()
135+
136+
then:
137+
message.contents.valueAsString() == '{"value":"This is some text"}'
138+
message.contents.contentType.toString() == 'application/json'
139+
message.metadata['contentType'] == 'application/json'
140+
message.matchingRules.rulesForCategory('body') == new MatchingRuleCategory('body',
141+
[
142+
'$.value': new MatchingRuleGroup([au.com.dius.pact.core.model.matchingrules.TypeMatcher.INSTANCE])
143+
]
144+
)
145+
}
146+
147+
def 'supports setting the body using a body builder'() {
148+
when:
149+
def message = builder
150+
.withContent(new PactXmlBuilder('test').build {
151+
it.attributes = [id: regexp('\\d+', '100')]
152+
})
153+
.build()
154+
155+
then:
156+
message.contents.valueAsString() == '<?xml version="1.0" encoding="UTF-8" standalone="no"?>' +
157+
System.lineSeparator() + '<test id="100"/>' + System.lineSeparator()
158+
message.contents.contentType.toString() == 'application/xml'
159+
message.metadata['contentType'] == 'application/xml'
160+
message.matchingRules.rulesForCategory('body') == new MatchingRuleCategory('body',
161+
[
162+
'$.test[\'@id\']': new MatchingRuleGroup([new RegexMatcher('\\d+', '100')])
163+
]
164+
)
165+
}
166+
167+
def 'supports setting up a content type matcher on the body'() {
168+
given:
169+
def gif1px = [
170+
0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377,
171+
0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104,
172+
0001, 0000, 0073
173+
] as byte[]
174+
175+
when:
176+
def message = builder
177+
.withContentsMatchingContentType('image/gif', gif1px)
178+
.build()
179+
180+
then:
181+
message.contents.value == gif1px
182+
message.contents.contentType.toString() == 'image/gif'
183+
message.metadata['contentType'] == 'image/gif'
184+
message.matchingRules.rulesForCategory('body') == new MatchingRuleCategory('body',
185+
[
186+
'$': new MatchingRuleGroup([new ContentTypeMatcher('image/gif')])
187+
]
188+
)
189+
}
190+
191+
def 'allows setting the contents of the message as a byte array'() {
192+
given:
193+
def gif1px = [
194+
0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377,
195+
0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104,
196+
0001, 0000, 0073
197+
] as byte[]
198+
199+
when:
200+
def message = builder
201+
.withContent(gif1px)
202+
.build()
203+
204+
then:
205+
message.contents.unwrap() == gif1px
206+
message.contents.contentType.toString() == 'application/octet-stream'
207+
message.metadata['contentType'] == 'application/octet-stream'
208+
}
209+
210+
def 'allows setting the contents of the message as a a byte array with a content type'() {
211+
given:
212+
def gif1px = [
213+
0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377,
214+
0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104,
215+
0001, 0000, 0073
216+
] as byte[]
217+
218+
when:
219+
def message = builder
220+
.withContent(gif1px, 'image/gif')
221+
.build()
222+
223+
then:
224+
message.contents.unwrap() == gif1px
225+
message.contents.contentType.toString() == 'image/gif'
226+
message.metadata['contentType'] == 'image/gif'
227+
}
228+
}

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

+2
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ class ContentType(val contentType: MediaType?) {
158158
@JvmStatic
159159
val TEXT_PLAIN = ContentType("text/plain; charset=ISO-8859-1")
160160
@JvmStatic
161+
val OCTET_STEAM = ContentType("application/octet-stream")
162+
@JvmStatic
161163
val HTML = ContentType("text/html")
162164
@JvmStatic
163165
val JSON = ContentType("application/json")

‎core/model/src/main/kotlin/au/com/dius/pact/core/model/v4/MessageContents.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package au.com.dius.pact.core.model.v4
22

3+
import au.com.dius.pact.core.model.ContentType
34
import au.com.dius.pact.core.model.OptionalBody
45
import au.com.dius.pact.core.model.PactSpecVersion
56
import au.com.dius.pact.core.model.bodyFromJson
@@ -22,7 +23,7 @@ data class MessageContents @JvmOverloads constructor(
2223
val generators: Generators = Generators(),
2324
val partName: String = ""
2425
) {
25-
fun getContentType() = contents.contentType.or(Message.contentType(metadata))
26+
fun getContentType() = contents.contentType.or(Message.contentType(metadata) ?: ContentType.OCTET_STEAM)
2627

2728
fun toMap(pactSpecVersion: PactSpecVersion?): Map<String, *> {
2829
val map = mutableMapOf(

0 commit comments

Comments
 (0)
Please sign in to comment.