From 49a0aaa793f96eb218b0754bece9f58daca656ab Mon Sep 17 00:00:00 2001
From: Sam Brannen <104798+sbrannen@users.noreply.github.com>
Date: Wed, 4 Sep 2024 16:25:46 +0200
Subject: [PATCH] GH-9383: Introduce `JsonIndexAccessor`
Fixes: #9383
Issue link: https://github.com/spring-projects/spring-integration/issues/9383
* Polish `JsonPropertyAccessor[Tests]`
* Introduce `JsonIndexAccessor`
This commit introduces a `JsonIndexAccessor` as a complement to the
existing `JsonPropertyAccessor`.
When a `JsonIndexAccessor` is registered with the SpEL `EvaluationContext`,
JSON arrays can be consistently indexed via integer literals (e.g.,[1]) instead of string literals representing integers (e.g., ['1']).
---
.../integration/json/JsonIndexAccessor.java | 75 ++++
.../json/JsonPropertyAccessor.java | 38 +-
.../json/AbstractJsonAccessorTests.java | 366 ++++++++++++++++++
.../json/JsonIndexAccessorTests.java | 115 ++++++
.../json/JsonPropertyAccessorTests.java | 269 +++----------
5 files changed, 620 insertions(+), 243 deletions(-)
create mode 100644 spring-integration-core/src/main/java/org/springframework/integration/json/JsonIndexAccessor.java
create mode 100644 spring-integration-core/src/test/java/org/springframework/integration/json/AbstractJsonAccessorTests.java
create mode 100644 spring-integration-core/src/test/java/org/springframework/integration/json/JsonIndexAccessorTests.java
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);
}
}