diff --git a/docs/src/main/asciidoc/verifier/contract.adoc b/docs/src/main/asciidoc/verifier/contract.adoc index f1b55e6561..07671ba8b0 100644 --- a/docs/src/main/asciidoc/verifier/contract.adoc +++ b/docs/src/main/asciidoc/verifier/contract.adoc @@ -522,7 +522,8 @@ match the regex for ISO Time - `byType()` - the value taken from the 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 where you can set `minOccurrence` and `maxOccurrence`. -That way you can assert on the size of the collection. +That way you can assert on the size of the flattened collection. To check the size +of an unflattened collection, use a custom method via `byCommand(...)` testMatcher. - `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 @@ -1241,4 +1242,4 @@ IMPORTANT: If you don't provide any implementation then the default one will be If you provide `repositoryRoot` property or `workOffline` flag then Aether based that will download stubs from a remote repo will be picked. If you don't provide these values then the `ClasspathStubProvider` will be picked that will scan the classpath. - If you provide more than one, then the first one on the list will be picked. \ No newline at end of file + If you provide more than one, then the first one on the list will be picked. diff --git a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverter.groovy b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverter.groovy index 1d1c4c49ad..1935c7835b 100644 --- a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverter.groovy +++ b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverter.groovy @@ -50,6 +50,7 @@ class JsonToJsonPathsConverter { private static final Boolean SERVER_SIDE = false private static final Boolean CLIENT_SIDE = true + private static final String ANY_ARRAY_NOTATION_IN_JSONPATH = "[*]" private final ContractVerifierConfigProperties configProperties @@ -80,6 +81,7 @@ class JsonToJsonPathsConverter { bodyMatchers.jsonPathMatchers().each { BodyMatcher matcher -> try { context.delete(matcher.path()) + removeTrailingContainers(matcher, context) } catch (RuntimeException e) { if (log.isDebugEnabled()) { log.debug("Exception occurred while trying to delete path [${matcher.path()}]", e) @@ -90,6 +92,36 @@ class JsonToJsonPathsConverter { return jsonCopy } + /** + * Related to #391. The converted body looks different when done via the String notation than + * it does when done via a map notation. When working with String body and when matchers + * are provided, even when all entries of a map / list got removed, the map / list itself + * remains. That leads to unnecessary creation of checks for empty collection. With this method + * we're checking if the JSON path matcher is related to array checking and we're trying to + * remove that trailing collection. All in all it's better to use the Groovy based notation for + * defining body... + */ + private static void removeTrailingContainers(BodyMatcher matcher, DocumentContext context) { + if (matcher.path().contains(ANY_ARRAY_NOTATION_IN_JSONPATH)) { + String pathWithoutAnyArray = matcher.path().substring(0, matcher.path().lastIndexOf(ANY_ARRAY_NOTATION_IN_JSONPATH)) + def object = context.read(pathWithoutAnyArray) + if (object instanceof Iterable && containsOnlyEmptyElements(object)) { + context.delete(pathWithoutAnyArray) + } + } + } + + private static boolean containsOnlyEmptyElements(Object object) { + return object.every { + if (it instanceof Map) { + return it.isEmpty() + } else if (it instanceof List) { + return it.isEmpty() + } + return false + } + } + // Doing a clone doesn't work for nested lists... private static Object cloneBody(Object object) { if (object instanceof List) { diff --git a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/MockMvcMethodBodyBuilderWithMatchersSpec.groovy b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/MockMvcMethodBodyBuilderWithMatchersSpec.groovy index 96da05eaa6..61a256a99f 100644 --- a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/MockMvcMethodBodyBuilderWithMatchersSpec.groovy +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/MockMvcMethodBodyBuilderWithMatchersSpec.groovy @@ -423,4 +423,94 @@ class MockMvcMethodBodyBuilderWithMatchersSpec extends Specification implements "JaxRsClientJUnitMethodBodyBuilder" | { Contract dsl -> new JaxRsClientJUnitMethodBodyBuilder(dsl, properties) } | '$' } + @Issue("#391") + def "should work for matchers and body with multiline string for [#methodBuilderName]"() { + given: + Contract contractDsl = Contract.make { + request { + name "ISSUE 391" + method 'GET' + urlPath '/item/factsheet?size=2&page=1' + headers { header "accept", "application/...json" } + } + response { + status 200 + body(""" + { + "items": [ + { + "id": "35309", + "title": "lorem ipsum" + } + ] + } + """) + testMatchers { + jsonPath('$.items[*].id', byRegex(nonBlank())) + jsonPath('$.items[*].title', byRegex(nonBlank())) + jsonPath('$.items[*]', byType { minOccurrence(2); maxOccurrence(2) }) + } + headers {header "content-type", "application/...json;charset=UTF-8"} + } + } + MethodBodyBuilder builder = methodBuilder(contractDsl) + BlockBuilder blockBuilder = new BlockBuilder(" ") + and: + builder.appendTo(blockBuilder) + String test = blockBuilder.toString() + when: + SyntaxChecker.tryToCompile(methodBuilderName, test) + then: + !test.contains('''assertThatJson(parsedJson).array("['items']").isEmpty()''') + 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("#391") + def "should work for matchers and body with multiline string with map body for [#methodBuilderName]"() { + given: + Contract contractDsl = Contract.make { + request { + name "ISSUE 391" + method 'GET' + urlPath '/item/factsheet?size=2&page=1' + headers { header "accept", "application/...json" } + } + response { + status 200 + body([ + "items": [ + "id" : "35309", + "title": "lorem ipsum" + ] + ]) + testMatchers { + jsonPath('$.items[*].id', byRegex(nonBlank())) + jsonPath('$.items[*].title', byRegex(nonBlank())) + jsonPath('$.items[*]', byType { minOccurrence(2); maxOccurrence(2) }) + } + headers {header "content-type", "application/...json;charset=UTF-8"} + } + } + MethodBodyBuilder builder = methodBuilder(contractDsl) + BlockBuilder blockBuilder = new BlockBuilder(" ") + and: + builder.appendTo(blockBuilder) + String test = blockBuilder.toString() + when: + SyntaxChecker.tryToCompile(methodBuilderName, test) + then: + !test.contains('''assertThatJson(parsedJson).array("['items']").isEmpty()''') + 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) } + } + }