Assert size of collections on the request side; fixes gh-824

This commit is contained in:
Marcin Grzejszczak
2018-12-20 14:53:38 +01:00
parent 6969d233b8
commit 9c7c896115
21 changed files with 202 additions and 117 deletions

View File

@@ -821,7 +821,7 @@ match the regex for an ISO DateTime value.
the regex for an ISO Time value.
** `byType()`: The value taken from the producer's response via the provided JSON Path needs to be
of the same type as the type defined in the body of the response in the contract.
`byType` can take a closure, in which you can set `minOccurrence` and `maxOccurrence`.
`byType` can take a closure, in which you can set `minOccurrence` and `maxOccurrence`. For the request side, you should use the closure to assert size of the collection.
That way, you can assert the size of the flattened collection. To check the size of an
unflattened collection, use a custom method with the `byCommand(...)` testMatcher.
** `byCommand(...)`: The value taken from the producer's response via the provided JSON Path is
@@ -869,6 +869,8 @@ Below you can find the allowed list of `type`s.
** `by_date`
** `by_timestamp`
** `by_time`
** `by_type`
*** there are 2 additional fields accepted: `minOccurrence` and `maxOccurrence`.
* For `testMatchers`:
** `by_equality`
** `by_regex`

View File

@@ -56,10 +56,6 @@ test {
}
}
task wrapper(type: Wrapper) {
gradleVersion = '4.0.2'
}
task resolveDependencies {
doLast {
project.rootProject.allprojects.each { subProject ->

View File

@@ -27,7 +27,7 @@ repositories {
}
// end::deps_repos[]
apply plugin: 'groovy'
apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'spring-cloud-contract'
@@ -65,10 +65,6 @@ test {
}
}
task wrapper(type: Wrapper) {
gradleVersion = '4.0.2'
}
clean.doFirst {
delete "~/.m2/repository/com/example/http-server-dsl-gradle"
}

View File

@@ -69,8 +69,4 @@ task resolveDependencies {
}
}
}
}
task wrapper(type: Wrapper) {
gradleVersion = '4.0.2'
}

View File

@@ -110,8 +110,4 @@ task resolveDependencies {
}
}
}
}
task wrapper(type: Wrapper) {
gradleVersion = '4.0.2'
}

View File

