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']).
This commit is contained in:
Sam Brannen
2024-09-04 16:25:46 +02:00
committed by GitHub
parent c179c06134
commit 49a0aaa793
5 changed files with 620 additions and 243 deletions

View File

@@ -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.
*
* <p>Supports indexes supplied as an integer literal &mdash; for example, {@code myJsonArray[1]}.
* Also supports negative indexes &mdash; 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");
}
}

View File

@@ -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.
* <p>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<T> extends Comparable<T> {
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;
}
}

View File

@@ -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<Integer> 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 = "<what>?</what>";
assertThatExceptionOfType(SpelEvaluationException.class)
.isThrownBy(() -> evaluate(xml, "what", Object.class));
}
@Test
void unsupportedJson() {
String json = "\"literal\"";
assertThat(evaluate(json, "foo", Object.class)).isNull();
}
}
protected <T> T evaluate(Object rootObject, String expression, Class<T> 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"}
]
}
""";
}

View File

@@ -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<Integer> 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);
}
}
}

View File

@@ -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 = "<what>?</what>";
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> T evaluate(Object target, String expression, Class<T> expectedType) {
return parser.parseExpression(expression).getValue(context, target, expectedType);
}
}