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 76e59118f7..45ca976d46 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 @@ -347,6 +347,13 @@ class ContentUtils { }, parsingClosure) } + protected static Object convertDslPropsToTemporaryRegexPatterns(Object parsedJson, + Function parsingFunction) { + MapConverter.transformValues(parsedJson, { Object value -> + return transformJSONStringValue(value, GET_TEST_SIDE) + }, parsingFunction) + } + private static Object convertAllTemporaryRegexPlaceholdersBackToPatterns(parsedJson) { MapConverter.transformValues(parsedJson, { Object value -> if (value instanceof String) { 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 deleted file mode 100644 index b4cae78263..0000000000 --- a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverter.groovy +++ /dev/null @@ -1,634 +0,0 @@ -/* - * Copyright 2013-present 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 - * - * https://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.util - -import java.util.function.Function -import java.util.regex.Matcher -import java.util.regex.Pattern - -import com.jayway.jsonpath.DocumentContext -import com.jayway.jsonpath.JsonPath -import com.jayway.jsonpath.PathNotFoundException -import com.toomuchcoding.jsonassert.JsonAssertion -import groovy.json.JsonOutput -import groovy.transform.CompileStatic -import groovy.util.logging.Commons - -import org.springframework.cloud.contract.spec.internal.BodyMatcher -import org.springframework.cloud.contract.spec.internal.BodyMatchers -import org.springframework.cloud.contract.spec.internal.ExecutionProperty -import org.springframework.cloud.contract.spec.internal.MatchingType -import org.springframework.cloud.contract.spec.internal.OptionalProperty -import org.springframework.cloud.contract.spec.internal.RegexProperty -import org.springframework.util.SerializationUtils -/** - * I would like to apologize to anyone who is reading this class. Since JSON is a hectic structure - * this class is also hectic. The idea is to traverse the JSON structure and build a set of - * JSON Paths together with methods needed to be called to build them. - * - * @author Marcin Grzejszczak - * @author Tim Ysewyn - * @author Olga Maciaszek-Sharma - */ -@Commons -class JsonToJsonPathsConverter { - - /** - * In case of issues with size assertion just provide this property as system property - * equal to "false" and then size assertion will be disabled - */ - private static final String SIZE_ASSERTION_SYSTEM_PROP = "spring.cloud.contract.verifier.assert.size" - - private static final Boolean SERVER_SIDE = false - private static final Boolean CLIENT_SIDE = true - private static final Pattern ANY_ARRAY_NOTATION_IN_JSONPATH = ~/\[(.*?)\]/ - private static final String DESCENDANT_OPERATOR = ".." - - private final boolean assertJsonSize - - JsonToJsonPathsConverter(boolean assertJsonSize) { - this.assertJsonSize = assertJsonSize - } - - JsonToJsonPathsConverter() { - this(false) - if (log.isTraceEnabled()) { - log.trace("Creating JsonToJsonPaths converter with default properties") - } - } - - /** - * 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) { - def jsonCopy = cloneBody(json) - DocumentContext context = JsonPath.parse(jsonCopy) - if (bodyMatchers?.hasMatchers()) { - List pathsToDelete = [] - List paths = bodyMatchers.matchers().collect { it.path() } - paths.each { String path -> - try { - def entry = entry(context, path) - if (entry != null) { - context.delete(path) - pathsToDelete.add(path) - } - } - catch (RuntimeException e) { - if (log.isTraceEnabled()) { - log.trace("Exception occurred while trying to delete path [${matcher.path()}]", e) - } - } - } - pathsToDelete.sort(Collections.reverseOrder()) - pathsToDelete.each { - removeTrailingContainers(it, context) - } - } - return jsonCopy - } - - private static def entry(DocumentContext context, String path) { - try { - return context.read(path) - } - catch (Exception ex) { - if (log.isTraceEnabled()) { - log.trace("Exception occurred while trying to retrieve element via path [${path}]", ex) - } - return null - } - } - - /** - * Retrieves the value from JSON via json path - * - * @param json - parsed JSON - * @param jsonPath - json path - * @return matching part of the json - */ - static def readElement(def json, String jsonPath) { - DocumentContext context = JsonPath.parse(json) - return context.read(jsonPath) - } - - /** - * Related to #391 and #1091 and #1414. 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 boolean removeTrailingContainers(String matcherPath, DocumentContext context) { - try { - Matcher matcher = ANY_ARRAY_NOTATION_IN_JSONPATH.matcher(matcherPath) - boolean containsArray = matcher.find() - String pathWithoutAnyArray = containsArray ? matcherPath. - substring(0, matcherPath.lastIndexOf(lastMatch(matcher))) : matcherPath - def object = entry(context, pathWithoutAnyArray) - // object got removed and it was the only element - // let's get its parent and see if it contains an empty element - if (isIterable(object) - && - containsOnlyEmptyElements(object) - && isNotRootArray(matcherPath)) { - String pathToDelete = pathToDelete(pathWithoutAnyArray) - if (pathToDelete.contains(DESCENDANT_OPERATOR)) { - Object root = context.read('$') - if (rootContainsEmptyContainers(root)) { - // now root contains only empty elements, we should remove the trailing containers - context.delete('$[*]') - return false - } - return false - } else { - context.delete(pathToDelete) - } - return removeTrailingContainers(pathToDelete, context) - } - else { - int lastIndexOfDot = matcherPath.lastIndexOf(".") - if (lastIndexOfDot == -1) { - return false - } - String lastParent = matcherPath.substring(0, lastIndexOfDot) - def lastParentObject = context.read(lastParent) - if (isIterable(lastParentObject) - && - containsOnlyEmptyElements(lastParentObject) - && isNotRoot(lastParent)) { - context.delete(lastParent) - return removeTrailingContainers(lastParent, context) - } - } - return false - } - catch (RuntimeException e) { - if (log.isTraceEnabled()) { - log.trace("Exception occurred while trying to delete path [${matcherPath}]", e) - } - return false - } - } - - private static boolean rootContainsEmptyContainers(root) { - root instanceof Iterable && root.every { containsOnlyEmptyElements(it) } - } - - private static String lastMatch(Matcher matcher) { - List matches = [] - while ({ - matches << matcher.group() - matcher.find() - }()) { - continue - } - return matches[matches.size() - 1] - } - - private static boolean isIterable(Object object) { - return object instanceof Iterable || object instanceof Map - } - - private static boolean isEmpty(Object object) { - return isIterable(object) && object instanceof Iterable ? object.isEmpty() : ((Map) object).isEmpty() - } - - private static String pathToDelete(String pathWithoutAnyArray) { - // we can't remove root - return pathWithoutAnyArray == '$' ? '$[*]' : pathWithoutAnyArray - } - - private static boolean isNotRoot(String path) { - // we can't remove root - return path != '$' - } - - private static boolean isNotRootArray(String path) { - // we can't remove root - return path != '$[*]' - } - - 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) { - byte[] serializedObject = SerializationUtils.serialize(object) - return SerializationUtils.deserialize(serializedObject) - } - - /** - * For the given matcher converts it into a JSON path - * that checks the regex pattern or equality - * - * @param bodyMatcher - * @return JSON path that checks the regex for its last element - */ - static String convertJsonPathAndRegexToAJsonPath(BodyMatcher bodyMatcher, def body = null) { - String path = bodyMatcher.path() - Object value = bodyMatcher.value() - if (value == null && bodyMatcher.matchingType() != MatchingType.EQUALITY - && - bodyMatcher.matchingType() != MatchingType.TYPE) { - return path - } - int lastIndexOfDot = lastIndexOfDot(path) - String fromLastDot = path.substring(lastIndexOfDot + 1) - String toLastDot = lastIndexOfDot == -1 ? '$' : path.substring(0, lastIndexOfDot) - String propertyName = lastIndexOfDot == -1 ? '@' : "@.${fromLastDot}" - String comparison = createComparison(propertyName, bodyMatcher, value, body) - return "${toLastDot}[?(${comparison})]" - } - - private static int lastIndexOfDot(String path) { - if (pathContainsDotSeparatedKey(path)) { - int lastIndexOfBracket = path.lastIndexOf("['") - return path.substring(0, lastIndexOfBracket).lastIndexOf(".") - } - return path.lastIndexOf(".") - } - - private static boolean pathContainsDotSeparatedKey(String path) { - return path.contains("['") - } - - - @CompileStatic - static Object generatedValueIfNeeded(Object value) { - if (value instanceof RegexProperty) { - return ((RegexProperty) value).generateAndEscapeJavaStringIfNeeded() - } - return value - } - - private static String createComparison(String propertyName, BodyMatcher bodyMatcher, Object value, def body) { - if (bodyMatcher.matchingType() == MatchingType.EQUALITY) { - Object convertedBody = body - if (!body) { - throw new IllegalStateException("Body hasn't been passed") - } - try { - convertedBody = MapConverter.transformValues(body) { - return generatedValueIfNeeded(it) - } - Object retrievedValue = JsonPath.parse(convertedBody). - read(bodyMatcher.path()) - String wrappedValue = retrievedValue instanceof Number ? retrievedValue : "'${retrievedValue.toString()}'" - 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 "${propertyName} =~ /(${convertedValue})/" - } - } - - JsonPaths transformToJsonPathWithTestsSideValues(def json, Function parsingClosure, boolean includeEmptyCheck) { - return transformToJsonPathWithValues(json, SERVER_SIDE, { parsingClosure.apply(it) }, includeEmptyCheck) - } - - JsonPaths transformToJsonPathWithTestsSideValues(def json, - Closure parsingClosure = MapConverter.JSON_PARSING_CLOSURE, - boolean includeEmptyCheck = false) { - return transformToJsonPathWithValues(json, SERVER_SIDE, parsingClosure, includeEmptyCheck) - } - - JsonPaths transformToJsonPathWithStubsSideValues(def json, - Closure parsingClosure = MapConverter.JSON_PARSING_CLOSURE, - boolean includeEmptyCheck = false) { - return transformToJsonPathWithValues(json, CLIENT_SIDE, parsingClosure, includeEmptyCheck) - } - - static JsonPaths transformToJsonPathWithStubsSideValuesAndNoArraySizeCheck(def json, - Closure parsingClosure = MapConverter.JSON_PARSING_CLOSURE) { - return new JsonToJsonPathsConverter() - .transformToJsonPathWithValues(json, CLIENT_SIDE, parsingClosure) - } - - private JsonPaths transformToJsonPathWithValues(def json, boolean clientSide, - Closure parsingClosure = MapConverter.JSON_PARSING_CLOSURE, - boolean includeEmptyCheck = false) { - if (json == null || (!json && !includeEmptyCheck)) { - return new JsonPaths() - } - Object convertedJson = MapConverter. - getClientOrServerSideValues(json, clientSide, parsingClosure) - Object jsonWithPatterns = ContentUtils. - convertDslPropsToTemporaryRegexPatterns(convertedJson, parsingClosure) - MethodBufferingJsonVerifiable methodBufferingJsonPathVerifiable = - new DelegatingJsonVerifiable(JsonAssertion. - assertThat(JsonOutput.toJson(jsonWithPatterns)) - .withoutThrowingException()) - JsonPaths pathsAndValues = [] as Set - if (isRootElement(methodBufferingJsonPathVerifiable) && !json) { - pathsAndValues.add(methodBufferingJsonPathVerifiable.isEmpty()) - return pathsAndValues - } - traverseRecursivelyForKey(jsonWithPatterns, methodBufferingJsonPathVerifiable, - { MethodBufferingJsonVerifiable key, Object value -> - if (value instanceof ExecutionProperty || !(key instanceof FinishedDelegatingJsonVerifiable)) { - return - } - pathsAndValues.add(key) - }, parsingClosure) - return pathsAndValues - } - - protected def traverseRecursively(Class parentType, MethodBufferingJsonVerifiable key, def value, - Closure closure, Closure parsingClosure = MapConverter.JSON_PARSING_CLOSURE) { - value = ContentUtils.returnParsedObject(value) - if (value instanceof String && value) { - try { - def json = parsingClosure(value) - if (json instanceof Map) { - return convertWithKey(parentType, key, json, closure, parsingClosure) - } - } - catch (Exception ignore) { - return runClosure(closure, key, value) - } - } - else if (isAnEntryWithNonCollectionLikeValue(value)) { - return convertWithKey(List, key, value as Map, closure, parsingClosure) - } - else if (isAnEntryWithoutNestedStructures(value)) { - return convertWithKey(List, key, value as Map, closure, parsingClosure) - } - else if (value instanceof Map && !value.isEmpty()) { - return convertWithKey(Map, key, value as Map, closure, parsingClosure) - } - else if (value instanceof Map && value.isEmpty()) { - return runClosure(closure, key.isEmpty(), value) - // JSON with a list of primitives ["a", "b", "c"] in root issue #266 - } - else if (key.isIteratingOverNamelessArray() && value instanceof List - && - listContainsOnlyPrimitives(value)) { - addSizeVerificationForListWithPrimitives(key, closure, value) - value.each { - traverseRecursively(Object, key.arrayField(). - contains(ContentUtils.returnParsedObject(it)), - ContentUtils.returnParsedObject(it), closure, parsingClosure) - } - // JSON containing list of primitives { "partners":[ { "role":"AGENT", "payment_methods":[ "BANK", "CASH" ] } ] - } - else if (value instanceof List && listContainsOnlyPrimitives(value)) { - addSizeVerificationForListWithPrimitives(key, closure, value) - value.each { - traverseRecursively(Object, - valueToAsserter(key.arrayField(), ContentUtils. - returnParsedObject(it)), - ContentUtils.returnParsedObject(it), closure, parsingClosure) - } - } - else if (value instanceof List && !value.empty) { - MethodBufferingJsonVerifiable jsonPathVerifiable = - createAsserterFromList(key, value) - addSizeVerificationForListWithPrimitives(key, closure, value) - value.each { def element -> - traverseRecursively(List, - createAsserterFromListElement(jsonPathVerifiable, ContentUtils. - returnParsedObject(element)), - ContentUtils.returnParsedObject(element), closure, parsingClosure) - } - return value - } - else if (value instanceof List && value.empty) { - return runClosure(closure, key, value) - } - else if (key.isIteratingOverArray()) { - traverseRecursively(Object, key.arrayField(). - contains(ContentUtils.returnParsedObject(value)), - ContentUtils.returnParsedObject(value), closure, parsingClosure) - } - try { - return runClosure(closure, key, value) - } - catch (Exception ignore) { - return value - } - } - - // Size verification: https://github.com/Codearte/accurest/issues/279 - private void addSizeVerificationForListWithPrimitives(MethodBufferingJsonVerifiable key, Closure closure, List value) { - String systemPropValue = System.getProperty(SIZE_ASSERTION_SYSTEM_PROP) - Boolean configPropValue = assertJsonSize - if ((systemPropValue != null && Boolean.parseBoolean(systemPropValue)) - || - configPropValue && listContainsOnlyPrimitives(value)) { - addArraySizeCheck(key, value, closure) - } - else { - if (log.isTraceEnabled()) { - log.trace("Turning off the incubating feature of JSON array check. " - + - "System property [$systemPropValue]. Config property [$configPropValue]") - } - return - } - } - - private void addArraySizeCheck(MethodBufferingJsonVerifiable key, List value, Closure closure) { - if (log.isDebugEnabled()) { - log.debug("WARNING: Turning on the incubating feature of JSON array check") - } - if (isRootElement(key) || key.assertsConcreteValue()) { - if (value.size() > 0) { - closure(key.hasSize(value.size()), value) - } - } - } - - private boolean isRootElement(MethodBufferingJsonVerifiable key) { - return key.jsonPath() == '$' - } - - // If you have a list of not-only primitives it can contain different sets of elements (maps, lists, primitives) - private MethodBufferingJsonVerifiable createAsserterFromList(MethodBufferingJsonVerifiable key, List value) { - if (key.isIteratingOverNamelessArray()) { - return key.array() - } - else if (key.isIteratingOverArray() && isAnEntryWithLists(value)) { - if (!value.every { listContainsOnlyPrimitives(it as List) }) { - return key.array() - } - else { - return key.iterationPassingArray() - } - } - else if (key.isIteratingOverArray()) { - return key.iterationPassingArray() - } - return key - } - - private MethodBufferingJsonVerifiable createAsserterFromListElement(MethodBufferingJsonVerifiable jsonPathVerifiable, def element) { - if (jsonPathVerifiable.isAssertingAValueInArray()) { - def object = ContentUtils.returnParsedObject(element) - if (object instanceof Pattern) { - return jsonPathVerifiable.matches((object as Pattern).pattern()) - } - return jsonPathVerifiable.contains(object) - } - else if (element instanceof List) { - if (listContainsOnlyPrimitives(element)) { - return jsonPathVerifiable.array() - } - } - return jsonPathVerifiable - } - - private def runClosure(Closure closure, MethodBufferingJsonVerifiable key, def value) { - if (key. - isAssertingAValueInArray() - && !(value instanceof List || value instanceof Map)) { - return closure(valueToAsserter(key, value), value) - } - return closure(key, value) - } - - private boolean isAnEntryWithNonCollectionLikeValue(def value) { - if (!(value instanceof Map)) { - return false - } - Map valueAsMap = ((Map) value) - boolean mapHasOneEntry = valueAsMap.size() == 1 - if (!mapHasOneEntry) { - return false - } - Object valueOfEntry = valueAsMap.entrySet().first().value - return !(valueOfEntry instanceof Map || valueOfEntry instanceof List) - } - - private boolean isAnEntryWithoutNestedStructures(def value) { - if (!(value instanceof Map)) { - return false - } - Map valueAsMap = ((Map) value) - if (valueAsMap.isEmpty()) { - return false - } - return valueAsMap.entrySet().every { Map.Entry entry -> - [String, Number, Boolean].any { it.isAssignableFrom(entry.value.getClass()) } - } - } - - private boolean listContainsOnlyPrimitives(List list) { - if (list.empty) { - return false - } - return list.every { def element -> - [String, Number, Boolean].any { - it.isAssignableFrom(element.getClass()) - } - } - } - - private boolean isAnEntryWithLists(def value) { - if (!(value instanceof Iterable)) { - return false - } - return value.every { def entry -> - entry instanceof List - } - } - - private Map convertWithKey(Class parentType, MethodBufferingJsonVerifiable parentKey, Map map, - Closure closureToExecute, Closure parsingClosure) { - return map.collectEntries { - Object entrykey, value -> - def convertedValue = ContentUtils.returnParsedObject(value) - [entrykey, traverseRecursively(parentType, - convertedValue instanceof List ? - list(convertedValue, entrykey, parentKey) : - convertedValue instanceof Map ? parentKey. - field(new ShouldTraverse(entrykey)) : - valueToAsserter(parentKey.field(entrykey), convertedValue) - , convertedValue, closureToExecute, parsingClosure)] - } - } - - protected MethodBufferingJsonVerifiable list(List convertedValue, Object entrykey, MethodBufferingJsonVerifiable parentKey) { - if (convertedValue.empty) { - return parentKey.array(entrykey).isEmpty() - } - return listContainsOnlyPrimitives(convertedValue) ? - parentKey.arrayField(entrykey) : - parentKey.array(entrykey) - } - - private void traverseRecursivelyForKey(def json, MethodBufferingJsonVerifiable rootKey, - Closure closure, Closure parsingClosure = MapConverter.JSON_PARSING_CLOSURE) { - traverseRecursively(Map, rootKey, json, closure, parsingClosure) - } - - protected MethodBufferingJsonVerifiable valueToAsserter(MethodBufferingJsonVerifiable key, Object value) { - def convertedValue = ContentUtils.returnParsedObject(value) - if (key instanceof FinishedDelegatingJsonVerifiable) { - return key - } - if (convertedValue instanceof Pattern) { - return key.matches((convertedValue as Pattern).pattern()) - } - else if (convertedValue instanceof OptionalProperty) { - return key.matches((convertedValue as OptionalProperty).optionalPattern()) - } - else if (convertedValue instanceof GString) { - return key. - matches(RegexpBuilders.buildGStringRegexpForTestSide(convertedValue)) - } - else if (convertedValue instanceof ExecutionProperty) { - return key - } - return key.isEqualTo(convertedValue) - } - -} diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BaseClassProvider.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BaseClassProvider.java index d4912910b1..166a012b6c 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BaseClassProvider.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BaseClassProvider.java @@ -22,6 +22,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; + import org.springframework.cloud.contract.verifier.util.NamesUtil; import org.springframework.util.StringUtils; diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BlockBuilder.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BlockBuilder.java index c1a3791672..59063d0a89 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BlockBuilder.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BlockBuilder.java @@ -37,7 +37,7 @@ public class BlockBuilder { private String labelPrefix = ""; /** - * @param spacer - char used for spacing + * @param spacer char used for spacing. */ public BlockBuilder(String spacer) { this.spacer = spacer; @@ -45,7 +45,7 @@ public BlockBuilder(String spacer) { } /** - * Setup line ending + * Setup line ending. */ public BlockBuilder setupLineEnding(String lineEnding) { this.lineEnding = lineEnding; @@ -53,7 +53,7 @@ public BlockBuilder setupLineEnding(String lineEnding) { } /** - * Setup label prefix + * Setup label prefix. */ public BlockBuilder setupLabelPrefix(String labelPrefix) { this.labelPrefix = labelPrefix; @@ -65,14 +65,14 @@ public String getLineEnding() { } /** - * Adds indents to start a new block + * Adds indents to start a new block. */ public BlockBuilder appendWithLabelPrefix(String label) { return append(this.labelPrefix).append(label); } /** - * Adds indents to start a new block + * Adds indents to start a new block. */ public BlockBuilder startBlock() { indents++; @@ -80,7 +80,7 @@ public BlockBuilder startBlock() { } /** - * Ends block by removing indents + * Ends block by removing indents. */ public BlockBuilder endBlock() { indents--; @@ -88,7 +88,7 @@ public BlockBuilder endBlock() { } /** - * Creates a block and adds indents + * Creates a block and adds indents. */ public BlockBuilder indent() { startBlock().startBlock(); @@ -96,7 +96,7 @@ public BlockBuilder indent() { } /** - * Removes indents and closes the block + * Removes indents and closes the block. */ public BlockBuilder unindent() { endBlock().endBlock(); @@ -183,7 +183,7 @@ public BlockBuilder addAtTheEndIfEndsWithAChar(String toAdd) { } /** - * Adds the given text at the end of the line + * Adds the given text at the end of the line. * @return updated BlockBuilder */ public BlockBuilder addAtTheEnd(String toAdd) { @@ -229,8 +229,8 @@ private boolean aSpecialSign(String character, String toAdd) { } /** - * Updates the current text with the provided one - * @param contents - text to replace the current content with + * Updates the current text with the provided one. + * @param contents text to replace the current content with * @return updated Block Builder */ public BlockBuilder updateContents(String contents) { diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BodyAssertionLineCreator.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BodyAssertionLineCreator.java index e29001bce6..51f4029032 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BodyAssertionLineCreator.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BodyAssertionLineCreator.java @@ -53,7 +53,7 @@ void appendBodyAssertionLine(SingleContractMetadata metadata, String property, O /** * Builds the code that for the given {@code property} will compare it to the given - * Object {@code value} + * Object {@code value}. */ private String getResponseBodyPropertyComparisonString(SingleContractMetadata singleContractMetadata, String property, Object value) { @@ -75,7 +75,7 @@ else if (value instanceof DslProperty) { /** * Builds the code that for the given {@code property} will compare it to the given - * byte[] {@code value} + * byte[] {@code value}. */ private String getResponseBodyPropertyComparisonString(SingleContractMetadata singleContractMetadata, String property, FromFileProperty value) { @@ -88,7 +88,7 @@ private String getResponseBodyPropertyComparisonString(SingleContractMetadata si /** * Builds the code that for the given {@code property} will compare it to the given - * String {@code value} + * String {@code value}. */ private String getResponseBodyPropertyComparisonString(String property, String value) { return this.comparisonBuilder.assertThatUnescaped("responseBody" + property, value); @@ -96,7 +96,7 @@ private String getResponseBodyPropertyComparisonString(String property, String v /** * Builds the code that for the given {@code property} will match it to the given - * regular expression {@code value} + * regular expression {@code value}. */ private String getResponseBodyPropertyComparisonString(String property, Pattern value) { return this.comparisonBuilder.assertThat("responseBody" + property, value); @@ -104,7 +104,7 @@ private String getResponseBodyPropertyComparisonString(String property, Pattern /** * Builds the code that for the given {@code property} will match it to the given - * {@link ExecutionProperty} value + * {@link ExecutionProperty} value. */ private String getResponseBodyPropertyComparisonString(String property, ExecutionProperty value) { return value.insertValue("responseBody" + property); diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BodyMethodVisitor.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BodyMethodVisitor.java index 1f0b06fc3d..42c398c566 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BodyMethodVisitor.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BodyMethodVisitor.java @@ -29,9 +29,9 @@ interface BodyMethodVisitor { /** * Adds a starting body method block. E.g. //given: together with all indents - * @param blockBuilder - * @param label - * @return + * @param blockBuilder block builder to modify + * @param label block label to append + * @return updated block builder */ default BlockBuilder startBodyBlock(BlockBuilder blockBuilder, String label) { return blockBuilder.addIndentation().appendWithLabelPrefix(label).addEmptyLine().startBlock(); @@ -39,9 +39,9 @@ default BlockBuilder startBodyBlock(BlockBuilder blockBuilder, String label) { /** * Picks matching elements, visits them and applies indents. - * @param blockBuilder - * @param methodVisitors - * @param singleContractMetadata + * @param blockBuilder block builder to modify + * @param methodVisitors visitors to apply + * @param singleContractMetadata contract metadata */ default void indentedBodyBlock(BlockBuilder blockBuilder, List methodVisitors, SingleContractMetadata singleContractMetadata) { @@ -58,9 +58,9 @@ default void indentedBodyBlock(BlockBuilder blockBuilder, List filterVisitors(List methodVisitors, SingleContractMetadata singleContractMetadata) { @@ -72,9 +72,9 @@ default List filterVisitors(List methodV /** * Picks matching elements, visits them. Doesn't apply indents. Useful for the // * then: block where there is no method chaining. - * @param blockBuilder - * @param methodVisitors - * @param singleContractMetadata + * @param blockBuilder block builder to modify + * @param methodVisitors visitors to apply + * @param singleContractMetadata contract metadata */ default void bodyBlock(BlockBuilder blockBuilder, List methodVisitors, SingleContractMetadata singleContractMetadata) { @@ -89,9 +89,9 @@ default void bodyBlock(BlockBuilder blockBuilder, List /** * Executes logic for all the matching visitors. - * @param blockBuilder - * @param singleContractMetadata - * @param visitors + * @param blockBuilder block builder to modify + * @param singleContractMetadata contract metadata + * @param visitors visitors to apply */ default void applyVisitors(BlockBuilder blockBuilder, SingleContractMetadata singleContractMetadata, List visitors) { @@ -108,9 +108,9 @@ default void applyVisitors(BlockBuilder blockBuilder, SingleContractMetadata sin /** * Executes logic for all the matching visitors. - * @param blockBuilder - * @param singleContractMetadata - * @param visitors + * @param blockBuilder block builder to modify + * @param singleContractMetadata contract metadata + * @param visitors visitors to apply */ default void applyVisitorsWithEnding(BlockBuilder blockBuilder, SingleContractMetadata singleContractMetadata, List visitors) { diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ClassBodyBuilder.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ClassBodyBuilder.java index 96fb4cb25c..16ffbbb450 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ClassBodyBuilder.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ClassBodyBuilder.java @@ -28,7 +28,7 @@ * @author Marcin Grzejszczak * @since 2.2.0 */ -class ClassBodyBuilder { +final class ClassBodyBuilder { private List fields = new LinkedList<>(); diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ClassVerifier.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ClassVerifier.java index 6797119d57..ca0a182cd4 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ClassVerifier.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ClassVerifier.java @@ -1,3 +1,19 @@ +/* + * Copyright 2013-present 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 + * + * https://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 java.util.List; diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/CommunicationType.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/CommunicationType.java index e0d95680f8..93444d6e0e 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/CommunicationType.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/CommunicationType.java @@ -24,6 +24,13 @@ */ public enum CommunicationType { - REQUEST, RESPONSE, INPUT, OUTPUT; + /** Request communication type. */ + REQUEST, + /** Response communication type. */ + RESPONSE, + /** Input communication type. */ + INPUT, + /** Output communication type. */ + OUTPUT; } diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ComparisonBuilder.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ComparisonBuilder.java index 9b92f01c3b..ca649abc8d 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ComparisonBuilder.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ComparisonBuilder.java @@ -24,8 +24,14 @@ interface ComparisonBuilder { + /** + * Java HTTP comparison builder instance. + */ ComparisonBuilder JAVA_HTTP_INSTANCE = () -> RestAssuredBodyParser.INSTANCE; + /** + * Java messaging comparison builder instance. + */ ComparisonBuilder JAVA_MESSAGING_INSTANCE = () -> JavaMessagingBodyParser.INSTANCE; default String createComparison(Object headerValue) { diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ContentHelper.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ContentHelper.java index c5b8b00672..7eefcd4500 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ContentHelper.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/ContentHelper.java @@ -37,7 +37,7 @@ static String getTestSideForNonBodyValue(Object object) { /** * Depending on the object type extracts the test side values and combines them into a - * String representation + * String representation. */ private static String getTestSideValue(Object object) { if (object instanceof ExecutionProperty) { @@ -50,4 +50,7 @@ private static String quotedAndEscaped(String string) { return '"' + StringEscapeUtils.escapeJava(string) + '"'; } + private ContentHelper() { + } + } diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/CustomModeBodyParser.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/CustomModeBodyParser.java index c1a1584842..abcd26fc59 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/CustomModeBodyParser.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/CustomModeBodyParser.java @@ -18,6 +18,9 @@ interface CustomModeBodyParser extends BodyParser { + /** + * Shared custom-mode body parser instance. + */ BodyParser INSTANCE = new CustomModeBodyParser() { }; diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/GeneratedTestClassBuilder.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/GeneratedTestClassBuilder.java index 5fc9afd952..bdee51cfe0 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/GeneratedTestClassBuilder.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/GeneratedTestClassBuilder.java @@ -29,7 +29,7 @@ * @author Marcin Grzejszczak * @since 2.2.0 */ -class GeneratedTestClassBuilder { +final class GeneratedTestClassBuilder { private List metaData = new LinkedList<>(); diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/GroovyComparisonBuilder.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/GroovyComparisonBuilder.java index b893fd044a..bc42aae9b3 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/GroovyComparisonBuilder.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/GroovyComparisonBuilder.java @@ -22,10 +22,19 @@ interface GroovyComparisonBuilder extends ComparisonBuilder { + /** + * Spock HTTP comparison builder instance. + */ ComparisonBuilder SPOCK_HTTP_INSTANCE = (GroovyComparisonBuilder) () -> SpockRestAssuredBodyParser.INSTANCE; + /** + * JAX-RS HTTP comparison builder instance. + */ ComparisonBuilder JAXRS_HTTP_INSTANCE = (GroovyComparisonBuilder) () -> JaxRsBodyParser.INSTANCE; + /** + * Spock messaging comparison builder instance. + */ ComparisonBuilder SPOCK_MESSAGING_INSTANCE = (GroovyComparisonBuilder) () -> SpockMessagingBodyParser.INSTANCE; @Override diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JavaMessagingBodyParser.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JavaMessagingBodyParser.java index d33cc4e99b..532990e292 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JavaMessagingBodyParser.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JavaMessagingBodyParser.java @@ -18,6 +18,9 @@ interface JavaMessagingBodyParser extends MessagingBodyParser { + /** + * Shared Java messaging body parser instance. + */ BodyParser INSTANCE = new JavaMessagingBodyParser() { }; diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JavaTestGenerator.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JavaTestGenerator.java index cf15166145..d608cc14da 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JavaTestGenerator.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JavaTestGenerator.java @@ -22,7 +22,7 @@ import org.springframework.cloud.contract.verifier.file.ContractMetadata; /** - * Builds a single test for the given {@link ContractVerifierConfigProperties properties} + * Builds a single test for the given {@link ContractVerifierConfigProperties properties}. * * @since 1.1.0 */ @@ -109,4 +109,4 @@ SingleMethodBuilder singleMethodBuilder(BlockBuilder builder, GeneratedClassMeta // @formatter:on } -} \ No newline at end of file +} diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JaxRsBodyParser.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JaxRsBodyParser.java index 3c8a255f71..c5707779aa 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JaxRsBodyParser.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JaxRsBodyParser.java @@ -18,6 +18,9 @@ interface JaxRsBodyParser extends BodyParser { + /** + * Shared JAX-RS body parser instance. + */ JaxRsBodyParser INSTANCE = new JaxRsBodyParser() { }; diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JsonBodyVerificationBuilder.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JsonBodyVerificationBuilder.java index b596deed84..836e65f1cd 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JsonBodyVerificationBuilder.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JsonBodyVerificationBuilder.java @@ -334,7 +334,7 @@ private boolean textContainsJsonPathTemplate(String method) { } /** - * Appends to {@link BlockBuilder} parsing of the JSON Path document + * Appends to {@link BlockBuilder} parsing of the JSON Path document. */ private void appendJsonPath(BlockBuilder blockBuilder, String json) { blockBuilder.addLine("DocumentContext parsedJson = JsonPath.parse(" + json + ")"); diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/MethodAnnotations.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/MethodAnnotations.java index 7b86fd95c1..114fca2a48 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/MethodAnnotations.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/MethodAnnotations.java @@ -1,3 +1,19 @@ +/* + * Copyright 2013-present 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 + * + * https://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; interface MethodAnnotations extends MethodVisitor { diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/QueryParamsResolver.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/QueryParamsResolver.java index fb599e1dae..761f111752 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/QueryParamsResolver.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/QueryParamsResolver.java @@ -24,7 +24,7 @@ interface QueryParamsResolver { /** - * Converts the query parameter value into String + * Converts the query parameter value into String. */ default String resolveParamValue(Object value) { if (value instanceof QueryParameter) { diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/RestAssuredBodyParser.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/RestAssuredBodyParser.java index fe8acd6578..ee3c5eb8c3 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/RestAssuredBodyParser.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/RestAssuredBodyParser.java @@ -18,6 +18,9 @@ interface RestAssuredBodyParser extends BodyParser { + /** + * Shared Rest Assured body parser instance. + */ BodyParser INSTANCE = new RestAssuredBodyParser() { }; diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/SingleMethodBuilder.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/SingleMethodBuilder.java index f1d7f9fcf6..083d7fcc30 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/SingleMethodBuilder.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/SingleMethodBuilder.java @@ -34,7 +34,7 @@ * @author Marcin Grzejszczak * @since 2.2.0 */ -class SingleMethodBuilder { +final class SingleMethodBuilder { private static final Log log = LogFactory.getLog(SingleMethodBuilder.class); @@ -154,7 +154,7 @@ SingleMethodBuilder methodPostProcessor(MethodPostProcessor methodPostProcessor) } /** - * Mutates the {@link BlockBuilder} to generate a methodBuilder + * Mutates the {@link BlockBuilder} to generate a methodBuilder. * @return block builder with contents of a single methodBuilder */ BlockBuilder build() { diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/SingleTestGenerator.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/SingleTestGenerator.java index 547292675b..8761be8090 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/SingleTestGenerator.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/SingleTestGenerator.java @@ -43,10 +43,19 @@ String buildClass(ContractVerifierConfigProperties properties, Collection> query; /** - * List of path entries + * List of path entries. */ private final Path path; /** - * Map containing request headers + * Map containing request headers. */ private final Map> headers; /** - * Request body as it would be sent to the controller + * Request body as it would be sent to the controller. */ private final String body; /** - * Escaped request body that can be put into test + * Escaped request body that can be put into test. */ private final String escapedBody; diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilder.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilder.java index 17f0d16eb8..b1c5433273 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilder.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilder.java @@ -1,3 +1,19 @@ +/* + * Copyright 2013-present 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 + * + * https://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 java.util.Arrays; diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/handlebars/HandlebarsEscapeHelper.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/handlebars/HandlebarsEscapeHelper.java index 164cb1e807..488d274f47 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/handlebars/HandlebarsEscapeHelper.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/handlebars/HandlebarsEscapeHelper.java @@ -34,8 +34,14 @@ */ public class HandlebarsEscapeHelper implements Helper> { + /** + * Helper name. + */ public static final String NAME = "escapejsonbody"; + /** + * Request model key used in the template context. + */ public static final String REQUEST_MODEL_NAME = "request"; @Override diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/handlebars/HandlebarsJsonPathHelper.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/handlebars/HandlebarsJsonPathHelper.java index f185a368c6..99b3ed45c3 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/handlebars/HandlebarsJsonPathHelper.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/handlebars/HandlebarsJsonPathHelper.java @@ -36,8 +36,14 @@ */ public class HandlebarsJsonPathHelper implements Helper { + /** + * Helper name. + */ public static final String NAME = "jsonpath"; + /** + * Request model key used in the template context. + */ public static final String REQUEST_MODEL_NAME = "request"; @Override diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/DelegatingJsonVerifiable.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/DelegatingJsonVerifiable.java index 4d81cd49c1..fa7b559f32 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/DelegatingJsonVerifiable.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/DelegatingJsonVerifiable.java @@ -152,10 +152,14 @@ public MethodBufferingJsonVerifiable array() { } @Override - public JsonVerifiable elementWithIndex(int i) { - DelegatingJsonVerifiable verifiable = new DelegatingJsonVerifiable(this.delegate.elementWithIndex(i), + public MethodBufferingJsonVerifiable elementWithIndex(int i) { + JsonVerifiable delegateToUse = this.delegate; + if (delegateToUse.jsonPath().endsWith("[*]")) { + delegateToUse = delegateToUse.arrayField(); + } + DelegatingJsonVerifiable verifiable = new DelegatingJsonVerifiable(delegateToUse.elementWithIndex(i), this.methodsBuffer); - this.methodsBuffer.offer(".elementWithIndex(" + i + ")"); + verifiable.methodsBuffer.offer(".elementWithIndex(" + i + ")"); return verifiable; } diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/FinishedDelegatingJsonVerifiable.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/FinishedDelegatingJsonVerifiable.java index 83f755854a..d25e637ae5 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/FinishedDelegatingJsonVerifiable.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/FinishedDelegatingJsonVerifiable.java @@ -53,4 +53,10 @@ public String keyBeforeChecking() { return this.keyBeforeChecking; } + @Override + public String jsonPath() { + String jsonPath = this.delegate.jsonPath(); + return jsonPath.contains("@.null") ? this.keyBeforeChecking : jsonPath; + } + } diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonPathMatcherUtils.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonPathMatcherUtils.java new file mode 100644 index 0000000000..e7f19d3b53 --- /dev/null +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonPathMatcherUtils.java @@ -0,0 +1,333 @@ +/* + * Copyright 2013-present 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 + * + * https://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.util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.PathNotFoundException; +import groovy.json.JsonOutput; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +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.RegexProperty; + +/** + * Utility class for JSON path matching operations including path cleanup, comparison + * creation, and body matcher conversions. + * + * @author Marcin Grzejszczak + * @since 5.1.0 + */ +public final class JsonPathMatcherUtils { + + private static final Log log = LogFactory.getLog(JsonPathMatcherUtils.class); + + private static final Pattern ANY_ARRAY_NOTATION_IN_JSONPATH = Pattern.compile("\\[(.*?)\\]"); + + private JsonPathMatcherUtils() { + } + + /** + * 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 + */ + public static Object removeMatchingJsonPaths(Object json, BodyMatchers bodyMatchers) { + Object jsonCopy = cloneBody(json); + if (bodyMatchers == null || !bodyMatchers.hasMatchers()) { + return jsonCopy; + } + DocumentContext context = JsonPath.parse(jsonCopy); + List pathsToDelete = deleteMatchingPaths(context, bodyMatchers); + cleanupEmptyContainers(context, pathsToDelete); + return jsonCopy; + } + + /** + * Retrieves a value from JSON via json path. + * @param json - parsed JSON + * @param jsonPath - json path + * @return matching part of the json + */ + public static Object readElement(Object json, String jsonPath) { + return JsonPath.parse(json).read(jsonPath); + } + + /** + * For the given matcher converts it into a JSON path that checks the regex pattern or + * equality. + * @param bodyMatcher the body matcher + * @return JSON path that checks the regex for its last element + */ + public static String convertJsonPathAndRegexToAJsonPath(BodyMatcher bodyMatcher) { + return convertJsonPathAndRegexToAJsonPath(bodyMatcher, null); + } + + /** + * For the given matcher converts it into a JSON path that checks the regex pattern or + * equality. + * @param bodyMatcher the body matcher + * @param body the body to read from (required for EQUALITY matching) + * @return JSON path that checks the regex for its last element + */ + public static String convertJsonPathAndRegexToAJsonPath(BodyMatcher bodyMatcher, Object body) { + String path = bodyMatcher.path(); + Object value = bodyMatcher.value(); + if (value == null && bodyMatcher.matchingType() != MatchingType.EQUALITY + && bodyMatcher.matchingType() != MatchingType.TYPE) { + return path; + } + int lastDotIndex = findLastDotIndex(path); + String toLastDot = lastDotIndex == -1 ? "$" : path.substring(0, lastDotIndex); + String fromLastDot = path.substring(lastDotIndex + 1); + String propertyName = lastDotIndex == -1 ? "@" : "@." + fromLastDot; + String comparison = createComparison(propertyName, bodyMatcher, value, body); + return toLastDot + "[?(" + comparison + ")]"; + } + + /** + * Returns generated value if the value is a RegexProperty. + * @param value the value to check + * @return generated value or original value + */ + public static Object generatedValueIfNeeded(Object value) { + if (value instanceof RegexProperty) { + return ((RegexProperty) value).generateAndEscapeJavaStringIfNeeded(); + } + return value; + } + + // ========== Path Deletion and Cleanup ========== + + private static List deleteMatchingPaths(DocumentContext context, BodyMatchers bodyMatchers) { + List pathsToDelete = new ArrayList<>(); + for (BodyMatcher matcher : bodyMatchers.matchers()) { + String path = matcher.path(); + try { + Object entry = readPath(context, path); + if (entry != null) { + context.delete(path); + pathsToDelete.add(path); + } + } + catch (RuntimeException e) { + if (log.isTraceEnabled()) { + log.trace("Exception deleting path [" + path + "]", e); + } + } + } + Collections.sort(pathsToDelete, Collections.reverseOrder()); + return pathsToDelete; + } + + private static void cleanupEmptyContainers(DocumentContext context, List paths) { + for (String path : paths) { + removeTrailingContainers(path, context); + } + } + + private static boolean removeTrailingContainers(String matcherPath, DocumentContext context) { + try { + Matcher matcher = ANY_ARRAY_NOTATION_IN_JSONPATH.matcher(matcherPath); + boolean containsArray = matcher.find(); + String pathWithoutArray = containsArray + ? matcherPath.substring(0, matcherPath.lastIndexOf(lastMatch(matcher))) : matcherPath; + Object object = readPath(context, pathWithoutArray); + if (isIterable(object) && containsOnlyEmptyElements(object) && !isRootArray(matcherPath)) { + String pathToDelete = pathWithoutArray.equals("$") ? "$[*]" : pathWithoutArray; + if (pathToDelete.contains("..")) { + Object root = context.read("$"); + if (rootContainsOnlyEmpty(root)) { + context.delete("$[*]"); + } + return false; + } + context.delete(pathToDelete); + return removeTrailingContainers(pathToDelete, context); + } + int lastDot = matcherPath.lastIndexOf("."); + if (lastDot == -1) { + return false; + } + String parent = matcherPath.substring(0, lastDot); + Object parentObject = context.read(parent); + if (isIterable(parentObject) && containsOnlyEmptyElements(parentObject) && !parent.equals("$")) { + context.delete(parent); + return removeTrailingContainers(parent, context); + } + return false; + } + catch (RuntimeException e) { + if (log.isTraceEnabled()) { + log.trace("Exception removing trailing containers for [" + matcherPath + "]", e); + } + return false; + } + } + + private static String lastMatch(Matcher matcher) { + List matches = new ArrayList<>(); + matches.add(matcher.group()); + while (matcher.find()) { + matches.add(matcher.group()); + } + return matches.get(matches.size() - 1); + } + + private static Object readPath(DocumentContext context, String path) { + try { + return context.read(path); + } + catch (Exception e) { + return null; + } + } + + private static boolean isIterable(Object object) { + return object instanceof Iterable || object instanceof Map; + } + + private static boolean isRootArray(String path) { + return "$[*]".equals(path); + } + + @SuppressWarnings("unchecked") + private static boolean rootContainsOnlyEmpty(Object root) { + if (!(root instanceof Iterable)) { + return false; + } + for (Object item : (Iterable) root) { + if (!containsOnlyEmptyElements(item)) { + return false; + } + } + return true; + } + + @SuppressWarnings("unchecked") + private static boolean containsOnlyEmptyElements(Object object) { + if (object instanceof Map) { + Map map = (Map) object; + if (map.isEmpty()) { + return true; + } + for (Object item : map.values()) { + if (item instanceof Map && !((Map) item).isEmpty()) { + return false; + } + if (item instanceof List && !((List) item).isEmpty()) { + return false; + } + if (!(item instanceof Map) && !(item instanceof List)) { + return false; + } + } + return true; + } + if (!(object instanceof Iterable)) { + return false; + } + for (Object item : (Iterable) object) { + if (item instanceof Map && !((Map) item).isEmpty()) { + return false; + } + if (item instanceof List && !((List) item).isEmpty()) { + return false; + } + if (!(item instanceof Map) && !(item instanceof List)) { + return false; + } + } + return true; + } + + static Object cloneBody(Object object) { + return CloneUtils.clone(object); + } + + // ========== Comparison Creation ========== + + private static int findLastDotIndex(String path) { + if (path.contains("['")) { + int bracketIndex = path.lastIndexOf("['"); + return path.substring(0, bracketIndex).lastIndexOf("."); + } + return path.lastIndexOf("."); + } + + private static String createComparison(String propertyName, BodyMatcher bodyMatcher, Object value, Object body) { + return switch (bodyMatcher.matchingType()) { + case EQUALITY -> createEqualityComparison(propertyName, bodyMatcher, body); + case TYPE -> createTypeComparison(propertyName, bodyMatcher); + default -> createRegexComparison(propertyName, value); + }; + } + + private static String createEqualityComparison(String propertyName, BodyMatcher bodyMatcher, Object body) { + if (body == null) { + throw new IllegalStateException("Body hasn't been passed"); + } + try { + Object convertedBody = MapConverter.transformValues(body, JsonPathMatcherUtils::generatedValueIfNeeded); + Object retrievedValue = JsonPath.parse(convertedBody).read(bodyMatcher.path()); + String wrappedValue = retrievedValue instanceof Number ? retrievedValue.toString() + : "'" + retrievedValue.toString() + "'"; + return propertyName + " == " + wrappedValue; + } + catch (PathNotFoundException e) { + throw new IllegalStateException( + "Value [" + bodyMatcher.path() + "] not found in JSON [" + JsonOutput.toJson(body) + "]", e); + } + } + + private static String createTypeComparison(String propertyName, BodyMatcher bodyMatcher) { + Integer min = bodyMatcher.minTypeOccurrence(); + Integer max = bodyMatcher.maxTypeOccurrence(); + StringBuilder result = new StringBuilder(); + if (min != null) { + result.append(propertyName).append(".size() >= ").append(min); + } + if (max != null) { + if (!result.isEmpty()) { + result.append(" && "); + } + result.append(propertyName).append(".size() <= ").append(max); + } + return result.toString(); + } + + private static String createRegexComparison(String propertyName, Object value) { + String convertedValue = value.toString(); + if (!convertedValue.contains("\\/")) { + convertedValue = convertedValue.replace("/", "\\\\/"); + } + return propertyName + " =~ /(" + convertedValue + ")/"; + } + +} diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonPathTraverser.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonPathTraverser.java new file mode 100644 index 0000000000..6c6d406631 --- /dev/null +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonPathTraverser.java @@ -0,0 +1,365 @@ +/* + * Copyright 2013-present 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 + * + * https://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.util; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.regex.Pattern; + +import groovy.lang.GString; + +import org.springframework.cloud.contract.spec.internal.ExecutionProperty; +import org.springframework.cloud.contract.spec.internal.OptionalProperty; + +/** + * Traverses JSON structures and builds JSON path assertions. Handles both ordered (exact + * index) and unordered (any matching) array verification. + * + * @author Marcin Grzejszczak + * @since 5.1.0 + */ +class JsonPathTraverser { + + private final boolean useOrderedArrayVerification; + + private final Function parsingFunction; + + JsonPathTraverser(boolean useOrderedArrayVerification, Function parsingFunction) { + this.useOrderedArrayVerification = useOrderedArrayVerification; + this.parsingFunction = parsingFunction; + } + + /** + * Traverses the JSON starting from the root key and collects all verifiable paths. + * @param json the JSON to traverse + * @param rootKey the root verifiable key + * @param collector collects finished verifiable keys + */ + void traverse(Object json, MethodBufferingJsonVerifiable rootKey, + Consumer collector) { + processValue(rootKey, json, collector); + } + + @SuppressWarnings("unchecked") + private Object processValue(MethodBufferingJsonVerifiable key, Object value, + Consumer collector) { + value = ContentUtils.returnParsedObject(value); + if (value instanceof String s && !s.isEmpty()) { + return processString(key, s, collector); + } + if (value instanceof Map) { + return processMap(key, (Map) value, collector); + } + if (value instanceof List) { + return processList(key, (List) value, collector); + } + if (key.isIteratingOverArray()) { + processValue(key.arrayField().contains(ContentUtils.returnParsedObject(value)), + ContentUtils.returnParsedObject(value), collector); + } + return emitValue(collector, key, value); + } + + private Object processString(MethodBufferingJsonVerifiable key, String value, + Consumer collector) { + try { + Object parsed = this.parsingFunction.apply(value); + if (parsed instanceof Map) { + return processMap(key, castToMap(parsed), collector); + } + } + catch (Exception ignore) { + // Not JSON, treat as regular string + } + return emitValue(collector, key, value); + } + + @SuppressWarnings("unchecked") + private Object processMap(MethodBufferingJsonVerifiable key, Map map, + Consumer collector) { + if (map.isEmpty()) { + return emitValue(collector, key.isEmpty(), map); + } + if (isSimpleEntryMap(map)) { + return convertMapEntries(key, map, collector); + } + return convertMapEntries(key, map, collector); + } + + private Object processList(MethodBufferingJsonVerifiable key, List list, + Consumer collector) { + + if (list.isEmpty()) { + return emitValue(collector, key, list); + } + + boolean isPrimitiveList = listContainsOnlyPrimitives(list); + + if (isPrimitiveList) { + addSizeCheckIfEnabled(key, list, collector); + return processPrimitiveList(key, list, collector); + } + + return processComplexList(key, list, collector); + } + + private Object processPrimitiveList(MethodBufferingJsonVerifiable key, List list, + Consumer collector) { + + if (this.useOrderedArrayVerification) { + MethodBufferingJsonVerifiable indexedBase = isRootElement(key) ? key.array() : key; + for (int i = 0; i < list.size(); i++) { + Object element = ContentUtils.returnParsedObject(list.get(i)); + MethodBufferingJsonVerifiable indexedKey = indexedBase.elementWithIndex(i); + processValue(valueToAsserter(indexedKey, element), element, collector); + } + } + else { + MethodBufferingJsonVerifiable arrayKey = key.arrayField(); + for (Object item : list) { + Object element = ContentUtils.returnParsedObject(item); + processValue(valueToAsserter(arrayKey, element), element, collector); + } + } + return list; + } + + private Object processComplexList(MethodBufferingJsonVerifiable key, List list, + Consumer collector) { + + addSizeCheckIfEnabled(key, list, collector); + + if (this.useOrderedArrayVerification) { + MethodBufferingJsonVerifiable indexedBase = isRootElement(key) ? key.array() : key; + for (int i = 0; i < list.size(); i++) { + Object element = ContentUtils.returnParsedObject(list.get(i)); + MethodBufferingJsonVerifiable indexedKey = indexedBase.elementWithIndex(i); + processValue(createListElementAsserter(indexedKey, element), element, collector); + } + } + else { + MethodBufferingJsonVerifiable arrayKey = createArrayAsserter(key, list); + for (Object element : list) { + Object parsed = ContentUtils.returnParsedObject(element); + processValue(createListElementAsserter(arrayKey, parsed), parsed, collector); + } + } + return list; + } + + // ========== Map Entry Processing ========== + + private Map convertMapEntries(MethodBufferingJsonVerifiable parentKey, Map map, + Consumer collector) { + + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + Object entryKey = entry.getKey(); + Object value = ContentUtils.returnParsedObject(entry.getValue()); + MethodBufferingJsonVerifiable verifiable = createKeyVerifiable(parentKey, entryKey, value); + result.put(entry.getKey(), processValue(verifiable, value, collector)); + } + return result; + } + + private MethodBufferingJsonVerifiable createKeyVerifiable(MethodBufferingJsonVerifiable parentKey, Object entryKey, + Object value) { + if (value instanceof List) { + return createListFieldVerifiable((List) value, entryKey, parentKey); + } + if (value instanceof Map) { + return parentKey.field(new ShouldTraverse(entryKey)); + } + if (this.useOrderedArrayVerification && parentKey.isIteratingOverArray()) { + // Use ShouldTraverse to ensure field() is used instead of contains() + // This is needed because after elementWithIndex, isIteratingOverArray() is + // true + // which would otherwise cause contains() to be used + return parentKey.field(new ShouldTraverse(entryKey)); + } + return valueToAsserter(parentKey.field(entryKey), value); + } + + private MethodBufferingJsonVerifiable createListFieldVerifiable(List list, Object entryKey, + MethodBufferingJsonVerifiable parentKey) { + if (list.isEmpty()) { + return parentKey.array(entryKey).isEmpty(); + } + if (listContainsOnlyPrimitives(list)) { + return this.useOrderedArrayVerification ? parentKey.array(entryKey) : parentKey.arrayField(entryKey); + } + return parentKey.array(entryKey); + } + + // ========== Asserter Creation ========== + + private MethodBufferingJsonVerifiable createArrayAsserter(MethodBufferingJsonVerifiable key, List list) { + if (key.isIteratingOverNamelessArray()) { + return key.array(); + } + if (key.isIteratingOverArray() && isListOfLists(list)) { + boolean allPrimitive = list.stream() + .filter(item -> item instanceof List) + .allMatch(item -> listContainsOnlyPrimitives((List) item)); + return allPrimitive ? key.iterationPassingArray() : key.array(); + } + if (key.isIteratingOverArray()) { + return key.iterationPassingArray(); + } + return key; + } + + private MethodBufferingJsonVerifiable createListElementAsserter(MethodBufferingJsonVerifiable verifiable, + Object element) { + if (verifiable.isAssertingAValueInArray()) { + Object parsed = ContentUtils.returnParsedObject(element); + // Don't call contains on Map or List elements - let processValue recurse into + // them + if (parsed instanceof Map || parsed instanceof List) { + return verifiable; + } + if (parsed instanceof Pattern) { + return verifiable.matches(((Pattern) parsed).pattern()); + } + return verifiable.contains(parsed); + } + if (element instanceof List && listContainsOnlyPrimitives((List) element)) { + if (this.useOrderedArrayVerification) { + return verifiable; + } + return verifiable.array(); + } + return verifiable; + } + + MethodBufferingJsonVerifiable valueToAsserter(MethodBufferingJsonVerifiable key, Object value) { + Object converted = ContentUtils.returnParsedObject(value); + + if (key instanceof FinishedDelegatingJsonVerifiable) { + return key; + } + if (converted instanceof Pattern) { + return key.matches(((Pattern) converted).pattern()); + } + if (converted instanceof OptionalProperty) { + return key.matches(((OptionalProperty) converted).optionalPattern()); + } + if (converted instanceof GString) { + return key.matches(RegexpBuilders.buildGStringRegexpForTestSide((GString) converted)); + } + if (converted instanceof ExecutionProperty) { + return key; + } + // Use specific overloads for Number and Boolean to preserve types + if (converted instanceof Number) { + return key.isEqualTo((Number) converted); + } + if (converted instanceof Boolean) { + return key.isEqualTo((Boolean) converted); + } + return key.isEqualTo(converted); + } + + // ========== Size Verification ========== + + private void addSizeCheckIfEnabled(MethodBufferingJsonVerifiable key, List list, + Consumer collector) { + if (!this.useOrderedArrayVerification) { + return; + } + if (!listContainsOnlyPrimitives(list)) { + return; + } + if ((isRootElement(key) || key.assertsConcreteValue()) && !list.isEmpty()) { + collector.accept((MethodBufferingJsonVerifiable) key.hasSize(list.size())); + } + } + + // ========== Emit Value ========== + + private Object emitValue(Consumer collector, MethodBufferingJsonVerifiable key, + Object value) { + boolean isCollection = value instanceof List || value instanceof Map; + + if (key.isAssertingAValueInArray() && !isCollection) { + collector.accept(valueToAsserter(key, value)); + } + else if (isCollection || key instanceof FinishedDelegatingJsonVerifiable) { + // For collections or already-finished keys, emit as-is + collector.accept(key); + } + else { + // For primitive values with non-finished keys, add equality assertion + collector.accept(valueToAsserter(key, value)); + } + return value; + } + + // ========== Helper Methods ========== + + private boolean isRootElement(MethodBufferingJsonVerifiable key) { + return "$".equals(key.jsonPath()); + } + + private boolean listContainsOnlyPrimitives(List list) { + if (list.isEmpty()) { + return false; + } + for (Object element : list) { + if (element == null || !isPrimitive(element)) { + return false; + } + } + return true; + } + + private boolean isPrimitive(Object obj) { + Class clazz = obj.getClass(); + return String.class.isAssignableFrom(clazz) || Number.class.isAssignableFrom(clazz) + || Boolean.class.isAssignableFrom(clazz); + } + + private boolean isSimpleEntryMap(Map map) { + if (map.isEmpty()) { + return false; + } + for (Object value : map.values()) { + if (value == null || !isPrimitive(value)) { + return false; + } + } + return true; + } + + private boolean isListOfLists(List list) { + for (Object item : list) { + if (!(item instanceof List)) { + return false; + } + } + return true; + } + + @SuppressWarnings("unchecked") + private Map castToMap(Object obj) { + return (Map) obj; + } + +} diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverter.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverter.java new file mode 100644 index 0000000000..d6d030baa8 --- /dev/null +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverter.java @@ -0,0 +1,246 @@ +/* + * Copyright 2013-present 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 + * + * https://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.util; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import com.toomuchcoding.jsonassert.JsonAssertion; +import groovy.json.JsonOutput; +import groovy.lang.GString; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.cloud.contract.spec.internal.BodyMatcher; +import org.springframework.cloud.contract.spec.internal.BodyMatchers; + +/** + * Converts JSON to a set of JSON paths together with methods needed to be called to build + * them for test assertions. + * + *

+ * When {@code spring.cloud.contract.verifier.assert.size} is set to {@code true}, array + * elements are verified in order using exact indices instead of wildcard matching. + *

+ * + * @author Marcin Grzejszczak + * @author Tim Ysewyn + * @author Olga Maciaszek-Sharma + * @see JsonPathTraverser + * @see JsonPathMatcherUtils + */ +public class JsonToJsonPathsConverter { + + private static final Log log = LogFactory.getLog(JsonToJsonPathsConverter.class); + + /** + * System property to enable size and order assertions on arrays. + */ + private static final String SIZE_ASSERTION_SYSTEM_PROP = "spring.cloud.contract.verifier.assert.size"; + + private static final boolean SERVER_SIDE = false; + + private static final boolean CLIENT_SIDE = true; + + private final boolean assertJsonSize; + + public JsonToJsonPathsConverter(boolean assertJsonSize) { + this.assertJsonSize = assertJsonSize; + } + + public JsonToJsonPathsConverter() { + this(false); + } + + // ========== Public API - Test Side ========== + + /** + * Transforms JSON to JSON paths with test (server) side values. + * @param json the JSON to transform + * @return set of JSON paths with assertions + */ + public JsonPaths transformToJsonPathWithTestsSideValues(Object json) { + return transform(json, SERVER_SIDE, MapConverter.JSON_PARSING_FUNCTION, false); + } + + /** + * Transforms JSON to JSON paths with test (server) side values. + * @param json the JSON to transform + * @param includeEmptyCheck whether to include empty check + * @return set of JSON paths with assertions + */ + public JsonPaths transformToJsonPathWithTestsSideValues(Object json, boolean includeEmptyCheck) { + return transform(json, SERVER_SIDE, MapConverter.JSON_PARSING_FUNCTION, includeEmptyCheck); + } + + /** + * Transforms JSON to JSON paths with test (server) side values. + * @param json the JSON to transform + * @param parsingFunction function to parse JSON strings + * @param includeEmptyCheck whether to include empty check + * @return set of JSON paths with assertions + */ + public JsonPaths transformToJsonPathWithTestsSideValues(Object json, Function parsingFunction, + boolean includeEmptyCheck) { + return transform(json, SERVER_SIDE, parsingFunction, includeEmptyCheck); + } + + // ========== Public API - Stub Side ========== + + /** + * Transforms JSON to JSON paths with stub (client) side values. + * @param json the JSON to transform + * @return set of JSON paths with assertions + */ + public JsonPaths transformToJsonPathWithStubsSideValues(Object json) { + return transform(json, CLIENT_SIDE, MapConverter.JSON_PARSING_FUNCTION, false); + } + + /** + * Transforms JSON to JSON paths with stub (client) side values. + * @param json the JSON to transform + * @param includeEmptyCheck whether to include empty check + * @return set of JSON paths with assertions + */ + public JsonPaths transformToJsonPathWithStubsSideValues(Object json, boolean includeEmptyCheck) { + return transform(json, CLIENT_SIDE, MapConverter.JSON_PARSING_FUNCTION, includeEmptyCheck); + } + + /** + * Transforms JSON to JSON paths with stub side values without array size check. + * @param json the JSON to transform + * @return set of JSON paths with assertions + */ + public static JsonPaths transformToJsonPathWithStubsSideValuesAndNoArraySizeCheck(Object json) { + return new JsonToJsonPathsConverter().transform(json, CLIENT_SIDE, MapConverter.JSON_PARSING_FUNCTION, false); + } + + // ========== Delegating Static Methods ========== + + /** + * Removes JSON path matching entries from the parsed JSON. + * @param json parsed JSON + * @param bodyMatchers matchers to remove + * @return json with removed entries + * @see JsonPathMatcherUtils#removeMatchingJsonPaths(Object, BodyMatchers) + */ + public static Object removeMatchingJsonPaths(Object json, BodyMatchers bodyMatchers) { + return JsonPathMatcherUtils.removeMatchingJsonPaths(json, bodyMatchers); + } + + /** + * Retrieves a value from JSON via json path. + * @param json parsed JSON + * @param jsonPath path to read + * @return matching part of the json + * @see JsonPathMatcherUtils#readElement(Object, String) + */ + public static Object readElement(Object json, String jsonPath) { + return JsonPathMatcherUtils.readElement(json, jsonPath); + } + + /** + * Converts a BodyMatcher to a JSON path with regex/equality check. + * @param bodyMatcher the body matcher + * @return JSON path with condition + * @see JsonPathMatcherUtils#convertJsonPathAndRegexToAJsonPath(BodyMatcher) + */ + public static String convertJsonPathAndRegexToAJsonPath(BodyMatcher bodyMatcher) { + return JsonPathMatcherUtils.convertJsonPathAndRegexToAJsonPath(bodyMatcher); + } + + /** + * Converts a BodyMatcher to a JSON path with regex/equality check. + * @param bodyMatcher the body matcher + * @param body the body to read from (required for EQUALITY matching) + * @return JSON path with condition + * @see JsonPathMatcherUtils#convertJsonPathAndRegexToAJsonPath(BodyMatcher, Object) + */ + public static String convertJsonPathAndRegexToAJsonPath(BodyMatcher bodyMatcher, Object body) { + return JsonPathMatcherUtils.convertJsonPathAndRegexToAJsonPath(bodyMatcher, body); + } + + /** + * Returns generated value if the value is a RegexProperty. + * @param value the value to check + * @return generated value or original value + * @see JsonPathMatcherUtils#generatedValueIfNeeded(Object) + */ + public static Object generatedValueIfNeeded(Object value) { + return JsonPathMatcherUtils.generatedValueIfNeeded(value); + } + + // ========== Main Transformation Logic ========== + + private JsonPaths transform(Object json, boolean clientSide, Function parsingFunction, + boolean includeEmptyCheck) { + if (json == null || (isEmptyJson(json) && !includeEmptyCheck)) { + return new JsonPaths(); + } + + Object convertedJson = MapConverter.getClientOrServerSideValues(json, clientSide, parsingFunction); + Object jsonWithPatterns = ContentUtils.convertDslPropsToTemporaryRegexPatterns(convertedJson, parsingFunction); + + MethodBufferingJsonVerifiable rootVerifiable = new DelegatingJsonVerifiable( + JsonAssertion.assertThat(JsonOutput.toJson(jsonWithPatterns)).withoutThrowingException()); + + JsonPaths pathsAndValues = new JsonPaths(); + + if (isRootElement(rootVerifiable) && isEmptyJson(json)) { + pathsAndValues.add(rootVerifiable.isEmpty()); + return pathsAndValues; + } + + boolean useOrderedVerification = shouldUseOrderedVerification(); + JsonPathTraverser traverser = new JsonPathTraverser(useOrderedVerification, parsingFunction); + traverser.traverse(jsonWithPatterns, rootVerifiable, pathsAndValues::add); + + return pathsAndValues; + } + + // ========== Helper Methods ========== + + private boolean shouldUseOrderedVerification() { + String systemProp = System.getProperty(SIZE_ASSERTION_SYSTEM_PROP); + return (systemProp != null && Boolean.parseBoolean(systemProp)) || this.assertJsonSize; + } + + private boolean isRootElement(MethodBufferingJsonVerifiable key) { + return "$".equals(key.jsonPath()); + } + + private boolean isEmptyJson(Object json) { + if (json == null) { + return true; + } + if (json instanceof String) { + return ((String) json).isEmpty(); + } + if (json instanceof GString) { + return ((GString) json).toString().isEmpty(); + } + if (json instanceof Map) { + return ((Map) json).isEmpty(); + } + if (json instanceof List) { + return ((List) json).isEmpty(); + } + return false; + } + +} diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/MethodBufferingJsonVerifiable.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/MethodBufferingJsonVerifiable.java index 2acf86ba12..dcba59dd57 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/MethodBufferingJsonVerifiable.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/MethodBufferingJsonVerifiable.java @@ -73,6 +73,9 @@ public interface MethodBufferingJsonVerifiable extends JsonVerifiable, MethodBuf @Override MethodBufferingJsonVerifiable value(); + @Override + MethodBufferingJsonVerifiable elementWithIndex(int index); + String keyBeforeChecking(); Object valueBeforeChecking(); diff --git a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/JaxRsClientMethodBuilderSpec.groovy b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/JaxRsClientMethodBuilderSpec.groovy index 64b62daace..1ac41cfab4 100644 --- a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/JaxRsClientMethodBuilderSpec.groovy +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/JaxRsClientMethodBuilderSpec.groovy @@ -245,8 +245,8 @@ class JaxRsClientMethodBuilderSpec extends Specification implements WireMockStub String test = singleTestGenerator(contractDsl) then: test.contains("""assertThatJson(parsedJson).field("['property1']").isEqualTo("a")""") - test.contains("""assertThatJson(parsedJson).array("['property2']").contains("['a']").isEqualTo("sth")""") - test.contains("""assertThatJson(parsedJson).array("['property2']").contains("['b']").isEqualTo("sthElse")""") + test.contains("""assertThatJson(parsedJson).array("['property2']").elementWithIndex(0).field("['a']").isEqualTo("sth")""") + test.contains("""assertThatJson(parsedJson).array("['property2']").elementWithIndex(1).field("['b']").isEqualTo("sthElse")""") and: stubMappingIsValidWireMockStub(new WireMockStubStrategy("Test", new ContractMetadata(null, false, 0, null, contractDsl), contractDsl).toWireMockClientStub()) and: @@ -287,9 +287,8 @@ class JaxRsClientMethodBuilderSpec extends Specification implements WireMockStub String test = singleTestGenerator(contractDsl) then: test.contains("""assertThatJson(parsedJson).field("['property1']").isEqualTo("a")""") - test.contains("""assertThatJson(parsedJson).array("['property2']").contains("['a']").isEqualTo("sth")""") - test.contains("""assertThatJson(parsedJson).array("['property2']").hasSize(2)""") - test.contains("""assertThatJson(parsedJson).array("['property2']").contains("['b']").isEqualTo("sthElse")""") + test.contains("""assertThatJson(parsedJson).array("['property2']").elementWithIndex(0).field("['a']").isEqualTo("sth")""") + test.contains("""assertThatJson(parsedJson).array("['property2']").elementWithIndex(1).field("['b']").isEqualTo("sthElse")""") and: stubMappingIsValidWireMockStub(new WireMockStubStrategy("Test", new ContractMetadata(null, false, 0, null, contractDsl), contractDsl).toWireMockClientStub()) and: @@ -394,8 +393,8 @@ class JaxRsClientMethodBuilderSpec extends Specification implements WireMockStub when: String test = singleTestGenerator(contractDsl) then: - test.contains("""assertThatJson(parsedJson).array().contains("['property1']").isEqualTo("a")""") - test.contains("""assertThatJson(parsedJson).array().contains("['property2']").isEqualTo("b")""") + test.contains("""assertThatJson(parsedJson).array().elementWithIndex(0).field("['property1']").isEqualTo("a")""") + test.contains("""assertThatJson(parsedJson).array().elementWithIndex(1).field("['property2']").isEqualTo("b")""") and: stubMappingIsValidWireMockStub(contractDsl) and: @@ -431,8 +430,8 @@ class JaxRsClientMethodBuilderSpec extends Specification implements WireMockStub when: String test = singleTestGenerator(contractDsl) then: - test.contains("""assertThatJson(parsedJson).array("['property1']").contains("['property2']").isEqualTo("test1")""") - test.contains("""assertThatJson(parsedJson).array("['property1']").contains("['property3']").isEqualTo("test2")""") + test.contains("""assertThatJson(parsedJson).array("['property1']").elementWithIndex(0).field("['property2']").isEqualTo("test1")""") + test.contains("""assertThatJson(parsedJson).array("['property1']").elementWithIndex(1).field("['property3']").isEqualTo("test2")""") and: stubMappingIsValidWireMockStub(contractDsl) and: diff --git a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/SpringTestMethodBodyBuildersSpec.groovy b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/SpringTestMethodBodyBuildersSpec.groovy index 38e0878be5..7a5c65a01a 100644 --- a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/SpringTestMethodBodyBuildersSpec.groovy +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/SpringTestMethodBodyBuildersSpec.groovy @@ -312,8 +312,8 @@ class SpringTestMethodBodyBuildersSpec extends Specification implements WireMock String test = singleTestGenerator(contractDsl) then: test.contains("""assertThatJson(parsedJson).field("['property1']").isEqualTo("a")""") - test.contains("""assertThatJson(parsedJson).array("['property2']").contains("['a']").isEqualTo("sth")""") - test.contains("""assertThatJson(parsedJson).array("['property2']").contains("['b']").isEqualTo("sthElse")""") + test.contains("""assertThatJson(parsedJson).array("['property2']").elementWithIndex(0).field("['a']").isEqualTo("sth")""") + test.contains("""assertThatJson(parsedJson).array("['property2']").elementWithIndex(1).field("['b']").isEqualTo("sthElse")""") and: stubMappingIsValidWireMockStub(contractDsl) and: @@ -352,9 +352,8 @@ class SpringTestMethodBodyBuildersSpec extends Specification implements WireMock String test = singleTestGenerator(contractDsl) then: test.contains("""assertThatJson(parsedJson).field("['property1']").isEqualTo("a")""") - test.contains("""assertThatJson(parsedJson).array("['property2']").contains("['a']").isEqualTo("sth")""") - test.contains("""assertThatJson(parsedJson).array("['property2']").hasSize(2)""") - test.contains("""assertThatJson(parsedJson).array("['property2']").contains("['b']").isEqualTo("sthElse")""") + test.contains("""assertThatJson(parsedJson).array("['property2']").elementWithIndex(0).field("['a']").isEqualTo("sth")""") + test.contains("""assertThatJson(parsedJson).array("['property2']").elementWithIndex(1).field("['b']").isEqualTo("sthElse")""") and: stubMappingIsValidWireMockStub(contractDsl) and: @@ -495,8 +494,8 @@ class SpringTestMethodBodyBuildersSpec extends Specification implements WireMock when: String test = singleTestGenerator(contractDsl) then: - test.contains("""assertThatJson(parsedJson).array().contains("['property2']").isEqualTo("b")""") - test.contains("""assertThatJson(parsedJson).array().contains("['property1']").isEqualTo("a")""") + test.contains("""assertThatJson(parsedJson).array().elementWithIndex(0).field("['property1']").isEqualTo("a")""") + test.contains("""assertThatJson(parsedJson).array().elementWithIndex(1).field("['property2']").isEqualTo("b")""") and: stubMappingIsValidWireMockStub(contractDsl) and: @@ -530,8 +529,8 @@ class SpringTestMethodBodyBuildersSpec extends Specification implements WireMock when: String test = singleTestGenerator(contractDsl) then: - test.contains("""assertThatJson(parsedJson).array("['property1']").contains("['property2']").isEqualTo("test1")""") - test.contains("""assertThatJson(parsedJson).array("['property1']").contains("['property3']").isEqualTo("test2")""") + test.contains("""assertThatJson(parsedJson).array("['property1']").elementWithIndex(0).field("['property2']").isEqualTo("test1")""") + test.contains("""assertThatJson(parsedJson).array("['property1']").elementWithIndex(1).field("['property3']").isEqualTo("test2")""") and: stubMappingIsValidWireMockStub(contractDsl) and: @@ -1053,8 +1052,8 @@ class SpringTestMethodBodyBuildersSpec extends Specification implements WireMock when: String test = singleTestGenerator(contractDsl) then: - test.contains("""assertThatJson(parsedJson).array("['errors']").contains("['property']").isEqualTo("bank_account_number")""") - test.contains("""assertThatJson(parsedJson).array("['errors']").contains("['message']").isEqualTo("incorrect_format")""") + test.contains("""assertThatJson(parsedJson).array("['errors']").elementWithIndex(0).field("['property']").isEqualTo("bank_account_number")""") + test.contains("""assertThatJson(parsedJson).array("['errors']").elementWithIndex(0).field("['message']").isEqualTo("incorrect_format")""") and: stubMappingIsValidWireMockStub(contractDsl) and: @@ -1738,7 +1737,7 @@ World.'''""" when: String test = singleTestGenerator(contractDsl) then: - test.contains('''assertThatJson(parsedJson).array("[\'authorities']").arrayField().matches("^[a-zA-Z0-9_\\\\- ]+\\$").value()''') + test.contains('''assertThatJson(parsedJson).array("[\'authorities']").elementWithIndex(0).matches("^[a-zA-Z0-9_\\\\- ]+\\$")''') and: SyntaxChecker.tryToCompileGroovy("spock", test) } @@ -1770,7 +1769,7 @@ World.'''""" when: String test = singleTestGenerator(contractDsl) then: - test.contains('''assertThatJson(parsedJson).array("[\'authorities']").arrayField().matches("^[a-zA-Z0-9_\\\\- ]+$").value()''') + test.contains('''assertThatJson(parsedJson).array("[\'authorities']").elementWithIndex(0).matches("^[a-zA-Z0-9_\\\\- ]+$")''') and: SyntaxChecker.tryToCompileJava("mockmvc", test) } @@ -1831,7 +1830,8 @@ World.'''""" when: String test = singleTestGenerator(contractDsl) then: - test.contains('assertThatJson(parsedJson).array().contains("[\'id\']").matches("[0-9]+")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(0).field("[\'id\']").matches("[0-9]+")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(1).field("[\'id\']").matches("[0-9]+")') and: SyntaxChecker.tryToCompileGroovy("mockmvc", test) } @@ -1856,11 +1856,11 @@ World.'''""" when: String test = singleTestGenerator(contractDsl) then: - test.contains('assertThatJson(parsedJson).arrayField().contains("Java8").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("Spring").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("Java").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("Stream").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("SpringBoot").value()') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(0).isEqualTo("Java")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(1).isEqualTo("Java8")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(2).isEqualTo("Spring")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(3).isEqualTo("SpringBoot")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(4).isEqualTo("Stream")') and: SyntaxChecker.tryToCompile(methodBuilderName, test) where: @@ -1894,11 +1894,11 @@ World.'''""" String test = singleTestGenerator(contractDsl) then: test.contains('assertThatJson(parsedJson).hasSize(5)') - test.contains('assertThatJson(parsedJson).arrayField().contains("Java8").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("Spring").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("Java").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("Stream").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("SpringBoot").value()') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(0).isEqualTo("Java")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(1).isEqualTo("Java8")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(2).isEqualTo("Spring")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(3).isEqualTo("SpringBoot")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(4).isEqualTo("Stream")') and: SyntaxChecker.tryToCompile(methodBuilderName, test) where: @@ -1929,10 +1929,12 @@ World.'''""" when: String test = singleTestGenerator(contractDsl) then: - test.contains('assertThatJson(parsedJson).array().array().arrayField().isEqualTo("Programming").value()') - test.contains('assertThatJson(parsedJson).array().array().arrayField().isEqualTo("Java").value()') - test.contains('assertThatJson(parsedJson).array().array().arrayField().isEqualTo("Spring").value()') - test.contains('assertThatJson(parsedJson).array().array().arrayField().isEqualTo("Boot").value()') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(0).elementWithIndex(0).isEqualTo("Programming")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(0).elementWithIndex(1).isEqualTo("Java")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(1).elementWithIndex(0).isEqualTo("Programming")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(1).elementWithIndex(1).isEqualTo("Java")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(1).elementWithIndex(2).isEqualTo("Spring")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(1).elementWithIndex(3).isEqualTo("Boot")') and: SyntaxChecker.tryToCompile(methodBuilderName, test) where: @@ -1966,8 +1968,8 @@ World.'''""" String test = singleTestGenerator(contractDsl) then: test.contains("""assertThatJson(parsedJson).array("['key']").hasSize(2)""") - test.contains("""assertThatJson(parsedJson).array("['key']").arrayField().isEqualTo("value1").value()""") - test.contains("""assertThatJson(parsedJson).array("['key']").arrayField().isEqualTo("value2").value()""") + test.contains("""assertThatJson(parsedJson).array("['key']").elementWithIndex(0).isEqualTo("value1")""") + test.contains("""assertThatJson(parsedJson).array("['key']").elementWithIndex(1).isEqualTo("value2")""") and: SyntaxChecker.tryToCompile(methodBuilderName, test) where: @@ -2064,8 +2066,8 @@ World.'''""" when: String test = singleTestGenerator(contractDsl) then: - test.contains('assertThatJson(parsedJson).array("[\'partners\']").array("[\'payment_methods\']").arrayField().isEqualTo("BANK").value()') - test.contains('assertThatJson(parsedJson).array("[\'partners\']").array("[\'payment_methods\']").arrayField().isEqualTo("CASH").value()') + test.contains('assertThatJson(parsedJson).array("[\'partners\']").elementWithIndex(0).array("[\'payment_methods\']").elementWithIndex(0).isEqualTo("BANK")') + test.contains('assertThatJson(parsedJson).array("[\'partners\']").elementWithIndex(0).array("[\'payment_methods\']").elementWithIndex(1).isEqualTo("CASH")') and: SyntaxChecker.tryToCompile(methodBuilderName, test) where: diff --git a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/YamlMockMvcMethodBodyBuilderSpec.groovy b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/YamlMockMvcMethodBodyBuilderSpec.groovy index 94bce2eaef..53ed32ca26 100644 --- a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/YamlMockMvcMethodBodyBuilderSpec.groovy +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/YamlMockMvcMethodBodyBuilderSpec.groovy @@ -181,8 +181,8 @@ response: String test = singleTestGenerator(contractDsl) then: test.contains("""assertThatJson(parsedJson).field("['property1']").isEqualTo("a")""") - test.contains("""assertThatJson(parsedJson).array("['property2']").contains("['a']").isEqualTo("sth")""") - test.contains("""assertThatJson(parsedJson).array("['property2']").contains("['b']").isEqualTo("sthElse")""") + test.contains("""assertThatJson(parsedJson).array("['property2']").elementWithIndex(0).field("['a']").isEqualTo("sth")""") + test.contains("""assertThatJson(parsedJson).array("['property2']").elementWithIndex(1).field("['b']").isEqualTo("sthElse")""") and: stubMappingIsValidWireMockStub(contractDsl) and: @@ -219,9 +219,8 @@ response: String test = singleTestGenerator(contractDsl) then: test.contains("""assertThatJson(parsedJson).field("['property1']").isEqualTo("a")""") - test.contains("""assertThatJson(parsedJson).array("['property2']").contains("['a']").isEqualTo("sth")""") - test.contains("""assertThatJson(parsedJson).array("['property2']").hasSize(2)""") - test.contains("""assertThatJson(parsedJson).array("['property2']").contains("['b']").isEqualTo("sthElse")""") + test.contains("""assertThatJson(parsedJson).array("['property2']").elementWithIndex(0).field("['a']").isEqualTo("sth")""") + test.contains("""assertThatJson(parsedJson).array("['property2']").elementWithIndex(1).field("['b']").isEqualTo("sthElse")""") and: stubMappingIsValidWireMockStub(contractDsl) and: @@ -361,8 +360,8 @@ response: when: String test = singleTestGenerator(contractDsl) then: - test.contains("""assertThatJson(parsedJson).array().contains("['property2']").isEqualTo("b")""") - test.contains("""assertThatJson(parsedJson).array().contains("['property1']").isEqualTo("a")""") + test.contains("""assertThatJson(parsedJson).array().elementWithIndex(0).field("['property1']").isEqualTo("a")""") + test.contains("""assertThatJson(parsedJson).array().elementWithIndex(1).field("['property2']").isEqualTo("b")""") and: stubMappingIsValidWireMockStub(contractDsl) and: @@ -398,8 +397,8 @@ response: when: String test = singleTestGenerator(contractDsl) then: - test.contains("""assertThatJson(parsedJson).array("['property1']").contains("['property2']").isEqualTo("test1")""") - test.contains("""assertThatJson(parsedJson).array("['property1']").contains("['property3']").isEqualTo("test2")""") + test.contains("""assertThatJson(parsedJson).array("['property1']").elementWithIndex(0).field("['property2']").isEqualTo("test1")""") + test.contains("""assertThatJson(parsedJson).array("['property1']").elementWithIndex(1).field("['property3']").isEqualTo("test2")""") and: stubMappingIsValidWireMockStub(contractDsl) and: @@ -850,8 +849,8 @@ response: when: String test = singleTestGenerator(contractDsl) then: - test.contains("""assertThatJson(parsedJson).array("['errors']").contains("['property']").isEqualTo("bank_account_number")""") - test.contains("""assertThatJson(parsedJson).array("['errors']").contains("['message']").isEqualTo("incorrect_format")""") + test.contains("""assertThatJson(parsedJson).array("['errors']").elementWithIndex(0).field("['property']").isEqualTo("bank_account_number")""") + test.contains("""assertThatJson(parsedJson).array("['errors']").elementWithIndex(0).field("['message']").isEqualTo("incorrect_format")""") and: stubMappingIsValidWireMockStub(contractDsl) and: @@ -1339,11 +1338,11 @@ response: when: String test = singleTestGenerator(contractDsl) then: - test.contains('assertThatJson(parsedJson).arrayField().contains("Java8").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("Spring").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("Java").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("Stream").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("SpringBoot").value()') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(0).isEqualTo("Java")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(1).isEqualTo("Java8")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(2).isEqualTo("Spring")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(3).isEqualTo("SpringBoot")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(4).isEqualTo("Stream")') and: SyntaxChecker.tryToCompile(methodBuilderName, test) where: @@ -1377,11 +1376,11 @@ response: String test = singleTestGenerator(contractDsl) then: test.contains('assertThatJson(parsedJson).hasSize(5)') - test.contains('assertThatJson(parsedJson).arrayField().contains("Java8").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("Spring").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("Java").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("Stream").value()') - test.contains('assertThatJson(parsedJson).arrayField().contains("SpringBoot").value()') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(0).isEqualTo("Java")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(1).isEqualTo("Java8")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(2).isEqualTo("Spring")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(3).isEqualTo("SpringBoot")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(4).isEqualTo("Stream")') and: SyntaxChecker.tryToCompile(methodBuilderName, test) where: @@ -1411,10 +1410,12 @@ response: when: String test = singleTestGenerator(contractDsl) then: - test.contains('assertThatJson(parsedJson).array().array().arrayField().isEqualTo("Programming").value()') - test.contains('assertThatJson(parsedJson).array().array().arrayField().isEqualTo("Java").value()') - test.contains('assertThatJson(parsedJson).array().array().arrayField().isEqualTo("Spring").value()') - test.contains('assertThatJson(parsedJson).array().array().arrayField().isEqualTo("Boot").value()') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(0).elementWithIndex(0).isEqualTo("Programming")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(0).elementWithIndex(1).isEqualTo("Java")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(1).elementWithIndex(0).isEqualTo("Programming")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(1).elementWithIndex(1).isEqualTo("Java")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(1).elementWithIndex(2).isEqualTo("Spring")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(1).elementWithIndex(3).isEqualTo("Boot")') and: SyntaxChecker.tryToCompile(methodBuilderName, test) where: @@ -1504,8 +1505,8 @@ response: when: String test = singleTestGenerator(contractDsl) then: - test.contains('assertThatJson(parsedJson).array().field("[\'partners\']").array("[\'payment_methods\']").arrayField().isEqualTo("BANK").value()') - test.contains('assertThatJson(parsedJson).array().field("[\'partners\']").array("[\'payment_methods\']").arrayField().isEqualTo("CASH").value()') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(0).field("[\'partners\']").array("[\'payment_methods\']").elementWithIndex(0).isEqualTo("BANK")') + test.contains('assertThatJson(parsedJson).array().elementWithIndex(0).field("[\'partners\']").array("[\'payment_methods\']").elementWithIndex(1).isEqualTo("CASH")') and: SyntaxChecker.tryToCompile(methodBuilderName, test) where: diff --git a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonPathMatcherUtilsSpec.groovy b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonPathMatcherUtilsSpec.groovy new file mode 100644 index 0000000000..cc7b8f3679 --- /dev/null +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonPathMatcherUtilsSpec.groovy @@ -0,0 +1,287 @@ +/* + * Copyright 2013-present 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 + * + * https://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.util + + +import spock.lang.Specification + +import org.springframework.cloud.contract.spec.internal.BodyMatchers +/** + * Tests for {@link JsonPathMatcherUtils}. + * + * @author Marcin Grzejszczak + * @since 5.1.0 + */ +class JsonPathMatcherUtilsSpec extends Specification { + + def 'should read element from JSON by path'() { + given: + def json = [ + person: [ + name: "John", + age: 30 + ] + ] + when: + def name = JsonPathMatcherUtils.readElement(json, '$.person.name') + def age = JsonPathMatcherUtils.readElement(json, '$.person.age') + then: + name == "John" + age == 30 + } + + def 'should read nested array element from JSON'() { + given: + def json = [ + items: [ + [id: 1, name: "first"], + [id: 2, name: "second"] + ] + ] + when: + def firstId = JsonPathMatcherUtils.readElement(json, '$.items[0].id') + def secondName = JsonPathMatcherUtils.readElement(json, '$.items[1].name') + then: + firstId == 1 + secondName == "second" + } + + def 'should remove matching JSON paths from body'() { + given: + def json = [ + person: [ + name: "John", + age: 30, + email: "john@example.com" + ] + ] + def bodyMatchers = new BodyMatchers() + bodyMatchers.jsonPath('$.person.email', bodyMatchers.byRegex('.*')) + when: + def result = JsonPathMatcherUtils.removeMatchingJsonPaths(json, bodyMatchers) + then: + result.person.name == "John" + result.person.age == 30 + result.person.email == null + } + + def 'should return original JSON when no matchers provided'() { + given: + def json = [name: "John"] + when: + def result = JsonPathMatcherUtils.removeMatchingJsonPaths(json, null) + then: + result.name == "John" + } + + def 'should return original JSON when matchers have no entries'() { + given: + def json = [name: "John"] + def bodyMatchers = new BodyMatchers() + when: + def result = JsonPathMatcherUtils.removeMatchingJsonPaths(json, bodyMatchers) + then: + result.name == "John" + } + + def 'should convert JSON path with regex to filter expression'() { + given: + def bodyMatchers = new BodyMatchers() + bodyMatchers.jsonPath('$.person.name', bodyMatchers.byRegex('[A-Z][a-z]+')) + def bodyMatcher = bodyMatchers.matchers().first() + when: + def result = JsonPathMatcherUtils.convertJsonPathAndRegexToAJsonPath(bodyMatcher) + then: + result == '$.person[?(@.name =~ /([A-Z][a-z]+)/)]' + } + + def 'should convert JSON path with equality to filter expression'() { + given: + def json = [ + person: [ + name: "John" + ] + ] + def bodyMatchers = new BodyMatchers() + bodyMatchers.jsonPath('$.person.name', bodyMatchers.byEquality()) + def bodyMatcher = bodyMatchers.matchers().first() + when: + def result = JsonPathMatcherUtils.convertJsonPathAndRegexToAJsonPath(bodyMatcher, json) + then: + result == "\$.person[?(@.name == 'John')]" + } + + def 'should convert JSON path with numeric equality'() { + given: + def json = [ + person: [ + age: 30 + ] + ] + def bodyMatchers = new BodyMatchers() + bodyMatchers.jsonPath('$.person.age', bodyMatchers.byEquality()) + def bodyMatcher = bodyMatchers.matchers().first() + when: + def result = JsonPathMatcherUtils.convertJsonPathAndRegexToAJsonPath(bodyMatcher, json) + then: + result == '$.person[?(@.age == 30)]' + } + + def 'should convert JSON path with type matching and min occurrence'() { + given: + def bodyMatchers = new BodyMatchers() + bodyMatchers.jsonPath('$.items', bodyMatchers.byType { minOccurrence(2) }) + def bodyMatcher = bodyMatchers.matchers().first() + when: + def result = JsonPathMatcherUtils.convertJsonPathAndRegexToAJsonPath(bodyMatcher) + then: + result == '$[?(@.items.size() >= 2)]' + } + + def 'should convert JSON path with type matching and max occurrence'() { + given: + def bodyMatchers = new BodyMatchers() + bodyMatchers.jsonPath('$.items', bodyMatchers.byType { maxOccurrence(5) }) + def bodyMatcher = bodyMatchers.matchers().first() + when: + def result = JsonPathMatcherUtils.convertJsonPathAndRegexToAJsonPath(bodyMatcher) + then: + result == '$[?(@.items.size() <= 5)]' + } + + def 'should convert JSON path with type matching with min and max occurrence'() { + given: + def bodyMatchers = new BodyMatchers() + bodyMatchers.jsonPath('$.items', bodyMatchers.byType { minOccurrence(2); maxOccurrence(5) }) + def bodyMatcher = bodyMatchers.matchers().first() + when: + def result = JsonPathMatcherUtils.convertJsonPathAndRegexToAJsonPath(bodyMatcher) + then: + result == '$[?(@.items.size() >= 2 && @.items.size() <= 5)]' + } + + def 'should return original value for non-RegexProperty'() { + given: + def value = "test value" + when: + def result = JsonPathMatcherUtils.generatedValueIfNeeded(value) + then: + result == "test value" + } + + def 'should return original value for numeric value'() { + given: + def value = 42 + when: + def result = JsonPathMatcherUtils.generatedValueIfNeeded(value) + then: + result == 42 + } + + def 'should clone body correctly'() { + given: + def original = [name: "John", age: 30, items: [1, 2, 3]] + when: + def cloned = JsonPathMatcherUtils.cloneBody(original) + then: + cloned == original + !cloned.is(original) + } + + def 'should handle bracket notation in path for equality'() { + given: + def json = [ + person: [ + "first-name": "John" + ] + ] + def bodyMatchers = new BodyMatchers() + bodyMatchers.jsonPath("\$.person['first-name']", bodyMatchers.byEquality()) + def bodyMatcher = bodyMatchers.matchers().first() + when: + def result = JsonPathMatcherUtils.convertJsonPathAndRegexToAJsonPath(bodyMatcher, json) + then: + result == "\$[?(@.person['first-name'] == 'John')]" + } + + def 'should remove array element matching path'() { + given: + def json = [ + items: [ + [id: 1, name: "first"], + [id: 2, name: "second"] + ] + ] + def bodyMatchers = new BodyMatchers() + bodyMatchers.jsonPath('$.items[0].id', bodyMatchers.byRegex('\\d+')) + when: + def result = JsonPathMatcherUtils.removeMatchingJsonPaths(json, bodyMatchers) + then: + result.items[0].id == null + result.items[0].name == "first" + result.items[1].id == 2 + result.items[1].name == "second" + } + + def 'should handle regex with forward slashes'() { + given: + def bodyMatchers = new BodyMatchers() + bodyMatchers.jsonPath('$.url', bodyMatchers.byRegex('http://example.com/path')) + def bodyMatcher = bodyMatchers.matchers().first() + when: + def result = JsonPathMatcherUtils.convertJsonPathAndRegexToAJsonPath(bodyMatcher) + then: + int[] expected = [ + 36, 91, 63, 40, 64, 46, 117, 114, 108, 32, 61, 126, 32, 47, 40, 104, + 116, 116, 112, 58, 92, 92, 47, 92, 92, 47, 101, 120, 97, 109, 112, 108, + 101, 46, 99, 111, 109, 92, 92, 47, 112, 97, 116, 104, 41, 47, 41, 93 + ] as int[] + Arrays.equals(result.chars().toArray(), expected) + } + + def 'should read root level array'() { + given: + def json = [ + [id: 1], + [id: 2] + ] + when: + def firstId = JsonPathMatcherUtils.readElement(json, '$[0].id') + def secondId = JsonPathMatcherUtils.readElement(json, '$[1].id') + then: + firstId == 1 + secondId == 2 + } + + def 'should handle deeply nested paths'() { + given: + def json = [ + level1: [ + level2: [ + level3: [ + value: "deep" + ] + ] + ] + ] + when: + def result = JsonPathMatcherUtils.readElement(json, '$.level1.level2.level3.value') + then: + result == "deep" + } + +} diff --git a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonPathTraverserSpec.groovy b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonPathTraverserSpec.groovy new file mode 100644 index 0000000000..6a95ae35d4 --- /dev/null +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonPathTraverserSpec.groovy @@ -0,0 +1,394 @@ +/* + * Copyright 2013-present 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 + * + * https://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.util + +import java.util.function.Function + +import com.toomuchcoding.jsonassert.JsonAssertion +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import spock.lang.Specification + +/** + * Tests for {@link JsonPathTraverser}. + * + * @author Marcin Grzejszczak + * @since 5.1.0 + */ +class JsonPathTraverserSpec extends Specification { + + Function parsingFunction = { String s -> new JsonSlurper().parseText(s) } + + def 'should traverse simple object without ordered verification'() { + given: + def json = new JsonSlurper().parseText(''' + { + "name": "John", + "age": 30 + } + ''') + def traverser = new JsonPathTraverser(false, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath() == '''$[?(@.['name'] == 'John')]''' } + collected.any { it.jsonPath() == '''$[?(@.['age'] == 30)]''' } + } + + def 'should traverse primitive array with ordered verification'() { + given: + def json = new JsonSlurper().parseText(''' + { + "numbers": [1, 2, 3] + } + ''') + def traverser = new JsonPathTraverser(true, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('[0]') } + collected.any { it.jsonPath().contains('[1]') } + collected.any { it.jsonPath().contains('[2]') } + } + + def 'should traverse primitive array without ordered verification'() { + given: + def json = new JsonSlurper().parseText(''' + { + "numbers": [1, 2, 3] + } + ''') + def traverser = new JsonPathTraverser(false, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('@ == 1') } + collected.any { it.jsonPath().contains('@ == 2') } + collected.any { it.jsonPath().contains('@ == 3') } + !collected.any { it.jsonPath().contains('[0]') } + !collected.any { it.jsonPath().contains('[1]') } + !collected.any { it.jsonPath().contains('[2]') } + } + + def 'should traverse object array with ordered verification'() { + given: + def json = new JsonSlurper().parseText(''' + { + "items": [ + {"id": 1, "name": "first"}, + {"id": 2, "name": "second"} + ] + } + ''') + def traverser = new JsonPathTraverser(true, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('[0]') && it.jsonPath().contains('id') && it.jsonPath().contains('1') } + collected.any { it.jsonPath().contains('[0]') && it.jsonPath().contains('name') && it.jsonPath().contains('first') } + collected.any { it.jsonPath().contains('[1]') && it.jsonPath().contains('id') && it.jsonPath().contains('2') } + collected.any { it.jsonPath().contains('[1]') && it.jsonPath().contains('name') && it.jsonPath().contains('second') } + } + + def 'should traverse object array without ordered verification'() { + given: + def json = new JsonSlurper().parseText(''' + { + "items": [ + {"id": 1, "name": "first"}, + {"id": 2, "name": "second"} + ] + } + ''') + def traverser = new JsonPathTraverser(false, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('[*]') && it.jsonPath().contains('id') && it.jsonPath().contains('1') } + collected.any { it.jsonPath().contains('[*]') && it.jsonPath().contains('name') && it.jsonPath().contains('first') } + collected.any { it.jsonPath().contains('[*]') && it.jsonPath().contains('id') && it.jsonPath().contains('2') } + collected.any { it.jsonPath().contains('[*]') && it.jsonPath().contains('name') && it.jsonPath().contains('second') } + } + + def 'should traverse nested objects'() { + given: + def json = new JsonSlurper().parseText(''' + { + "person": { + "address": { + "city": "NYC", + "zip": "10001" + } + } + } + ''') + def traverser = new JsonPathTraverser(false, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('city') && it.jsonPath().contains('NYC') } + collected.any { it.jsonPath().contains('zip') && it.jsonPath().contains('10001') } + } + + def 'should handle empty map'() { + given: + def json = new JsonSlurper().parseText(''' + { + "empty": {} + } + ''') + def traverser = new JsonPathTraverser(false, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('empty') } + } + + def 'should handle empty array'() { + given: + def json = new JsonSlurper().parseText(''' + { + "items": [] + } + ''') + def traverser = new JsonPathTraverser(false, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('items') } + } + + def 'should traverse string array with ordered verification'() { + given: + def json = new JsonSlurper().parseText(''' + { + "tags": ["red", "green", "blue"] + } + ''') + def traverser = new JsonPathTraverser(true, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('[0]') } + collected.any { it.jsonPath().contains('[1]') } + collected.any { it.jsonPath().contains('[2]') } + } + + def 'should traverse nested array with ordered verification'() { + given: + def json = new JsonSlurper().parseText(''' + { + "matrix": [[1, 2], [3, 4]] + } + ''') + def traverser = new JsonPathTraverser(true, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('[0]') } + collected.any { it.jsonPath().contains('[1]') } + } + + def 'should traverse root level array with ordered verification'() { + given: + def json = new JsonSlurper().parseText(''' + [ + {"id": 1}, + {"id": 2} + ] + ''') + def traverser = new JsonPathTraverser(true, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('[0]') && it.jsonPath().contains('id') && it.jsonPath().contains('1') } + collected.any { it.jsonPath().contains('[1]') && it.jsonPath().contains('id') && it.jsonPath().contains('2') } + } + + def 'should traverse root level array without ordered verification'() { + given: + def json = new JsonSlurper().parseText(''' + [ + {"id": 1}, + {"id": 2} + ] + ''') + def traverser = new JsonPathTraverser(false, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('[*]') && it.jsonPath().contains('id') && it.jsonPath().contains('1') } + collected.any { it.jsonPath().contains('[*]') && it.jsonPath().contains('id') && it.jsonPath().contains('2') } + } + + def 'should add size check for primitive arrays with ordered verification'() { + given: + def json = new JsonSlurper().parseText(''' + { + "numbers": [1, 2, 3] + } + ''') + def traverser = new JsonPathTraverser(true, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('hasSize') || it.method().contains('hasSize') } + } + + def 'should not add size check without ordered verification'() { + given: + def json = new JsonSlurper().parseText(''' + { + "numbers": [1, 2, 3] + } + ''') + def traverser = new JsonPathTraverser(false, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + !collected.any { it.method().contains('hasSize') } + } + + def 'should traverse mixed primitive types in array with ordered verification'() { + given: + def json = new JsonSlurper().parseText(''' + { + "mixed": ["text", 42, true, 3.14] + } + ''') + def traverser = new JsonPathTraverser(true, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('[0]') } + collected.any { it.jsonPath().contains('[1]') } + collected.any { it.jsonPath().contains('[2]') } + collected.any { it.jsonPath().contains('[3]') } + } + + def 'should traverse deeply nested structure'() { + given: + def json = new JsonSlurper().parseText(''' + { + "level1": { + "level2": { + "level3": { + "level4": { + "value": "deep" + } + } + } + } + } + ''') + def traverser = new JsonPathTraverser(false, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('value') && it.jsonPath().contains('deep') } + } + + def 'should handle boolean values'() { + given: + def json = new JsonSlurper().parseText(''' + { + "active": true, + "deleted": false + } + ''') + def traverser = new JsonPathTraverser(false, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('active') && it.jsonPath().contains('true') } + collected.any { it.jsonPath().contains('deleted') && it.jsonPath().contains('false') } + } + + def 'should traverse array of arrays at root level with ordered verification'() { + given: + def json = new JsonSlurper().parseText(''' + [[1, 2], [3, 4], [5, 6]] + ''') + def traverser = new JsonPathTraverser(true, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.size() > 0 + collected.any { it.jsonPath().contains('[0]') } + } + + def 'should handle special characters in keys'() { + given: + def json = new JsonSlurper().parseText(''' + { + "special-key": "value1", + "key.with.dots": "value2" + } + ''') + def traverser = new JsonPathTraverser(false, parsingFunction) + def rootKey = createRootVerifiable(json) + def collected = [] + when: + traverser.traverse(json, rootKey, { collected.add(it) }) + then: + collected.any { it.jsonPath().contains('special-key') && it.jsonPath().contains('value1') } + collected.any { it.jsonPath().contains('key.with.dots') && it.jsonPath().contains('value2') } + } + + private MethodBufferingJsonVerifiable createRootVerifiable(Object json) { + return new DelegatingJsonVerifiable( + JsonAssertion.assertThat(JsonOutput.toJson(json)).withoutThrowingException() + ) + } + +} diff --git a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverterSpec.groovy b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverterSpec.groovy index d50adb5f87..dff8bd62df 100644 --- a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverterSpec.groovy +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverterSpec.groovy @@ -400,19 +400,15 @@ class JsonToJsonPathsConverterSpec extends Specification { it.jsonPath() == """\$[?(@.['property1'] == 'a')]""" } pathAndValues.find { - it.method() == """.array("['property2']").contains("['a']").isEqualTo("sth")""" && - it.jsonPath() == """\$.['property2'][*][?(@.['a'] == 'sth')]""" - } - pathAndValues.find { - it.method() == """.array("['property2']").hasSize(2)""" && - it.jsonPath() == """\$.['property2'][*]""" + it.method() == """.array("['property2']").elementWithIndex(0).field("['a']").isEqualTo("sth")""" && + it.jsonPath() == """\$.['property2'][0][?(@.['a'] == 'sth')]""" } pathAndValues.find { - it.method() == """.array("['property2']").contains("['b']").isEqualTo("sthElse")""" && - it.jsonPath() == """\$.['property2'][*][?(@.['b'] == 'sthElse')]""" + it.method() == """.array("['property2']").elementWithIndex(1).field("['b']").isEqualTo("sthElse")""" && + it.jsonPath() == """\$.['property2'][1][?(@.['b'] == 'sthElse')]""" } and: - pathAndValues.size() == 4 + pathAndValues.size() == 3 } def "should generate assertions for a response body containing map with integers as keys"() { @@ -475,21 +471,17 @@ class JsonToJsonPathsConverterSpec extends Specification { }]""" when: JsonPaths pathAndValues = new JsonToJsonPathsConverter().transformToJsonPathWithTestsSideValues(new JsonSlurper().parseText(json)) - then: - pathAndValues.find { - it.method() == """.array().contains("['property1']").isEqualTo("a")""" && - it.jsonPath() == """\$[*][?(@.['property1'] == 'a')]""" - } - pathAndValues.find { - it.method() == """.array().contains("['property2']").isEqualTo("b")""" && - it.jsonPath() == """\$[*][?(@.['property2'] == 'b')]""" - } - pathAndValues.find { - it.method() == """.hasSize(2)""" && - it.jsonPath() == """\$""" - } - and: - pathAndValues.size() == 3 + then: + pathAndValues.find { + it.method() == """.array().elementWithIndex(0).field("['property1']").isEqualTo("a")""" && + it.jsonPath() == """\$[*][0][?(@.['property1'] == 'a')]""" + } + pathAndValues.find { + it.method() == """.array().elementWithIndex(1).field("['property2']").isEqualTo("b")""" && + it.jsonPath() == """\$[*][1][?(@.['property2'] == 'b')]""" + } + and: + pathAndValues.size() == 2 } def "should generate assertions for array inside response body element"() { @@ -529,19 +521,15 @@ class JsonToJsonPathsConverterSpec extends Specification { JsonPaths pathAndValues = new JsonToJsonPathsConverter().transformToJsonPathWithTestsSideValues(new JsonSlurper().parseText(json)) then: pathAndValues.find { - it.method() == """.array("['property1']").contains("['property2']").isEqualTo("test1")""" && - it.jsonPath() == """\$.['property1'][*][?(@.['property2'] == 'test1')]""" + it.method() == """.array("['property1']").elementWithIndex(0).field("['property2']").isEqualTo("test1")""" && + it.jsonPath() == """\$.['property1'][0][?(@.['property2'] == 'test1')]""" } pathAndValues.find { - it.method() == """.array("['property1']").contains("['property3']").isEqualTo("test2")""" && - it.jsonPath() == """\$.['property1'][*][?(@.['property3'] == 'test2')]""" - } - pathAndValues.find { - it.method() == """.array("['property1']").hasSize(2)""" && - it.jsonPath() == """\$.['property1'][*]""" + it.method() == """.array("['property1']").elementWithIndex(1).field("['property3']").isEqualTo("test2")""" && + it.jsonPath() == """\$.['property1'][1][?(@.['property3'] == 'test2')]""" } and: - pathAndValues.size() == 3 + pathAndValues.size() == 2 } def "should generate assertions for nested objects in response body"() { @@ -639,19 +627,15 @@ class JsonToJsonPathsConverterSpec extends Specification { JsonPaths pathAndValues = new JsonToJsonPathsConverter().transformToJsonPathWithTestsSideValues(json) then: pathAndValues.find { - it.method() == """.array("['errors']").contains("['property']").isEqualTo("bank_account_number")""" && - it.jsonPath() == """\$.['errors'][*][?(@.['property'] == 'bank_account_number')]""" - } - pathAndValues.find { - it.method() == """.array("['errors']").contains("['message']").isEqualTo("incorrect_format")""" && - it.jsonPath() == """\$.['errors'][*][?(@.['message'] == 'incorrect_format')]""" + it.method() == """.array("['errors']").elementWithIndex(0).field("['property']").isEqualTo("bank_account_number")""" && + it.jsonPath() == """\$.['errors'][0][?(@.['property'] == 'bank_account_number')]""" } pathAndValues.find { - it.method() == """.array("['errors']").hasSize(1)""" && - it.jsonPath() == """\$.['errors'][*]""" + it.method() == """.array("['errors']").elementWithIndex(0).field("['message']").isEqualTo("incorrect_format")""" && + it.jsonPath() == """\$.['errors'][0][?(@.['message'] == 'incorrect_format')]""" } and: - pathAndValues.size() == 3 + pathAndValues.size() == 2 } def "should manage to parse a double array"() { @@ -724,49 +708,37 @@ class JsonToJsonPathsConverterSpec extends Specification { ''' when: JsonPaths pathAndValues = new JsonToJsonPathsConverter().transformToJsonPathWithTestsSideValues(new JsonSlurper().parseText(json)) - then: - DocumentContext context = JsonPath.parse(json) - pathAndValues.each { - assert context.read(it.jsonPath(), JSONArray) - } - pathAndValues.find { - it.method() == """.hasSize(1)""" && - it.jsonPath() == """\$""" - } - pathAndValues.find { - it.method() == """.array().field("['place']").field("['bounding_box']").array("['coordinates']").array().array().arrayField().isEqualTo(-77.119759)""" && - it.jsonPath() == """\$[*].['place'].['bounding_box'].['coordinates'][*][*][?(@ == -77.119759)]""" - } - pathAndValues.find { - it.method() == """.array().field("['place']").field("['bounding_box']").array("['coordinates']").array().array().arrayField().isEqualTo(38.995548)""" && - it.jsonPath() == """\$[*].['place'].['bounding_box'].['coordinates'][*][*][?(@ == 38.995548)]""" - } - pathAndValues.find { - it.method() == """.array().field("['place']").field("['bounding_box']").array("['coordinates']").hasSize(1)""" && - it.jsonPath() == """\$[*].['place'].['bounding_box'].['coordinates'][*]""" - } - pathAndValues.find { - it.method() == """.array().field("['place']").field("['bounding_box']").array("['coordinates']").array().hasSize(2)""" && - it.jsonPath() == """\$[*].['place'].['bounding_box'].['coordinates'][*][*]""" - } - pathAndValues.find { - it.method() == """.array().field("['place']").field("['bounding_box']").array("['coordinates']").array().array().arrayField().isEqualTo(38.791645)""" && - it.jsonPath() == """\$[*].['place'].['bounding_box'].['coordinates'][*][*][?(@ == 38.791645)]""" - } - pathAndValues.find { - it.method() == """.array().field("['place']").field("['bounding_box']").array("['coordinates']").array().array().hasSize(2)""" && - it.jsonPath() == """\$[*].['place'].['bounding_box'].['coordinates'][*][*]""" - } - pathAndValues.find { - it.method() == """.array().field("['place']").field("['bounding_box']").array("['coordinates']").array().array().arrayField().isEqualTo(-76.909393)""" && - it.jsonPath() == """\$[*].['place'].['bounding_box'].['coordinates'][*][*][?(@ == -76.909393)]""" - } - and: - pathAndValues.size() == 8 + then: + DocumentContext context = JsonPath.parse(json) + pathAndValues.each { + assert context.read(it.jsonPath()) != null + } + pathAndValues.find { + it.method() == """.array().elementWithIndex(0).field("['place']").field("['bounding_box']").array("['coordinates']").elementWithIndex(0).elementWithIndex(0).hasSize(2)""" && + it.jsonPath() == """\$[*][0].['place'].['bounding_box'].['coordinates'][0][0]""" + } + pathAndValues.find { + it.method() == """.array().elementWithIndex(0).field("['place']").field("['bounding_box']").array("['coordinates']").elementWithIndex(0).elementWithIndex(0).elementWithIndex(0).isEqualTo(-77.119759)""" && + it.jsonPath() == """\$[*][0].['place'].['bounding_box'].['coordinates'][0][0][0]""" + } + pathAndValues.find { + it.method() == """.array().elementWithIndex(0).field("['place']").field("['bounding_box']").array("['coordinates']").elementWithIndex(0).elementWithIndex(0).elementWithIndex(1).isEqualTo(38.995548)""" && + it.jsonPath() == """\$[*][0].['place'].['bounding_box'].['coordinates'][0][0][1]""" + } + pathAndValues.find { + it.method() == """.array().elementWithIndex(0).field("['place']").field("['bounding_box']").array("['coordinates']").elementWithIndex(0).elementWithIndex(1).hasSize(2)""" && + it.jsonPath() == """\$[*][0].['place'].['bounding_box'].['coordinates'][0][1]""" + } + pathAndValues.find { + it.method() == """.array().elementWithIndex(0).field("['place']").field("['bounding_box']").array("['coordinates']").elementWithIndex(0).elementWithIndex(1).elementWithIndex(0).isEqualTo(-76.909393)""" && + it.jsonPath() == """\$[*][0].['place'].['bounding_box'].['coordinates'][0][1][0]""" + } + pathAndValues.find { + it.method() == """.array().elementWithIndex(0).field("['place']").field("['bounding_box']").array("['coordinates']").elementWithIndex(0).elementWithIndex(1).elementWithIndex(1).isEqualTo(38.791645)""" && + it.jsonPath() == """\$[*][0].['place'].['bounding_box'].['coordinates'][0][1][1]""" + } and: - pathAndValues.each { - JsonAssertion.assertThat(json).matchesJsonPath(it.jsonPath()) - } + pathAndValues.size() == 6 } def "should convert a json path with regex to a regex checking json path"() { @@ -885,7 +857,68 @@ class JsonToJsonPathsConverterSpec extends Specification { pathAndValues.size() == 2 } - private BodyMatcher matcher(final MatchingType matchingType, final String jsonPath, final Object value) { + @RestoreSystemProperties + def "should generate ordered assertions for primitive array when assert size is enabled"() { + given: + System.setProperty('spring.cloud.contract.verifier.assert.size', 'true') + Map json = [ + items: ["first", "second", "third"] + ] + when: + JsonPaths pathAndValues = new JsonToJsonPathsConverter().transformToJsonPathWithTestsSideValues(json) + then: + pathAndValues.find { + it.method() == """.array("['items']").hasSize(3)""" && + it.jsonPath() == """\$.['items']""" + } + pathAndValues.find { + it.method() == """.array("['items']").elementWithIndex(0).isEqualTo("first")""" && + it.jsonPath() == """\$.['items'][0]""" + } + pathAndValues.find { + it.method() == """.array("['items']").elementWithIndex(1).isEqualTo("second")""" && + it.jsonPath() == """\$.['items'][1]""" + } + pathAndValues.find { + it.method() == """.array("['items']").elementWithIndex(2).isEqualTo("third")""" && + it.jsonPath() == """\$.['items'][2]""" + } + and: + pathAndValues.size() == 4 + } + + @RestoreSystemProperties + def "should generate ordered assertions for array of objects when assert size is enabled"() { + given: + System.setProperty('spring.cloud.contract.verifier.assert.size', 'true') + Map json = [ + users: [ + [name: "Alice", age: 30], + [name: "Bob", age: 25] + ] + ] + when: + JsonPaths pathAndValues = new JsonToJsonPathsConverter().transformToJsonPathWithTestsSideValues(json) + then: + pathAndValues.find { + it.method() == """.array("['users']").elementWithIndex(0).field("['name']").isEqualTo("Alice")""" && + it.jsonPath() == """\$.['users'][0][?(@.['name'] == 'Alice')]""" + } + pathAndValues.find { + it.method() == """.array("['users']").elementWithIndex(0).field("['age']").isEqualTo(30)""" && + it.jsonPath() == """\$.['users'][0][?(@.['age'] == 30)]""" + } + pathAndValues.find { + it.method() == """.array("['users']").elementWithIndex(1).field("['name']").isEqualTo("Bob")""" && + it.jsonPath() == """\$.['users'][1][?(@.['name'] == 'Bob')]""" + } + pathAndValues.find { + it.method() == """.array("['users']").elementWithIndex(1).field("['age']").isEqualTo(25)""" && + it.jsonPath() == """\$.['users'][1][?(@.['age'] == 25)]""" + } + } + + private static BodyMatcher matcher(final MatchingType matchingType, final String jsonPath, final Object value) { return new BodyMatcher() { @Override MatchingType matchingType() { @@ -914,7 +947,7 @@ class JsonToJsonPathsConverterSpec extends Specification { } } - private void assertThatJsonPathsInMapAreValid(String json, JsonPaths pathAndValues) { + private static void assertThatJsonPathsInMapAreValid(String json, JsonPaths pathAndValues) { DocumentContext parsedJson = JsonPath.using(Configuration.builder().options(Option.ALWAYS_RETURN_LIST).build()).parse(json) pathAndValues.each { assert !parsedJson.read(it.jsonPath(), JSONArray).empty diff --git a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverterWithArrayCheckSpec.groovy b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverterWithArrayCheckSpec.groovy new file mode 100644 index 0000000000..cc927d1b0e --- /dev/null +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverterWithArrayCheckSpec.groovy @@ -0,0 +1,817 @@ +/* + * Copyright 2013-present 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 + * + * https://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.util + +import com.jayway.jsonpath.DocumentContext +import com.jayway.jsonpath.JsonPath +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import spock.lang.Specification + +/** + * Tests for {@link JsonToJsonPathsConverter} with ordered array verification enabled. + * All tests in this class run with `spring.cloud.contract.verifier.assert.size` set to true, + * which enables exact index-based array element verification instead of wildcard matching. + * + * @author Marcin Grzejszczak + * @since 5.1.0 + */ +class JsonToJsonPathsConverterWithArrayCheckSpec extends Specification { + + /** + * Creates a converter with ordered array verification enabled. + */ + private static JsonToJsonPathsConverter converter() { + return new JsonToJsonPathsConverter(true) + } + + // ========== Primitive Arrays ========== + + def "should generate ordered assertions for simple string array"() { + given: + Map json = [ + items: ["first", "second", "third"] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have size check" + pathAndValues.find { + it.method() == """.array("['items']").hasSize(3)""" && + it.jsonPath() == """\$.['items']""" + } + and: "should have assertion for first element" + pathAndValues.find { + it.method() == """.array("['items']").elementWithIndex(0).isEqualTo("first")""" && + it.jsonPath() == """\$.['items'][0]""" + } + and: "should have assertion for second element" + pathAndValues.find { + it.method() == """.array("['items']").elementWithIndex(1).isEqualTo("second")""" && + it.jsonPath() == """\$.['items'][1]""" + } + and: "should have assertion for third element" + pathAndValues.find { + it.method() == """.array("['items']").elementWithIndex(2).isEqualTo("third")""" && + it.jsonPath() == """\$.['items'][2]""" + } + and: "should have exactly 4 assertions (1 size + 3 elements)" + pathAndValues.size() == 4 + } + + def "should generate ordered assertions for number array"() { + given: + Map json = [ + numbers: [10, 20, 30, 40] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have size check" + pathAndValues.find { + it.method() == """.array("['numbers']").hasSize(4)""" && + it.jsonPath() == """\$.['numbers']""" + } + and: "should have assertion for element at index 0" + pathAndValues.find { + it.method() == """.array("['numbers']").elementWithIndex(0).isEqualTo(10)""" && + it.jsonPath() == """\$.['numbers'][0]""" + } + and: "should have assertion for element at index 1" + pathAndValues.find { + it.method() == """.array("['numbers']").elementWithIndex(1).isEqualTo(20)""" && + it.jsonPath() == """\$.['numbers'][1]""" + } + and: "should have assertion for element at index 2" + pathAndValues.find { + it.method() == """.array("['numbers']").elementWithIndex(2).isEqualTo(30)""" && + it.jsonPath() == """\$.['numbers'][2]""" + } + and: "should have assertion for element at index 3" + pathAndValues.find { + it.method() == """.array("['numbers']").elementWithIndex(3).isEqualTo(40)""" && + it.jsonPath() == """\$.['numbers'][3]""" + } + and: "should have exactly 5 assertions (1 size + 4 elements)" + pathAndValues.size() == 5 + } + + def "should generate ordered assertions for boolean array"() { + given: + Map json = [ + flags: [true, false, true] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have size check" + pathAndValues.find { + it.method() == """.array("['flags']").hasSize(3)""" && + it.jsonPath() == """\$.['flags']""" + } + and: "should have assertion for element at index 0 (true)" + pathAndValues.find { + it.method() == """.array("['flags']").elementWithIndex(0).isEqualTo(true)""" && + it.jsonPath() == """\$.['flags'][0]""" + } + and: "should have assertion for element at index 1 (false)" + pathAndValues.find { + it.method() == """.array("['flags']").elementWithIndex(1).isEqualTo(false)""" && + it.jsonPath() == """\$.['flags'][1]""" + } + and: "should have assertion for element at index 2 (true)" + pathAndValues.find { + it.method() == """.array("['flags']").elementWithIndex(2).isEqualTo(true)""" && + it.jsonPath() == """\$.['flags'][2]""" + } + and: "should have exactly 4 assertions (1 size + 3 elements)" + pathAndValues.size() == 4 + } + + def "should generate ordered assertions for mixed primitive array"() { + given: + Map json = [ + mixed: ["text", 123, true] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have size check" + pathAndValues.find { + it.method() == """.array("['mixed']").hasSize(3)""" && + it.jsonPath() == """\$.['mixed']""" + } + and: "should have assertion for string at index 0" + pathAndValues.find { + it.method() == """.array("['mixed']").elementWithIndex(0).isEqualTo("text")""" && + it.jsonPath() == """\$.['mixed'][0]""" + } + and: "should have assertion for number at index 1" + pathAndValues.find { + it.method() == """.array("['mixed']").elementWithIndex(1).isEqualTo(123)""" && + it.jsonPath() == """\$.['mixed'][1]""" + } + and: "should have assertion for boolean at index 2" + pathAndValues.find { + it.method() == """.array("['mixed']").elementWithIndex(2).isEqualTo(true)""" && + it.jsonPath() == """\$.['mixed'][2]""" + } + and: "should have exactly 4 assertions (1 size + 3 elements)" + pathAndValues.size() == 4 + } + + // ========== Object Arrays ========== + + def "should generate ordered assertions for array of objects"() { + given: + Map json = [ + users: [ + [name: "Alice", age: 30], + [name: "Bob", age: 25] + ] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have assertion for users[0].name" + pathAndValues.find { + it.method() == """.array("['users']").elementWithIndex(0).field("['name']").isEqualTo("Alice")""" && + it.jsonPath() == """\$.['users'][0][?(@.['name'] == 'Alice')]""" + } + and: "should have assertion for users[0].age" + pathAndValues.find { + it.method() == """.array("['users']").elementWithIndex(0).field("['age']").isEqualTo(30)""" && + it.jsonPath() == """\$.['users'][0][?(@.['age'] == 30)]""" + } + and: "should have assertion for users[1].name" + pathAndValues.find { + it.method() == """.array("['users']").elementWithIndex(1).field("['name']").isEqualTo("Bob")""" && + it.jsonPath() == """\$.['users'][1][?(@.['name'] == 'Bob')]""" + } + and: "should have assertion for users[1].age" + pathAndValues.find { + it.method() == """.array("['users']").elementWithIndex(1).field("['age']").isEqualTo(25)""" && + it.jsonPath() == """\$.['users'][1][?(@.['age'] == 25)]""" + } + and: "should have exactly 4 assertions (2 users x 2 fields)" + pathAndValues.size() == 4 + } + + def "should generate ordered assertions for array of objects with same values"() { + given: + Map json = [ + entries: [ + [status: "active"], + [status: "active"], + [status: "inactive"] + ] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have assertion for entries[0].status = active" + pathAndValues.find { + it.method() == """.array("['entries']").elementWithIndex(0).field("['status']").isEqualTo("active")""" && + it.jsonPath() == """\$.['entries'][0][?(@.['status'] == 'active')]""" + } + and: "should have assertion for entries[1].status = active (same value, different index)" + pathAndValues.find { + it.method() == """.array("['entries']").elementWithIndex(1).field("['status']").isEqualTo("active")""" && + it.jsonPath() == """\$.['entries'][1][?(@.['status'] == 'active')]""" + } + and: "should have assertion for entries[2].status = inactive" + pathAndValues.find { + it.method() == """.array("['entries']").elementWithIndex(2).field("['status']").isEqualTo("inactive")""" && + it.jsonPath() == """\$.['entries'][2][?(@.['status'] == 'inactive')]""" + } + and: "should have exactly 3 assertions" + pathAndValues.size() == 3 + } + + // ========== Nested Arrays ========== + + def "should generate ordered assertions for nested primitive arrays"() { + given: + Map json = [ + matrix: [ + ["a", "b"], + ["c", "d"] + ] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have assertion for matrix[0][0] = a" + pathAndValues.find { + it.method().contains("elementWithIndex(0)") && it.method().contains("isEqualTo(\"a\")") + } + and: "should have assertion for matrix[0][1] = b" + pathAndValues.find { + it.method().contains("elementWithIndex(0)") && it.method().contains("isEqualTo(\"b\")") + } + and: "should have assertion for matrix[1][0] = c" + pathAndValues.find { + it.method().contains("elementWithIndex(1)") && it.method().contains("isEqualTo(\"c\")") + } + and: "should have assertion for matrix[1][1] = d" + pathAndValues.find { + it.method().contains("elementWithIndex(1)") && it.method().contains("isEqualTo(\"d\")") + } + } + + def "should generate ordered assertions for array with nested objects - all fields"() { + given: + Map json = [ + orders: [ + [ + id: 1, + items: [ + [name: "item1", qty: 2], + [name: "item2", qty: 3] + ] + ], + [ + id: 2, + items: [ + [name: "item3", qty: 1] + ] + ] + ] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have assertion for orders[0].id" + pathAndValues.find { + it.method() == """.array("['orders']").elementWithIndex(0).field("['id']").isEqualTo(1)""" && + it.jsonPath() == """\$.['orders'][0][?(@.['id'] == 1)]""" + } + and: "should have assertion for orders[0].items[0].name" + pathAndValues.find { + it.method().contains("elementWithIndex(0)") && + it.method().contains("['items']") && + it.method().contains("['name']") && + it.method().contains("isEqualTo(\"item1\")") + } + and: "should have assertion for orders[0].items[0].qty" + pathAndValues.find { + it.method().contains("elementWithIndex(0)") && + it.method().contains("['items']") && + it.method().contains("['qty']") && + it.method().contains("isEqualTo(2)") + } + and: "should have assertion for orders[0].items[1].name" + pathAndValues.find { + it.method().contains("elementWithIndex(0)") && + it.method().contains("['items']") && + it.method().contains("['name']") && + it.method().contains("isEqualTo(\"item2\")") + } + and: "should have assertion for orders[0].items[1].qty" + pathAndValues.find { + it.method().contains("elementWithIndex(0)") && + it.method().contains("['items']") && + it.method().contains("['qty']") && + it.method().contains("isEqualTo(3)") + } + and: "should have assertion for orders[1].id" + pathAndValues.find { + it.method() == """.array("['orders']").elementWithIndex(1).field("['id']").isEqualTo(2)""" && + it.jsonPath() == """\$.['orders'][1][?(@.['id'] == 2)]""" + } + and: "should have assertion for orders[1].items[0].name" + pathAndValues.find { + it.method().contains("elementWithIndex(1)") && + it.method().contains("['items']") && + it.method().contains("['name']") && + it.method().contains("isEqualTo(\"item3\")") + } + and: "should have assertion for orders[1].items[0].qty" + pathAndValues.find { + it.method().contains("elementWithIndex(1)") && + it.method().contains("['items']") && + it.method().contains("['qty']") && + it.method().contains("isEqualTo(1)") + } + } + + // ========== Root Level Arrays ========== + + def "should generate ordered assertions for root level array of primitives"() { + given: + String json = """["first", "second", "third"]""" + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(new JsonSlurper().parseText(json)) + then: "should have size check for root array" + pathAndValues.find { + it.method() == """.hasSize(3)""" && + it.jsonPath() == """\$""" + } + and: "should have assertion for element at index 0" + pathAndValues.find { + it.method() == """.array().elementWithIndex(0).isEqualTo("first")""" && + it.jsonPath() == """\$[*][0]""" + } + and: "should have assertion for element at index 1" + pathAndValues.find { + it.method() == """.array().elementWithIndex(1).isEqualTo("second")""" && + it.jsonPath() == """\$[*][1]""" + } + and: "should have assertion for element at index 2" + pathAndValues.find { + it.method() == """.array().elementWithIndex(2).isEqualTo("third")""" && + it.jsonPath() == """\$[*][2]""" + } + and: "should have exactly 4 assertions (1 size + 3 elements)" + pathAndValues.size() == 4 + } + + def "should generate ordered assertions for root level array of objects"() { + given: + String json = """[ + {"property1": "a"}, + {"property2": "b"} +]""" + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(new JsonSlurper().parseText(json)) + then: "should have assertion for [0].property1" + pathAndValues.find { + it.method() == """.array().elementWithIndex(0).field("['property1']").isEqualTo("a")""" && + it.jsonPath() == """\$[*][0][?(@.['property1'] == 'a')]""" + } + and: "should have assertion for [1].property2" + pathAndValues.find { + it.method() == """.array().elementWithIndex(1).field("['property2']").isEqualTo("b")""" && + it.jsonPath() == """\$[*][1][?(@.['property2'] == 'b')]""" + } + and: "should have exactly 2 assertions (2 objects with 1 field each)" + pathAndValues.size() == 2 + } + + // ========== Complex Real-World Scenarios ========== + + def "should generate ordered assertions for response with errors array - all fields"() { + given: + Map json = [ + errors: [ + [property: "email", message: "invalid format"], + [property: "phone", message: "required field"], + [property: "age", message: "must be positive"] + ] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have assertion for errors[0].property" + pathAndValues.find { + it.method() == """.array("['errors']").elementWithIndex(0).field("['property']").isEqualTo("email")""" && + it.jsonPath() == """\$.['errors'][0][?(@.['property'] == 'email')]""" + } + and: "should have assertion for errors[0].message" + pathAndValues.find { + it.method() == """.array("['errors']").elementWithIndex(0).field("['message']").isEqualTo("invalid format")""" && + it.jsonPath() == """\$.['errors'][0][?(@.['message'] == 'invalid format')]""" + } + and: "should have assertion for errors[1].property" + pathAndValues.find { + it.method() == """.array("['errors']").elementWithIndex(1).field("['property']").isEqualTo("phone")""" && + it.jsonPath() == """\$.['errors'][1][?(@.['property'] == 'phone')]""" + } + and: "should have assertion for errors[1].message" + pathAndValues.find { + it.method() == """.array("['errors']").elementWithIndex(1).field("['message']").isEqualTo("required field")""" && + it.jsonPath() == """\$.['errors'][1][?(@.['message'] == 'required field')]""" + } + and: "should have assertion for errors[2].property" + pathAndValues.find { + it.method() == """.array("['errors']").elementWithIndex(2).field("['property']").isEqualTo("age")""" && + it.jsonPath() == """\$.['errors'][2][?(@.['property'] == 'age')]""" + } + and: "should have assertion for errors[2].message" + pathAndValues.find { + it.method() == """.array("['errors']").elementWithIndex(2).field("['message']").isEqualTo("must be positive")""" && + it.jsonPath() == """\$.['errors'][2][?(@.['message'] == 'must be positive')]""" + } + and: "should have exactly 6 assertions (3 errors x 2 fields)" + pathAndValues.size() == 6 + } + + def "should generate ordered assertions for paginated response - all fields"() { + given: + Map json = [ + page: 1, + totalPages: 5, + data: [ + [id: 101, name: "First Item"], + [id: 102, name: "Second Item"], + [id: 103, name: "Third Item"] + ] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have assertion for page" + pathAndValues.find { + it.method() == """.field("['page']").isEqualTo(1)""" && + it.jsonPath() == """\$[?(@.['page'] == 1)]""" + } + and: "should have assertion for totalPages" + pathAndValues.find { + it.method() == """.field("['totalPages']").isEqualTo(5)""" && + it.jsonPath() == """\$[?(@.['totalPages'] == 5)]""" + } + and: "should have assertion for data[0].id" + pathAndValues.find { + it.method() == """.array("['data']").elementWithIndex(0).field("['id']").isEqualTo(101)""" && + it.jsonPath() == """\$.['data'][0][?(@.['id'] == 101)]""" + } + and: "should have assertion for data[0].name" + pathAndValues.find { + it.method() == """.array("['data']").elementWithIndex(0).field("['name']").isEqualTo("First Item")""" && + it.jsonPath() == """\$.['data'][0][?(@.['name'] == 'First Item')]""" + } + and: "should have assertion for data[1].id" + pathAndValues.find { + it.method() == """.array("['data']").elementWithIndex(1).field("['id']").isEqualTo(102)""" && + it.jsonPath() == """\$.['data'][1][?(@.['id'] == 102)]""" + } + and: "should have assertion for data[1].name" + pathAndValues.find { + it.method() == """.array("['data']").elementWithIndex(1).field("['name']").isEqualTo("Second Item")""" && + it.jsonPath() == """\$.['data'][1][?(@.['name'] == 'Second Item')]""" + } + and: "should have assertion for data[2].id" + pathAndValues.find { + it.method() == """.array("['data']").elementWithIndex(2).field("['id']").isEqualTo(103)""" && + it.jsonPath() == """\$.['data'][2][?(@.['id'] == 103)]""" + } + and: "should have assertion for data[2].name" + pathAndValues.find { + it.method() == """.array("['data']").elementWithIndex(2).field("['name']").isEqualTo("Third Item")""" && + it.jsonPath() == """\$.['data'][2][?(@.['name'] == 'Third Item')]""" + } + and: "should have exactly 8 assertions (2 metadata fields + 3 data items x 2 fields)" + pathAndValues.size() == 8 + } + + def "should generate ordered assertions for timeline/sequence data - all fields"() { + given: + Map json = [ + events: [ + [timestamp: "2024-01-01T10:00:00Z", action: "created"], + [timestamp: "2024-01-01T10:05:00Z", action: "updated"], + [timestamp: "2024-01-01T10:10:00Z", action: "published"] + ] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have assertion for events[0].timestamp" + pathAndValues.find { + it.method() == """.array("['events']").elementWithIndex(0).field("['timestamp']").isEqualTo("2024-01-01T10:00:00Z")""" && + it.jsonPath() == """\$.['events'][0][?(@.['timestamp'] == '2024-01-01T10:00:00Z')]""" + } + and: "should have assertion for events[0].action" + pathAndValues.find { + it.method() == """.array("['events']").elementWithIndex(0).field("['action']").isEqualTo("created")""" && + it.jsonPath() == """\$.['events'][0][?(@.['action'] == 'created')]""" + } + and: "should have assertion for events[1].timestamp" + pathAndValues.find { + it.method() == """.array("['events']").elementWithIndex(1).field("['timestamp']").isEqualTo("2024-01-01T10:05:00Z")""" && + it.jsonPath() == """\$.['events'][1][?(@.['timestamp'] == '2024-01-01T10:05:00Z')]""" + } + and: "should have assertion for events[1].action" + pathAndValues.find { + it.method() == """.array("['events']").elementWithIndex(1).field("['action']").isEqualTo("updated")""" && + it.jsonPath() == """\$.['events'][1][?(@.['action'] == 'updated')]""" + } + and: "should have assertion for events[2].timestamp" + pathAndValues.find { + it.method() == """.array("['events']").elementWithIndex(2).field("['timestamp']").isEqualTo("2024-01-01T10:10:00Z")""" && + it.jsonPath() == """\$.['events'][2][?(@.['timestamp'] == '2024-01-01T10:10:00Z')]""" + } + and: "should have assertion for events[2].action" + pathAndValues.find { + it.method() == """.array("['events']").elementWithIndex(2).field("['action']").isEqualTo("published")""" && + it.jsonPath() == """\$.['events'][2][?(@.['action'] == 'published')]""" + } + and: "should have exactly 6 assertions (3 events x 2 fields)" + pathAndValues.size() == 6 + } + + // ========== Edge Cases ========== + + def "should generate ordered assertions for single element array"() { + given: + Map json = [ + items: ["only"] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have size check" + pathAndValues.find { + it.method() == """.array("['items']").hasSize(1)""" && + it.jsonPath() == """\$.['items']""" + } + and: "should have assertion for the single element at index 0" + pathAndValues.find { + it.method() == """.array("['items']").elementWithIndex(0).isEqualTo("only")""" && + it.jsonPath() == """\$.['items'][0]""" + } + and: "should have exactly 2 assertions (1 size + 1 element)" + pathAndValues.size() == 2 + } + + def "should not generate assertions for empty array"() { + given: + Map json = [ + items: [] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have exactly 1 assertion (empty array check)" + pathAndValues.size() == 1 + and: "should not have any elementWithIndex assertions" + !pathAndValues.find { it.method().contains("elementWithIndex") } + } + + def "should handle array with decimal numbers"() { + given: + Map json = [ + prices: [19.99, 29.99, 9.99] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have size check" + pathAndValues.find { + it.method() == """.array("['prices']").hasSize(3)""" && + it.jsonPath() == """\$.['prices']""" + } + and: "should have assertion for prices[0]" + pathAndValues.find { + it.method() == """.array("['prices']").elementWithIndex(0).isEqualTo(19.99)""" && + it.jsonPath() == """\$.['prices'][0]""" + } + and: "should have assertion for prices[1]" + pathAndValues.find { + it.method() == """.array("['prices']").elementWithIndex(1).isEqualTo(29.99)""" && + it.jsonPath() == """\$.['prices'][1]""" + } + and: "should have assertion for prices[2]" + pathAndValues.find { + it.method() == """.array("['prices']").elementWithIndex(2).isEqualTo(9.99)""" && + it.jsonPath() == """\$.['prices'][2]""" + } + and: "should have exactly 4 assertions (1 size + 3 elements)" + pathAndValues.size() == 4 + } + + // ========== Comparison with Unordered ========== + + def "ordered verification should produce different results than unordered"() { + given: + Map json = [ + items: ["a", "b", "c"] + ] + when: + JsonPaths orderedPaths = converter().transformToJsonPathWithTestsSideValues(json) + JsonPaths unorderedPaths = new JsonToJsonPathsConverter(false).transformToJsonPathWithTestsSideValues(json) + then: "ordered should use elementWithIndex" + orderedPaths.any { it.method().contains("elementWithIndex") } + and: "unordered should not use elementWithIndex" + !unorderedPaths.any { it.method().contains("elementWithIndex") } + and: "ordered should have exact index paths [0], [1], [2]" + orderedPaths.find { it.method() == """.array("['items']").elementWithIndex(0).isEqualTo("a")""" && + it.jsonPath() == """\$.['items'][0]""" } + orderedPaths.find { it.method() == """.array("['items']").elementWithIndex(1).isEqualTo("b")""" && + it.jsonPath() == """\$.['items'][1]""" } + orderedPaths.find { it.method() == """.array("['items']").elementWithIndex(2).isEqualTo("c")""" && + it.jsonPath() == """\$.['items'][2]""" } + and: "unordered should use arrayField with filtered json paths" + unorderedPaths.find { it.method() == """.array("['items']").arrayField().isEqualTo("a").value()""" && + it.jsonPath() == """\$.['items'][?(@ == 'a')]""" } + unorderedPaths.find { it.method() == """.array("['items']").arrayField().isEqualTo("b").value()""" && + it.jsonPath() == """\$.['items'][?(@ == 'b')]""" } + unorderedPaths.find { it.method() == """.array("['items']").arrayField().isEqualTo("c").value()""" && + it.jsonPath() == """\$.['items'][?(@ == 'c')]""" } + } + + // ========== JSON Path Validity ========== + + def "all generated json paths should be valid and account for all elements"() { + given: + Map json = [ + users: [ + [name: "Alice", roles: ["admin", "user"]], + [name: "Bob", roles: ["user"]] + ], + metadata: [ + version: "1.0", + tags: ["important", "reviewed"] + ] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + DocumentContext context = JsonPath.parse(JsonOutput.toJson(json)) + then: "all JSON paths should be valid" + pathAndValues.each { path -> + try { + context.read(path.jsonPath()) + } + catch (Exception e) { + // Some paths with filters may not match but should still be valid syntax + assert path.jsonPath().startsWith('$') + } + } + and: "should have assertions for users[0].name" + pathAndValues.find { it.method().contains("elementWithIndex(0)") && it.method().contains("['name']") && it.method().contains("Alice") } + and: "should have assertions for users[1].name" + pathAndValues.find { it.method().contains("elementWithIndex(1)") && it.method().contains("['name']") && it.method().contains("Bob") } + and: "should have assertions for metadata.version" + pathAndValues.find { it.method().contains("['version']") && it.method().contains("1.0") } + and: "should have assertions for metadata.tags" + pathAndValues.find { it.method().contains("['tags']") && it.method().contains("important") } + pathAndValues.find { it.method().contains("['tags']") && it.method().contains("reviewed") } + } + + // ========== Stub Side Values ========== + + def "should generate ordered assertions for stub side values - all elements"() { + given: + Map json = [ + items: ["one", "two", "three"] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithStubsSideValues(json) + then: "should have size check" + pathAndValues.find { it.method().contains("hasSize(3)") } + and: "should have assertion for items[0]" + pathAndValues.find { + it.method().contains("elementWithIndex(0)") && it.method().contains("isEqualTo(\"one\")") + } + and: "should have assertion for items[1]" + pathAndValues.find { + it.method().contains("elementWithIndex(1)") && it.method().contains("isEqualTo(\"two\")") + } + and: "should have assertion for items[2]" + pathAndValues.find { + it.method().contains("elementWithIndex(2)") && it.method().contains("isEqualTo(\"three\")") + } + and: "should have exactly 4 assertions (1 size + 3 elements)" + pathAndValues.size() == 4 + } + + // ========== Additional Complex Scenarios ========== + + def "should generate ordered assertions for deeply nested structure"() { + given: + Map json = [ + level1: [ + level2: [ + items: [ + [id: 1, data: [value: "a"]], + [id: 2, data: [value: "b"]] + ] + ] + ] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have assertion for level1.level2.items[0].id" + pathAndValues.find { + it.method().contains("['level1']") && + it.method().contains("['level2']") && + it.method().contains("['items']") && + it.method().contains("elementWithIndex(0)") && + it.method().contains("['id']") && + it.method().contains("isEqualTo(1)") + } + and: "should have assertion for level1.level2.items[0].data.value" + pathAndValues.find { + it.method().contains("elementWithIndex(0)") && + it.method().contains("['data']") && + it.method().contains("['value']") && + it.method().contains("isEqualTo(\"a\")") + } + and: "should have assertion for level1.level2.items[1].id" + pathAndValues.find { + it.method().contains("elementWithIndex(1)") && + it.method().contains("['id']") && + it.method().contains("isEqualTo(2)") + } + and: "should have assertion for level1.level2.items[1].data.value" + pathAndValues.find { + it.method().contains("elementWithIndex(1)") && + it.method().contains("['data']") && + it.method().contains("['value']") && + it.method().contains("isEqualTo(\"b\")") + } + } + + def "should generate ordered assertions for array with null values"() { + given: + Map json = [ + items: [ + [name: "first", value: null], + [name: "second", value: 123] + ] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have assertion for items[0].name" + pathAndValues.find { + it.method().contains("elementWithIndex(0)") && + it.method().contains("['name']") && + it.method().contains("isEqualTo(\"first\")") + } + and: "should have assertion for items[0].value being null" + pathAndValues.find { + it.method().contains("elementWithIndex(0)") && + it.method().contains("['value']") && + it.method().contains("isNull()") + } + and: "should have assertion for items[1].name" + pathAndValues.find { + it.method().contains("elementWithIndex(1)") && + it.method().contains("['name']") && + it.method().contains("isEqualTo(\"second\")") + } + and: "should have assertion for items[1].value" + pathAndValues.find { + it.method().contains("elementWithIndex(1)") && + it.method().contains("['value']") && + it.method().contains("isEqualTo(123)") + } + } + + def "should generate ordered assertions for multiple arrays in same object"() { + given: + Map json = [ + names: ["Alice", "Bob"], + ages: [30, 25], + active: [true, false] + ] + when: + JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) + then: "should have size checks for all arrays" + pathAndValues.find { it.method().contains("['names']") && it.method().contains("hasSize(2)") } + pathAndValues.find { it.method().contains("['ages']") && it.method().contains("hasSize(2)") } + pathAndValues.find { it.method().contains("['active']") && it.method().contains("hasSize(2)") } + and: "should have assertions for names array" + pathAndValues.find { it.method().contains("['names']") && it.method().contains("elementWithIndex(0)") && it.method().contains("Alice") } + pathAndValues.find { it.method().contains("['names']") && it.method().contains("elementWithIndex(1)") && it.method().contains("Bob") } + and: "should have assertions for ages array" + pathAndValues.find { it.method().contains("['ages']") && it.method().contains("elementWithIndex(0)") && it.method().contains("isEqualTo(30)") } + pathAndValues.find { it.method().contains("['ages']") && it.method().contains("elementWithIndex(1)") && it.method().contains("isEqualTo(25)") } + and: "should have assertions for active array" + pathAndValues.find { it.method().contains("['active']") && it.method().contains("elementWithIndex(0)") && it.method().contains("isEqualTo(true)") } + pathAndValues.find { it.method().contains("['active']") && it.method().contains("elementWithIndex(1)") && it.method().contains("isEqualTo(false)") } + and: "should have exactly 9 assertions (3 arrays x (1 size + 2 elements))" + pathAndValues.size() == 9 + } + +} diff --git a/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilderTest.java b/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilderTest.java index 5b48d0d6ab..08537014e6 100644 --- a/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilderTest.java +++ b/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilderTest.java @@ -1,11 +1,28 @@ +/* + * Copyright 2013-present 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 + * + * https://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 java.util.Optional; + import org.junit.Test; + import org.springframework.cloud.contract.spec.Contract; import org.springframework.cloud.contract.spec.internal.BodyMatchers; -import java.util.Optional; - import static com.toomuchcoding.jsonassert.JsonAssertion.assertThat; public class XmlBodyVerificationBuilderTest { @@ -30,4 +47,4 @@ public void shouldAddXmlProcessingLines() { .contains(xml); } -} \ No newline at end of file +}