@@ -58,10 +58,6 @@ test {
}
}
task wrapper(type: Wrapper) {
gradleVersion = '4.0.2'
}
task resolveDependencies {
doLast {
project.rootProject.allprojects.each { subProject ->

View File

@@ -64,10 +64,6 @@ test {
}
}
task wrapper(type: Wrapper) {
gradleVersion = '4.0.2'
}
clean.doFirst {
delete "~/.m2/repository/com/example/http-server-pact-gradle"
}

View File

@@ -60,10 +60,6 @@ test {
}
}
task wrapper(type: Wrapper) {
gradleVersion = '4.0.2'
}
task resolveDependencies {
doLast {
project.rootProject.allprojects.each { subProject ->

View File

@@ -60,10 +60,6 @@ test {
}
}
task wrapper(type: Wrapper) {
gradleVersion = '4.0.2'
}
task resolveDependencies {
doLast {
project.rootProject.allprojects.each { subProject ->

View File

@@ -62,10 +62,6 @@ test {
}
}
task wrapper(type: Wrapper) {
gradleVersion = '4.0.2'
}
task stubsJar(type: Jar, dependsOn: ['copySnippets', 'copySources', 'copyClasses']) {
baseName = project.name
classifier = 'stubs'

View File

@@ -73,6 +73,13 @@ class BodyMatchers {
return new MatchingTypeValue(MatchingType.EQUALITY, null)
}
MatchingTypeValue byType(@DelegatesTo(MatchingTypeValueHolder) Closure closure) {
MatchingTypeValueHolder matchingTypeValue = new MatchingTypeValueHolder()
closure.delegate = matchingTypeValue
closure()
return matchingTypeValue.matchingTypeValue
}
boolean equals(o) {
if (this.is(o)) return true
if (this.getClass() != o.class) return false
@@ -147,3 +154,26 @@ class MatchingTypeValue {
*/
Integer maxTypeOccurrence
}
@CompileStatic
@ToString(includePackage = false)
@EqualsAndHashCode
class MatchingTypeValueHolder {
MatchingTypeValue matchingTypeValue = new MatchingTypeValue(type: MatchingType.TYPE)
MatchingTypeValue minOccurrence(int number) {
this.matchingTypeValue.minTypeOccurrence = number
return this.matchingTypeValue
}
MatchingTypeValue maxOccurrence(int number) {
this.matchingTypeValue.maxTypeOccurrence = number
return this.matchingTypeValue
}
MatchingTypeValue occurrence(int number) {
this.matchingTypeValue.minTypeOccurrence = number
this.matchingTypeValue.maxTypeOccurrence = number
return this.matchingTypeValue
}
}

View File

@@ -16,9 +16,7 @@
package org.springframework.cloud.contract.spec.internal
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
/**
* Body matchers for the response side (output message, REST response)
*
@@ -38,31 +36,7 @@ class ResponseBodyMatchers extends BodyMatchers {
return new MatchingTypeValue(MatchingType.COMMAND, new ExecutionProperty(execute))
}
MatchingTypeValue byType(@DelegatesTo(MatchingTypeValueHolder) Closure closure) {
MatchingTypeValueHolder matchingTypeValue = new MatchingTypeValueHolder()
closure.delegate = matchingTypeValue
closure()
return matchingTypeValue.matchingTypeValue
}
MatchingTypeValue byNull() {
return new MatchingTypeValue(MatchingType.NULL, null)
}
}
@CompileStatic
@ToString(includePackage = false)
@EqualsAndHashCode
class MatchingTypeValueHolder {
MatchingTypeValue matchingTypeValue = new MatchingTypeValue(type: MatchingType.TYPE)
MatchingTypeValue minOccurrence(int number) {
this.matchingTypeValue.minTypeOccurrence = number
return this.matchingTypeValue
}
MatchingTypeValue maxOccurrence(int number) {
this.matchingTypeValue.maxTypeOccurrence = number
return this.matchingTypeValue
}
}

View File

@@ -581,6 +581,15 @@ class DslToWireMockClientConverterSpec extends Specification {
anothervalue: 4
]
]
],
valueWithMin: [
1,2,3
],
valueWithMax: [
1,2,3
],
valueWithMinMax: [
1,2,3
]
])
bodyMatchers {
@@ -594,6 +603,23 @@ class DslToWireMockClientConverterSpec extends Specification {
jsonPath('$.dateTime', byTimestamp())
jsonPath('$.time', byTime())
jsonPath('$.list.some.nested.json', byRegex(".*"))
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)
})
jsonPath('$.valueWithOccurrence', byType {
// results in verification of size of array (min 4 & max 4)
occurrence(4)
})
}
headers {
contentType(applicationJson())
@@ -620,6 +646,9 @@ class DslToWireMockClientConverterSpec extends Specification {
valueWithMinMax: [
1,2,3
],
valueWithOccurrence: [
1,2,3,4
],
])
bodyMatchers {
// asserts the jsonpath value against manual regex
@@ -649,6 +678,10 @@ class DslToWireMockClientConverterSpec extends Specification {
minOccurrence(1)
maxOccurrence(3)
})
jsonPath('$.valueWithOccurrence', byType {
// results in verification of size of array (min 4 & max 4)
occurrence(4)
})
}
headers {
contentType(applicationJson())
@@ -664,51 +697,60 @@ class DslToWireMockClientConverterSpec extends Specification {
'''
{
"request" : {
"urlPath" : "/get",
"method" : "POST",
"headers" : {
"Content-Type" : {
"matches" : "application/json.*"
}
},
"bodyPatterns" : [ {
"matchesJsonPath" : "$[?(@.['valueWithoutAMatcher'] == 'foo')]"
}, {
"matchesJsonPath" : "$[?(@.['valueWithTypeMatch'] == 'string')]"
}, {
"matchesJsonPath" : "$.['list'].['some'].['nested'][?(@.['anothervalue'] == 4)]"
}, {
"matchesJsonPath" : "$.['list'].['someother'].['nested'][?(@.['anothervalue'] == 4)]"
}, {
"matchesJsonPath" : "$.['list'].['someother'].['nested'][?(@.['json'] == 'with value')]"
}, {
"matchesJsonPath" : "$[?(@.duck =~ /([0-9]{3})/)]"
}, {
"matchesJsonPath" : "$[?(@.duck == 123)]"
}, {
"matchesJsonPath" : "$[?(@.alpha =~ /([\\\\p{L}]*)/)]"
}, {
"matchesJsonPath" : "$[?(@.alpha == 'abc')]"
}, {
"matchesJsonPath" : "$[?(@.number =~ /(-?(\\\\d*\\\\.\\\\d+|\\\\d+))/)]"
}, {
"matchesJsonPath" : "$[?(@.aBoolean =~ /((true|false))/)]"
}, {
"matchesJsonPath" : "$[?(@.date =~ /((\\\\d\\\\d\\\\d\\\\d)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01]))/)]"
}, {
"matchesJsonPath" : "$[?(@.dateTime =~ /(([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]))/)]"
}, {
"matchesJsonPath" : "$[?(@.time =~ /((2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9]))/)]"
}, {
"matchesJsonPath" : "$.list.some.nested[?(@.json =~ /(.*)/)]"
} ]
"urlPath" : "/get",
"method" : "POST",
"headers" : {
"Content-Type" : {
"matches" : "application/json.*"
}
},
"bodyPatterns" : [ {
"matchesJsonPath" : "$.['list'].['some'].['nested'][?(@.['anothervalue'] == 4)]"
}, {
"matchesJsonPath" : "$[?(@.['valueWithoutAMatcher'] == 'foo')]"
}, {
"matchesJsonPath" : "$[?(@.['valueWithTypeMatch'] == 'string')]"
}, {
"matchesJsonPath" : "$.['list'].['someother'].['nested'][?(@.['json'] == 'with value')]"
}, {
"matchesJsonPath" : "$.['list'].['someother'].['nested'][?(@.['anothervalue'] == 4)]"
}, {
"matchesJsonPath" : "$[?(@.duck =~ /([0-9]{3})/)]"
}, {
"matchesJsonPath" : "$[?(@.duck == 123)]"
}, {
"matchesJsonPath" : "$[?(@.alpha =~ /([\\\\p{L}]*)/)]"
}, {
"matchesJsonPath" : "$[?(@.alpha == 'abc')]"
}, {
"matchesJsonPath" : "$[?(@.number =~ /(-?(\\\\d*\\\\.\\\\d+|\\\\d+))/)]"
}, {
"matchesJsonPath" : "$[?(@.aBoolean =~ /((true|false))/)]"
}, {
"matchesJsonPath" : "$[?(@.date =~ /((\\\\d\\\\d\\\\d\\\\d)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01]))/)]"
}, {
"matchesJsonPath" : "$[?(@.dateTime =~ /(([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]))/)]"
}, {
"matchesJsonPath" : "$[?(@.time =~ /((2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9]))/)]"
}, {
"matchesJsonPath" : "$.list.some.nested[?(@.json =~ /(.*)/)]"
}, {
"matchesJsonPath" : "$[?(@.valueWithMin.size() >= 1)]"
}, {
"matchesJsonPath" : "$[?(@.valueWithMax.size() <= 3)]"
}, {
"matchesJsonPath" : "$[?(@.valueWithMinMax.size() >= 1 && @.valueWithMinMax.size() <= 3)]"
}, {
"matchesJsonPath" : "$[?(@.valueWithOccurrence.size() >= 4 && @.valueWithOccurrence.size() <= 4)]"
} ]
},
"response" : {
"status" : 200,
"body" : "{\\"date\\":\\"2017-01-01\\",\\"dateTime\\":\\"2017-01-01T01:23:45\\",\\"number\\":123,\\"aBoolean\\":true,\\"duck\\":123,\\"alpha\\":\\"abc\\",\\"valueWithMin\\":[1,2,3],\\"time\\":\\"01:02:34\\",\\"valueWithTypeMatch\\":\\"string\\",\\"valueWithMax\\":[1,2,3],\\"valueWithMinMax\\":[1,2,3],\\"valueWithoutAMatcher\\":\\"foo\\"}",
"headers" : {
"Content-Type" : "application/json"
}
"status" : 200,
"body" : "{\\"date\\":\\"2017-01-01\\",\\"dateTime\\":\\"2017-01-01T01:23:45\\",\\"aBoolean\\":true,\\"valueWithMax\\":[1,2,3],\\"valueWithOccurrence\\":[1,2,3,4],\\"number\\":123,\\"duck\\":123,\\"alpha\\":\\"abc\\",\\"valueWithMin\\":[1,2,3],\\"time\\":\\"01:02:34\\",\\"valueWithTypeMatch\\":\\"string\\",\\"valueWithMinMax\\":[1,2,3],\\"valueWithoutAMatcher\\":\\"foo\\"}",
"headers" : {
"Content-Type" : "application/json"
},
"transformers" : [ "response-template" ]
}
}
'''
@@ -744,6 +786,18 @@ class DslToWireMockClientConverterSpec extends Specification {
anothervalue: 4
]
]
],
valueWithMin: [
1,2,3
],
valueWithMax: [
1,2,3
],
valueWithMinMax: [
1,2,3
],
valueWithOccurrence: [
1,2,3,4
]
]))
, String)
@@ -768,6 +822,9 @@ class DslToWireMockClientConverterSpec extends Specification {
valueWithMinMax: [
1,2,3
],
valueWithOccurrence: [
1,2,3,4
],
]), response.body, false)
}

View File

@@ -128,7 +128,9 @@ class ContractsToYaml {
request.matchers.body << new YamlContract.BodyStubMatcher(
path: matcher.path(),
type: stubMatcherType(matcher.matchingType()),
value: matcher.value()?.toString()
value: matcher.value()?.toString(),
minOccurrence: matcher.minTypeOccurrence(),
maxOccurrence: matcher.maxTypeOccurrence(),
)
}
Object url = contract.request.url?.clientValue

View File

@@ -104,6 +104,8 @@ class YamlContract {
public StubMatcherType type
public String value
public PredefinedRegex predefined
public Integer minOccurrence
public Integer maxOccurrence
}
@CompileStatic
@@ -199,7 +201,7 @@ class YamlContract {
@CompileStatic
static enum StubMatcherType {
by_date, by_time, by_timestamp, by_regex, by_equality, by_null
by_date, by_time, by_timestamp, by_regex, by_equality, by_type, by_null
}
@CompileStatic

View File

@@ -199,6 +199,12 @@ class YamlToContracts {
case YamlContract.StubMatcherType.by_equality:
value = byEquality()
break
case YamlContract.StubMatcherType.by_type:
value = byType {
if (matcher.minOccurrence != null) minOccurrence(matcher.minOccurrence)
if (matcher.maxOccurrence != null) maxOccurrence(matcher.maxOccurrence)
}
break
case YamlContract.StubMatcherType.by_null:
// do nothing
break

View File

@@ -142,14 +142,16 @@ class JsonToJsonPathsConverter {
static String convertJsonPathAndRegexToAJsonPath(BodyMatcher bodyMatcher, def body = null) {
String path = bodyMatcher.path()
Object value = bodyMatcher.value()
if (value == null && bodyMatcher.matchingType() != MatchingType.EQUALITY) {
if (value == null && bodyMatcher.matchingType() != MatchingType.EQUALITY &&
bodyMatcher.matchingType() != MatchingType.TYPE) {
return path
}
int lastIndexOfDot = lastIndexOfDot(path)
String toLastDot = path.substring(0, lastIndexOfDot)
String fromLastDot = path.substring(lastIndexOfDot + 1)
String comparison = createComparison(bodyMatcher, value, body)
return "${toLastDot}[?(@.${fromLastDot} ${comparison})]"
String propertyName = "@.${fromLastDot}"
String comparison = createComparison(propertyName, bodyMatcher, value, body)
return "${toLastDot}[?(${comparison})]"
}
private static int lastIndexOfDot(String path) {
@@ -173,7 +175,7 @@ class JsonToJsonPathsConverter {
return value
}
private static String createComparison(BodyMatcher bodyMatcher, Object value, def body) {
private static String createComparison(String propertyName, BodyMatcher bodyMatcher, Object value, def body) {
if (bodyMatcher.matchingType() == MatchingType.EQUALITY) {
Object convertedBody = body
if (!body) {
@@ -185,13 +187,25 @@ class JsonToJsonPathsConverter {
}
Object retrievedValue = JsonPath.parse(convertedBody).read(bodyMatcher.path())
String wrappedValue = retrievedValue instanceof Number ? retrievedValue : "'${retrievedValue.toString()}'"
return "== ${wrappedValue}"
return "${propertyName} == ${wrappedValue}"
} catch (PathNotFoundException e) {
throw new IllegalStateException("Value [${bodyMatcher.path()}] not found in JSON [${JsonOutput.toJson(convertedBody)}]", e)
}
} else if (bodyMatcher.matchingType() == MatchingType.TYPE) {
Integer min = bodyMatcher.minTypeOccurrence()
Integer max = bodyMatcher.maxTypeOccurrence()
String result = ""
if (min != null) {
result = "${propertyName}.size() >= ${min}"
}
if (max != null) {
String maxResult = "${propertyName}.size() <= ${max}"
result = result ? "${result} && ${maxResult}" : maxResult
}
return result
} else {
String convertedValue = value.toString().replace('/', '\\\\/')
return "=~ /(${convertedValue})/"
return "${propertyName} =~ /(${convertedValue})/"
}
}

View File

@@ -1192,7 +1192,7 @@ class JaxRsClientMethodBuilderSpec extends Specification implements WireMockStub
where:
methodBuilderName | methodBuilder | endOfLineRegexSymbol
"JaxRsClientSpockMethodRequestProcessingBodyBuilder"| { org.springframework.cloud.contract.spec.Contract dsl -> new JaxRsClientSpockMethodRequestProcessingBodyBuilder(dsl, properties, generatedClassDataForMethod) } | '\\$'
"JaxRsClientJUnitMethodBodyBuilder" | { org.springframework.cloud.contract.spec.Contract dsl -> new JaxRsClientJUnitMethodBodyBuilder(dsl, properties, generatedClassDataForMethod) } | '$'
"JaxRsClientJUnitMethodBodyBuilder" | { org.springframework.cloud.contract.spec.Contract dsl -> new JaxRsClientJUnitMethodBodyBuilder(dsl, properties, generatedClassDataForMethod) }| '$'
}
private String stripped(String string) {

View File

@@ -475,7 +475,7 @@ class MockMvcMethodBodyBuilderWithMatchersSpec extends Specification implements
bodyMatchers {
jsonPath('$.items[*].id', byRegex(nonBlank()))
jsonPath('$.items[*].title', byRegex(nonBlank()))
jsonPath('$.items[*]', byType { minOccurrence(2); maxOccurrence(2) })
jsonPath('$.items[*]', byType { occurrence(2) })
}
headers { header 'content-type', 'application/...json;charset=UTF-8' }
}

View File

@@ -239,6 +239,16 @@ class YamlContractConverterSpec extends Specification {
contract.request.bodyMatchers.jsonPathRegexMatchers[8].value() == patterns.isoTime()
contract.request.bodyMatchers.jsonPathRegexMatchers[9].path() == "\$.['key'].['complex.key']"
contract.request.bodyMatchers.jsonPathRegexMatchers[9].matchingType() == MatchingType.EQUALITY
contract.request.bodyMatchers.jsonPathRegexMatchers[10].path() == '$.valueWithMin'
contract.request.bodyMatchers.jsonPathRegexMatchers[10].matchingType() == MatchingType.TYPE
contract.request.bodyMatchers.jsonPathRegexMatchers[10].minTypeOccurrence() == 1
contract.request.bodyMatchers.jsonPathRegexMatchers[11].path() == '$.valueWithMax'
contract.request.bodyMatchers.jsonPathRegexMatchers[11].matchingType() == MatchingType.TYPE
contract.request.bodyMatchers.jsonPathRegexMatchers[11].maxTypeOccurrence() == 3
contract.request.bodyMatchers.jsonPathRegexMatchers[12].path() == '$.valueWithMinMax'
contract.request.bodyMatchers.jsonPathRegexMatchers[12].matchingType() == MatchingType.TYPE
contract.request.bodyMatchers.jsonPathRegexMatchers[12].minTypeOccurrence() == 1
contract.request.bodyMatchers.jsonPathRegexMatchers[12].maxTypeOccurrence() == 3
contract.request.cookies.entries.find { it.key == "foo" }.clientValue instanceof Pattern
contract.request.cookies.entries.find { it.key == "bar" }.serverValue == new ExecutionProperty('equals($it)')
and:

View File

@@ -28,6 +28,20 @@ request:
key:
"complex.key": 'foo'
nullValue: null
valueWithMin:
- 1
- 2
- 3
valueWithMax:
- 1
- 2
- 3
valueWithMinMax:
- 1
- 2
- 3
valueWithMinEmpty: []
valueWithMaxEmpty: []
matchers:
url:
regex: /get/[0-9]
@@ -90,6 +104,16 @@ request:
type: by_equality
- path: $.nullvalue
type: by_null
- path: $.valueWithMin
type: by_type
minOccurrence: 1
- path: $.valueWithMax
type: by_type
maxOccurrence: 3
- path: $.valueWithMinMax
type: by_type
minOccurrence: 1
maxOccurrence: 3
response:
status: 200
cookies: