Merge branch '1.0.x'

Stub / Test Matchers (#186)
Without this change we're forcing users to embed their dynamic properties inside the body. For some this is natural and acceptable, but especially for the users coming from the Pact world this sounds bizarre. Also some other people have a problem with remembering who the consumer / producer is etc.

With this change we're introducing the stubMatchers and testMatchers section. Thanks to this one can separate the body from defining the dynamic properties. Especially for Pact users this is more natural. Speaking of which this is a prerequisite for #96

fixes #185
This commit is contained in:
Marcin Grzejszczak
2017-01-10 17:21:38 +01:00
25 changed files with 1346 additions and 54 deletions

View File

@@ -148,4 +148,8 @@ class JaxRsClientSpockMethodRequestProcessingBodyBuilder extends SpockMethodRequ
blockBuilder.addLine("response.getHeaderString('$property') ${convertHeaderComparison(value)}")
}
@Override
protected String postProcessJsonPathCall(String jsonPath) {
return jsonPath.replace('$', '\\$')
}
}

View File

@@ -82,7 +82,7 @@ abstract class MessagingMethodBodyBuilder extends MethodBodyBuilder {
if (outputMessage.headers) {
bb.addLine(addCommentSignIfRequired('and:')).startBlock()
}
validateResponseBodyBlock(bb, outputMessage.body.serverValue)
validateResponseBodyBlock(bb, outputMessage.matchers, outputMessage.body.serverValue)
}
if (outputMessage.assertThat) {
bb.addLine(outputMessage.assertThat.executionCommand)

View File

@@ -16,6 +16,8 @@
package org.springframework.cloud.contract.verifier.builder
import com.jayway.jsonpath.JsonPath
import groovy.json.JsonOutput
import groovy.transform.PackageScope
import groovy.transform.TypeChecked
import org.apache.commons.lang3.StringEscapeUtils
@@ -29,7 +31,6 @@ import org.springframework.cloud.contract.verifier.util.MapConverter
import java.util.regex.Pattern
import static org.springframework.cloud.contract.verifier.util.ContentUtils.extractValue
/**
* Main class for building method body.
*
@@ -109,17 +110,17 @@ abstract class MethodBodyBuilder {
protected abstract void processBodyElement(BlockBuilder blockBuilder, String property, Map.Entry entry)
/**
* Appends to the {@link BlockBuilder} the assertion for the given header element
* Appends to the {@link BlockBuilder} the assertion for the given header path
*/
protected abstract void processHeaderElement(BlockBuilder blockBuilder, String property, Pattern pattern)
/**
* Appends to the {@link BlockBuilder} the assertion for the given header element
* Appends to the {@link BlockBuilder} the assertion for the given header path
*/
protected abstract void processHeaderElement(BlockBuilder blockBuilder, String property, ExecutionProperty exec)
/**
* Appends to the {@link BlockBuilder} the assertion for the given header element
* Appends to the {@link BlockBuilder} the assertion for the given header path
*/
protected abstract void processHeaderElement(BlockBuilder blockBuilder, String property, String value)
@@ -258,12 +259,22 @@ abstract class MethodBodyBuilder {
/**
* Builds the response body verification part. The code will differ depending on the
* ContentType, type of response etc. The result will be appended to {@link BlockBuilder}
* @deprecated - use {@link MethodBodyBuilder#validateResponseBodyBlock(org.springframework.cloud.contract.verifier.builder.BlockBuilder, org.springframework.cloud.contract.spec.internal.BodyMatchers, java.lang.Object)}
*/
@Deprecated
protected void validateResponseBodyBlock(BlockBuilder bb, Object responseBody) {
validateResponseBodyBlock(bb, null, responseBody)
}
/**
* Builds the response body verification part. The code will differ depending on the
* ContentType, type of response etc. The result will be appended to {@link BlockBuilder}
*/
protected void validateResponseBodyBlock(BlockBuilder bb, BodyMatchers bodyMatchers, Object responseBody) {
ContentType contentType = getResponseContentType()
Object convertedResponseBody = responseBody
if (convertedResponseBody instanceof GString) {
convertedResponseBody = extractValue(convertedResponseBody, contentType, { Object o -> o instanceof DslProperty ? o.serverValue : o })
convertedResponseBody = extractValue(convertedResponseBody as GString, contentType, { Object o -> o instanceof DslProperty ? o.serverValue : o })
}
if (contentType != ContentType.TEXT) {
convertedResponseBody = MapConverter.getTestSideValues(convertedResponseBody)
@@ -271,15 +282,7 @@ abstract class MethodBodyBuilder {
convertedResponseBody = StringEscapeUtils.escapeJava(convertedResponseBody.toString())
}
if (contentType == ContentType.JSON) {
appendJsonPath(bb, getResponseAsString())
JsonPaths jsonPaths = new JsonToJsonPathsConverter(configProperties).transformToJsonPathWithTestsSideValues(convertedResponseBody)
jsonPaths.each {
String method = it.method()
String postProcessedMethod = postProcessJsonPathCall(method)
bb.addLine("assertThatJson(parsedJson)" + postProcessedMethod)
addColonIfRequired(bb)
}
processBodyElement(bb, "", convertedResponseBody)
addJsonResponseBodyCheck(bb, convertedResponseBody, bodyMatchers)
} else if (contentType == ContentType.XML) {
bb.addLine(getParsedXmlResponseBodyString(getResponseAsString()))
addColonIfRequired(bb)
@@ -291,6 +294,80 @@ abstract class MethodBodyBuilder {
}
}
private void addJsonResponseBodyCheck(BlockBuilder bb, convertedResponseBody, BodyMatchers bodyMatchers) {
appendJsonPath(bb, getResponseAsString())
Object copiedBody = convertedResponseBody.clone()
convertedResponseBody = JsonToJsonPathsConverter.removeMatchingJsonPaths(convertedResponseBody, bodyMatchers)
JsonPaths jsonPaths = new JsonToJsonPathsConverter(configProperties).transformToJsonPathWithTestsSideValues(convertedResponseBody)
jsonPaths.each {
String method = it.method()
String postProcessedMethod = postProcessJsonPathCall(method)
bb.addLine("assertThatJson(parsedJson)" + postProcessedMethod)
addColonIfRequired(bb)
bb.endBlock()
}
if (bodyMatchers?.hasMatchers()) {
bb.addLine(addCommentSignIfRequired('and:'))
bb.startBlock()
// for the rest we'll do JsonPath matching in brute force
bodyMatchers.jsonPathMatchers().each {
if (it.value()) {
String method = "assertThat(parsedJson.read(${quotedAndEscaped(it.path())}, String.class)).matches(${quotedAndEscaped(it.value())})"
bb.addLine(postProcessJsonPathCall(method))
addColonIfRequired(bb)
} else {
Object elementFromBody = JsonPath.parse(copiedBody).read(it.path())
if (!elementFromBody) {
throw new IllegalStateException("Entry for the provided JSON path [${it.path()}] doesn't exist in the body [${JsonOutput.toJson(copiedBody)}]")
}
if (it.minTypeOccurrence() || it.maxTypeOccurrence()) {
checkType(bb, it, elementFromBody)
String method = "assertThat(parsedJson.read(${quotedAndEscaped(it.path())}, java.util.Collection.class).size()).${sizeCheckMethod(it)}"
bb.addLine(postProcessJsonPathCall(method))
addColonIfRequired(bb)
} else {
checkType(bb, it, elementFromBody)
}
}
}
}
processBodyElement(bb, "", convertedResponseBody)
}
protected void checkType(BlockBuilder bb, BodyMatcher it, Object elementFromBody) {
String method = "assertThat((Object) parsedJson.read(${quotedAndEscaped(it.path())})).isInstanceOf(${classToCheck(elementFromBody).name}.class)"
bb.addLine(postProcessJsonPathCall(method))
addColonIfRequired(bb)
}
// we want to make the type more generic (e.g. not ArrayList but List)
protected Class classToCheck(Object elementFromBody) {
switch (elementFromBody.class) {
case List:
return List
case Set:
return Set
case Map:
return Map
default:
return elementFromBody.class
}
}
protected String sizeCheckMethod(BodyMatcher bodyMatcher) {
if (bodyMatcher.minTypeOccurrence() != null && bodyMatcher.maxTypeOccurrence() != null) {
return "isStrictlyBetween(${bodyMatcher.minTypeOccurrence()}, ${bodyMatcher.maxTypeOccurrence()})"
} else if (bodyMatcher.minTypeOccurrence() != null ) {
return "isLessThanOrEqualTo(${bodyMatcher.minTypeOccurrence()})"
} else if (bodyMatcher.maxTypeOccurrence() != null) {
return "isGreaterThanOrEqualTo(${bodyMatcher.maxTypeOccurrence()})"
}
}
protected String quotedAndEscaped(String string) {
return '"' + StringEscapeUtils.escapeJava(string) + '"'
}
/**
* Post processing of each JSON path entry
*/
@@ -324,7 +401,7 @@ abstract class MethodBodyBuilder {
}
/**
* Appends to the {@link BlockBuilder} the assertion for the given header element
* Appends to the {@link BlockBuilder} the assertion for the given header path
*/
protected void processHeaderElement(BlockBuilder blockBuilder, String property, Object value) {
}

View File

@@ -145,7 +145,7 @@ abstract class RequestProcessingMethodBodyBuilder extends MethodBodyBuilder {
if (response.body) {
bb.endBlock()
bb.addLine(addCommentSignIfRequired('and:')).startBlock()
validateResponseBodyBlock(bb, response.body.serverValue)
validateResponseBodyBlock(bb, response.matchers, response.body.serverValue)
}
}

View File

@@ -75,15 +75,22 @@ class WireMockRequestStubStrategy extends BaseWireMockStubStrategy {
}
ContentType contentType = tryToGetContentType(request.body.clientValue, request.headers)
if (contentType == ContentType.JSON) {
JsonPaths values = JsonToJsonPathsConverter.transformToJsonPathWithStubsSideValuesAndNoArraySizeCheck(
getMatchingStrategyFromBody(request.body)?.clientValue)
if (values.empty) {
def body = getMatchingStrategyFromBody(request.body)?.clientValue
body = JsonToJsonPathsConverter.removeMatchingJsonPaths(body, request.matchers)
JsonPaths values = JsonToJsonPathsConverter.transformToJsonPathWithStubsSideValuesAndNoArraySizeCheck(body)
if (values.empty && !request.matchers?.hasMatchers()) {
requestPattern.withRequestBody(WireMock.equalToJson(JsonOutput.toJson(getMatchingStrategy(request.body.clientValue).clientValue), false, false))
} else {
values.findAll{ !it.assertsSize() }.each {
requestPattern.withRequestBody(WireMock.matchingJsonPath(it.jsonPath().replace("\\\\", "\\")))
}
}
if (request.matchers?.hasMatchers()) {
request.matchers.jsonPathMatchers().each {
String newPath = JsonToJsonPathsConverter.convertJsonPathAndRegexToAJsonPath(it.path(), it.value())
requestPattern.withRequestBody(WireMock.matchingJsonPath(newPath.replace("\\\\", "\\")))
}
}
} else if (contentType == ContentType.XML) {
requestPattern.withRequestBody(WireMock.equalToXml(getMatchingStrategy(request.body.clientValue).clientValue.toString()))
} else if (containsPattern(request?.body)) {

View File

@@ -16,11 +16,15 @@
package org.springframework.cloud.contract.verifier.util
import com.jayway.jsonpath.JsonPath
import com.toomuchcoding.jsonassert.JsonAssertion
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import groovy.util.logging.Slf4j
import org.springframework.cloud.contract.spec.internal.OptionalProperty
import org.springframework.cloud.contract.spec.internal.BodyMatcher
import org.springframework.cloud.contract.spec.internal.BodyMatchers
import org.springframework.cloud.contract.spec.internal.MatchingType
import org.springframework.cloud.contract.spec.internal.ExecutionProperty
import org.springframework.cloud.contract.verifier.config.ContractVerifierConfigProperties
@@ -58,15 +62,52 @@ class JsonToJsonPathsConverter {
}
}
public JsonPaths transformToJsonPathWithTestsSideValues(def json) {
/**
* Removes from the parsed json any JSON path matching entries.
* That way we remain with values that should be checked in the auto-generated
* fashion.
*
* @param json - parsed JSON
* @param bodyMatchers - the part of request / response that contains matchers
* @return json with removed entries
*/
static def removeMatchingJsonPaths(def json, BodyMatchers bodyMatchers) {
if (bodyMatchers?.hasMatchers()) {
// remove all jsonpaths from the body - for those that remain we continue as usual
bodyMatchers.jsonPathMatchers().findAll { it.matchingType() != MatchingType.EQUALITY }.each { BodyMatcher matcher ->
JsonPath.parse(json).delete(matcher.path())
}
}
return json
}
/**
* For the given JSON path and regex pattern converts it into a JSON path
* that checks the Pattern
*
* @param path - JSON path
* @param pattern - pattern to check for the last element of JSON path
* @return JSON path that checks the regex for its last element
*/
static String convertJsonPathAndRegexToAJsonPath(String path, String pattern) {
if (!pattern) {
return path
}
int lastIndexOfDot = path.lastIndexOf(".")
String toLastDot = path.substring(0, lastIndexOfDot)
String fromLastDot = path.substring(lastIndexOfDot + 1)
return "${toLastDot}[?(@.${fromLastDot} =~ /(${pattern})/)]"
}
JsonPaths transformToJsonPathWithTestsSideValues(def json) {
return transformToJsonPathWithValues(json, SERVER_SIDE)
}
public JsonPaths transformToJsonPathWithStubsSideValues(def json) {
JsonPaths transformToJsonPathWithStubsSideValues(def json) {
return transformToJsonPathWithValues(json, CLIENT_SIDE)
}
public static JsonPaths transformToJsonPathWithStubsSideValuesAndNoArraySizeCheck(def json) {
static JsonPaths transformToJsonPathWithStubsSideValuesAndNoArraySizeCheck(def json) {
return new JsonToJsonPathsConverter()
.transformToJsonPathWithValues(json, CLIENT_SIDE)
}

View File

@@ -0,0 +1,151 @@
/*
* Copyright 2013-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.contract.verifier.builder
import org.springframework.cloud.contract.spec.Contract
import org.springframework.cloud.contract.verifier.config.ContractVerifierConfigProperties
import org.springframework.cloud.contract.verifier.dsl.WireMockStubVerifier
import org.springframework.cloud.contract.verifier.util.SyntaxChecker
import spock.lang.Issue
import spock.lang.Shared
import spock.lang.Specification
class MockMvcMethodBodyBuilderWithMatchersSpec extends Specification implements WireMockStubVerifier {
@Shared ContractVerifierConfigProperties properties = new ContractVerifierConfigProperties(
assertJsonSize: true
)
@Issue('#185')
def "should allow to set dynamic values via stub / test matchers for [#methodBuilderName]"() {
given:
//tag::matchers[]
Contract contractDsl = Contract.make {
request {
method 'GET'
urlPath '/get'
body([
duck: 123,
alpha: "abc",
number: 123,
aBoolean: true,
date: "2017-01-01",
dateTime: "2017-01-01T01:23:45",
time: "01:02:34",
valueWithoutAMatcher: "foo",
valueWithTypeMatch: "string"
])
stubMatchers {
jsonPath('$.duck', byRegex("[0-9]{3}"))
jsonPath('$.alpha', byRegex(onlyAlphaUnicode()))
jsonPath('$.number', byRegex(number()))
jsonPath('$.aBoolean', byRegex(anyBoolean()))
jsonPath('$.date', byDate())
jsonPath('$.dateTime', byTimestamp())
jsonPath('$.time', byTime())
}
headers {
contentType(applicationJson())
}
}
response {
status 200
body([
duck: 123,
alpha: "abc",
number: 123,
aBoolean: true,
date: "2017-01-01",
dateTime: "2017-01-01T01:23:45",
time: "01:02:34",
valueWithoutAMatcher: "foo",
valueWithTypeMatch: "string",
valueWithMin: [
1,2,3
],
valueWithMax: [
1,2,3
],
valueWithMinMax: [
1,2,3
],
])
testMatchers {
// asserts the jsonpath value against manual regex
jsonPath('$.duck', byRegex("[0-9]{3}"))
// asserts the jsonpath value against some default regex
jsonPath('$.alpha', byRegex(onlyAlphaUnicode()))
jsonPath('$.number', byRegex(number()))
jsonPath('$.aBoolean', byRegex(anyBoolean()))
// asserts vs inbuilt time related regex
jsonPath('$.date', byDate())
jsonPath('$.dateTime', byTimestamp())
jsonPath('$.time', byTime())
// asserts that the resulting type is the same as in response body
jsonPath('$.valueWithTypeMatch', byType())
jsonPath('$.valueWithMin', byType {
// results in verification of size of array (min 1)
minOccurrence(1)
})
jsonPath('$.valueWithMax', byType {
// results in verification of size of array (max 3)
maxOccurrence(3)
})
jsonPath('$.valueWithMinMax', byType {
// results in verification of size of array (min 1 & max 3)
minOccurrence(1)
maxOccurrence(3)
})
}
headers {
contentType(applicationJson())
}
}
}
//end::matchers[]
MethodBodyBuilder builder = methodBuilder(contractDsl)
BlockBuilder blockBuilder = new BlockBuilder(" ")
when:
builder.appendTo(blockBuilder)
def test = blockBuilder.toString()
then:
test.contains('assertThat(parsedJson.read("' + rootElement + '.duck", String.class)).matches("[0-9]{3}")')
test.contains('assertThat(parsedJson.read("' + rootElement + '.alpha", String.class)).matches("[\\\\p{L}]*")')
test.contains('assertThat(parsedJson.read("' + rootElement + '.number", String.class)).matches("-?\\\\d*(\\\\.\\\\d+)?")')
test.contains('assertThat(parsedJson.read("' + rootElement + '.aBoolean", String.class)).matches("(true|false)")')
test.contains('assertThat(parsedJson.read("' + rootElement + '.date", String.class)).matches("(\\\\d\\\\d\\\\d\\\\d)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])")')
test.contains('assertThat(parsedJson.read("' + rootElement + '.dateTime", String.class)).matches("([0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])")')
test.contains('assertThat(parsedJson.read("' + rootElement + '.time", String.class)).matches("(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])")')
test.contains('assertThat((Object) parsedJson.read("' + rootElement + '.valueWithTypeMatch")).isInstanceOf(java.lang.String.class)')
test.contains('assertThat((Object) parsedJson.read("' + rootElement + '.valueWithMin")).isInstanceOf(java.util.List.class)')
test.contains('assertThat(parsedJson.read("' + rootElement + '.valueWithMin", java.util.Collection.class).size()).isLessThanOrEqualTo(1)')
test.contains('assertThat((Object) parsedJson.read("' + rootElement + '.valueWithMax")).isInstanceOf(java.util.List.class)')
test.contains('assertThat(parsedJson.read("' + rootElement + '.valueWithMax", java.util.Collection.class).size()).isGreaterThanOrEqualTo(3)')
test.contains('assertThat((Object) parsedJson.read("' + rootElement + '.valueWithMinMax")).isInstanceOf(java.util.List.class)')
test.contains('assertThat(parsedJson.read("' + rootElement + '.valueWithMinMax", java.util.Collection.class).size()).isStrictlyBetween(1, 3)')
!test.contains('cursor')
and:
SyntaxChecker.tryToCompile(methodBuilderName, blockBuilder.toString())
where:
methodBuilderName | methodBuilder | rootElement
"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) } | '$'
}
}

View File

@@ -753,6 +753,21 @@ class JsonToJsonPathsConverterSpec extends Specification {
}
}
def "should convert a json path with regex to a regex checking json path"() {
given:
String jsonPath = '$.a.b.c.d'
String regexPattern = ".*"
expect:
'$.a.b.c[?(@.d =~ /(.*)/)]' == JsonToJsonPathsConverter.convertJsonPathAndRegexToAJsonPath(jsonPath, regexPattern)
}
def "should return the path if no regex pattern is provided"() {
given:
String jsonPath = '$.a.b.c.d'
expect:
'$.a.b.c.d' == JsonToJsonPathsConverter.convertJsonPathAndRegexToAJsonPath(jsonPath, null)
}
private void assertThatJsonPathsInMapAreValid(String json, JsonPaths pathAndValues) {
DocumentContext parsedJson = JsonPath.using(Configuration.builder().options(Option.ALWAYS_RETURN_LIST).build()).parse(json);
pathAndValues.each {