From 1f38c4171a02557d3c6453f53b003a18aa0f9d0d Mon Sep 17 00:00:00 2001 From: marcin Date: Wed, 7 Jan 2026 00:28:01 +0100 Subject: [PATCH 1/4] Adds support for doing exact, array based tests we're reusing the old property (spring.cloud.contract.verifier.assert.size) that can be set through system props or plugins. With this change finally there's an option to generate exact array verificaiton checks. Before the checks were indeed lazy, they assumed that any element should contain given values. Now they verify each element and its contents. Also this terrible JsonToJsonPathConverter class got cut into pieces and converted to Java. fixes gh-1927 --- .../verifier/util/ContentUtils.groovy | 7 + .../util/JsonToJsonPathsConverter.groovy | 634 -------------- .../util/DelegatingJsonVerifiable.java | 4 +- .../verifier/util/JsonPathMatcherUtils.java | 312 +++++++ .../verifier/util/JsonPathTraverser.java | 333 +++++++ .../util/JsonToJsonPathsConverter.java | 246 ++++++ .../util/MethodBufferingJsonVerifiable.java | 3 + .../util/JsonPathMatcherUtilsSpec.groovy | 319 +++++++ .../util/JsonPathTraverserSpec.groovy | 396 +++++++++ .../util/JsonToJsonPathsConverterSpec.groovy | 65 +- ...sonPathsConverterWithArrayCheckSpec.groovy | 814 ++++++++++++++++++ 11 files changed, 2495 insertions(+), 638 deletions(-) delete mode 100644 spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverter.groovy create mode 100644 spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonPathMatcherUtils.java create mode 100644 spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonPathTraverser.java create mode 100644 spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverter.java create mode 100644 spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonPathMatcherUtilsSpec.groovy create mode 100644 spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonPathTraverserSpec.groovy create mode 100644 spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverterWithArrayCheckSpec.groovy 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/util/DelegatingJsonVerifiable.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/DelegatingJsonVerifiable.java index 4d81cd49c1..c4a125ad0f 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,10 @@ public MethodBufferingJsonVerifiable array() { } @Override - public JsonVerifiable elementWithIndex(int i) { + public MethodBufferingJsonVerifiable elementWithIndex(int i) { DelegatingJsonVerifiable verifiable = new DelegatingJsonVerifiable(this.delegate.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/JsonPathMatcherUtils.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonPathMatcherUtils.java new file mode 100644 index 0000000000..36d4f32364 --- /dev/null +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonPathMatcherUtils.java @@ -0,0 +1,312 @@ +/* + * 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 Iterable)) { + return false; + } + for (Object item : (Iterable) object) { + if (item instanceof Map && !((java.util.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().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..4f7684df2e --- /dev/null +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/JsonPathTraverser.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.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(Map.class, rootKey, json, collector); + } + + @SuppressWarnings("unchecked") + private Object processValue(Class parentType, 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(Object.class, 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(List.class, key, map, collector); + } + return convertMapEntries(Map.class, 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) { + for (int i = 0; i < list.size(); i++) { + Object element = ContentUtils.returnParsedObject(list.get(i)); + MethodBufferingJsonVerifiable indexedKey = key.elementWithIndex(i); + processValue(Object.class, valueToAsserter(indexedKey, element), element, collector); + } + } + else { + MethodBufferingJsonVerifiable arrayKey = key.arrayField(); + for (Object item : list) { + Object element = ContentUtils.returnParsedObject(item); + processValue(Object.class, valueToAsserter(arrayKey, element), element, collector); + } + } + return list; + } + + private Object processComplexList(MethodBufferingJsonVerifiable key, List list, + Consumer collector) { + + addSizeCheckIfEnabled(key, list, collector); + + if (this.useOrderedArrayVerification) { + for (int i = 0; i < list.size(); i++) { + Object element = ContentUtils.returnParsedObject(list.get(i)); + MethodBufferingJsonVerifiable indexedKey = key.elementWithIndex(i); + processValue(List.class, createListElementAsserter(indexedKey, element), element, collector); + } + } + else { + MethodBufferingJsonVerifiable arrayKey = createArrayAsserter(key, list); + for (Object element : list) { + Object parsed = ContentUtils.returnParsedObject(element); + processValue(List.class, createListElementAsserter(arrayKey, parsed), parsed, collector); + } + } + return list; + } + + // ========== Map Entry Processing ========== + + private Map convertMapEntries(Class parentType, 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(parentType, 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)); + } + return valueToAsserter(parentKey.field(entryKey), value); + } + + private MethodBufferingJsonVerifiable createListFieldVerifiable(List list, Object entryKey, + MethodBufferingJsonVerifiable parentKey) { + if (list.isEmpty()) { + return parentKey.array(entryKey).isEmpty(); + } + return listContainsOnlyPrimitives(list) ? parentKey.arrayField(entryKey) : 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); + if (parsed instanceof Pattern) { + return verifiable.matches(((Pattern) parsed).pattern()); + } + return verifiable.contains(parsed); + } + if (element instanceof List && listContainsOnlyPrimitives((List) element)) { + 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; + } + 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) { + if (value instanceof ExecutionProperty || !(key instanceof FinishedDelegatingJsonVerifiable)) { + if (key.isAssertingAValueInArray() && !(value instanceof List || value instanceof Map)) { + collector.accept(valueToAsserter(key, value)); + } + else { + collector.accept(key); + } + } + 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/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..e89077f205 --- /dev/null +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonPathMatcherUtilsSpec.groovy @@ -0,0 +1,319 @@ +/* + * 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 groovy.json.JsonSlurper +import spock.lang.Specification + +import org.springframework.cloud.contract.spec.internal.BodyMatchers +import org.springframework.cloud.contract.spec.internal.RegexProperty + +/** + * 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 = new JsonSlurper().parseText(''' + { + "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 = new JsonSlurper().parseText(''' + { + "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 = new JsonSlurper().parseText(''' + { + "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 = new JsonSlurper().parseText(''' + { + "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 = new JsonSlurper().parseText(''' + { + "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 = new JsonSlurper().parseText(''' + { + "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 = new JsonSlurper().parseText(''' + { + "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 generated value for RegexProperty'() { + given: + def regexProperty = new RegexProperty('[A-Z]+').asString() + when: + def result = JsonPathMatcherUtils.generatedValueIfNeeded(regexProperty) + then: + result instanceof String + } + + 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 = new JsonSlurper().parseText(''' + { + "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 = new JsonSlurper().parseText(''' + { + "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: + result.contains('http:\\/\\/example.com\\/path') + } + + def 'should read root level array'() { + given: + def json = new JsonSlurper().parseText(''' + [ + {"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 = new JsonSlurper().parseText(''' + { + "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..2c9ddbaa97 --- /dev/null +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonPathTraverserSpec.groovy @@ -0,0 +1,396 @@ +/* + * 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]') && it.jsonPath().contains('1') } + collected.any { it.jsonPath().contains('[1]') && it.jsonPath().contains('2') } + collected.any { it.jsonPath().contains('[2]') && it.jsonPath().contains('3') } + } + + 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('[*]') && it.jsonPath().contains('1') } + collected.any { it.jsonPath().contains('[*]') && it.jsonPath().contains('2') } + collected.any { it.jsonPath().contains('[*]') && 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]') && it.jsonPath().contains('red') } + collected.any { it.jsonPath().contains('[1]') && it.jsonPath().contains('green') } + collected.any { it.jsonPath().contains('[2]') && it.jsonPath().contains('blue') } + } + + 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]') && it.jsonPath().contains('[0]') && it.jsonPath().contains('1') } + collected.any { it.jsonPath().contains('[0]') && it.jsonPath().contains('[1]') && it.jsonPath().contains('2') } + collected.any { it.jsonPath().contains('[1]') && it.jsonPath().contains('[0]') && it.jsonPath().contains('3') } + collected.any { it.jsonPath().contains('[1]') && it.jsonPath().contains('[1]') && it.jsonPath().contains('4') } + } + + 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]') && it.jsonPath().contains('text') } + collected.any { it.jsonPath().contains('[1]') && it.jsonPath().contains('42') } + collected.any { it.jsonPath().contains('[2]') && it.jsonPath().contains('true') } + collected.any { it.jsonPath().contains('[3]') && it.jsonPath().contains('3.14') } + } + + 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..ed67055fe1 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 @@ -885,7 +885,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 +975,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..39e8b53736 --- /dev/null +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/JsonToJsonPathsConverterWithArrayCheckSpec.groovy @@ -0,0 +1,814 @@ +/* + * 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 size check for root array" + pathAndValues.find { + it.method() == """.hasSize(2)""" && + it.jsonPath() == """\$""" + } + and: "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 3 assertions (1 size + 2 objects with 1 field each)" + pathAndValues.size() == 3 + } + + // ========== 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.any { it.jsonPath().contains("[0]") } + orderedPaths.any { it.jsonPath().contains("[1]") } + orderedPaths.any { it.jsonPath().contains("[2]") } + and: "unordered should use wildcard [*]" + unorderedPaths.every { it.jsonPath().contains("[*]") || !it.jsonPath().contains("[") } + } + + // ========== 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 + } + +} From 2a30f6aa94be818c49a746ad0052a2e990f19794 Mon Sep 17 00:00:00 2001 From: marcin Date: Wed, 7 Jan 2026 01:16:05 +0100 Subject: [PATCH 2/4] Adds support for doing exact, array-based tests we're reusing the old property (spring.cloud.contract.verifier.assert.size) that can be set through system props or plugins. With this change finally there's an option to generate exact array verificaiton checks. Before the checks were indeed lazy, they assumed that any element should contain given values. Now they verify each element and its contents. Also this terrible JsonToJsonPathConverter class got cut into pieces and converted to Java. fixes gh-1927 --- .../FinishedDelegatingJsonVerifiable.java | 5 + .../verifier/util/JsonPathTraverser.java | 55 ++++--- .../util/JsonPathMatcherUtilsSpec.groovy | 145 +++++++----------- 3 files changed, 96 insertions(+), 109 deletions(-) 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..c4e4644570 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,9 @@ public String keyBeforeChecking() { return this.keyBeforeChecking; } + @Override + public String jsonPath() { + return this.keyBeforeChecking; + } + } 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 index 4f7684df2e..e29417d084 100644 --- 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 @@ -65,7 +65,7 @@ private Object processValue(Class parentType, MethodBufferingJsonVerifiable k return processString(key, s, collector); } if (value instanceof Map) { - return processMap(key, (Map) value, collector); + return processMap(key, (Map) value, collector); } if (value instanceof List) { return processList(key, (List) value, collector); @@ -92,7 +92,7 @@ private Object processString(MethodBufferingJsonVerifiable key, String value, } @SuppressWarnings("unchecked") - private Object processMap(MethodBufferingJsonVerifiable key, Map map, + private Object processMap(MethodBufferingJsonVerifiable key, Map map, Consumer collector) { if (map.isEmpty()) { return emitValue(collector, key.isEmpty(), map); @@ -164,11 +164,11 @@ private Object processComplexList(MethodBufferingJsonVerifiable key, List lis // ========== Map Entry Processing ========== - private Map convertMapEntries(Class parentType, MethodBufferingJsonVerifiable parentKey, - Map map, Consumer collector) { + private Map convertMapEntries(Class parentType, MethodBufferingJsonVerifiable parentKey, + Map map, Consumer collector) { - Map result = new LinkedHashMap<>(); - for (Map.Entry entry : map.entrySet()) { + 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); @@ -182,10 +182,10 @@ private MethodBufferingJsonVerifiable createKeyVerifiable(MethodBufferingJsonVer if (value instanceof List) { return createListFieldVerifiable((List) value, entryKey, parentKey); } - if (value instanceof Map) { - return parentKey.field(new ShouldTraverse(entryKey)); - } - return valueToAsserter(parentKey.field(entryKey), value); + // 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)); } private MethodBufferingJsonVerifiable createListFieldVerifiable(List list, Object entryKey, @@ -218,6 +218,11 @@ private MethodBufferingJsonVerifiable createListElementAsserter(MethodBufferingJ 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()); } @@ -247,6 +252,13 @@ MethodBufferingJsonVerifiable valueToAsserter(MethodBufferingJsonVerifiable key, 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); } @@ -269,13 +281,18 @@ private void addSizeCheckIfEnabled(MethodBufferingJsonVerifiable key, List li private Object emitValue(Consumer collector, MethodBufferingJsonVerifiable key, Object value) { - if (value instanceof ExecutionProperty || !(key instanceof FinishedDelegatingJsonVerifiable)) { - if (key.isAssertingAValueInArray() && !(value instanceof List || value instanceof Map)) { - collector.accept(valueToAsserter(key, value)); - } - else { - collector.accept(key); - } + 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; } @@ -326,8 +343,8 @@ private boolean isListOfLists(List list) { } @SuppressWarnings("unchecked") - private Map castToMap(Object obj) { - return (Map) obj; + private Map castToMap(Object obj) { + return (Map) obj; } } 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 index e89077f205..bb4d287835 100644 --- 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 @@ -32,14 +32,12 @@ class JsonPathMatcherUtilsSpec extends Specification { def 'should read element from JSON by path'() { given: - def json = new JsonSlurper().parseText(''' - { - "person": { - "name": "John", - "age": 30 - } - } - ''') + def json = [ + person: [ + name: "John", + age: 30 + ] + ] when: def name = JsonPathMatcherUtils.readElement(json, '$.person.name') def age = JsonPathMatcherUtils.readElement(json, '$.person.age') @@ -50,14 +48,12 @@ class JsonPathMatcherUtilsSpec extends Specification { def 'should read nested array element from JSON'() { given: - def json = new JsonSlurper().parseText(''' - { - "items": [ - {"id": 1, "name": "first"}, - {"id": 2, "name": "second"} - ] - } - ''') + 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') @@ -68,15 +64,13 @@ class JsonPathMatcherUtilsSpec extends Specification { def 'should remove matching JSON paths from body'() { given: - def json = new JsonSlurper().parseText(''' - { - "person": { - "name": "John", - "age": 30, - "email": "john@example.com" - } - } - ''') + def json = [ + person: [ + name: "John", + age: 30, + email: "john@example.com" + ] + ] def bodyMatchers = new BodyMatchers() bodyMatchers.jsonPath('$.person.email', bodyMatchers.byRegex('.*')) when: @@ -89,11 +83,7 @@ class JsonPathMatcherUtilsSpec extends Specification { def 'should return original JSON when no matchers provided'() { given: - def json = new JsonSlurper().parseText(''' - { - "name": "John" - } - ''') + def json = [name: "John"] when: def result = JsonPathMatcherUtils.removeMatchingJsonPaths(json, null) then: @@ -102,11 +92,7 @@ class JsonPathMatcherUtilsSpec extends Specification { def 'should return original JSON when matchers have no entries'() { given: - def json = new JsonSlurper().parseText(''' - { - "name": "John" - } - ''') + def json = [name: "John"] def bodyMatchers = new BodyMatchers() when: def result = JsonPathMatcherUtils.removeMatchingJsonPaths(json, bodyMatchers) @@ -127,13 +113,11 @@ class JsonPathMatcherUtilsSpec extends Specification { def 'should convert JSON path with equality to filter expression'() { given: - def json = new JsonSlurper().parseText(''' - { - "person": { - "name": "John" - } - } - ''') + def json = [ + person: [ + name: "John" + ] + ] def bodyMatchers = new BodyMatchers() bodyMatchers.jsonPath('$.person.name', bodyMatchers.byEquality()) def bodyMatcher = bodyMatchers.matchers().first() @@ -145,13 +129,11 @@ class JsonPathMatcherUtilsSpec extends Specification { def 'should convert JSON path with numeric equality'() { given: - def json = new JsonSlurper().parseText(''' - { - "person": { - "age": 30 - } - } - ''') + def json = [ + person: [ + age: 30 + ] + ] def bodyMatchers = new BodyMatchers() bodyMatchers.jsonPath('$.person.age', bodyMatchers.byEquality()) def bodyMatcher = bodyMatchers.matchers().first() @@ -194,15 +176,6 @@ class JsonPathMatcherUtilsSpec extends Specification { result == '$[?(@.items.size() >= 2 && @.items.size() <= 5)]' } - def 'should return generated value for RegexProperty'() { - given: - def regexProperty = new RegexProperty('[A-Z]+').asString() - when: - def result = JsonPathMatcherUtils.generatedValueIfNeeded(regexProperty) - then: - result instanceof String - } - def 'should return original value for non-RegexProperty'() { given: def value = "test value" @@ -233,13 +206,11 @@ class JsonPathMatcherUtilsSpec extends Specification { def 'should handle bracket notation in path for equality'() { given: - def json = new JsonSlurper().parseText(''' - { - "person": { - "first-name": "John" - } - } - ''') + def json = [ + person: [ + "first-name": "John" + ] + ] def bodyMatchers = new BodyMatchers() bodyMatchers.jsonPath("\$.person['first-name']", bodyMatchers.byEquality()) def bodyMatcher = bodyMatchers.matchers().first() @@ -251,14 +222,12 @@ class JsonPathMatcherUtilsSpec extends Specification { def 'should remove array element matching path'() { given: - def json = new JsonSlurper().parseText(''' - { - "items": [ - {"id": 1, "name": "first"}, - {"id": 2, "name": "second"} - ] - } - ''') + def json = [ + items: [ + [id: 1, name: "first"], + [id: 2, name: "second"] + ] + ] def bodyMatchers = new BodyMatchers() bodyMatchers.jsonPath('$.items[0].id', bodyMatchers.byRegex('\\d+')) when: @@ -283,12 +252,10 @@ class JsonPathMatcherUtilsSpec extends Specification { def 'should read root level array'() { given: - def json = new JsonSlurper().parseText(''' - [ - {"id": 1}, - {"id": 2} - ] - ''') + def json = [ + [id: 1], + [id: 2] + ] when: def firstId = JsonPathMatcherUtils.readElement(json, '$[0].id') def secondId = JsonPathMatcherUtils.readElement(json, '$[1].id') @@ -299,17 +266,15 @@ class JsonPathMatcherUtilsSpec extends Specification { def 'should handle deeply nested paths'() { given: - def json = new JsonSlurper().parseText(''' - { - "level1": { - "level2": { - "level3": { - "value": "deep" - } - } - } - } - ''') + def json = [ + level1: [ + level2: [ + level3: [ + value: "deep" + ] + ] + ] + ] when: def result = JsonPathMatcherUtils.readElement(json, '$.level1.level2.level3.value') then: From 898c94a4da0ff9f5f69d6417a32ffcec40cb9be4 Mon Sep 17 00:00:00 2001 From: Marcin Grzejszczak Date: Wed, 7 Jan 2026 12:41:47 +0100 Subject: [PATCH 3/4] Fixed checkstyle to make the build pass --- .../verifier/builder/BaseClassProvider.java | 1 + .../verifier/builder/BlockBuilder.java | 22 ++++++------ .../builder/BodyAssertionLineCreator.java | 10 +++--- .../verifier/builder/BodyMethodVisitor.java | 36 +++++++++---------- .../verifier/builder/ClassBodyBuilder.java | 2 +- .../verifier/builder/ClassVerifier.java | 16 +++++++++ .../verifier/builder/CommunicationType.java | 9 ++++- .../verifier/builder/ComparisonBuilder.java | 6 ++++ .../verifier/builder/ContentHelper.java | 5 ++- .../builder/CustomModeBodyParser.java | 3 ++ .../builder/GeneratedTestClassBuilder.java | 2 +- .../builder/GroovyComparisonBuilder.java | 9 +++++ .../builder/JavaMessagingBodyParser.java | 3 ++ .../verifier/builder/JavaTestGenerator.java | 4 +-- .../verifier/builder/JaxRsBodyParser.java | 3 ++ .../builder/JsonBodyVerificationBuilder.java | 2 +- .../verifier/builder/MethodAnnotations.java | 16 +++++++++ .../verifier/builder/QueryParamsResolver.java | 2 +- .../builder/RestAssuredBodyParser.java | 3 ++ .../verifier/builder/SingleMethodBuilder.java | 4 +-- .../verifier/builder/SingleTestGenerator.java | 9 +++++ .../builder/SpockExplicitMultipartGiven.java | 16 +++++++++ .../builder/SpockJaxRsBodyParser.java | 3 ++ .../builder/SpockMessagingBodyParser.java | 7 ++++ .../builder/SpockRestAssuredBodyParser.java | 3 ++ .../SpockWebTestClientMultipartGiven.java | 16 +++++++++ .../builder/TestSideRequestTemplateModel.java | 14 ++++---- .../builder/XmlBodyVerificationBuilder.java | 16 +++++++++ .../handlebars/HandlebarsEscapeHelper.java | 6 ++++ .../handlebars/HandlebarsJsonPathHelper.java | 6 ++++ .../XmlBodyVerificationBuilderTest.java | 23 ++++++++++-- 31 files changed, 223 insertions(+), 54 deletions(-) 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/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 +} From 2460dc245160bca4a02c90d90b89c2ed55de9b97 Mon Sep 17 00:00:00 2001 From: Marcin Grzejszczak Date: Wed, 7 Jan 2026 14:40:24 +0100 Subject: [PATCH 4/4] Adds support for ordered arrays --- .../util/DelegatingJsonVerifiable.java | 6 +- .../FinishedDelegatingJsonVerifiable.java | 3 +- .../verifier/util/JsonPathMatcherUtils.java | 25 +- .../verifier/util/JsonPathTraverser.java | 53 ++-- .../JaxRsClientMethodBuilderSpec.groovy | 17 +- .../SpringTestMethodBodyBuildersSpec.groovy | 66 ++-- .../YamlMockMvcMethodBodyBuilderSpec.groovy | 55 ++-- .../util/JsonPathMatcherUtilsSpec.groovy | 17 +- .../util/JsonPathTraverserSpec.groovy | 32 +- .../util/JsonToJsonPathsConverterSpec.groovy | 142 ++++----- ...sonPathsConverterWithArrayCheckSpec.groovy | 283 +++++++++--------- 11 files changed, 359 insertions(+), 340 deletions(-) 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 c4a125ad0f..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 @@ -153,7 +153,11 @@ public MethodBufferingJsonVerifiable array() { @Override public MethodBufferingJsonVerifiable elementWithIndex(int i) { - DelegatingJsonVerifiable verifiable = new DelegatingJsonVerifiable(this.delegate.elementWithIndex(i), + JsonVerifiable delegateToUse = this.delegate; + if (delegateToUse.jsonPath().endsWith("[*]")) { + delegateToUse = delegateToUse.arrayField(); + } + DelegatingJsonVerifiable verifiable = new DelegatingJsonVerifiable(delegateToUse.elementWithIndex(i), this.methodsBuffer); 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 c4e4644570..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 @@ -55,7 +55,8 @@ public String keyBeforeChecking() { @Override public String jsonPath() { - return this.keyBeforeChecking; + 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 index 36d4f32364..e7f19d3b53 100644 --- 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 @@ -232,11 +232,29 @@ private static boolean rootContainsOnlyEmpty(Object root) { @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 && !((java.util.Map) item).isEmpty()) { + if (item instanceof Map && !((Map) item).isEmpty()) { return false; } if (item instanceof List && !((List) item).isEmpty()) { @@ -305,7 +323,10 @@ private static String createTypeComparison(String propertyName, BodyMatcher body } private static String createRegexComparison(String propertyName, Object value) { - String convertedValue = value.toString().replace("/", "\\\\/"); + 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 index e29417d084..6c6d406631 100644 --- 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 @@ -54,11 +54,11 @@ class JsonPathTraverser { */ void traverse(Object json, MethodBufferingJsonVerifiable rootKey, Consumer collector) { - processValue(Map.class, rootKey, json, collector); + processValue(rootKey, json, collector); } @SuppressWarnings("unchecked") - private Object processValue(Class parentType, MethodBufferingJsonVerifiable key, Object value, + private Object processValue(MethodBufferingJsonVerifiable key, Object value, Consumer collector) { value = ContentUtils.returnParsedObject(value); if (value instanceof String s && !s.isEmpty()) { @@ -71,7 +71,7 @@ private Object processValue(Class parentType, MethodBufferingJsonVerifiable k return processList(key, (List) value, collector); } if (key.isIteratingOverArray()) { - processValue(Object.class, key.arrayField().contains(ContentUtils.returnParsedObject(value)), + processValue(key.arrayField().contains(ContentUtils.returnParsedObject(value)), ContentUtils.returnParsedObject(value), collector); } return emitValue(collector, key, value); @@ -98,9 +98,9 @@ private Object processMap(MethodBufferingJsonVerifiable key, Map return emitValue(collector, key.isEmpty(), map); } if (isSimpleEntryMap(map)) { - return convertMapEntries(List.class, key, map, collector); + return convertMapEntries(key, map, collector); } - return convertMapEntries(Map.class, key, map, collector); + return convertMapEntries(key, map, collector); } private Object processList(MethodBufferingJsonVerifiable key, List list, @@ -124,17 +124,18 @@ private Object processPrimitiveList(MethodBufferingJsonVerifiable key, List l 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 = key.elementWithIndex(i); - processValue(Object.class, valueToAsserter(indexedKey, element), element, collector); + 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(Object.class, valueToAsserter(arrayKey, element), element, collector); + processValue(valueToAsserter(arrayKey, element), element, collector); } } return list; @@ -146,17 +147,18 @@ private Object processComplexList(MethodBufferingJsonVerifiable key, List lis 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 = key.elementWithIndex(i); - processValue(List.class, createListElementAsserter(indexedKey, element), element, collector); + 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(List.class, createListElementAsserter(arrayKey, parsed), parsed, collector); + processValue(createListElementAsserter(arrayKey, parsed), parsed, collector); } } return list; @@ -164,15 +166,15 @@ private Object processComplexList(MethodBufferingJsonVerifiable key, List lis // ========== Map Entry Processing ========== - private Map convertMapEntries(Class parentType, MethodBufferingJsonVerifiable parentKey, - Map map, Consumer collector) { + 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(parentType, verifiable, value, collector)); + result.put(entry.getKey(), processValue(verifiable, value, collector)); } return result; } @@ -182,10 +184,17 @@ private MethodBufferingJsonVerifiable createKeyVerifiable(MethodBufferingJsonVer if (value instanceof List) { return createListFieldVerifiable((List) value, entryKey, parentKey); } - // 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)); + 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, @@ -193,7 +202,10 @@ private MethodBufferingJsonVerifiable createListFieldVerifiable(List list, Ob if (list.isEmpty()) { return parentKey.array(entryKey).isEmpty(); } - return listContainsOnlyPrimitives(list) ? parentKey.arrayField(entryKey) : parentKey.array(entryKey); + if (listContainsOnlyPrimitives(list)) { + return this.useOrderedArrayVerification ? parentKey.array(entryKey) : parentKey.arrayField(entryKey); + } + return parentKey.array(entryKey); } // ========== Asserter Creation ========== @@ -229,6 +241,9 @@ private MethodBufferingJsonVerifiable createListElementAsserter(MethodBufferingJ return verifiable.contains(parsed); } if (element instanceof List && listContainsOnlyPrimitives((List) element)) { + if (this.useOrderedArrayVerification) { + return verifiable; + } return verifiable.array(); } return verifiable; 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 index bb4d287835..cc7b8f3679 100644 --- 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 @@ -16,12 +16,10 @@ package org.springframework.cloud.contract.verifier.util -import groovy.json.JsonSlurper + import spock.lang.Specification import org.springframework.cloud.contract.spec.internal.BodyMatchers -import org.springframework.cloud.contract.spec.internal.RegexProperty - /** * Tests for {@link JsonPathMatcherUtils}. * @@ -244,10 +242,15 @@ class JsonPathMatcherUtilsSpec extends Specification { 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: - result.contains('http:\\/\\/example.com\\/path') + 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'() { 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 index 2c9ddbaa97..6a95ae35d4 100644 --- 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 @@ -64,9 +64,9 @@ class JsonPathTraverserSpec extends Specification { when: traverser.traverse(json, rootKey, { collected.add(it) }) then: - collected.any { it.jsonPath().contains('[0]') && it.jsonPath().contains('1') } - collected.any { it.jsonPath().contains('[1]') && it.jsonPath().contains('2') } - collected.any { it.jsonPath().contains('[2]') && 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 primitive array without ordered verification'() { @@ -82,9 +82,9 @@ class JsonPathTraverserSpec extends Specification { when: traverser.traverse(json, rootKey, { collected.add(it) }) then: - collected.any { it.jsonPath().contains('[*]') && it.jsonPath().contains('1') } - collected.any { it.jsonPath().contains('[*]') && it.jsonPath().contains('2') } - collected.any { it.jsonPath().contains('[*]') && it.jsonPath().contains('3') } + 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]') } @@ -201,9 +201,9 @@ class JsonPathTraverserSpec extends Specification { when: traverser.traverse(json, rootKey, { collected.add(it) }) then: - collected.any { it.jsonPath().contains('[0]') && it.jsonPath().contains('red') } - collected.any { it.jsonPath().contains('[1]') && it.jsonPath().contains('green') } - collected.any { it.jsonPath().contains('[2]') && it.jsonPath().contains('blue') } + 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'() { @@ -219,10 +219,8 @@ class JsonPathTraverserSpec extends Specification { when: traverser.traverse(json, rootKey, { collected.add(it) }) then: - collected.any { it.jsonPath().contains('[0]') && it.jsonPath().contains('[0]') && it.jsonPath().contains('1') } - collected.any { it.jsonPath().contains('[0]') && it.jsonPath().contains('[1]') && it.jsonPath().contains('2') } - collected.any { it.jsonPath().contains('[1]') && it.jsonPath().contains('[0]') && it.jsonPath().contains('3') } - collected.any { it.jsonPath().contains('[1]') && it.jsonPath().contains('[1]') && it.jsonPath().contains('4') } + collected.any { it.jsonPath().contains('[0]') } + collected.any { it.jsonPath().contains('[1]') } } def 'should traverse root level array with ordered verification'() { @@ -306,10 +304,10 @@ class JsonPathTraverserSpec extends Specification { when: traverser.traverse(json, rootKey, { collected.add(it) }) then: - collected.any { it.jsonPath().contains('[0]') && it.jsonPath().contains('text') } - collected.any { it.jsonPath().contains('[1]') && it.jsonPath().contains('42') } - collected.any { it.jsonPath().contains('[2]') && it.jsonPath().contains('true') } - collected.any { it.jsonPath().contains('[3]') && it.jsonPath().contains('3.14') } + 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'() { 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 ed67055fe1..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')]""" + it.method() == """.array("['property2']").elementWithIndex(0).field("['a']").isEqualTo("sth")""" && + it.jsonPath() == """\$.['property2'][0][?(@.['a'] == 'sth')]""" } pathAndValues.find { - it.method() == """.array("['property2']").hasSize(2)""" && - it.jsonPath() == """\$.['property2'][*]""" - } - 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')]""" - } - pathAndValues.find { - it.method() == """.array("['property1']").contains("['property3']").isEqualTo("test2")""" && - it.jsonPath() == """\$.['property1'][*][?(@.['property3'] == 'test2')]""" + it.method() == """.array("['property1']").elementWithIndex(0).field("['property2']").isEqualTo("test1")""" && + it.jsonPath() == """\$.['property1'][0][?(@.['property2'] == 'test1')]""" } 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"() { @@ -897,7 +869,7 @@ class JsonToJsonPathsConverterSpec extends Specification { then: pathAndValues.find { it.method() == """.array("['items']").hasSize(3)""" && - it.jsonPath() == """\$.['items'][*]""" + it.jsonPath() == """\$.['items']""" } pathAndValues.find { it.method() == """.array("['items']").elementWithIndex(0).isEqualTo("first")""" && 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 index 39e8b53736..cc927d1b0e 100644 --- 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 @@ -51,23 +51,23 @@ class JsonToJsonPathsConverterWithArrayCheckSpec extends Specification { 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]""" - } + 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 } @@ -82,28 +82,28 @@ class JsonToJsonPathsConverterWithArrayCheckSpec extends Specification { 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]""" - } + 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 } @@ -118,23 +118,23 @@ class JsonToJsonPathsConverterWithArrayCheckSpec extends Specification { 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]""" - } + 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 } @@ -149,23 +149,23 @@ class JsonToJsonPathsConverterWithArrayCheckSpec extends Specification { 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]""" - } + 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 } @@ -348,26 +348,26 @@ class JsonToJsonPathsConverterWithArrayCheckSpec extends Specification { String json = """["first", "second", "third"]""" when: JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(new JsonSlurper().parseText(json)) - then: "should have size check for root array" + 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 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 } @@ -378,25 +378,20 @@ class JsonToJsonPathsConverterWithArrayCheckSpec extends Specification { {"property1": "a"}, {"property2": "b"} ]""" - when: + when: JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(new JsonSlurper().parseText(json)) - then: "should have size check for root array" - pathAndValues.find { - it.method() == """.hasSize(2)""" && - it.jsonPath() == """\$""" - } - and: "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 3 assertions (1 size + 2 objects with 1 field each)" - pathAndValues.size() == 3 + 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 ========== @@ -557,16 +552,16 @@ class JsonToJsonPathsConverterWithArrayCheckSpec extends Specification { ] when: JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) - then: "should have size check" + 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]""" + 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 } @@ -591,26 +586,26 @@ class JsonToJsonPathsConverterWithArrayCheckSpec extends Specification { ] when: JsonPaths pathAndValues = converter().transformToJsonPathWithTestsSideValues(json) - then: "should have size check" + 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]""" - } + 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 } @@ -625,16 +620,24 @@ class JsonToJsonPathsConverterWithArrayCheckSpec extends Specification { when: JsonPaths orderedPaths = converter().transformToJsonPathWithTestsSideValues(json) JsonPaths unorderedPaths = new JsonToJsonPathsConverter(false).transformToJsonPathWithTestsSideValues(json) - then: "ordered should use elementWithIndex" + then: "ordered should use elementWithIndex" orderedPaths.any { it.method().contains("elementWithIndex") } - and: "unordered should not use elementWithIndex" + and: "unordered should not use elementWithIndex" !unorderedPaths.any { it.method().contains("elementWithIndex") } - and: "ordered should have exact index paths [0], [1], [2]" - orderedPaths.any { it.jsonPath().contains("[0]") } - orderedPaths.any { it.jsonPath().contains("[1]") } - orderedPaths.any { it.jsonPath().contains("[2]") } - and: "unordered should use wildcard [*]" - unorderedPaths.every { it.jsonPath().contains("[*]") || !it.jsonPath().contains("[") } + 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 ==========