From 02a0d5699312b547fb7f6d45f19656f779a7153d Mon Sep 17 00:00:00 2001 From: Ronald Holshausen Date: Thu, 21 Sep 2023 10:56:54 +1000 Subject: [PATCH] feat: Update the new builder DSL to allow setting contents as byte arrays #600 --- .../dius/pact/consumer/dsl/HttpPartBuilder.kt | 29 +++ .../pact/consumer/dsl/HttpRequestBuilder.kt | 8 + .../pact/consumer/dsl/HttpResponseBuilder.kt | 8 + .../consumer/dsl/MessageContentsBuilder.kt | 60 ++++- .../dsl/HttpRequestBuilderSpec.groovy | 42 +++- .../dsl/HttpResponseBuilderSpec.groovy | 38 +++ .../dsl/MessageContentsBuilderSpec.groovy | 228 ++++++++++++++++++ .../com/dius/pact/core/model/ContentType.kt | 2 + .../pact/core/model/v4/MessageContents.kt | 3 +- 9 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 consumer/src/test/groovy/au/com/dius/pact/consumer/dsl/MessageContentsBuilderSpec.groovy 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 c27ab2718..008c87e6c 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 @@ -205,6 +205,35 @@ abstract class HttpPartBuilder(private val part: IHttpPart) { return this } + /** + * Sets the body of the HTTP part as a byte array. If the content type is not already set, will default to + * application/octet-stream. + */ + open fun body(body: ByteArray) = body(body, null) + + /** + * Sets the body of the HTTP part as a string value. If the content type is not provided or already set, will + * default to application/octet-stream. + */ + open fun body(body: ByteArray, contentTypeString: String?): HttpPartBuilder { + val contentTypeHeader = part.contentTypeHeader() + val contentType = if (!contentTypeString.isNullOrEmpty()) { + ContentType.fromString(contentTypeString) + } else if (contentTypeHeader != null) { + ContentType.fromString(contentTypeHeader) + } else { + ContentType.OCTET_STEAM + } + + part.body = OptionalBody.body(body, contentType) + + if (contentTypeHeader == null || contentTypeString.isNotEmpty()) { + part.headers["content-type"] = listOf(contentType.toString()) + } + + return this + } + /** * Sets the body, content type and matching rules from a DslPart */ diff --git a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/HttpRequestBuilder.kt b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/HttpRequestBuilder.kt index 96f3f8bf0..e9e88e75d 100644 --- a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/HttpRequestBuilder.kt +++ b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/HttpRequestBuilder.kt @@ -73,6 +73,14 @@ open class HttpRequestBuilder(private val request: HttpRequest): HttpPartBuilder return super.body(body, contentTypeString) as HttpRequestBuilder } + override fun body(body: ByteArray): HttpRequestBuilder { + return super.body(body) as HttpRequestBuilder + } + + override fun body(body: ByteArray, contentTypeString: String?): HttpRequestBuilder { + return super.body(body, contentTypeString) as HttpRequestBuilder + } + override fun body(dslPart: DslPart): HttpRequestBuilder { return super.body(dslPart) as HttpRequestBuilder } diff --git a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/HttpResponseBuilder.kt b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/HttpResponseBuilder.kt index dae8c6b8d..b7cd2fa0e 100644 --- a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/HttpResponseBuilder.kt +++ b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/HttpResponseBuilder.kt @@ -151,6 +151,14 @@ open class HttpResponseBuilder(private val response: HttpResponse): HttpPartBuil return super.body(body, contentTypeString) as HttpResponseBuilder } + override fun body(body: ByteArray): HttpResponseBuilder { + return super.body(body) as HttpResponseBuilder + } + + override fun body(body: ByteArray, contentTypeString: String?): HttpResponseBuilder { + return super.body(body, contentTypeString) as HttpResponseBuilder + } + override fun body(dslPart: DslPart): HttpResponseBuilder { return super.body(dslPart) as HttpResponseBuilder } diff --git a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/MessageContentsBuilder.kt b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/MessageContentsBuilder.kt index 14e5f0cc7..2b195c55c 100644 --- a/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/MessageContentsBuilder.kt +++ b/consumer/src/main/kotlin/au/com/dius/pact/consumer/dsl/MessageContentsBuilder.kt @@ -4,19 +4,26 @@ import au.com.dius.pact.consumer.xml.PactXmlBuilder import au.com.dius.pact.core.model.ContentType import au.com.dius.pact.core.model.OptionalBody import au.com.dius.pact.core.model.generators.Category +import au.com.dius.pact.core.model.matchingrules.ContentTypeMatcher +import au.com.dius.pact.core.model.messaging.Message import au.com.dius.pact.core.model.v4.MessageContents +import au.com.dius.pact.core.support.isNotEmpty /** * DSL builder for the message contents part of a V4 message */ class MessageContentsBuilder(var contents: MessageContents) { + fun build() = contents + /** * Adds the expected metadata to the message contents */ fun withMetadata(metadata: Map): MessageContentsBuilder { contents = contents.copy(metadata = metadata.mapValues { (key, value) -> if (value is Matcher) { - contents.matchingRules.addCategory("metadata").addRule(key, value.matcher!!) + if (value.matcher != null) { + contents.matchingRules.addCategory("metadata").addRule(key, value.matcher!!) + } if (value.generator != null) { contents.generators.addGenerator(Category.METADATA, key, value.generator!!) } @@ -98,14 +105,57 @@ class MessageContentsBuilder(var contents: MessageContents) { } /** - * Adds the string as the message contents with the given content type + * Adds the string as the message contents with the given content type. If the content type is not supplied, + * it will try to detect it otherwise will default to plain text. */ - fun withContent(payload: String, contentType: String): MessageContentsBuilder { - val ct = ContentType(contentType) + @JvmOverloads + fun withContent(payload: String, contentType: String? = null): MessageContentsBuilder { + val contentTypeMetadata = Message.contentType(contents.metadata) + val ct = if (contentType.isNotEmpty()) { + ContentType.fromString(contentType) + } else if (contentTypeMetadata.contentType != null) { + contentTypeMetadata + } else { + OptionalBody.detectContentTypeInByteArray(payload.toByteArray()) ?: ContentType.TEXT_PLAIN + } contents = contents.copy( contents = OptionalBody.body(payload.toByteArray(ct.asCharset()), ct), - metadata = (contents.metadata + Pair("contentType", contentType)).toMutableMap() + metadata = (contents.metadata + Pair("contentType", ct.toString())).toMutableMap() + ) + return this + } + + /** + * Sets the contents of the message as a byte array. If the content type is not provided or already set, will + * default to application/octet-stream. + */ + @JvmOverloads + fun withContent(payload: ByteArray, contentType: String? = null): MessageContentsBuilder { + val contentTypeMetadata = Message.contentType(contents.metadata) + val ct = if (contentType.isNotEmpty()) { + ContentType.fromString(contentType) + } else if (contentTypeMetadata.contentType != null) { + contentTypeMetadata + } else { + ContentType.OCTET_STEAM + } + + contents = contents.copy( + contents = OptionalBody.body(payload, ct), + metadata = (contents.metadata + Pair("contentType", ct.toString())).toMutableMap() ) + + return this + } + + /** + * Sets up a content type matcher to match any payload of the given content type + */ + fun withContentsMatchingContentType(contentType: String, exampleContents: ByteArray): MessageContentsBuilder { + val ct = ContentType(contentType) + contents.contents = OptionalBody.body(exampleContents, ct) + contents.metadata["contentType"] = contentType + contents.matchingRules.addCategory("body").addRule("$", ContentTypeMatcher(contentType)) return this } } diff --git a/consumer/src/test/groovy/au/com/dius/pact/consumer/dsl/HttpRequestBuilderSpec.groovy b/consumer/src/test/groovy/au/com/dius/pact/consumer/dsl/HttpRequestBuilderSpec.groovy index c881d2179..ffcc99047 100644 --- a/consumer/src/test/groovy/au/com/dius/pact/consumer/dsl/HttpRequestBuilderSpec.groovy +++ b/consumer/src/test/groovy/au/com/dius/pact/consumer/dsl/HttpRequestBuilderSpec.groovy @@ -232,12 +232,14 @@ class HttpRequestBuilderSpec extends Specification { } def 'supports setting up a content type matcher on the body'() { - when: + given: def gif1px = [ 0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377, 0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104, 0001, 0000, 0073 ] as byte[] + + when: def request = builder .bodyMatchingContentType('image/gif', gif1px) .build() @@ -253,6 +255,44 @@ class HttpRequestBuilderSpec extends Specification { ) } + def 'allows setting the body of the request as a byte array'() { + given: + def gif1px = [ + 0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377, + 0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104, + 0001, 0000, 0073 + ] as byte[] + + when: + def request = builder + .body(gif1px) + .build() + + then: + request.body.unwrap() == gif1px + request.body.contentType.toString() == 'application/octet-stream' + request.headers['content-type'] == ['application/octet-stream'] + } + + def 'allows setting the body of the request as a a byte array with a content type'() { + given: + def gif1px = [ + 0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377, + 0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104, + 0001, 0000, 0073 + ] as byte[] + + when: + def request = builder + .body(gif1px, 'image/gif') + .build() + + then: + request.body.unwrap() == gif1px + request.body.contentType.toString() == 'image/gif' + request.headers['content-type'] == ['image/gif'] + } + def 'allows adding query parameters to the request'() { when: def request = builder diff --git a/consumer/src/test/groovy/au/com/dius/pact/consumer/dsl/HttpResponseBuilderSpec.groovy b/consumer/src/test/groovy/au/com/dius/pact/consumer/dsl/HttpResponseBuilderSpec.groovy index 372a44baf..dde365d78 100644 --- a/consumer/src/test/groovy/au/com/dius/pact/consumer/dsl/HttpResponseBuilderSpec.groovy +++ b/consumer/src/test/groovy/au/com/dius/pact/consumer/dsl/HttpResponseBuilderSpec.groovy @@ -275,4 +275,42 @@ class HttpResponseBuilderSpec extends Specification { ] ) } + + def 'allows setting the body of the response as a byte array'() { + given: + def gif1px = [ + 0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377, + 0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104, + 0001, 0000, 0073 + ] as byte[] + + when: + def response = builder + .body(gif1px) + .build() + + then: + response.body.unwrap() == gif1px + response.body.contentType.toString() == 'application/octet-stream' + response.headers['content-type'] == ['application/octet-stream'] + } + + def 'allows setting the body of the response as a a byte array with a content type'() { + given: + def gif1px = [ + 0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377, + 0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104, + 0001, 0000, 0073 + ] as byte[] + + when: + def response = builder + .body(gif1px, 'image/gif') + .build() + + then: + response.body.unwrap() == gif1px + response.body.contentType.toString() == 'image/gif' + response.headers['content-type'] == ['image/gif'] + } } diff --git a/consumer/src/test/groovy/au/com/dius/pact/consumer/dsl/MessageContentsBuilderSpec.groovy b/consumer/src/test/groovy/au/com/dius/pact/consumer/dsl/MessageContentsBuilderSpec.groovy new file mode 100644 index 000000000..12ab7dff8 --- /dev/null +++ b/consumer/src/test/groovy/au/com/dius/pact/consumer/dsl/MessageContentsBuilderSpec.groovy @@ -0,0 +1,228 @@ +package au.com.dius.pact.consumer.dsl + +import au.com.dius.pact.consumer.xml.PactXmlBuilder +import au.com.dius.pact.core.model.generators.Category +import au.com.dius.pact.core.model.generators.ProviderStateGenerator +import au.com.dius.pact.core.model.matchingrules.ContentTypeMatcher +import au.com.dius.pact.core.model.matchingrules.MatchingRuleCategory +import au.com.dius.pact.core.model.matchingrules.MatchingRuleGroup +import au.com.dius.pact.core.model.matchingrules.RegexMatcher +import au.com.dius.pact.core.model.v4.MessageContents +import spock.lang.Specification + +import static au.com.dius.pact.consumer.dsl.Matchers.fromProviderState +import static au.com.dius.pact.consumer.dsl.Matchers.regexp + +class MessageContentsBuilderSpec extends Specification { + + MessageContentsBuilder builder + + def setup() { + builder = new MessageContentsBuilder(new MessageContents()) + } + + def 'allows adding metadata to the message'() { + when: + def message = builder + .withMetadata([x: 'y', y: ['a', 'b', 'c']]) + .build() + + then: + message.metadata == [ + 'x': 'y', + 'y': ['a', 'b', 'c'] + ] + } + + def 'allows using matching rules with the metadata'() { + when: + def message = builder + .withMetadata([x: regexp('\\d+', '111')]) + .build() + + then: + message.metadata == [ + 'x': '111' + ] + message.matchingRules.rulesForCategory('metadata') == new MatchingRuleCategory('metadata', + [ + x: new MatchingRuleGroup([new RegexMatcher('\\d+', '111')]) + ] + ) + } + + def 'supports setting metadata values from provider states'() { + when: + def message = builder + .withMetadata(['A': fromProviderState('$a', '111')]) + .build() + + then: + message.metadata == [ + 'A': '111' + ] + message.matchingRules.rulesForCategory('metadata') == new MatchingRuleCategory('metadata', [:]) + message.generators.categoryFor(Category.METADATA) == [A: new ProviderStateGenerator('$a')] + } + + def 'allows setting the contents of the message as a string value'() { + when: + def message = builder + .withContent('This is some text') + .build() + + then: + message.contents.valueAsString() == 'This is some text' + message.contents.contentType.toString() == 'text/plain; charset=ISO-8859-1' + message.metadata['contentType'] == 'text/plain; charset=ISO-8859-1' + } + + def 'allows setting the contents of the message as a string value with a given content type'() { + when: + def message = builder + .withContent('This is some text', 'text/test-special') + .build() + + then: + message.contents.valueAsString() == 'This is some text' + message.contents.contentType.toString() == 'text/test-special' + message.metadata['contentType'] == 'text/test-special' + } + + def 'when setting the body, tries to detect the content type from the body contents'() { + when: + def message = builder + .withContent('{"value": "This is some text"}') + .build() + + then: + message.contents.valueAsString() == '{"value": "This is some text"}' + message.contents.contentType.toString() == 'application/json' + message.metadata['contentType'] == 'application/json' + } + + def 'when setting the body, uses any existing content type metadata value'() { + when: + def message = builder + .withMetadata(['contentType': 'text/plain']) + .withContent('{"value": "This is some text"}') + .build() + + then: + message.contents.valueAsString() == '{"value": "This is some text"}' + message.contents.contentType.toString() == 'text/plain' + message.metadata['contentType'] == 'text/plain' + } + + def 'when setting the body, overrides any existing content type header if the content type is given'() { + when: + def message = builder + .withMetadata(['contentType': 'text/plain']) + .withContent('{"value": "This is some text"}', 'application/json') + .build() + + then: + message.contents.valueAsString() == '{"value": "This is some text"}' + message.contents.contentType.toString() == 'application/json' + message.metadata['contentType'] == 'application/json' + } + + def 'supports setting the body from a DSLPart object'() { + when: + def message = builder + .withContent(new PactDslJsonBody().stringType('value', 'This is some text')) + .build() + + then: + message.contents.valueAsString() == '{"value":"This is some text"}' + message.contents.contentType.toString() == 'application/json' + message.metadata['contentType'] == 'application/json' + message.matchingRules.rulesForCategory('body') == new MatchingRuleCategory('body', + [ + '$.value': new MatchingRuleGroup([au.com.dius.pact.core.model.matchingrules.TypeMatcher.INSTANCE]) + ] + ) + } + + def 'supports setting the body using a body builder'() { + when: + def message = builder + .withContent(new PactXmlBuilder('test').build { + it.attributes = [id: regexp('\\d+', '100')] + }) + .build() + + then: + message.contents.valueAsString() == '' + + System.lineSeparator() + '' + System.lineSeparator() + message.contents.contentType.toString() == 'application/xml' + message.metadata['contentType'] == 'application/xml' + message.matchingRules.rulesForCategory('body') == new MatchingRuleCategory('body', + [ + '$.test[\'@id\']': new MatchingRuleGroup([new RegexMatcher('\\d+', '100')]) + ] + ) + } + + def 'supports setting up a content type matcher on the body'() { + given: + def gif1px = [ + 0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377, + 0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104, + 0001, 0000, 0073 + ] as byte[] + + when: + def message = builder + .withContentsMatchingContentType('image/gif', gif1px) + .build() + + then: + message.contents.value == gif1px + message.contents.contentType.toString() == 'image/gif' + message.metadata['contentType'] == 'image/gif' + message.matchingRules.rulesForCategory('body') == new MatchingRuleCategory('body', + [ + '$': new MatchingRuleGroup([new ContentTypeMatcher('image/gif')]) + ] + ) + } + + def 'allows setting the contents of the message as a byte array'() { + given: + def gif1px = [ + 0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377, + 0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104, + 0001, 0000, 0073 + ] as byte[] + + when: + def message = builder + .withContent(gif1px) + .build() + + then: + message.contents.unwrap() == gif1px + message.contents.contentType.toString() == 'application/octet-stream' + message.metadata['contentType'] == 'application/octet-stream' + } + + def 'allows setting the contents of the message as a a byte array with a content type'() { + given: + def gif1px = [ + 0107, 0111, 0106, 0070, 0067, 0141, 0001, 0000, 0001, 0000, 0200, 0000, 0000, 0377, 0377, 0377, + 0377, 0377, 0377, 0054, 0000, 0000, 0000, 0000, 0001, 0000, 0001, 0000, 0000, 0002, 0002, 0104, + 0001, 0000, 0073 + ] as byte[] + + when: + def message = builder + .withContent(gif1px, 'image/gif') + .build() + + then: + message.contents.unwrap() == gif1px + message.contents.contentType.toString() == 'image/gif' + message.metadata['contentType'] == 'image/gif' + } +} diff --git a/core/model/src/main/kotlin/au/com/dius/pact/core/model/ContentType.kt b/core/model/src/main/kotlin/au/com/dius/pact/core/model/ContentType.kt index c63591854..086828a64 100644 --- a/core/model/src/main/kotlin/au/com/dius/pact/core/model/ContentType.kt +++ b/core/model/src/main/kotlin/au/com/dius/pact/core/model/ContentType.kt @@ -158,6 +158,8 @@ class ContentType(val contentType: MediaType?) { @JvmStatic val TEXT_PLAIN = ContentType("text/plain; charset=ISO-8859-1") @JvmStatic + val OCTET_STEAM = ContentType("application/octet-stream") + @JvmStatic val HTML = ContentType("text/html") @JvmStatic val JSON = ContentType("application/json") diff --git a/core/model/src/main/kotlin/au/com/dius/pact/core/model/v4/MessageContents.kt b/core/model/src/main/kotlin/au/com/dius/pact/core/model/v4/MessageContents.kt index 8daa31cd5..71c0f27ff 100644 --- a/core/model/src/main/kotlin/au/com/dius/pact/core/model/v4/MessageContents.kt +++ b/core/model/src/main/kotlin/au/com/dius/pact/core/model/v4/MessageContents.kt @@ -1,5 +1,6 @@ package au.com.dius.pact.core.model.v4 +import au.com.dius.pact.core.model.ContentType import au.com.dius.pact.core.model.OptionalBody import au.com.dius.pact.core.model.PactSpecVersion import au.com.dius.pact.core.model.bodyFromJson @@ -22,7 +23,7 @@ data class MessageContents @JvmOverloads constructor( val generators: Generators = Generators(), val partName: String = "" ) { - fun getContentType() = contents.contentType.or(Message.contentType(metadata)) + fun getContentType() = contents.contentType.or(Message.contentType(metadata) ?: ContentType.OCTET_STEAM) fun toMap(pactSpecVersion: PactSpecVersion?): Map { val map = mutableMapOf(