diff --git a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/builder/MethodBodyBuilder.groovy b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/builder/MethodBodyBuilder.groovy index aaa2cf54dc..ceda668c17 100644 --- a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/builder/MethodBodyBuilder.groovy +++ b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/builder/MethodBodyBuilder.groovy @@ -43,6 +43,7 @@ import org.springframework.cloud.contract.verifier.config.ContractVerifierConfig import org.springframework.cloud.contract.verifier.template.HandlebarsTemplateProcessor import org.springframework.cloud.contract.verifier.template.TemplateProcessor import org.springframework.cloud.contract.verifier.util.ContentType +import org.springframework.cloud.contract.verifier.util.ContentUtils import org.springframework.cloud.contract.verifier.util.JsonPaths import org.springframework.cloud.contract.verifier.util.JsonToJsonPathsConverter import org.springframework.cloud.contract.verifier.util.MapConverter @@ -314,7 +315,7 @@ abstract class MethodBodyBuilder { if (convertedResponseBody instanceof GString) { convertedResponseBody = extractValue(convertedResponseBody as GString, contentType, { Object o -> o instanceof DslProperty ? o.serverValue : o }) } - if (contentType != ContentType.TEXT) { + if (contentType != ContentType.TEXT && contentType != ContentType.FORM) { convertedResponseBody = MapConverter.getTestSideValues(convertedResponseBody) } else { convertedResponseBody = StringEscapeUtils.escapeJava(convertedResponseBody.toString()) @@ -632,13 +633,13 @@ abstract class MethodBodyBuilder { */ protected Object extractServerValueFromBody(bodyValue) { if (bodyValue instanceof GString) { - bodyValue = extractValue(bodyValue, ContentType.from(MapConverter.getTestSideValues(this.contract.request?.headers?.entries?.find { - it.name.toLowerCase() == "Content-Type".toLowerCase() - }).toString()), GET_SERVER_VALUE) - } else { - bodyValue = MapConverter.transformValues(bodyValue, GET_SERVER_VALUE) + return extractValue(bodyValue, contentType(), GET_SERVER_VALUE) } - return bodyValue + return MapConverter.transformValues(bodyValue, GET_SERVER_VALUE) + } + + protected ContentType contentType() { + return ContentUtils.recognizeContentTypeFromTestHeader(this.contract.request?.headers) } /** diff --git a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/builder/RequestProcessingMethodBodyBuilder.groovy b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/builder/RequestProcessingMethodBodyBuilder.groovy index b4af946ecd..8632ffcdc3 100644 --- a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/builder/RequestProcessingMethodBodyBuilder.groovy +++ b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/builder/RequestProcessingMethodBodyBuilder.groovy @@ -200,9 +200,23 @@ abstract class RequestProcessingMethodBodyBuilder extends MethodBodyBuilder { @Override protected String getBodyAsString() { Object bodyValue = extractServerValueFromBody(request.body.serverValue) - String json = new JsonOutput().toJson(bodyValue) - json = convertUnicodeEscapesIfRequired(json) - return trimRepeatedQuotes(json) + if (contentType() == ContentType.FORM) { + if (bodyValue instanceof Map) { + // [a:3, b:4] == "a=3&b=4" + return ((Map) bodyValue).collect { + convertUnicodeEscapesIfRequired(it.key.toString() + "=" + it.value) + }.join("&") + } else if (bodyValue instanceof List) { + // ["a=3", "b=4"] == "a=3&b=4" + return ((List) bodyValue).collect { + convertUnicodeEscapesIfRequired(it.toString()) + }.join("&") + } + } else { + String json = new JsonOutput().toJson(bodyValue) + json = convertUnicodeEscapesIfRequired(json) + return trimRepeatedQuotes(json) + } } /** diff --git a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockRequestStubStrategy.groovy b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockRequestStubStrategy.groovy index 1550e13773..480c4e6271 100755 --- a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockRequestStubStrategy.groovy +++ b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockRequestStubStrategy.groovy @@ -23,6 +23,7 @@ import com.github.tomakehurst.wiremock.matching.RequestPatternBuilder import com.github.tomakehurst.wiremock.matching.StringValuePattern import com.github.tomakehurst.wiremock.matching.UrlPattern import groovy.json.JsonOutput +import groovy.json.StringEscapeUtils import groovy.transform.PackageScope import groovy.transform.TypeChecked import groovy.transform.TypeCheckingMode @@ -71,9 +72,10 @@ class WireMockRequestStubStrategy extends BaseWireMockStubStrategy { return null } RequestPatternBuilder requestPatternBuilder = appendMethodAndUrl() - appendHeaders(requestPatternBuilder) - appendQueryParameters(requestPatternBuilder) - appendBody(requestPatternBuilder) + ContentType contentType = tryToGetContentType(request?.body?.clientValue, request?.headers) + appendHeaders(requestPatternBuilder, contentType) + appendQueryParameters(requestPatternBuilder, contentType) + appendBody(requestPatternBuilder, contentType) appendMultipart(requestPatternBuilder) return requestPatternBuilder.build() } @@ -87,11 +89,10 @@ class WireMockRequestStubStrategy extends BaseWireMockStubStrategy { return RequestPatternBuilder.newRequestPattern(requestMethod, urlPattern) } - private void appendBody(RequestPatternBuilder requestPattern) { + private void appendBody(RequestPatternBuilder requestPattern, ContentType contentType) { if (!request.body) { return } - ContentType contentType = tryToGetContentType(request.body.clientValue, request.headers) if (contentType == ContentType.JSON) { def originalBody = getMatchingStrategyFromBody(request.body)?.clientValue def body = JsonToJsonPathsConverter.removeMatchingJsonPaths(originalBody, request.matchers) @@ -113,9 +114,10 @@ class WireMockRequestStubStrategy extends BaseWireMockStubStrategy { requestPattern.withRequestBody(WireMock.equalToXml(getMatchingStrategy(request.body.clientValue).clientValue.toString())) } else if (containsPattern(request?.body)) { MatchingStrategy matchingStrategy = appendBodyRegexpMatchPattern(request.body) - requestPattern.withRequestBody(convertToValuePattern(matchingStrategy)) + requestPattern.withRequestBody(convertToValuePattern(matchingStrategy, contentType)) } else { - requestPattern.withRequestBody(convertToValuePattern(getMatchingStrategy(request.body.clientValue))) + requestPattern.withRequestBody(convertToValuePattern( + getMatchingStrategy(request.body.clientValue), contentType)) } } @@ -139,12 +141,12 @@ class WireMockRequestStubStrategy extends BaseWireMockStubStrategy { } } - private void appendHeaders(RequestPatternBuilder requestPattern) { + private void appendHeaders(RequestPatternBuilder requestPattern, ContentType contentType) { if(!request.headers) { return } request.headers.entries.each { - requestPattern.withHeader(it.name, convertToValuePattern(it.clientValue)) + requestPattern.withHeader(it.name, convertToValuePattern(it.clientValue, contentType)) } } @@ -190,15 +192,15 @@ class WireMockRequestStubStrategy extends BaseWireMockStubStrategy { return clientSide } - private void appendQueryParameters(RequestPatternBuilder requestPattern) { + private void appendQueryParameters(RequestPatternBuilder requestPattern, ContentType contentType) { QueryParameters queryParameters = request?.urlPath?.queryParameters ?: request?.url?.queryParameters queryParameters?.parameters?.each { - requestPattern.withQueryParam(it.name, convertToValuePattern(it.clientValue)) + requestPattern.withQueryParam(it.name, convertToValuePattern(it.clientValue, contentType)) } } @TypeChecked(TypeCheckingMode.SKIP) - private static StringValuePattern convertToValuePattern(Object object) { + private static StringValuePattern convertToValuePattern(Object object, ContentType contentType) { switch (object) { case Pattern: Pattern value = object as Pattern @@ -215,7 +217,7 @@ class WireMockRequestStubStrategy extends BaseWireMockStubStrategy { return WireMock.absent() default: try { - return WireMock."${value.type.name}"(value.clientValue) + return WireMock."${value.type.name}"(clientBody(value.clientValue, contentType)) } catch (Throwable t) { log.error("Exception occurred while trying to call WireMock.${value.type.name}(${value.clientValue})", t) throw t @@ -226,6 +228,23 @@ class WireMockRequestStubStrategy extends BaseWireMockStubStrategy { } } + protected static Object clientBody(Object bodyValue, ContentType contentType) { + if (contentType == ContentType.FORM) { + if (bodyValue instanceof Map) { + // [a:3, b:4] == "a=3&b=4" + return ((Map) bodyValue).collect { + StringEscapeUtils.unescapeJavaScript(it.key.toString() + "=" + it.value) + }.join("&") + } else if (bodyValue instanceof List) { + // ["a=3", "b=4"] == "a=3&b=4" + return ((List) bodyValue).collect { + StringEscapeUtils.unescapeJavaScript(it.toString()) + }.join("&") + } + } + return bodyValue + } + private MatchingStrategy getMatchingStrategyFromBody(Body body) { if(!body) { return null diff --git a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/ContentType.groovy b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/ContentType.groovy index 13ec29fe00..77684f2b92 100644 --- a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/ContentType.groovy +++ b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/ContentType.groovy @@ -26,6 +26,7 @@ enum ContentType { JSON("application/json"), XML("application/xml"), TEXT("text/plain"), + FORM("application/x-www-form-urlencoded"), UNKNOWN("application/octet-stream") final String mimeType @@ -34,19 +35,4 @@ enum ContentType { this.mimeType = mimeType } - static ContentType from(String header) { - try { - if (header.contains("json")) { - return JSON - } else if (header.contains("xml")) { - return XML - } else if (header.contains("text") || - header.contains("application/x-www-form-urlencoded")) { - // we want both to be treated as text - return TEXT - } - } catch(e) {} - return UNKNOWN - } - } \ No newline at end of file diff --git a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/ContentUtils.groovy b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/ContentUtils.groovy index ea2e38098b..fb59ff32c4 100644 --- a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/ContentUtils.groovy +++ b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/ContentUtils.groovy @@ -24,6 +24,7 @@ import groovy.util.logging.Slf4j import org.codehaus.groovy.runtime.GStringImpl import org.springframework.cloud.contract.spec.internal.DslProperty import org.springframework.cloud.contract.spec.internal.ExecutionProperty +import org.springframework.cloud.contract.spec.internal.Header import org.springframework.cloud.contract.spec.internal.Headers import org.springframework.cloud.contract.spec.internal.MatchingStrategy import org.springframework.cloud.contract.spec.internal.NamedProperty @@ -76,7 +77,7 @@ class ContentUtils { if (bodyAsValue.isEmpty()){ return bodyAsValue } - if (contentType == ContentType.TEXT) { + if (contentType == ContentType.TEXT || contentType == ContentType.FORM) { return extractValueForText(bodyAsValue, valueProvider) } if (contentType == ContentType.JSON) { @@ -281,8 +282,9 @@ class ContentUtils { return val[1] } - static ContentType recognizeContentTypeFromHeader(Headers headers) { - String content = headers?.entries.find { it.name == "Content-Type" } ?.clientValue?.toString() + static ContentType recognizeContentTypeFromHeader(Headers headers, Closure closure) { + Header header = headers?.entries?.find { it.name == "Content-Type" } + String content = closure(header)?.toString() if (content?.contains("json")) { return ContentType.JSON } @@ -292,9 +294,20 @@ class ContentUtils { if (content?.contains("text")) { return ContentType.TEXT } + if (content?.contains("form-urlencoded")) { + return ContentType.FORM + } return ContentType.UNKNOWN } + static ContentType recognizeContentTypeFromHeader(Headers headers) { + return recognizeContentTypeFromHeader(headers, { Header header -> header?.clientValue }) + } + + static ContentType recognizeContentTypeFromTestHeader(Headers headers) { + return recognizeContentTypeFromHeader(headers, { Header header -> header?.serverValue }) + } + static MatchingStrategy.Type getEqualsTypeFromContentType(ContentType contentType) { switch (contentType) { case ContentType.JSON: diff --git a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/MethodBodyBuilderSpec.groovy b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/MethodBodyBuilderSpec.groovy index e8e125533a..775161e374 100644 --- a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/MethodBodyBuilderSpec.groovy +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/MethodBodyBuilderSpec.groovy @@ -528,6 +528,61 @@ DocumentContext parsedJson = JsonPath.parse(json); "JaxRsClientJUnitMethodBodyBuilder" | { Contract dsl -> new JaxRsClientJUnitMethodBodyBuilder(dsl, properties) } } + @Issue("#578") + def "should work for form parameters [#methodBuilderName]"() { + given: + Contract contractDsl = Contract.make { + request { + method POST() + urlPath('/oauth/token') + headers { + header(authorization(), anyNonBlankString()) + header(contentType(), applicationFormUrlencoded()) + header(accept(), applicationJson()) + } + body([ + username : 'user', + password : 'password', + grant_type: 'password' + ]) + } + response { + status 200 + headers { + header(contentType(), applicationJson()) + } + body([ + refresh_token: 'RANDOM_REFRESH_TOKEN', + access_token : 'RANDOM_ACCESS_TOKEN', + token_type : 'bearer', + expires_in : 3600, + scope : ['task'], + user : [ + id : 1, + username: 'user', + name : 'User' + ] + ]) + } + } + MethodBodyBuilder builder = methodBuilder(contractDsl) + BlockBuilder blockBuilder = new BlockBuilder(" ") + when: + builder.appendTo(blockBuilder) + then: + String test = blockBuilder.toString() + SyntaxChecker.tryToCompileWithoutCompileStatic(methodBuilderName, test) + test.contains("username=user&password=password&grant_type=password") + and: + stubMappingIsValidWireMockStub(contractDsl) + where: + methodBuilderName | methodBuilder + "MockMvcSpockMethodBuilder" | { Contract dsl -> new MockMvcSpockMethodRequestProcessingBodyBuilder(dsl, properties) } + "MockMvcJUnitMethodBuilder" | { Contract dsl -> new MockMvcJUnitMethodBodyBuilder(dsl, properties) } + "JaxRsClientSpockMethodRequestProcessingBodyBuilder" | { Contract dsl -> new JaxRsClientSpockMethodRequestProcessingBodyBuilder(dsl, properties) } + "JaxRsClientJUnitMethodBodyBuilder" | { Contract dsl -> new JaxRsClientJUnitMethodBodyBuilder(dsl, properties) } + } + @Issue("#509") def "classToCheck() should return class of object"() { given: diff --git a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/dsl/WireMockGroovyDslSpec.groovy b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/dsl/WireMockGroovyDslSpec.groovy index 90f76c6369..e5a8830cf7 100755 --- a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/dsl/WireMockGroovyDslSpec.groovy +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/dsl/WireMockGroovyDslSpec.groovy @@ -1834,6 +1834,80 @@ class WireMockGroovyDslSpec extends Specification implements WireMockStubVerifie server?.shutdown() } + @Issue('#578') + def "should generate a stub for a request with form parameters"() { + given: + Contract groovyDsl = Contract.make { + request { + method POST() + urlPath('/oauth/token') + headers { + header(authorization(), anyNonBlankString()) + header(contentType(), applicationFormUrlencoded()) + header(accept(), applicationJson()) + } + body([ + username : 'user', + password : 'password', + grant_type: 'password' + ]) + } + response { + status 200 + headers { + header(contentType(), applicationJson()) + } + body([ + refresh_token: 'RANDOM_REFRESH_TOKEN', + access_token : 'RANDOM_ACCESS_TOKEN', + token_type : 'bearer', + expires_in : 3600, + scope : ['task'], + user : [ + id : 1, + username: 'user', + name : 'User' + ] + ]) + } + } + when: + def json = toWireMockClientJsonStub(groovyDsl) + then: + AssertionUtil.assertThatJsonsAreEqual((''' + { + "request" : { + "urlPath" : "/oauth/token", + "method" : "POST", + "headers" : { + "Authorization" : { + "matches" : "^\\\\s*\\\\S[\\\\S\\\\s]*" + }, + "Content-Type" : { + "equalTo" : "application/x-www-form-urlencoded" + }, + "Accept" : { + "equalTo" : "application/json" + } + }, + "bodyPatterns" : [ { + "equalTo" : "username=user&password=password&grant_type=password" + } ] + }, + "response" : { + "status" : 200, + "body" : "{\\"access_token\\":\\"RANDOM_ACCESS_TOKEN\\",\\"refresh_token\\":\\"RANDOM_REFRESH_TOKEN\\",\\"scope\\":[\\"task\\"],\\"token_type\\":\\"bearer\\",\\"expires_in\\":3600,\\"user\\":{\\"name\\":\\"User\\",\\"id\\":1,\\"username\\":\\"user\\"}}", + "headers" : { + "Content-Type" : "application/json" + }, + "transformers" : [ "response-template", "foo-transformer" ] + } + } + '''), json) + and: + stubMappingIsValidWireMockStub(json) + } + @Issue('#269') def "should create a stub for dot separated keys"() { given: