Skip to content

Commit

Permalink
feat: Support generators with URI FORM encoded bodies #1610
Browse files Browse the repository at this point in the history
  • Loading branch information
rholshausen committed Oct 25, 2022
1 parent 79d2174 commit 870a999
Show file tree
Hide file tree
Showing 9 changed files with 310 additions and 37 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package au.com.dius.pact.consumer.junit5;

import au.com.dius.pact.consumer.MockServer;
import au.com.dius.pact.consumer.dsl.FormPostBuilder;
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.core.model.PactSpecVersion;
import au.com.dius.pact.core.model.RequestResponsePact;
import au.com.dius.pact.core.model.annotations.Pact;
import org.apache.hc.client5.http.fluent.Request;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.message.BasicNameValuePair;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.io.IOException;
import java.util.UUID;

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

@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "FormPostProvider", pactVersion = PactSpecVersion.V3)
public class FormPostWithProviderStateTest {
@Pact(consumer = "FormPostConsumer")
public RequestResponsePact formpost(PactDslWithProvider builder) {
return builder
.given("provider state 1")
.uponReceiving("FORM POST request with provider state")
.path("/form")
.method("POST")
.body(
new FormPostBuilder()
.parameterFromProviderState("value", "value", "1000"))
.willRespondWith()
.status(200)
.toPact();
}

@Test
void testFormPost(MockServer mockServer) throws IOException {
HttpResponse httpResponse = Request.post(mockServer.getUrl() + "/form")
.bodyForm(
new BasicNameValuePair("value", "1000")).execute().returnResponse();
assertThat(httpResponse.getCode(), is(equalTo(200)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ class FormPostBuilder(
*/
fun datetime(name: String): FormPostBuilder {
val pattern = DateFormatUtils.ISO_DATETIME_FORMAT.pattern
generators.addGenerator(Category.BODY, name, DateTimeGenerator(pattern, null))
generators.addGenerator(Category.BODY, ROOT + name, DateTimeGenerator(pattern, null))
body[name] = listOf(DateFormatUtils.ISO_DATETIME_FORMAT.format(Date(DslPart.DATE_2000)))
matchers.addRule(ROOT + name, PM.timestamp(pattern))
return this
Expand All @@ -120,7 +120,7 @@ class FormPostBuilder(
* @param format datetime format
*/
fun datetime(name: String, format: String): FormPostBuilder {
generators.addGenerator(Category.BODY, name, DateTimeGenerator(format, null))
generators.addGenerator(Category.BODY, ROOT + name, DateTimeGenerator(format, null))
val formatter = DateTimeFormatter.ofPattern(format).withZone(ZoneId.systemDefault())
body[name] = listOf(formatter.format(Date(DslPart.DATE_2000).toInstant()))
matchers.addRule(ROOT + name, PM.timestamp(format))
Expand Down Expand Up @@ -181,7 +181,7 @@ class FormPostBuilder(
*/
fun date(name: String): FormPostBuilder {
val pattern = DateFormatUtils.ISO_DATE_FORMAT.pattern
generators.addGenerator(Category.BODY, name, DateGenerator(pattern, null))
generators.addGenerator(Category.BODY, ROOT + name, DateGenerator(pattern, null))
body[name] = listOf(DateFormatUtils.ISO_DATE_FORMAT.format(Date(DslPart.DATE_2000)))
matchers.addRule(ROOT + name, PM.date(pattern))
return this
Expand All @@ -193,7 +193,7 @@ class FormPostBuilder(
* @param format date format to match
*/
fun date(name: String, format: String): FormPostBuilder {
generators.addGenerator(Category.BODY, name, DateGenerator(format, null))
generators.addGenerator(Category.BODY, ROOT + name, DateGenerator(format, null))
val instance = FastDateFormat.getInstance(format)
body[name] = listOf(instance.format(Date(DslPart.DATE_2000)))
matchers.addRule(ROOT + name, PM.date(format))
Expand Down Expand Up @@ -230,7 +230,7 @@ class FormPostBuilder(
*/
fun time(name: String): FormPostBuilder {
val pattern = DateFormatUtils.ISO_TIME_FORMAT.pattern
generators.addGenerator(Category.BODY, name, TimeGenerator(pattern, null))
generators.addGenerator(Category.BODY, ROOT + name, TimeGenerator(pattern, null))
body[name] = listOf(DateFormatUtils.ISO_TIME_FORMAT.format(Date(DslPart.DATE_2000)))
matchers.addRule(ROOT + name, PM.time(pattern))
return this
Expand All @@ -242,7 +242,7 @@ class FormPostBuilder(
* @param format time format to match
*/
fun time(name: String, format: String): FormPostBuilder {
generators.addGenerator(Category.BODY, name, TimeGenerator(format, null))
generators.addGenerator(Category.BODY, ROOT + name, TimeGenerator(format, null))
val instance = FastDateFormat.getInstance(format)
body[name] = listOf(instance.format(Date(DslPart.DATE_2000)))
matchers.addRule(ROOT + name, PM.time(format))
Expand Down Expand Up @@ -278,7 +278,7 @@ class FormPostBuilder(
* @param name attribute name
*/
fun hexValue(name: String): FormPostBuilder {
generators.addGenerator(Category.BODY, name, RandomHexadecimalGenerator(10))
generators.addGenerator(Category.BODY, ROOT + name, RandomHexadecimalGenerator(10))
return hexValue(name, "1234a")
}

Expand All @@ -301,7 +301,7 @@ class FormPostBuilder(
* @param name attribute name
*/
fun uuid(name: String): FormPostBuilder {
generators.addGenerator(Category.BODY, name, UuidGenerator())
generators.addGenerator(Category.BODY, ROOT + name, UuidGenerator())
return uuid(name, "e2490de5-5bd3-43d5-b7c4-526e33f71304")
}

Expand Down Expand Up @@ -346,7 +346,7 @@ class FormPostBuilder(
* @param example Example value to be used in the consumer test
*/
fun parameterFromProviderState(name: String, expression: String, example: String): FormPostBuilder {
generators.addGenerator(Category.BODY, name, ProviderStateGenerator(expression, DataType.STRING))
generators.addGenerator(Category.BODY, ROOT + name, ProviderStateGenerator(expression, DataType.STRING))
body[name] = listOf(example)
return this
}
Expand All @@ -367,7 +367,7 @@ class FormPostBuilder(
* @param format Date format to use
*/
fun dateExpression(name: String, expression: String, format: String): FormPostBuilder {
generators.addGenerator(Category.BODY, name, DateGenerator(format, expression))
generators.addGenerator(Category.BODY, ROOT + name, DateGenerator(format, expression))
val instance = FastDateFormat.getInstance(format)
body[name] = listOf(instance.format(Date(DslPart.DATE_2000)))
matchers.addRule(ROOT + name, PM.date(format))
Expand All @@ -390,7 +390,7 @@ class FormPostBuilder(
* @param format Time format to use
*/
fun timeExpression(name: String, expression: String, format: String): FormPostBuilder {
generators.addGenerator(Category.BODY, name, TimeGenerator(format, expression))
generators.addGenerator(Category.BODY, ROOT + name, TimeGenerator(format, expression))
val instance = FastDateFormat.getInstance(format)
body[name] = listOf(instance.format(Date(DslPart.DATE_2000)))
matchers.addRule(ROOT + name, PM.time(format))
Expand All @@ -413,7 +413,7 @@ class FormPostBuilder(
* @param format Datetime format to use
*/
fun datetimeExpression(name: String, expression: String, format: String): FormPostBuilder {
generators.addGenerator(Category.BODY, name, DateTimeGenerator(format, expression))
generators.addGenerator(Category.BODY, ROOT + name, DateTimeGenerator(format, expression))
val instance = FastDateFormat.getInstance(format)
body[name] = listOf(instance.format(Date(DslPart.DATE_2000)))
matchers.addRule(ROOT + name, PM.timestamp(format))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import au.com.dius.pact.core.matchers.MatchingContext
import au.com.dius.pact.core.model.PactSpecVersion
import au.com.dius.pact.core.model.generators.Generator
import au.com.dius.pact.core.model.generators.JsonContentTypeHandler
import au.com.dius.pact.core.model.generators.QueryResult
import au.com.dius.pact.core.model.generators.JsonQueryResult
import au.com.dius.pact.core.model.matchingrules.MatchingRuleCategory
import au.com.dius.pact.core.support.json.JsonValue
import mu.KLogging
Expand All @@ -20,12 +20,12 @@ object ArrayContainsJsonGenerator : KLogging(), Generator {
val variant = findMatchingVariant(example, context)
if (variant != null) {
logger.debug { "Generating values for variant $variant and value $example" }
val json = QueryResult(example)
val json = JsonQueryResult(example)
for ((key, generator) in variant.third) {
JsonContentTypeHandler.applyKey(json, key, generator, context)
}
logger.debug { "Generated value ${json.value}" }
exampleValue[index] = json.value ?: JsonValue.Null
exampleValue[index] = json.jsonValue ?: JsonValue.Null
}
}
exampleValue
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package au.com.dius.pact.core.model.generators

import au.com.dius.pact.core.model.OptionalBody
import au.com.dius.pact.core.model.PathToken
import au.com.dius.pact.core.model.parsePath
import org.apache.hc.core5.http.NameValuePair
import org.apache.hc.core5.http.message.BasicNameValuePair
import org.apache.hc.core5.net.WWWFormCodec
import java.nio.charset.Charset

object FormUrlEncodedContentTypeHandler : ContentTypeHandler {
override fun processBody(value: OptionalBody, fn: (QueryResult) -> Unit): OptionalBody {
val charset = value.contentType.asCharset()
val body = FormQueryResult(WWWFormCodec.parse(value.valueAsString(), charset))
fn.invoke(body)
return OptionalBody.body(WWWFormCodec.format(body.body, charset).toByteArray(charset), value.contentType)
}

override fun applyKey(body: QueryResult, key: String, generator: Generator, context: MutableMap<String, Any>) {
val values = (body as FormQueryResult).body
val pathExp = parsePath(key)
values.forEachIndexed { index, entry ->
if (pathMatches(pathExp, entry.name.orEmpty())) {
values[index] = BasicNameValuePair(entry.name, generator.generate(context, entry.value)?.toString())
}
}
}

private fun pathMatches(pathExp: List<PathToken>, name: String): Boolean {
val root = pathExp[0]
val levelOne = pathExp[1]
return pathExp.size == 2 && root is PathToken.Root &&
(levelOne is PathToken.Star || (levelOne is PathToken.Field && levelOne.name == name))
}
}

class FormQueryResult(var body: MutableList<NameValuePair>, override val key: Any? = null) : QueryResult {
override var value: Any?
get() = body
set(value) {
body = if (value is List<*>) {
(value as List<NameValuePair>).toMutableList()
} else {
WWWFormCodec.parse(value.toString(), Charset.defaultCharset())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,37 @@ interface ContentTypeHandler {
}

val contentTypeHandlers: MutableMap<String, ContentTypeHandler> = mutableMapOf(
"application/json" to JsonContentTypeHandler
"application/json" to JsonContentTypeHandler,
"application/x-www-form-urlencoded" to FormUrlEncodedContentTypeHandler
)

fun setupDefaultContentTypeHandlers() {
contentTypeHandlers.clear()
contentTypeHandlers["application/json"] = JsonContentTypeHandler
contentTypeHandlers["application/x-www-form-urlencoded"] = FormUrlEncodedContentTypeHandler
}

data class QueryResult(var value: JsonValue?, val key: Any? = null, val parent: JsonValue? = null)
interface QueryResult {
var value: Any?
val key: Any?
}
data class JsonQueryResult(var jsonValue: JsonValue?, override val key: Any? = null, val parent: JsonValue? = null): QueryResult {
override var value: Any?
get() = jsonValue
set(value) { jsonValue = Json.toJson(value) }
}

object JsonContentTypeHandler : ContentTypeHandler {
override fun processBody(value: OptionalBody, fn: (QueryResult) -> Unit): OptionalBody {
val bodyJson = QueryResult(JsonParser.parseString(value.valueAsString()))
val bodyJson = JsonQueryResult(JsonParser.parseString(value.valueAsString()))
fn.invoke(bodyJson)
return OptionalBody.body(bodyJson.value.orNull().serialise()
return OptionalBody.body(bodyJson.jsonValue.orNull().serialise()
.toByteArray(value.contentType.asCharset()), ContentType.JSON)
}

override fun applyKey(body: QueryResult, key: String, generator: Generator, context: MutableMap<String, Any>) {
val pathExp = parsePath(key)
queryObjectGraph(pathExp.iterator(), body) { (_, valueKey, parent) ->
queryObjectGraph(pathExp.iterator(), body as JsonQueryResult) { (_, valueKey, parent) ->
when (parent) {
is JsonValue.Object ->
parent[valueKey.toString()] = Json.toJson(generator.generate(context, parent[valueKey.toString()]))
Expand All @@ -56,25 +66,25 @@ object JsonContentTypeHandler : ContentTypeHandler {
}

@Suppress("ReturnCount")
private fun queryObjectGraph(pathExp: Iterator<PathToken>, body: QueryResult, fn: (QueryResult) -> Unit) {
private fun queryObjectGraph(pathExp: Iterator<PathToken>, body: JsonQueryResult, fn: (JsonQueryResult) -> Unit) {
var bodyCursor = body
while (pathExp.hasNext()) {
val cursorValue = bodyCursor.value
when (val token = pathExp.next()) {
is PathToken.Field -> if (cursorValue is JsonValue.Object && cursorValue.has(token.name)) {
bodyCursor = QueryResult(cursorValue[token.name], token.name, bodyCursor.value)
bodyCursor = JsonQueryResult(cursorValue[token.name], token.name, bodyCursor.jsonValue)
} else {
return
}
is PathToken.Index -> if (cursorValue is JsonValue.Array && cursorValue.values.size > token.index) {
bodyCursor = QueryResult(cursorValue[token.index], token.index, bodyCursor.value)
bodyCursor = JsonQueryResult(cursorValue[token.index], token.index, bodyCursor.jsonValue)
} else {
return
}
is PathToken.Star -> if (cursorValue is JsonValue.Object) {
val pathIterator = IteratorUtils.toList(pathExp)
cursorValue.entries.forEach { (key, value) ->
queryObjectGraph(pathIterator.iterator(), QueryResult(value, key, cursorValue), fn)
queryObjectGraph(pathIterator.iterator(), JsonQueryResult(value, key, cursorValue), fn)
}
return
} else {
Expand All @@ -83,7 +93,7 @@ object JsonContentTypeHandler : ContentTypeHandler {
is PathToken.StarIndex -> if (cursorValue is JsonValue.Array) {
val pathIterator = IteratorUtils.toList(pathExp)
cursorValue.values.forEachIndexed { index, item ->
queryObjectGraph(pathIterator.iterator(), QueryResult(item, index, cursorValue), fn)
queryObjectGraph(pathIterator.iterator(), JsonQueryResult(item, index, cursorValue), fn)
}
return
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package au.com.dius.pact.core.model.generators

import org.apache.hc.core5.net.WWWFormCodec
import spock.lang.Specification

import java.nio.charset.Charset

class FormUrlEncodedContentTypeHandlerSpec extends Specification {
def 'applies the generator to the field in the body'() {
given:
def body = 'a=A&b=B&c=C'
def charset = Charset.defaultCharset()
def queryResult = new FormQueryResult(WWWFormCodec.parse(body, charset), null)
def key = '$.b'
def generator = Mock(Generator) {
generate(_, _) >> 'X'
}

when:
FormUrlEncodedContentTypeHandler.INSTANCE.applyKey(queryResult, key, generator, [:])

then:
WWWFormCodec.format(queryResult.body, charset) == 'a=A&b=X&c=C'
}

def 'does not apply the generator when field is not in the body'() {
def body = 'a=A&b=B&c=C'
def charset = Charset.defaultCharset()
def queryResult = new FormQueryResult(WWWFormCodec.parse(body, charset), null)
def key = '$.d'
def generator = Mock(Generator) {
generate(_, _) >> 'X'
}

when:
FormUrlEncodedContentTypeHandler.INSTANCE.applyKey(queryResult, key, generator, [:])

then:
WWWFormCodec.format(queryResult.body, charset) == 'a=A&b=B&c=C'
}

def 'does not apply the generator to empty body'() {
given:
def body = new FormQueryResult([], null)
def key = '$.d'
def generator = Mock(Generator) {
generate(_, _) >> 'X'
}

when:
FormUrlEncodedContentTypeHandler.INSTANCE.applyKey(body, key, generator, [:])

then:
WWWFormCodec.format(body.body, Charset.defaultCharset()) == ''
}

def 'applies the generator to all map entries'() {
given:
def body = 'a=A&b=B&c=C'
def charset = Charset.defaultCharset()
def queryResult = new FormQueryResult(WWWFormCodec.parse(body, charset), null)
def key = '$.*'
def generator = Mock(Generator) {
generate(_, _) >> 'X'
}

when:
FormUrlEncodedContentTypeHandler.INSTANCE.applyKey(queryResult, key, generator, [:])

then:
WWWFormCodec.format(queryResult.body, charset) == 'a=X&b=X&c=X'
}
}
Loading

0 comments on commit 870a999

Please sign in to comment.