Added the byCommand matcher

with this change the users can pass their own custom methods for the given matcher

hopefully fixes #217
This commit is contained in:
Marcin Grzejszczak
2017-02-10 13:30:09 +01:00
parent 576330652f
commit 47102d722b
12 changed files with 194 additions and 108 deletions

View File

@@ -282,6 +282,10 @@ match the regex for ISO Time
be of the same type as the type defined in the body of the response in the contract.
`byType` can take a closure where you can set `minOccurrence` and `maxOccurrence`.
That way you can assert on the size of the collection.
- `byCommand(...)` - the value taken from the response via the provided JSON Path will be
passed as an input to the custom method that you're providing. E.g. `byCommand('foo($it)')`
will result in calling a `foo` method to which the value matching the JSON Path will get
passed.
Let's take a look at the following example:
@@ -311,38 +315,43 @@ assertions and the one from matchers with an `and` section):
[source,java,indent=0]
----
// given:
MockMvcRequestSpecification request = given()
.header("Content-Type", "application/json")
.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\"}");
// given:
MockMvcRequestSpecification request = given()
.header("Content-Type", "application/json")
.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\"}");
// when:
ResponseOptions response = given().spec(request)
.get("/get");
// when:
ResponseOptions response = given().spec(request)
.get("/get");
// then:
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.header("Content-Type")).matches("application/json.*");
// and:
DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
assertThatJson(parsedJson).field("valueWithoutAMatcher").isEqualTo("foo");
// and:
assertThat(parsedJson.read("$.duck", String.class)).matches("[0-9]{3}");
assertThat(parsedJson.read("$.duck", Integer.class)).isEqualTo(123);
assertThat(parsedJson.read("$.alpha", String.class)).matches("[\\p{L}]*");
assertThat(parsedJson.read("$.alpha", String.class)).isEqualTo("abc");
assertThat(parsedJson.read("$.number", String.class)).matches("-?\\d*(\\.\\d+)?");
assertThat(parsedJson.read("$.aBoolean", String.class)).matches("(true|false)");
assertThat(parsedJson.read("$.date", String.class)).matches("(\\d\\d\\d\\d)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])");
assertThat(parsedJson.read("$.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])");
assertThat(parsedJson.read("$.time", String.class)).matches("(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])");
assertThat((Object) parsedJson.read("$.valueWithTypeMatch")).isInstanceOf(java.lang.String.class);
assertThat((Object) parsedJson.read("$.valueWithMin")).isInstanceOf(java.util.List.class);
assertThat(parsedJson.read("$.valueWithMin", java.util.Collection.class).size()).isGreaterThanOrEqualTo(1);
assertThat((Object) parsedJson.read("$.valueWithMax")).isInstanceOf(java.util.List.class);
assertThat(parsedJson.read("$.valueWithMax", java.util.Collection.class).size()).isLessThanOrEqualTo(3);
assertThat((Object) parsedJson.read("$.valueWithMinMax")).isInstanceOf(java.util.List.class);
assertThat(parsedJson.read("$.valueWithMinMax", java.util.Collection.class).size()).isBetween(1, 3);
// then:
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.header("Content-Type")).matches("application/json.*");
// and:
DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
assertThatJson(parsedJson).field("valueWithoutAMatcher").isEqualTo("foo");
// and:
assertThat(parsedJson.read("$.duck", String.class)).matches("[0-9]{3}");
assertThat(parsedJson.read("$.duck", Integer.class)).isEqualTo(123);
assertThat(parsedJson.read("$.alpha", String.class)).matches("[\\p{L}]*");
assertThat(parsedJson.read("$.alpha", String.class)).isEqualTo("abc");
assertThat(parsedJson.read("$.number", String.class)).matches("-?\\d*(\\.\\d+)?");
assertThat(parsedJson.read("$.aBoolean", String.class)).matches("(true|false)");
assertThat(parsedJson.read("$.date", String.class)).matches("(\\d\\d\\d\\d)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])");
assertThat(parsedJson.read("$.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])");
assertThat(parsedJson.read("$.time", String.class)).matches("(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])");
assertThat((Object) parsedJson.read("$.valueWithTypeMatch")).isInstanceOf(java.lang.String.class);
assertThat((Object) parsedJson.read("$.valueWithMin")).isInstanceOf(java.util.List.class);
assertThat(parsedJson.read("$.valueWithMin", java.util.Collection.class).size()).isGreaterThanOrEqualTo(1);
assertThat((Object) parsedJson.read("$.valueWithMax")).isInstanceOf(java.util.List.class);
assertThat(parsedJson.read("$.valueWithMax", java.util.Collection.class).size()).isLessThanOrEqualTo(3);
assertThat((Object) parsedJson.read("$.valueWithMinMax")).isInstanceOf(java.util.List.class);
assertThat(parsedJson.read("$.valueWithMinMax", java.util.Collection.class).size()).isBetween(1, 3);
assertThat((Object) parsedJson.read("$.valueWithMinEmpty")).isInstanceOf(java.util.List.class);
assertThat(parsedJson.read("$.valueWithMinEmpty", java.util.Collection.class).size()).isGreaterThanOrEqualTo(0);
assertThat((Object) parsedJson.read("$.valueWithMaxEmpty")).isInstanceOf(java.util.List.class);
assertThat(parsedJson.read("$.valueWithMaxEmpty", java.util.Collection.class).size()).isLessThanOrEqualTo(0);
assertThatValueIsANumber(parsedJson.read("$.duck"));
----
and the WireMock stub like this:

View File

@@ -17,7 +17,6 @@
package org.springframework.cloud.contract.spec.internal
import groovy.transform.CompileStatic
/**
* Represents a property that will become an executable method in the
* generated tests
@@ -27,7 +26,7 @@ import groovy.transform.CompileStatic
@CompileStatic
class ExecutionProperty {
private static final String PLACEHOLDER_VALUE = '\\$it'
private static final String PLACEHOLDER_VALUE = '$it'
final String executionCommand
@@ -40,7 +39,7 @@ class ExecutionProperty {
* the code that represents that method execution
*/
String insertValue(String valueToInsert) {
return executionCommand.replaceAll(PLACEHOLDER_VALUE, valueToInsert)
return executionCommand.replace(PLACEHOLDER_VALUE, valueToInsert)
}
@Override

View File

@@ -38,10 +38,14 @@ enum MatchingType {
* Verification if the value for the given path matches the
* provided regex
*/
REGEX
REGEX,
/**
* The user can provide custom command to execute
*/
COMMAND
static boolean regexRelated(MatchingType type) {
if (type == EQUALITY || type == TYPE ) {
if (type == EQUALITY || type == TYPE || type == COMMAND ) {
return false
}
return true

View File

@@ -18,6 +18,10 @@ class ResponseBodyMatchers extends BodyMatchers {
return new MatchingTypeValue(type: MatchingType.TYPE)
}
MatchingTypeValue byCommand(String execute) {
return new MatchingTypeValue(MatchingType.COMMAND, new ExecutionProperty(execute))
}
MatchingTypeValue byType(@DelegatesTo(MatchingTypeValueHolder) Closure closure) {
MatchingTypeValueHolder matchingTypeValue = new MatchingTypeValueHolder()
closure.delegate = matchingTypeValue

View File

@@ -32,4 +32,16 @@ class ExecutionPropertySpec extends Specification {
'commandToExecute(someObject.itsValue)' == commandWithInsertedValue
}
def 'should insert passed value with a $ sign in place of $it placeholder'() {
given:
String commandToExecute = 'commandToExecute($it)'
ExecutionProperty executionProperty = new ExecutionProperty(commandToExecute)
and:
String valueToInsert = '$.someObject.itsValue'
when:
String commandWithInsertedValue = executionProperty.insertValue(valueToInsert)
then:
'commandToExecute($.someObject.itsValue)' == commandWithInsertedValue
}
}

View File

@@ -13,6 +13,7 @@ class MatchingTypeSpec extends Specification {
type | expected
MatchingType.EQUALITY | false
MatchingType.TYPE | false
MatchingType.COMMAND | false
MatchingType.REGEX | true
MatchingType.DATE | true
MatchingType.TIME | true

View File

@@ -31,7 +31,6 @@ import java.util.regex.Pattern
import static groovy.json.StringEscapeUtils.escapeJava
import static org.springframework.cloud.contract.verifier.config.TestFramework.JUNIT
import static org.springframework.cloud.contract.verifier.util.ContentUtils.getJavaMultipartFileParameterContent
/**
* Root class for JUnit method building
*
@@ -88,7 +87,7 @@ abstract class JUnitMethodBodyBuilder extends RequestProcessingMethodBodyBuilder
@Override
protected void processBodyElement(BlockBuilder blockBuilder, String property, ExecutionProperty exec) {
blockBuilder.addLine("${exec.insertValue("parsedJson.read(\"\\\$$property\")")};")
blockBuilder.addLine("${exec.insertValue("parsedJson.read(\"\$$property\")")};")
}
@Override
@@ -104,14 +103,18 @@ abstract class JUnitMethodBodyBuilder extends RequestProcessingMethodBodyBuilder
@Override
protected void processBodyElement(BlockBuilder blockBuilder, String property, Map.Entry entry) {
processBodyElement(blockBuilder, property + getMapKeyReferenceString(entry), entry.value)
processBodyElement(blockBuilder, getMapKeyReferenceString(property, entry), entry.value)
}
private String getMapKeyReferenceString(Map.Entry entry) {
private String getMapKeyReferenceString(String property, Map.Entry entry) {
if (entry.value instanceof ExecutionProperty) {
return "." + entry.key
return provideProperJsonPathNotation(property) + "." + entry.key
}
return """.get(\\\"$entry.key\\\")"""
return property + """.get(\\\"$entry.key\\\")"""
}
private String provideProperJsonPathNotation(String property) {
return property.replaceAll('(get\\(\\\\")(.*)(\\\\"\\))', '$2')
}
@Override

View File

@@ -312,40 +312,59 @@ abstract class MethodBodyBuilder {
bb.startBlock()
// for the rest we'll do JsonPath matching in brute force
bodyMatchers.jsonPathMatchers().each {
if (it.value() || it.matchingType() == MatchingType.EQUALITY) {
String path = quotedAndEscaped(it.path())
Object retrievedValue = value(copiedBody, it)
String valueAsParam = retrievedValue instanceof String ? quotedAndEscaped(retrievedValue.toString()) : retrievedValue.toString()
if (arrayRelated(path) && MatchingType.regexRelated(it.matchingType())) {
buildCustomMatchingConditionForEachElement(bb, path, valueAsParam)
} else {
String comparisonMethod = it.matchingType() == MatchingType.EQUALITY ? "isEqualTo" : "matches"
String classToCastTo = "${retrievedValue.class.simpleName}.class"
String method = "assertThat(parsedJson.read(${path}, ${classToCastTo})).${comparisonMethod}(${valueAsParam})"
bb.addLine(postProcessJsonPathCall(method))
}
addColonIfRequired(bb)
if (MatchingType.regexRelated(it.matchingType()) || it.matchingType() == MatchingType.EQUALITY) {
methodForEqualityCheck(it, bb, copiedBody)
} else if (it.matchingType() == MatchingType.COMMAND) {
methodForCommandExecution(it, bb, copiedBody)
} else {
Object elementFromBody = value(copiedBody, it)
if (it.minTypeOccurrence() != null || it.maxTypeOccurrence() != null) {
if (arrayRelated(it.path())) {
throw new UnsupportedOperationException("Version 1.0.x doesn't support checking sizes when JSON Path contains [*]. " +
"For more information check out https://github.com/spring-cloud/spring-cloud-contract/issues/217 . " +
"Please upgrade to the latest version of Spring Cloud Contract for this feature.")
}
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)
}
methodForTypeCheck(it, bb, copiedBody)
}
}
}
processBodyElement(bb, "", convertedResponseBody)
}
protected void methodForEqualityCheck(BodyMatcher bodyMatcher, BlockBuilder bb, Object copiedBody) {
String path = quotedAndEscaped(bodyMatcher.path())
Object retrievedValue = value(copiedBody, bodyMatcher)
String valueAsParam = retrievedValue instanceof String ? quotedAndEscaped(retrievedValue.toString()) : retrievedValue.toString()
if (arrayRelated(path) && MatchingType.regexRelated(bodyMatcher.matchingType())) {
buildCustomMatchingConditionForEachElement(bb, path, valueAsParam)
} else {
String comparisonMethod = bodyMatcher.matchingType() == MatchingType.EQUALITY ? "isEqualTo" : "matches"
String classToCastTo = "${retrievedValue.class.simpleName}.class"
String method = "assertThat(parsedJson.read(${path}, ${classToCastTo})).${comparisonMethod}(${valueAsParam})"
bb.addLine(postProcessJsonPathCall(method))
}
addColonIfRequired(bb)
}
protected void methodForCommandExecution(BodyMatcher bodyMatcher, BlockBuilder bb, Object copiedBody) {
String path = quotedAndEscaped(bodyMatcher.path())
// assert that path exists
retrieveObjectByPath(copiedBody, bodyMatcher.path())
ExecutionProperty property = bodyMatcher.value() as ExecutionProperty
bb.addLine(postProcessJsonPathCall(property.insertValue("parsedJson.read(${path})")))
addColonIfRequired(bb)
}
protected void methodForTypeCheck(BodyMatcher bodyMatcher, BlockBuilder bb, Object copiedBody) {
Object elementFromBody = value(copiedBody, bodyMatcher)
if (bodyMatcher.minTypeOccurrence() != null || bodyMatcher.maxTypeOccurrence() != null) {
if (arrayRelated(bodyMatcher.path())) {
throw new UnsupportedOperationException("Version 1.0.x doesn't support checking sizes when JSON Path contains [*]. " +
"For more information check out https://github.com/spring-cloud/spring-cloud-contract/issues/217 . " +
"Please upgrade to the latest version of Spring Cloud Contract for this feature.")
}
checkType(bb, bodyMatcher, elementFromBody)
String method = "assertThat(parsedJson.read(${quotedAndEscaped(bodyMatcher.path())}, java.util.Collection.class).size()).${sizeCheckMethod(bodyMatcher)}"
bb.addLine(postProcessJsonPathCall(method))
addColonIfRequired(bb)
} else {
checkType(bb, bodyMatcher, elementFromBody)
}
}
protected boolean arrayRelated(String path) {
return path.contains("[*]") || path.contains("..")
}
@@ -445,7 +464,7 @@ abstract class MethodBodyBuilder {
}
private String stripFirstChar(String s) {
return s.substring(1);
return s.substring(1)
}
/**

View File

@@ -68,7 +68,7 @@ abstract class SpockMethodRequestProcessingBodyBuilder extends RequestProcessing
@Override
protected void processBodyElement(BlockBuilder blockBuilder, String property, ExecutionProperty exec) {
blockBuilder.addLine("${exec.insertValue("parsedJson.read('\\\$$property')")}")
blockBuilder.addLine("${exec.insertValue("parsedJson.read('\$$property')")}")
}
@Override

View File

@@ -777,7 +777,7 @@ class JaxRsClientMethodBuilderSpec extends Specification implements WireMockStub
builder.appendTo(blockBuilder)
def test = blockBuilder.toString()
then:
test.contains('assertThatRejectionReasonIsNull(parsedJson.read("$.get("rejectionReason").title"));')
test.contains('assertThatRejectionReasonIsNull(parsedJson.read("$.rejectionReason.title"));')
}
@Issue('#85')

View File

@@ -125,6 +125,8 @@ class MockMvcMethodBodyBuilderWithMatchersSpec extends Specification implements
// results in verification of size of array (max 0)
maxOccurrence(0)
})
// will execute a method `assertThatValueIsANumber`
jsonPath('$.duck', byCommand('assertThatValueIsANumber($it)'))
}
headers {
contentType(applicationJson())
@@ -279,4 +281,74 @@ class MockMvcMethodBodyBuilderWithMatchersSpec extends Specification implements
"JaxRsClientJUnitMethodBodyBuilder" | { Contract dsl -> new JaxRsClientJUnitMethodBodyBuilder(dsl, properties) } | '$'
}
@Issue('#217')
def "should allow matcher with command to execute [#methodBuilderName]"() {
given:
Contract contractDsl = Contract.make {
request {
method 'GET'
url 'person'
}
response {
status 200
body([
"phoneNumbers": [
number: "foo"
]
])
testMatchers {
jsonPath('$.phoneNumbers[*].number', byCommand('foo($it)'))
}
}
}
MethodBodyBuilder builder = methodBuilder(contractDsl)
BlockBuilder blockBuilder = new BlockBuilder(" ")
when:
builder.appendTo(blockBuilder)
def test = blockBuilder.toString()
then:
test.contains('foo(parsedJson.read("' + rootElement + '.phoneNumbers[*].number")')
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) } | '$'
}
@Issue('#217')
def "should throw an exception when command to execute references a non existing entry in the body [#methodBuilderName]"() {
given:
Contract contractDsl = Contract.make {
request {
method 'GET'
url 'person'
}
response {
status 200
body([
"phoneNumbers": [
number: "foo"
]
])
testMatchers {
jsonPath('$.nonExistingPhoneNumbers[*].number', byCommand('foo($it)'))
}
}
}
MethodBodyBuilder builder = methodBuilder(contractDsl)
BlockBuilder blockBuilder = new BlockBuilder(" ")
when:
builder.appendTo(blockBuilder)
then:
IllegalStateException e = thrown(IllegalStateException)
e.message.contains("Entry for the provided JSON path [\$.nonExistingPhoneNumbers[*].number] doesn't exist in the body")
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

@@ -1,37 +0,0 @@
/*
* Copyright 2013-2017 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.dsl.internal
import org.springframework.cloud.contract.spec.internal.ExecutionProperty
import spock.lang.Specification
class ExecutionPropertySpec extends Specification {
def 'should insert passed value in place of $it placeholder'() {
given:
String commandToExecute = 'commandToExecute($it)'
ExecutionProperty executionProperty = new ExecutionProperty(commandToExecute)
and:
String valueToInsert = 'someObject.itsValue'
when:
String commandWithInsertedValue = executionProperty.insertValue(valueToInsert)
then:
'commandToExecute(someObject.itsValue)' == commandWithInsertedValue
}
}