diff --git a/spring-integration-core/src/main/java/org/springframework/integration/json/JsonIndexAccessor.java b/spring-integration-core/src/main/java/org/springframework/integration/json/JsonIndexAccessor.java new file mode 100644 index 0000000000..8a7270762a --- /dev/null +++ b/spring-integration-core/src/main/java/org/springframework/integration/json/JsonIndexAccessor.java @@ -0,0 +1,75 @@ +/* + * Copyright 2013-2024 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.integration.json; + +import com.fasterxml.jackson.databind.node.ArrayNode; + +import org.springframework.expression.AccessException; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.IndexAccessor; +import org.springframework.expression.TypedValue; +import org.springframework.lang.Nullable; + +/** + * A SpEL {@link IndexAccessor} that knows how to read indexes from JSON arrays, using + * Jackson's {@link ArrayNode} API. + * + *

Supports indexes supplied as an integer literal — for example, {@code myJsonArray[1]}. + * Also supports negative indexes — for example, {@code myJsonArray[-1]} which equates + * to {@code myJsonArray[myJsonArray.length - 1]}. Furthermore, {@code null} is returned for + * any index that is out of bounds (see {@link ArrayNode#get(int)} for details). + * + * @author Sam Brannen + * @since 6.4 + * @see JsonPropertyAccessor + */ +public class JsonIndexAccessor implements IndexAccessor { + + private static final Class[] SUPPORTED_CLASSES = { ArrayNode.class }; + + @Override + public Class[] getSpecificTargetClasses() { + return SUPPORTED_CLASSES; + } + + @Override + public boolean canRead(EvaluationContext context, Object target, Object index) { + return (target instanceof ArrayNode && index instanceof Integer); + } + + @Override + public TypedValue read(EvaluationContext context, Object target, Object index) throws AccessException { + ArrayNode arrayNode = (ArrayNode) target; + Integer intIndex = (Integer) index; + if (intIndex < 0) { + // negative index: get from the end of array, for compatibility with JsonPropertyAccessor.ArrayNodeAsList. + intIndex = arrayNode.size() + intIndex; + } + return JsonPropertyAccessor.typedValue(arrayNode.get(intIndex)); + } + + @Override + public boolean canWrite(EvaluationContext context, Object target, Object index) { + return false; + } + + @Override + public void write(EvaluationContext context, Object target, Object index, @Nullable Object newValue) { + throw new UnsupportedOperationException("Write is not supported"); + } + +} diff --git a/spring-integration-core/src/main/java/org/springframework/integration/json/JsonPropertyAccessor.java b/spring-integration-core/src/main/java/org/springframework/integration/json/JsonPropertyAccessor.java index 966e3cc719..feae2feafd 100644 --- a/spring-integration-core/src/main/java/org/springframework/integration/json/JsonPropertyAccessor.java +++ b/spring-integration-core/src/main/java/org/springframework/integration/json/JsonPropertyAccessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2023 the original author or authors. + * Copyright 2013-2024 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. @@ -24,6 +24,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.NullNode; import org.springframework.expression.AccessException; import org.springframework.expression.EvaluationContext; @@ -35,7 +36,7 @@ import org.springframework.util.StringUtils; /** * A SpEL {@link PropertyAccessor} that knows how to read properties from JSON objects. - * Uses Jackson {@link JsonNode} API for nested properties access. + *

Uses Jackson {@link JsonNode} API for nested properties access. * * @author Eric Bottard * @author Artem Bilan @@ -43,8 +44,10 @@ import org.springframework.util.StringUtils; * @author Gary Russell * @author Pierre Lakreb * @author Vladislav Fefelov + * @author Sam Brannen * * @since 3.0 + * @see JsonIndexAccessor */ public class JsonPropertyAccessor implements PropertyAccessor { @@ -80,23 +83,22 @@ public class JsonPropertyAccessor implements PropertyAccessor { // Cannot parse - treat as not a JSON return false; } - Integer index = maybeIndex(name); if (node instanceof ArrayNode) { - return index != null; + return maybeIndex(name) != null; } return true; } private JsonNode asJson(Object target) throws AccessException { - if (target instanceof JsonNode) { - return (JsonNode) target; + if (target instanceof JsonNode jsonNode) { + return jsonNode; } - else if (target instanceof JsonNodeWrapper) { - return ((JsonNodeWrapper) target).getRealNode(); + else if (target instanceof JsonNodeWrapper jsonNodeWrapper) { + return jsonNodeWrapper.getRealNode(); } - else if (target instanceof String) { + else if (target instanceof String content) { try { - return this.objectMapper.readTree((String) target); + return this.objectMapper.readTree(content); } catch (JsonProcessingException e) { throw new AccessException("Exception while trying to deserialize String", e); @@ -160,8 +162,8 @@ public class JsonPropertyAccessor implements PropertyAccessor { return true; } - private static TypedValue typedValue(JsonNode json) throws AccessException { - if (json == null) { + static TypedValue typedValue(JsonNode json) throws AccessException { + if (json == null || json instanceof NullNode) { return TypedValue.NULL; } else if (json.isValueNode()) { @@ -199,8 +201,8 @@ public class JsonPropertyAccessor implements PropertyAccessor { if (json == null) { return null; } - else if (json instanceof ArrayNode) { - return new ArrayNodeAsList((ArrayNode) json); + else if (json instanceof ArrayNode arrayNode) { + return new ArrayNodeAsList(arrayNode); } else if (json.isValueNode()) { return getValue(json); @@ -212,8 +214,6 @@ public class JsonPropertyAccessor implements PropertyAccessor { interface JsonNodeWrapper extends Comparable { - String toString(); - JsonNode getRealNode(); } @@ -309,10 +309,8 @@ public class JsonPropertyAccessor implements PropertyAccessor { @Override public int compareTo(Object o) { - if (o instanceof JsonNodeWrapper) { - return this.delegate.equals(((JsonNodeWrapper) o).getRealNode()) ? 0 : 1; - } - return this.delegate.equals(o) ? 0 : 1; + Object that = (o instanceof JsonNodeWrapper wrapper ? wrapper.getRealNode() : o); + return this.delegate.equals(that) ? 0 : 1; } } diff --git a/spring-integration-core/src/test/java/org/springframework/integration/json/AbstractJsonAccessorTests.java b/spring-integration-core/src/test/java/org/springframework/integration/json/AbstractJsonAccessorTests.java new file mode 100644 index 0000000000..c0d4c58e63 --- /dev/null +++ b/spring-integration-core/src/test/java/org/springframework/integration/json/AbstractJsonAccessorTests.java @@ -0,0 +1,366 @@ +/* + * Copyright 2013-2024 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.integration.json; + +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.SpelEvaluationException; +import org.springframework.expression.spel.SpelMessage; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.expression.spel.support.StandardTypeConverter; +import org.springframework.integration.json.JsonPropertyAccessor.ArrayNodeAsList; +import org.springframework.integration.json.JsonPropertyAccessor.ComparableJsonNode; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Abstract base class for tests involving {@link JsonPropertyAccessor} and {@link JsonIndexAccessor}. + * + * @author Eric Bottard + * @author Artem Bilan + * @author Paul Martin + * @author Pierre Lakreb + * @author Sam Brannen + * + * @since 3.0 + */ +abstract class AbstractJsonAccessorTests { + + protected final SpelExpressionParser parser = new SpelExpressionParser(); + + protected final ObjectMapper mapper = new ObjectMapper(); + + protected final StandardEvaluationContext context = new StandardEvaluationContext(); + + @BeforeEach + void setUpEvaluationContext() { + DefaultConversionService conversionService = new DefaultConversionService(); + conversionService.addConverter(new JsonNodeWrapperToJsonNodeConverter()); + context.setTypeConverter(new StandardTypeConverter(conversionService)); + } + + /** + * Tests for JSON accessors that use an instance of {@link JsonNode} as the + * root context object. + */ + @Nested + class JsonNodeTests { + + @Test + void textNode() throws Exception { + TextNode json = (TextNode) mapper.readTree("\"foo\""); + String result = evaluate(json, "#root", String.class); + assertThat(result).isEqualTo("\"foo\""); + } + + @Test + void nullProperty() throws Exception { + JsonNode json = mapper.readTree("{\"foo\": null}"); + assertThat(evaluate(json, "foo", String.class)).isNull(); + } + + @Test + void missingProperty() throws Exception { + JsonNode json = mapper.readTree(FOO_BAR_JSON); + assertThat(evaluate(json, "fizz", String.class)).isNull(); + } + + @Test + void propertyLookup() throws Exception { + JsonNode json1 = mapper.readTree(FOO_BAR_JSON); + String value1 = evaluate(json1, "foo", String.class); + assertThat(value1).isEqualTo("bar"); + JsonNode json2 = mapper.readTree(FOO_BAR_JSON); + String value2 = evaluate(json2, "foo", String.class); + assertThat(value1).isEqualTo(value2).hasSameHashCodeAs(value2); + } + + @Test + void arrayLookupWithIntegerIndexAndExplicitWrapping() throws Exception { + ArrayNode json = (ArrayNode) mapper.readTree("[3, 4, 5]"); + // Have to wrap the root array because ArrayNode itself is not a List + Integer actual = evaluate(JsonPropertyAccessor.wrap(json), "[1]", Integer.class); + assertThat(actual).isEqualTo(4); + } + + @Test + void arrayLookupWithIntegerIndexForNullValueAndExplicitWrapping() throws Exception { + ArrayNode json = (ArrayNode) mapper.readTree("[3, null, 5]"); + // Have to wrap the root array because ArrayNode itself is not a List + Integer actual = evaluate(JsonPropertyAccessor.wrap(json), "[1]", Integer.class); + assertThat(actual).isNull(); + } + + @Test + void arrayLookupWithNegativeIntegerIndex() throws Exception { + JsonNode json = mapper.readTree("{\"foo\": [3, 4, 5]}"); + // ArrayNodeAsList allows one to index into a JSON array via a negative index. + assertThat(evaluate(json, "foo[-1]", Integer.class)).isEqualTo(5); + } + + @Test + void arrayLookupWithNegativeIntegerIndexGreaterThanArrayLength() throws Exception { + JsonNode json = mapper.readTree("{\"foo\": [3, 4, 5]}"); + // Although ArrayNodeAsList allows one to index into a JSON array via a negative + // index, if the result of (array.length - index) is still negative, Jackson's + // ArrayNode.get() method returns null instead of throwing an IndexOutOfBoundsException. + assertThat(evaluate(json, "foo[-99]", Integer.class)).isNull(); + } + + @Test + void arrayLookupWithNegativeIntegerIndexForNullValue() throws Exception { + JsonNode json = mapper.readTree("{\"foo\": [3, 4, null]}"); + // ArrayNodeAsList allows one to index into a JSON array via a negative index. + assertThat(evaluate(json, "foo[-1]", Integer.class)).isNull(); + } + + @Test + void arrayLookupWithIntegerIndexOutOfBounds() throws Exception { + JsonNode json = mapper.readTree("{\"foo\": [3, 4, 5]}"); + assertThatExceptionOfType(SpelEvaluationException.class) + .isThrownBy(() -> evaluate(json, "foo[3]", Object.class)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.COLLECTION_INDEX_OUT_OF_BOUNDS)); + } + + @Test + void arrayLookupWithStringIndex() throws Exception { + JsonNode json = mapper.readTree("[3, 4, 5]"); + Integer actual = evaluate(json, "['1']", Integer.class); + assertThat(actual).isEqualTo(4); + } + + @Test + void nestedArrayLookupWithIntegerIndexAndExplicitWrapping() throws Exception { + ArrayNode json = (ArrayNode) mapper.readTree("[[3], [4, 5], []]"); + // JsonNode actual = evaluate(json, "1.1", JsonNode.class); // Does not work + Object actual = evaluate(JsonPropertyAccessor.wrap(json), "[1][1]", Object.class); + assertThat(actual).isEqualTo(5); + } + + @Test + void nestedArrayLookupWithStringIndex() throws Exception { + JsonNode json = mapper.readTree("[[3], [4, 5], []]"); + Integer actual = evaluate(json, "['1']['1']", Integer.class); + assertThat(actual).isEqualTo(5); + } + + @Test + @SuppressWarnings("unchecked") + void nestedArrayLookupWithStringIndexAndThenIntegerIndex() throws Exception { + ArrayNode arrayNode = (ArrayNode) mapper.readTree("[[3], [4, 5], []]"); + + List list = evaluate(arrayNode, "['0']", List.class); + assertThat(list).isInstanceOf(ArrayNodeAsList.class).containsExactly(3); + list = evaluate(arrayNode, "['2']", List.class); + assertThat(list).isInstanceOf(ArrayNodeAsList.class).isEmpty(); + + Integer number = evaluate(arrayNode, "['0'][0]", Integer.class); + assertThat(number).isEqualTo(3); + number = evaluate(arrayNode, "['1'][1]", Integer.class); + assertThat(number).isEqualTo(5); + } + + @Test + void arrayProjection() throws Exception { + JsonNode json = mapper.readTree(FOO_BAR_ARRAY_FIZZ_JSON); + + // Filter the bar array to return only the fizz value of each element (to prove that SpEL considers bar + // an array/list) + List actualArray = evaluate(json, "foo.bar.![fizz]", List.class); + assertThat(actualArray).hasSize(3); + assertThat(evaluate(actualArray, "[0]", Object.class)).isEqualTo(5); + assertThat(evaluate(actualArray, "[1]", Object.class)).isEqualTo(7); + assertThat(evaluate(actualArray, "[2]", Object.class)).isEqualTo(8); + } + + @Test + void arraySelection() throws Exception { + JsonNode json = mapper.readTree(FOO_BAR_ARRAY_FIZZ_JSON); + + // Filter bar objects so that none match + List actualArray = evaluate(json, "foo.bar.?[fizz == 0]", List.class); + assertThat(actualArray).isEmpty(); + + // Filter bar objects so that one match + actualArray = evaluate(json, "foo.bar.?[fizz == 8]", List.class); + assertThat(actualArray).hasSize(1); + assertThat(((ComparableJsonNode) actualArray.get(0)).getRealNode()).isEqualTo(mapper.readTree("{\"fizz\": 8}")); + + // Filter bar objects so several match + actualArray = evaluate(json, "foo.bar.?[fizz > 6]", List.class); + assertThat(actualArray).hasSize(2); + assertThat(((ComparableJsonNode) actualArray.get(0)).getRealNode()).isEqualTo(mapper.readTree("{\"fizz\": 7}")); + assertThat(((ComparableJsonNode) actualArray.get(1)).getRealNode()).isEqualTo(mapper.readTree("{\"fizz\": 8}")); + } + + @Test + void nestedPropertyAccessViaJsonNode() throws Exception { + JsonNode json = mapper.readTree(FOO_BAR_FIZZ_JSON); + + assertThat(evaluate(json, "foo.bar", Integer.class)).isEqualTo(4); + assertThat(evaluate(json, "foo.fizz", Integer.class)).isEqualTo(5); + } + + @Test + void noNullPointerExceptionWithCachedReadAccessor() throws Exception { + Expression expression = parser.parseExpression("foo"); + JsonNode json1 = mapper.readTree(FOO_BAR_JSON); + String value1 = expression.getValue(context, json1, String.class); + assertThat(value1).isEqualTo("bar"); + JsonNode json2 = mapper.readTree("{}"); + Object value2 = expression.getValue(context, json2); + assertThat(value2).isNull(); + } + + } + + /** + * Tests for JSON accessors that use a String-representation of a JSON document + * as the root context object. + */ + @Nested + class JsonAsStringTests { + + @Test + void selectorAccess() { + String actual = evaluate(PROPERTY_NAMES_JSON, "property.^[name == 'value1'].name", String.class); + assertThat(actual).isEqualTo("value1"); + } + + @Test + void nestedPropertyAccessViaJsonAsString() throws Exception { + String json = FOO_BAR_FIZZ_JSON; + + assertThat(evaluate(json, "foo.bar", Integer.class)).isEqualTo(4); + assertThat(evaluate(json, "foo.fizz", Integer.class)).isEqualTo(5); + } + + @Test + void jsonGetValueConversionAsJsonNode() throws Exception { + // use JsonNode conversion + JsonNode node = evaluate(PROPERTY_NAMES_JSON, "property.^[name == 'value1']", JsonNode.class); + assertThat(node).isEqualTo(mapper.readTree("{\"name\":\"value1\"}")); + } + + @Test + void jsonGetValueConversionAsObjectNode() throws Exception { + // use ObjectNode conversion + ObjectNode node = evaluate(PROPERTY_NAMES_JSON, "property.^[name == 'value1']", ObjectNode.class); + assertThat(node).isEqualTo(mapper.readTree("{\"name\":\"value1\"}")); + } + + @Test + void jsonGetValueConversionAsArrayNode() throws Exception { + // use ArrayNode conversion + ArrayNode node = evaluate(PROPERTY_NAMES_JSON, "property", ArrayNode.class); + assertThat(node).isEqualTo(mapper.readTree("[{\"name\":\"value1\"},{\"name\":\"value2\"}]")); + } + + @Test + void comparingArrayNode() throws Exception { + Boolean actual = evaluate(PROPERTIES_WITH_NAMES_JSON, "property1 eq property2", Boolean.class); + assertThat(actual).isTrue(); + } + + @Test + void comparingJsonNode() throws Exception { + Boolean actual = evaluate(PROPERTIES_WITH_NAMES_JSON, "property1[0] eq property2[0]", Boolean.class); + assertThat(actual).isTrue(); + } + + @Test + void unsupportedString() { + String xml = "?"; + assertThatExceptionOfType(SpelEvaluationException.class) + .isThrownBy(() -> evaluate(xml, "what", Object.class)); + } + + @Test + void unsupportedJson() { + String json = "\"literal\""; + assertThat(evaluate(json, "foo", Object.class)).isNull(); + } + + } + + protected T evaluate(Object rootObject, String expression, Class expectedType) { + return parser.parseExpression(expression).getValue(context, rootObject, expectedType); + } + + private static final String FOO_BAR_JSON = """ + { + "foo": "bar" + } + """; + + private static final String FOO_BAR_FIZZ_JSON = """ + { + "foo": { + "bar": 4, + "fizz": 5 + } + } + """; + + private static final String FOO_BAR_ARRAY_FIZZ_JSON = """ + { + "foo": { + "bar": [ + {"fizz": 5, "buzz": 6}, + {"fizz": 7}, + {"fizz": 8} + ] + } + } + """; + + private static final String PROPERTY_NAMES_JSON = """ + { + "property" : [ + {"name": "value1"}, + {"name": "value2"} + ] + } + """; + + private static final String PROPERTIES_WITH_NAMES_JSON = """ + { + "property1": [ + {"name": "value1"}, + {"name": "value2"} + ], + "property2": [ + {"name": "value1"}, + {"name": "value2"} + ] + } + """; + +} diff --git a/spring-integration-core/src/test/java/org/springframework/integration/json/JsonIndexAccessorTests.java b/spring-integration-core/src/test/java/org/springframework/integration/json/JsonIndexAccessorTests.java new file mode 100644 index 0000000000..250a2c2142 --- /dev/null +++ b/spring-integration-core/src/test/java/org/springframework/integration/json/JsonIndexAccessorTests.java @@ -0,0 +1,115 @@ +/* + * Copyright 2013-2024 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.integration.json; + +import java.util.List; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.integration.json.JsonPropertyAccessor.ArrayNodeAsList; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JsonIndexAccessor} combined with {@link JsonPropertyAccessor}. + * + * @author Sam Brannen + * @since 6.4 + * @see JsonPropertyAccessorTests + */ +class JsonIndexAccessorTests extends AbstractJsonAccessorTests { + + @BeforeEach + void registerJsonAccessors() { + context.addIndexAccessor(new JsonIndexAccessor()); + // We also register a JsonPropertyAccessor to ensure that the JsonIndexAccessor + // does not interfere with the feature set of the JsonPropertyAccessor. + context.addPropertyAccessor(new JsonPropertyAccessor()); + } + + /** + * Tests which index directly into a Jackson {@link ArrayNode}, which is only supported + * by {@link JsonIndexAccessor}. + */ + @Nested + class ArrayNodeTests { + + @Test + void indexDirectlyIntoArrayNodeWithIntegerIndex() throws Exception { + ArrayNode arrayNode = (ArrayNode) mapper.readTree("[3, 4, 5]"); + Integer actual = evaluate(arrayNode, "[1]", Integer.class); + assertThat(actual).isEqualTo(4); + } + + @Test + void indexDirectlyIntoArrayNodeWithIntegerIndexForNullValue() throws Exception { + ArrayNode arrayNode = (ArrayNode) mapper.readTree("[3, null, 5]"); + Integer actual = evaluate(arrayNode, "[1]", Integer.class); + assertThat(actual).isNull(); + } + + @Test + void indexDirectlyIntoArrayNodeWithNegativeIntegerIndex() throws Exception { + ArrayNode arrayNode = (ArrayNode) mapper.readTree("[3, 4, 5]"); + Integer actual = evaluate(arrayNode, "[-1]", Integer.class); + // JsonIndexAccessor allows one to index into a JSON array via a negative index. + assertThat(actual).isEqualTo(5); + } + + @Test + void indexDirectlyIntoArrayNodeWithNegativeIntegerIndexGreaterThanArrayLength() throws Exception { + ArrayNode arrayNode = (ArrayNode) mapper.readTree("[3, 4, 5]"); + Integer actual = evaluate(arrayNode, "[-99]", Integer.class); + // Although JsonIndexAccessor allows one to index into a JSON array via a negative + // index, if the result of (array.length - index) is still negative, Jackson's + // ArrayNode.get() method returns null instead of throwing an IndexOutOfBoundsException. + assertThat(actual).isNull(); + } + + @Test + void indexDirectlyIntoArrayNodeWithIntegerIndexOutOfBounds() throws Exception { + ArrayNode arrayNode = (ArrayNode) mapper.readTree("[3, 4, 5]"); + Integer actual = evaluate(arrayNode, "[9999]", Integer.class); + // Jackson's ArrayNode.get() method always returns null instead of throwing an IndexOutOfBoundsException. + assertThat(actual).isNull(); + } + + /** + * @see AbstractJsonAccessorTests.JsonNodeTests#nestedArrayLookupWithStringIndexAndThenIntegerIndex() + */ + @Test + @SuppressWarnings("unchecked") + void nestedArrayLookupsWithIntegerIndexes() throws Exception { + ArrayNode arrayNode = (ArrayNode) mapper.readTree("[[3], [4, 5], []]"); + + List list = evaluate(arrayNode, "[0]", List.class); + assertThat(list).isInstanceOf(ArrayNodeAsList.class).containsExactly(3); + list = evaluate(arrayNode, "[2]", List.class); + assertThat(list).isInstanceOf(ArrayNodeAsList.class).isEmpty(); + + Integer number = evaluate(arrayNode, "[0][0]", Integer.class); + assertThat(number).isEqualTo(3); + number = evaluate(arrayNode, "[1][1]", Integer.class); + assertThat(number).isEqualTo(5); + } + + } + +} diff --git a/spring-integration-core/src/test/java/org/springframework/integration/json/JsonPropertyAccessorTests.java b/spring-integration-core/src/test/java/org/springframework/integration/json/JsonPropertyAccessorTests.java index 0a425fbf1d..2cdf158b6d 100644 --- a/spring-integration-core/src/test/java/org/springframework/integration/json/JsonPropertyAccessorTests.java +++ b/spring-integration-core/src/test/java/org/springframework/integration/json/JsonPropertyAccessorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2022 the original author or authors. + * Copyright 2013-2024 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. @@ -16,22 +16,13 @@ package org.springframework.integration.json; -import java.util.List; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.springframework.core.convert.converter.ConverterRegistry; -import org.springframework.core.convert.support.DefaultConversionService; -import org.springframework.expression.Expression; import org.springframework.expression.spel.SpelEvaluationException; -import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.expression.spel.support.StandardEvaluationContext; -import org.springframework.integration.json.JsonPropertyAccessor.ComparableJsonNode; +import org.springframework.expression.spel.SpelMessage; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -43,232 +34,64 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; * @author Artem Bilan * @author Paul Martin * @author Pierre Lakreb + * @author Sam Brannen * * @since 3.0 + * @see JsonIndexAccessorTests */ -public class JsonPropertyAccessorTests { - - private final SpelExpressionParser parser = new SpelExpressionParser(); - - private final StandardEvaluationContext context = new StandardEvaluationContext(); - - private final ObjectMapper mapper = new ObjectMapper(); +class JsonPropertyAccessorTests extends AbstractJsonAccessorTests { @BeforeEach - public void setup() { + void registerJsonPropertyAccessor() { context.addPropertyAccessor(new JsonPropertyAccessor()); - ConverterRegistry converterRegistry = (ConverterRegistry) DefaultConversionService.getSharedInstance(); - converterRegistry.addConverter(new JsonNodeWrapperToJsonNodeConverter()); } - @Test - public void testSimpleLookup() throws Exception { - JsonNode json = mapper.readTree("{\"foo\": \"bar\"}"); - String value = evaluate(json, "foo", String.class); - assertThat(value).isInstanceOf(String.class); - assertThat(value).isEqualTo("bar"); - JsonNode json2 = mapper.readTree("{\"foo\": \"bar\"}"); - String value2 = evaluate(json2, "foo", String.class); - assertThat(value2).isInstanceOf(String.class); - assertThat(value.equals(value2)).isTrue(); - assertThat(value2.hashCode()).isEqualTo(value.hashCode()); - } + /** + * Tests which index directly into a Jackson {@link ArrayNode}, which is not supported + * by {@link JsonPropertyAccessor}. + */ + @Nested + class ArrayNodeTests { - @Test - public void testTextNode() throws Exception { - JsonNode json = mapper.readTree("\"foo\""); - String result = evaluate(json, "#root", String.class); - assertThat(result).isEqualTo("\"foo\""); - } + @Test + void indexDirectlyIntoArrayNodeWithIntegerIndex() throws Exception { + ArrayNode arrayNode = (ArrayNode) mapper.readTree("[3, 4, 5]"); + assertIndexingNotSupported(arrayNode, "[1]"); + } - @Test - public void testMissingProperty() throws Exception { - JsonNode json = mapper.readTree("{\"foo\": \"bar\"}"); - assertThat(evaluate(json, "fizz", String.class)).isNull(); - } + @Test + void indexDirectlyIntoArrayNodeWithIntegerIndexForNullValue() throws Exception { + ArrayNode arrayNode = (ArrayNode) mapper.readTree("[3, null, 5]"); + assertIndexingNotSupported(arrayNode, "[1]"); + } - @Test - public void testArrayLookup() throws Exception { - ArrayNode json = (ArrayNode) mapper.readTree("[3, 4, 5]"); - // Have to wrap the root array because ArrayNode itself is not a List - Integer actual = evaluate(JsonPropertyAccessor.wrap(json), "[1]", Integer.class); - assertThat(actual).isEqualTo(4); - } + @Test + void indexDirectlyIntoArrayNodeWithNegativeIntegerIndex() throws Exception { + ArrayNode arrayNode = (ArrayNode) mapper.readTree("[3, 4, 5]"); + assertIndexingNotSupported(arrayNode, "[-1]"); + } - @Test - public void testArrayNegativeIndex() throws Exception { - JsonNode json = mapper.readTree("{\"foo\":[3, 4, 5]}"); - // help access json list items with json-path negative index - assertThat(evaluate(json, "foo[-1]", Integer.class)).isEqualTo(5); - } + @Test + void indexDirectlyIntoArrayNodeWithIntegerIndexOutOfBounds() throws Exception { + ArrayNode arrayNode = (ArrayNode) mapper.readTree("[3, 4, 5]"); + assertIndexingNotSupported(arrayNode, "[9999]"); + } - @Test - public void testArrayIndexOutOfBounds() throws Exception { - JsonNode json = mapper.readTree("{\"foo\":[3, 4, 5]}"); - assertThatExceptionOfType(SpelEvaluationException.class) - .isThrownBy(() -> evaluate(json, "foo[3]", Object.class)); - } + /** + * @see AbstractJsonAccessorTests.JsonNodeTests#nestedArrayLookupWithStringIndexAndThenIntegerIndex() + */ + @Test + void nestedArrayLookupWithIntegerIndexAndThenIntegerIndex() throws Exception { + ArrayNode arrayNode = (ArrayNode) mapper.readTree("[[3], [4, 5], []]"); + assertIndexingNotSupported(arrayNode, "[1][1]"); + } - @Test - public void testArrayLookupWithStringIndex() throws Exception { - JsonNode json = mapper.readTree("[3, 4, 5]"); - Integer actual = evaluate(json, "['1']", Integer.class); - assertThat(actual).isEqualTo(4); - } + private void assertIndexingNotSupported(ArrayNode arrayNode, String expression) { + assertThatExceptionOfType(SpelEvaluationException.class) + .isThrownBy(() -> parser.parseExpression(expression).getValue(context, arrayNode)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE)); + } - @Test - public void testNestedArrayConstruct() throws Exception { - ArrayNode json = (ArrayNode) mapper.readTree("[[3], [4, 5], []]"); - // JsonNode actual = evaluate("1.1", json, JsonNode.class); // Does not work - Object actual = evaluate(JsonPropertyAccessor.wrap(json), "[1][1]", Object.class); - assertThat(actual).isEqualTo(5); - } - - @Test - public void testNestedArrayConstructWithStringIndex() throws Exception { - Object json = mapper.readTree("[[3], [4, 5], []]"); - Object actual = evaluate(json, "['1']['1']", Object.class); - assertThat(actual).isEqualTo(5); - } - - @Test - public void testArrayProjectionResult() throws Exception { - Object json = mapper.readTree( - "{\"foo\": {\"bar\": [ { \"fizz\": 5, \"buzz\": 6 }, {\"fizz\": 7}, {\"fizz\": 8} ] } }"); - // Filter the bar array to return only the fizz value of each element (to prove that SPeL considers bar - // an array/list) - List actualArray = evaluate(json, "foo.bar.![fizz]", List.class); - assertThat(actualArray).hasSize(3); - assertThat(evaluate(actualArray, "[0]", Object.class)).isEqualTo(5); - assertThat(evaluate(actualArray, "[1]", Object.class)).isEqualTo(7); - assertThat(evaluate(actualArray, "[2]", Object.class)).isEqualTo(8); - } - - @Test - public void testFilterOnArraySelection() throws Exception { - Object json = mapper.readTree( - "{\"foo\": {\"bar\": [ { \"fizz\": 5, \"buzz\": 6 }, {\"fizz\": 7}, {\"fizz\": 8} ] } }"); - - // Filter bar objects so that none match - List actualArray = evaluate(json, "foo.bar.?[fizz == 0]", List.class); - assertThat(actualArray).hasSize(0); - - // Filter bar objects so that one match - actualArray = evaluate(json, "foo.bar.?[fizz == 8]", List.class); - assertThat(actualArray).hasSize(1); - assertThat(((ComparableJsonNode) actualArray.get(0)).getRealNode()).isEqualTo(mapper.readTree("{\"fizz\": 8}")); - - // Filter bar objects so several match - actualArray = evaluate(json, "foo.bar.?[fizz > 6]", List.class); - assertThat(actualArray).hasSize(2); - assertThat(((ComparableJsonNode) actualArray.get(0)).getRealNode()).isEqualTo(mapper.readTree("{\"fizz\": 7}")); - assertThat(((ComparableJsonNode) actualArray.get(1)).getRealNode()).isEqualTo(mapper.readTree("{\"fizz\": 8}")); - } - - @Test - public void testNestedHashConstruct() throws Exception { - Object json = mapper.readTree("{\"foo\": {\"bar\": 4, \"fizz\": 5} }"); - Object actual = evaluate(json, "foo.fizz", Object.class); - assertThat(actual).isEqualTo(5); - } - - @Test - public void testImplicitStringConversion() { - String json = "{\"foo\": {\"bar\": 4, \"fizz\": 5} }"; - Object actual = evaluate(json, "foo.fizz", Object.class); - assertThat(actual).isEqualTo(5); - } - - @Test - public void testSelectorAccess() { - String json = "{\"property\":[{\"name\":\"value1\"},{\"name\":\"value2\"}]}"; - Object actual = evaluate(json, "property.^[name == 'value1'].name", Object.class); - assertThat(actual).isEqualTo("value1"); - } - - @Test - public void testJsonGetValueConversionAsJsonNode() throws Exception { - String json = "{\"property\":[{\"name\":\"value1\"},{\"name\":\"value2\"}]}"; - - // use JsonNode conversion - Object node = evaluate(json, "property.^[name == 'value1']", JsonNode.class); - assertThat(node).isInstanceOf(JsonNode.class); - assertThat(((JsonNode) node)).isEqualTo(mapper.readTree("{\"name\":\"value1\"}")); - } - - @Test - public void testJsonGetValueConversionAsObjectNode() throws Exception { - String json = "{\"property\":[{\"name\":\"value1\"},{\"name\":\"value2\"}]}"; - - // use ObjectNode conversion - Object node = evaluate(json, "property.^[name == 'value1']", JsonNode.class); - assertThat(node).isInstanceOf(ObjectNode.class); - assertThat(((ObjectNode) node)).isEqualTo(mapper.readTree("{\"name\":\"value1\"}")); - } - - @Test - public void testJsonGetValueConversionAsArrayNode() throws Exception { - String json = "{\"property\":[{\"name\":\"value1\"},{\"name\":\"value2\"}]}"; - - // use ArrayNode conversion - Object node = evaluate(json, "property", ArrayNode.class); - assertThat(node).isInstanceOf(ArrayNode.class); - assertThat(((ArrayNode) node)).isEqualTo(mapper.readTree("[{\"name\":\"value1\"},{\"name\":\"value2\"}]")); - } - - @Test - public void testJsonGetValueConversionAsString() { - String json = "{\"property\":[{\"name\":\"value1\"},{\"name\":\"value2\"}]}"; - - // use ArrayNode conversion - Object node = evaluate(json, "#root", String.class); - assertThat(node).isInstanceOf(String.class); - assertThat(((String) node)).isEqualTo("{\"property\":[{\"name\":\"value1\"},{\"name\":\"value2\"}]}"); - } - - @Test - public void testSelectorComparingJsonNode() throws Exception { - String json = "{\"property\":[{\"name\":\"value1\"},{\"name\":\"value2\"}], " + - "\"property2\":[{\"name\":\"value1\"},{\"name\":\"value2\"}]}"; - Object actual = evaluate(json, "property[0] eq property2[0]", Object.class); - assertThat(actual).isEqualTo(true); - } - - @Test - public void testSelectorComparingArrayNode() throws Exception { - String json = "{\"property\":[{\"name\":\"value1\"},{\"name\":\"value2\"}], " + - "\"property2\":[{\"name\":\"value1\"},{\"name\":\"value2\"}]}"; - Object actual = evaluate(json, "property eq property2", Object.class); - assertThat(actual).isEqualTo(true); - } - - @Test - public void testUnsupportedString() { - String xml = "?"; - assertThatExceptionOfType(SpelEvaluationException.class) - .isThrownBy(() -> evaluate(xml, "what", Object.class)); - } - - @Test - public void testUnsupportedJson() { - String json = "\"literal\""; - assertThat(evaluate(json, "foo", Object.class)).isNull(); - } - - @Test - public void testNoNullPointerWithCachedReadAccessor() throws Exception { - Expression expression = parser.parseExpression("foo"); - Object json = mapper.readTree("{\"foo\": \"bar\"}"); - Object value = expression.getValue(this.context, json); - assertThat(value).isInstanceOf(String.class); - assertThat(value).isEqualTo("bar"); - Object json2 = mapper.readTree("{}"); - Object value2 = expression.getValue(this.context, json2); - assertThat(value2).isNull(); - } - - private T evaluate(Object target, String expression, Class expectedType) { - return parser.parseExpression(expression).getValue(context, target, expectedType); } }