Skip to content

Commit b306919

Browse files
authored
GH-10058: Add SpEL JSON accessors and converter with Jackson 3
Related to: #10058 * Add `JsonNodePropertyAccessor` for reading JSON object properties * Add `JsonArrayNodeIndexAccessor` for array index access * Add `JsonNodeWrapperConverter` for type conversion between `JsonNodeWrapper` and `JsonNode` * Add `EmbeddedJsonMessageHeadersMessageMapper` for JSON message serialization with embedded headers * Deprecate Jackson 2 SpEL JSON accessors * Adopt more intuitive naming for Jackson 3 migration * Remove deprecated classes tests * Fix JavaDoc formatting and class visibility * Restore all `@author` names and copyright years from original classes Signed-off-by: Jooyoung Pyoung <pyoungjy@gmail.com>
1 parent 4b6fc55 commit b306919

File tree

11 files changed

+969
-70
lines changed

11 files changed

+969
-70
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright 2025-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.integration.json;
18+
19+
import org.jspecify.annotations.Nullable;
20+
import tools.jackson.databind.node.ArrayNode;
21+
22+
import org.springframework.expression.AccessException;
23+
import org.springframework.expression.EvaluationContext;
24+
import org.springframework.expression.IndexAccessor;
25+
import org.springframework.expression.TypedValue;
26+
27+
/**
28+
* A SpEL {@link IndexAccessor} that knows how to read indexes from JSON arrays, using
29+
* Jackson's {@link ArrayNode} API.
30+
*
31+
* <p>Supports indexes supplied as an integer literal &mdash; for example, {@code myJsonArray[1]}.
32+
* Also supports negative indexes &mdash; for example, {@code myJsonArray[-1]} which equates
33+
* to {@code myJsonArray[myJsonArray.length - 1]}. Furthermore, {@code null} is returned for
34+
* any index that is out of bounds (see {@link ArrayNode#get(int)} for details).
35+
*
36+
* @author Jooyoung Pyoung
37+
*
38+
* @since 7.0
39+
* @see JacksonPropertyAccessor
40+
*/
41+
public class JacksonIndexAccessor implements IndexAccessor {
42+
43+
private static final Class<?>[] SUPPORTED_CLASSES = { ArrayNode.class };
44+
45+
@Override
46+
public Class<?>[] getSpecificTargetClasses() {
47+
return SUPPORTED_CLASSES;
48+
}
49+
50+
@Override
51+
public boolean canRead(EvaluationContext context, Object target, Object index) {
52+
return (target instanceof ArrayNode && index instanceof Integer);
53+
}
54+
55+
@Override
56+
public TypedValue read(EvaluationContext context, Object target, Object index) throws AccessException {
57+
ArrayNode arrayNode = (ArrayNode) target;
58+
Integer intIndex = (Integer) index;
59+
if (intIndex < 0) {
60+
// negative index: get from the end of array, for compatibility with JacksonPropertyAccessor.ArrayNodeAsList.
61+
intIndex = arrayNode.size() + intIndex;
62+
}
63+
return JacksonPropertyAccessor.typedValue(arrayNode.get(intIndex));
64+
}
65+
66+
@Override
67+
public boolean canWrite(EvaluationContext context, Object target, Object index) {
68+
return false;
69+
}
70+
71+
@Override
72+
public void write(EvaluationContext context, Object target, Object index, @Nullable Object newValue) {
73+
throw new UnsupportedOperationException("Write is not supported");
74+
}
75+
76+
}
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
/*
2+
* Copyright 2025-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.integration.json;
18+
19+
import java.util.AbstractList;
20+
import java.util.Iterator;
21+
22+
import org.jspecify.annotations.Nullable;
23+
import tools.jackson.core.JacksonException;
24+
import tools.jackson.databind.JsonNode;
25+
import tools.jackson.databind.ObjectMapper;
26+
import tools.jackson.databind.json.JsonMapper;
27+
import tools.jackson.databind.node.ArrayNode;
28+
import tools.jackson.databind.node.NullNode;
29+
30+
import org.springframework.expression.AccessException;
31+
import org.springframework.expression.EvaluationContext;
32+
import org.springframework.expression.PropertyAccessor;
33+
import org.springframework.expression.TypedValue;
34+
import org.springframework.util.Assert;
35+
import org.springframework.util.StringUtils;
36+
37+
/**
38+
* A SpEL {@link PropertyAccessor} that knows how to read properties from JSON objects.
39+
* <p>Uses Jackson {@link JsonNode} API for nested properties access.
40+
*
41+
* @author Jooyoung Pyoung
42+
*
43+
* @since 7.0
44+
* @see JacksonIndexAccessor
45+
*/
46+
public class JacksonPropertyAccessor implements PropertyAccessor {
47+
48+
/**
49+
* The kind of types this can work with.
50+
*/
51+
private static final Class<?>[] SUPPORTED_CLASSES =
52+
{
53+
String.class,
54+
JsonNodeWrapper.class,
55+
JsonNode.class
56+
};
57+
58+
private ObjectMapper objectMapper = JsonMapper.builder()
59+
.findAndAddModules(JacksonPropertyAccessor.class.getClassLoader())
60+
.build();
61+
62+
public void setObjectMapper(ObjectMapper objectMapper) {
63+
Assert.notNull(objectMapper, "'objectMapper' cannot be null");
64+
this.objectMapper = objectMapper;
65+
}
66+
67+
@Override
68+
public Class<?>[] getSpecificTargetClasses() {
69+
return SUPPORTED_CLASSES;
70+
}
71+
72+
@Override
73+
public boolean canRead(EvaluationContext context, Object target, String name) throws AccessException {
74+
JsonNode node;
75+
try {
76+
node = asJson(target);
77+
}
78+
catch (AccessException e) {
79+
// Cannot parse - treat as not a JSON
80+
return false;
81+
}
82+
if (node instanceof ArrayNode) {
83+
return maybeIndex(name) != null;
84+
}
85+
return true;
86+
}
87+
88+
private JsonNode asJson(Object target) throws AccessException {
89+
if (target instanceof JsonNode jsonNode) {
90+
return jsonNode;
91+
}
92+
else if (target instanceof JsonNodeWrapper<?> jsonNodeWrapper) {
93+
return jsonNodeWrapper.getRealNode();
94+
}
95+
else if (target instanceof String content) {
96+
try {
97+
return this.objectMapper.readTree(content);
98+
}
99+
catch (JacksonException e) {
100+
throw new AccessException("Exception while trying to deserialize String", e);
101+
}
102+
}
103+
else {
104+
throw new IllegalStateException("Can't happen. Check SUPPORTED_CLASSES");
105+
}
106+
}
107+
108+
/**
109+
* Return an integer if the String property name can be parsed as an int, or null otherwise.
110+
*/
111+
private static Integer maybeIndex(String name) {
112+
if (!isNumeric(name)) {
113+
return null;
114+
}
115+
try {
116+
return Integer.valueOf(name);
117+
}
118+
catch (NumberFormatException e) {
119+
return null;
120+
}
121+
}
122+
123+
@Override
124+
public TypedValue read(EvaluationContext context, @Nullable Object target, String name) throws AccessException {
125+
JsonNode node = asJson(target);
126+
Integer index = maybeIndex(name);
127+
if (index != null && node.has(index)) {
128+
return typedValue(node.get(index));
129+
}
130+
else {
131+
return typedValue(node.get(name));
132+
}
133+
}
134+
135+
@Override
136+
public boolean canWrite(EvaluationContext context, Object target, String name) {
137+
return false;
138+
}
139+
140+
@Override
141+
public void write(EvaluationContext context, Object target, String name, Object newValue) {
142+
throw new UnsupportedOperationException("Write is not supported");
143+
}
144+
145+
/**
146+
* Check if the string is a numeric representation (all digits) or not.
147+
*/
148+
private static boolean isNumeric(String str) {
149+
if (!StringUtils.hasLength(str)) {
150+
return false;
151+
}
152+
int length = str.length();
153+
for (int i = 0; i < length; i++) {
154+
if (!Character.isDigit(str.charAt(i))) {
155+
return false;
156+
}
157+
}
158+
return true;
159+
}
160+
161+
static TypedValue typedValue(JsonNode json) throws AccessException {
162+
if (json == null || json instanceof NullNode) {
163+
return TypedValue.NULL;
164+
}
165+
else if (json.isValueNode()) {
166+
return new TypedValue(getValue(json));
167+
}
168+
return new TypedValue(wrap(json));
169+
}
170+
171+
private static Object getValue(JsonNode json) throws AccessException {
172+
if (json.isString()) {
173+
return json.asString();
174+
}
175+
else if (json.isNumber()) {
176+
return json.numberValue();
177+
}
178+
else if (json.isBoolean()) {
179+
return json.asBoolean();
180+
}
181+
else if (json.isNull()) {
182+
return null;
183+
}
184+
else if (json.isBinary()) {
185+
try {
186+
return json.binaryValue();
187+
}
188+
catch (JacksonException e) {
189+
throw new AccessException(
190+
"Can not get content of binary value: " + json, e);
191+
}
192+
}
193+
throw new IllegalArgumentException("Json is not ValueNode.");
194+
}
195+
196+
public static Object wrap(JsonNode json) throws AccessException {
197+
if (json == null) {
198+
return null;
199+
}
200+
else if (json instanceof ArrayNode arrayNode) {
201+
return new ArrayNodeAsList(arrayNode);
202+
}
203+
else if (json.isValueNode()) {
204+
return getValue(json);
205+
}
206+
else {
207+
return new ComparableJsonNode(json);
208+
}
209+
}
210+
211+
interface JsonNodeWrapper<T> extends Comparable<T> {
212+
213+
JsonNode getRealNode();
214+
215+
}
216+
217+
static class ComparableJsonNode implements JsonNodeWrapper<ComparableJsonNode> {
218+
219+
private final JsonNode delegate;
220+
221+
ComparableJsonNode(JsonNode delegate) {
222+
this.delegate = delegate;
223+
}
224+
225+
@Override
226+
public JsonNode getRealNode() {
227+
return this.delegate;
228+
}
229+
230+
@Override
231+
public String toString() {
232+
return this.delegate.toString();
233+
}
234+
235+
@Override
236+
public int compareTo(ComparableJsonNode o) {
237+
return this.delegate.equals(o.delegate) ? 0 : 1;
238+
}
239+
240+
}
241+
242+
/**
243+
* An {@link AbstractList} implementation around {@link ArrayNode} with {@link JsonNodeWrapper} aspect.
244+
* @since 5.0
245+
*/
246+
static class ArrayNodeAsList extends AbstractList<Object> implements JsonNodeWrapper<Object> {
247+
248+
private final ArrayNode delegate;
249+
250+
ArrayNodeAsList(ArrayNode node) {
251+
this.delegate = node;
252+
}
253+
254+
@Override
255+
public JsonNode getRealNode() {
256+
return this.delegate;
257+
}
258+
259+
@Override
260+
public String toString() {
261+
return this.delegate.toString();
262+
}
263+
264+
@Override
265+
public Object get(int index) {
266+
// negative index - get from the end of list
267+
int i = index < 0 ? this.delegate.size() + index : index;
268+
try {
269+
return wrap(this.delegate.get(i));
270+
}
271+
catch (AccessException ex) {
272+
throw new IllegalArgumentException(ex);
273+
}
274+
}
275+
276+
@Override
277+
public int size() {
278+
return this.delegate.size();
279+
}
280+
281+
@Override
282+
public Iterator<Object> iterator() {
283+
284+
return new Iterator<>() {
285+
286+
private final Iterator<JsonNode> it = ArrayNodeAsList.this.delegate.iterator();
287+
288+
@Override
289+
public boolean hasNext() {
290+
return this.it.hasNext();
291+
}
292+
293+
@Override
294+
public Object next() {
295+
try {
296+
return wrap(this.it.next());
297+
}
298+
catch (AccessException e) {
299+
throw new IllegalArgumentException(e);
300+
}
301+
}
302+
303+
};
304+
}
305+
306+
@Override
307+
public int compareTo(Object o) {
308+
Object that = (o instanceof JsonNodeWrapper<?> wrapper ? wrapper.getRealNode() : o);
309+
return this.delegate.equals(that) ? 0 : 1;
310+
}
311+
312+
}
313+
314+
}

0 commit comments

Comments
 (0)