This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/main by this push:
new 944ad2f1e WW-4428 feat(json): add java.time serialization and
deserialization support (#1603)
944ad2f1e is described below
commit 944ad2f1e3055902deeb535327d7bcbd3598d635
Author: Lukasz Lenart <[email protected]>
AuthorDate: Wed Mar 11 12:51:21 2026 +0100
WW-4428 feat(json): add java.time serialization and deserialization support
(#1603)
- Add serialization support for LocalDate, LocalDateTime, LocalTime,
ZonedDateTime, OffsetDateTime, and Instant in DefaultJSONWriter
- Add deserialization support for the same types in JSONPopulator
- Support @JSON(format=...) custom formats for all temporal types
- Fix Instant custom-format serialization requiring UTC zone
- Add Calendar serialization/deserialization via temporal bridge
- Add comprehensive tests for all temporal types including custom
formats, malformed input, and null handling
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../org/apache/struts2/json/DefaultJSONWriter.java | 154 ++++++++------
.../org/apache/struts2/json/JSONPopulator.java | 145 ++++++++++----
.../apache/struts2/json/DefaultJSONWriterTest.java | 150 ++++++++++++--
.../org/apache/struts2/json/JSONPopulatorTest.java | 221 +++++++++++++++++----
.../java/org/apache/struts2/json/TemporalBean.java | 131 ++++++++++++
...-02-27-WW-4428-json-plugin-java-time-support.md | 154 ++++++++++++++
6 files changed, 810 insertions(+), 145 deletions(-)
diff --git
a/plugins/json/src/main/java/org/apache/struts2/json/DefaultJSONWriter.java
b/plugins/json/src/main/java/org/apache/struts2/json/DefaultJSONWriter.java
index df4ba2dec..f911139fc 100644
--- a/plugins/json/src/main/java/org/apache/struts2/json/DefaultJSONWriter.java
+++ b/plugins/json/src/main/java/org/apache/struts2/json/DefaultJSONWriter.java
@@ -33,19 +33,30 @@ import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Array;
+import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.text.CharacterIterator;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.text.StringCharacterIterator;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.TemporalAccessor;
+import java.util.ArrayDeque;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
+import java.util.Deque;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Locale;
import java.util.Map;
-import java.util.Stack;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.regex.Pattern;
@@ -60,23 +71,22 @@ public class DefaultJSONWriter implements JSONWriter {
private static final Logger LOG =
LogManager.getLogger(DefaultJSONWriter.class);
- private static char[] hex = "0123456789ABCDEF".toCharArray();
+ private static final char[] hex = "0123456789ABCDEF".toCharArray();
private static final ConcurrentMap<Class<?>, BeanInfo>
BEAN_INFO_CACHE_IGNORE_HIERARCHY = new ConcurrentHashMap<>();
private static final ConcurrentMap<Class<?>, BeanInfo> BEAN_INFO_CACHE =
new ConcurrentHashMap<>();
- private StringBuilder buf = new StringBuilder();
- private Stack<Object> stack = new Stack<>();
+ private final StringBuilder buf = new StringBuilder();
+ private final Deque<Object> stack = new ArrayDeque<>();
private boolean ignoreHierarchy = true;
private Object root;
private boolean buildExpr = true;
private String exprStack = "";
private Collection<Pattern> excludeProperties;
private Collection<Pattern> includeProperties;
- private DateFormat formatter;
+ private DateFormat dateFormat;
private boolean enumAsBean = ENUM_AS_BEAN_DEFAULT;
private boolean excludeNullProperties;
- private boolean cacheBeanInfo = true;
private boolean excludeProxyProperties;
private ProxyService proxyService;
@@ -101,14 +111,10 @@ public class DefaultJSONWriter implements JSONWriter {
}
/**
- * @param object
- * Object to be serialized into JSON
- * @param excludeProperties
- * Patterns matching properties to ignore
- * @param includeProperties
- * Patterns matching properties to include
- * @param excludeNullProperties
- * enable/disable excluding of null properties
+ * @param object Object to be serialized into JSON
+ * @param excludeProperties Patterns matching properties to ignore
+ * @param includeProperties Patterns matching properties to include
+ * @param excludeNullProperties enable/disable excluding of null properties
* @return JSON string for object
* @throws JSONException in case of error during serialize
*/
@@ -134,7 +140,6 @@ public class DefaultJSONWriter implements JSONWriter {
*
* @param object Object to be serialized into JSON
* @param method method
- *
* @throws JSONException in case of error during serialize
*/
protected void value(Object object, Method method) throws JSONException {
@@ -144,7 +149,7 @@ public class DefaultJSONWriter implements JSONWriter {
}
if (this.stack.contains(object)) {
- Class clazz = object.getClass();
+ Class<?> clazz = object.getClass();
// cyclic reference
if (clazz.isPrimitive() || clazz.equals(String.class)) {
@@ -165,8 +170,7 @@ public class DefaultJSONWriter implements JSONWriter {
*
* @param object Object to be serialized into JSON
* @param method method
- *
- * @throws JSONException in case of error during serialize
+ * @throws JSONException in case of error during serialize
*/
protected void process(Object object, Method method) throws JSONException {
this.stack.push(object);
@@ -181,20 +185,22 @@ public class DefaultJSONWriter implements JSONWriter {
this.string(object);
} else if (object instanceof Character) {
this.string(object);
- } else if (object instanceof Map) {
- this.map((Map) object, method);
+ } else if (object instanceof Map<?, ?> map) {
+ this.map(map, method);
} else if (object.getClass().isArray()) {
this.array(object, method);
- } else if (object instanceof Iterable) {
- this.array(((Iterable) object).iterator(), method);
+ } else if (object instanceof Iterable<?> iterable) {
+ this.array(iterable.iterator(), method);
} else if (object instanceof Date) {
this.date((Date) object, method);
} else if (object instanceof Calendar) {
this.date(((Calendar) object).getTime(), method);
+ } else if (object instanceof TemporalAccessor temporalAccessor) {
+ this.temporal(temporalAccessor, method);
} else if (object instanceof Locale) {
this.string(object);
- } else if (object instanceof Enum) {
- this.enumeration((Enum) object);
+ } else if (object instanceof Enum<?> enumValue) {
+ this.enumeration(enumValue);
} else {
processCustom(object, method);
}
@@ -207,8 +213,7 @@ public class DefaultJSONWriter implements JSONWriter {
*
* @param object object
* @param method method
- *
- * @throws JSONException in case of error during serialize
+ * @throws JSONException in case of error during serialize
*/
protected void processCustom(Object object, Method method) throws
JSONException {
this.bean(object);
@@ -218,8 +223,7 @@ public class DefaultJSONWriter implements JSONWriter {
* Instrospect bean and serialize its properties
*
* @param object object
- *
- * @throws JSONException in case of error during serialize
+ * @throws JSONException in case of error during serialize
*/
protected void bean(Object object) throws JSONException {
this.add("{");
@@ -279,7 +283,7 @@ public class DefaultJSONWriter implements JSONWriter {
// special-case handling for an Enumeration - include the name() as
// a property */
if (object instanceof Enum) {
- Object value = ((Enum) object).name();
+ Object value = ((Enum<?>) object).name();
this.add("_name", value, object.getClass().getMethod("name"),
hasData);
}
} catch (Exception e) {
@@ -309,11 +313,11 @@ public class DefaultJSONWriter implements JSONWriter {
return beanInfo;
}
- protected Object getBridgedValue(Method baseAccessor, Object value) throws
InstantiationException, IllegalAccessException {
+ protected Object getBridgedValue(Method baseAccessor, Object value) throws
InstantiationException, IllegalAccessException, NoSuchMethodException,
InvocationTargetException {
JSONFieldBridge fieldBridgeAnn =
baseAccessor.getAnnotation(JSONFieldBridge.class);
if (fieldBridgeAnn != null) {
- Class impl = fieldBridgeAnn.impl();
- FieldBridge instance = (FieldBridge) impl.newInstance();
+ Class<?> impl = fieldBridgeAnn.impl();
+ FieldBridge instance = (FieldBridge)
impl.getDeclaredConstructor().newInstance();
if (fieldBridgeAnn.params().length > 0 &&
ParameterizedBridge.class.isAssignableFrom(impl)) {
Map<String, String> params = new
HashMap<>(fieldBridgeAnn.params().length);
@@ -327,7 +331,7 @@ public class DefaultJSONWriter implements JSONWriter {
return value;
}
- protected Method findBaseAccessor(Class clazz, Method accessor) {
+ protected Method findBaseAccessor(Class<?> clazz, Method accessor) {
Method baseAccessor = null;
if (clazz.getName().contains("$$EnhancerByCGLIB$$")) {
try {
@@ -340,23 +344,22 @@ public class DefaultJSONWriter implements JSONWriter {
} else if (clazz.getName().contains("$$_javassist")) {
try {
baseAccessor = Class.forName(
- clazz.getName().substring(0,
clazz.getName().indexOf("_$$")))
+ clazz.getName().substring(0,
clazz.getName().indexOf("_$$")))
.getMethod(accessor.getName(),
accessor.getParameterTypes());
} catch (Exception ex) {
LOG.debug(ex.getMessage(), ex);
}
- //in hibernate4.3.7,because javassist3.18.1's class name generate rule
is '_$$_jvst'+...
- } else if(clazz.getName().contains("$$_jvst")){
+ //in hibernate4.3.7,because javassist3.18.1's class name generate
rule is '_$$_jvst'+...
+ } else if (clazz.getName().contains("$$_jvst")) {
try {
baseAccessor = Class.forName(
- clazz.getName().substring(0,
clazz.getName().indexOf("_$$")))
+ clazz.getName().substring(0,
clazz.getName().indexOf("_$$")))
.getMethod(accessor.getName(),
accessor.getParameterTypes());
} catch (Exception ex) {
LOG.debug(ex.getMessage(), ex);
}
- }
- else {
+ } else {
return accessor;
}
return baseAccessor;
@@ -367,10 +370,9 @@ public class DefaultJSONWriter implements JSONWriter {
* including all its own properties
*
* @param enumeration the enum
- *
- * @throws JSONException in case of error during serialize
+ * @throws JSONException in case of error during serialize
*/
- protected void enumeration(Enum enumeration) throws JSONException {
+ protected void enumeration(Enum<?> enumeration) throws JSONException {
if (enumAsBean) {
this.bean(enumeration);
} else {
@@ -378,7 +380,7 @@ public class DefaultJSONWriter implements JSONWriter {
}
}
- protected boolean shouldExcludeProperty(PropertyDescriptor prop) throws
SecurityException, NoSuchFieldException {
+ protected boolean shouldExcludeProperty(PropertyDescriptor prop) throws
SecurityException {
String name = prop.getName();
return name.equals("class")
|| name.equals("declaringClass")
@@ -391,7 +393,7 @@ public class DefaultJSONWriter implements JSONWriter {
}
protected String expandExpr(String property) {
- if (this.exprStack.length() == 0) {
+ if (this.exprStack.isEmpty()) {
return property;
}
return this.exprStack + "." + property;
@@ -421,7 +423,7 @@ public class DefaultJSONWriter implements JSONWriter {
return false;
}
}
- if (LOG.isDebugEnabled()){
+ if (LOG.isDebugEnabled()) {
LOG.debug("Ignoring property because of include rule: " +
expr);
}
return true;
@@ -449,15 +451,15 @@ public class DefaultJSONWriter implements JSONWriter {
/*
* Add map to buffer
*/
- protected void map(Map map, Method method) throws JSONException {
+ protected void map(Map<?, ?> map, Method method) throws JSONException {
this.add("{");
- Iterator it = map.entrySet().iterator();
+ Iterator<?> it = map.entrySet().iterator();
boolean warnedNonString = false; // one report per map
boolean hasData = false;
while (it.hasNext()) {
- Map.Entry entry = (Map.Entry) it.next();
+ Map.Entry<?, ?> entry = (Map.Entry<?, ?>) it.next();
if (excludeNullProperties && entry.getValue() == null) {
continue;
}
@@ -504,18 +506,56 @@ public class DefaultJSONWriter implements JSONWriter {
JSON json = null;
if (method != null)
json = method.getAnnotation(JSON.class);
- if (this.formatter == null)
- this.formatter = new SimpleDateFormat(JSONUtil.RFC3339_FORMAT);
+ if (this.dateFormat == null)
+ this.dateFormat = new SimpleDateFormat(JSONUtil.RFC3339_FORMAT);
- DateFormat formatter = (json != null) && (json.format().length() > 0)
? new SimpleDateFormat(json
- .format()) : this.formatter;
+ DateFormat formatter = (json != null) && (!json.format().isEmpty()) ?
new SimpleDateFormat(json
+ .format()) : this.dateFormat;
this.string(formatter.format(date));
}
+ /*
+ * Add temporal (java.time) value to buffer
+ */
+ protected void temporal(TemporalAccessor temporal, Method method) {
+ JSON json = null;
+ if (method != null) {
+ json = method.getAnnotation(JSON.class);
+ }
+
+ DateTimeFormatter formatter;
+ if (json != null && !json.format().isEmpty()) {
+ formatter = DateTimeFormatter.ofPattern(json.format());
+ if (temporal instanceof Instant) {
+ formatter = formatter.withZone(ZoneOffset.UTC);
+ }
+ } else {
+ formatter = getDefaultDateTimeFormatter(temporal);
+ }
+ this.string(formatter.format(temporal));
+ }
+
+ private static DateTimeFormatter
getDefaultDateTimeFormatter(TemporalAccessor temporal) {
+ if (temporal instanceof LocalDate) {
+ return DateTimeFormatter.ISO_LOCAL_DATE;
+ } else if (temporal instanceof LocalDateTime) {
+ return DateTimeFormatter.ISO_LOCAL_DATE_TIME;
+ } else if (temporal instanceof LocalTime) {
+ return DateTimeFormatter.ISO_LOCAL_TIME;
+ } else if (temporal instanceof ZonedDateTime) {
+ return DateTimeFormatter.ISO_ZONED_DATE_TIME;
+ } else if (temporal instanceof OffsetDateTime) {
+ return DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+ } else if (temporal instanceof Instant) {
+ return DateTimeFormatter.ISO_INSTANT;
+ }
+ return DateTimeFormatter.ISO_DATE_TIME;
+ }
+
/*
* Add array to buffer
*/
- protected void array(Iterator it, Method method) throws JSONException {
+ protected void array(Iterator<?> it, Method method) throws JSONException {
this.add("[");
boolean hasData = false;
@@ -670,13 +710,13 @@ public class DefaultJSONWriter implements JSONWriter {
@Override
public void setDateFormatter(String defaultDateFormat) {
if (defaultDateFormat != null) {
- this.formatter = new SimpleDateFormat(defaultDateFormat);
+ this.dateFormat = new SimpleDateFormat(defaultDateFormat);
}
}
@Override
public void setCacheBeanInfo(boolean cacheBeanInfo) {
- this.cacheBeanInfo = cacheBeanInfo;
+ // no-op
}
@Override
@@ -686,7 +726,7 @@ public class DefaultJSONWriter implements JSONWriter {
protected static class JSONAnnotationFinder {
private boolean serialize = true;
- private Method accessor;
+ private final Method accessor;
private String name;
public JSONAnnotationFinder(Method accessor) {
@@ -705,7 +745,7 @@ public class DefaultJSONWriter implements JSONWriter {
public JSONAnnotationFinder invoke() {
JSON json = accessor.getAnnotation(JSON.class);
serialize = json.serialize();
- if (serialize && json.name().length() > 0) {
+ if (serialize && !json.name().isEmpty()) {
name = json.name();
}
return this;
diff --git
a/plugins/json/src/main/java/org/apache/struts2/json/JSONPopulator.java
b/plugins/json/src/main/java/org/apache/struts2/json/JSONPopulator.java
index ef1ac77bd..2b330631f 100644
--- a/plugins/json/src/main/java/org/apache/struts2/json/JSONPopulator.java
+++ b/plugins/json/src/main/java/org/apache/struts2/json/JSONPopulator.java
@@ -32,6 +32,15 @@ import java.math.BigInteger;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.TemporalQuery;
import java.util.*;
/**
@@ -59,11 +68,10 @@ public class JSONPopulator {
this.dateFormat = dateFormat;
}
- @SuppressWarnings("unchecked")
public void populateObject(Object object, final Map elements) throws
IllegalAccessException,
InvocationTargetException, NoSuchMethodException,
IntrospectionException,
IllegalArgumentException, JSONException, InstantiationException {
- Class clazz = object.getClass();
+ Class<?> clazz = object.getClass();
BeanInfo info = Introspector.getBeanInfo(clazz);
PropertyDescriptor[] props = info.getPropertyDescriptors();
@@ -84,12 +92,12 @@ public class JSONPopulator {
// use only public setters
if (Modifier.isPublic(method.getModifiers())) {
- Class[] paramTypes = method.getParameterTypes();
+ Class<?>[] paramTypes = method.getParameterTypes();
Type[] genericTypes =
method.getGenericParameterTypes();
if (paramTypes.length == 1) {
Object convertedValue =
this.convert(paramTypes[0], genericTypes[0], value, method);
- method.invoke(object, new Object[] {
convertedValue });
+ method.invoke(object, convertedValue);
}
}
}
@@ -97,8 +105,7 @@ public class JSONPopulator {
}
}
- @SuppressWarnings("unchecked")
- public Object convert(Class clazz, Type type, Object value, Method method)
+ public Object convert(Class<?> clazz, Type type, Object value, Method
method)
throws IllegalArgumentException, JSONException,
IllegalAccessException,
InvocationTargetException, InstantiationException,
NoSuchMethodException, IntrospectionException {
@@ -116,7 +123,7 @@ public class JSONPopulator {
return convertToArray(clazz, type, value, method);
else if (value instanceof Map) {
// nested bean
- Object convertedValue = clazz.newInstance();
+ Object convertedValue =
clazz.getDeclaredConstructor().newInstance();
this.populateObject(convertedValue, (Map) value);
return convertedValue;
} else if (BigDecimal.class.equals(clazz)) {
@@ -127,22 +134,25 @@ public class JSONPopulator {
throw new JSONException("Incompatible types for property " +
method.getName());
}
- private static boolean isJSONPrimitive(Class clazz) {
+ private static boolean isJSONPrimitive(Class<?> clazz) {
return clazz.isPrimitive() || clazz.equals(String.class) ||
clazz.equals(Date.class)
|| clazz.equals(Boolean.class) || clazz.equals(Byte.class) ||
clazz.equals(Character.class)
|| clazz.equals(Double.class) || clazz.equals(Float.class) ||
clazz.equals(Integer.class)
|| clazz.equals(Long.class) || clazz.equals(Short.class) ||
clazz.equals(Locale.class)
- || clazz.isEnum();
+ || clazz.isEnum()
+ || Calendar.class.isAssignableFrom(clazz)
+ || clazz.equals(LocalDate.class) ||
clazz.equals(LocalDateTime.class)
+ || clazz.equals(LocalTime.class) ||
clazz.equals(ZonedDateTime.class)
+ || clazz.equals(OffsetDateTime.class) ||
clazz.equals(Instant.class);
}
- @SuppressWarnings("unchecked")
- private Object convertToArray(Class clazz, Type type, Object value, Method
accessor)
+ private Object convertToArray(Class<?> clazz, Type type, Object value,
Method accessor)
throws JSONException, IllegalArgumentException,
IllegalAccessException,
InvocationTargetException, InstantiationException,
NoSuchMethodException, IntrospectionException {
if (value == null)
return null;
else if (value instanceof List) {
- Class arrayType = clazz.getComponentType();
+ Class<?> arrayType = clazz.getComponentType();
List values = (List) value;
Object newArray = Array.newInstance(arrayType, values.size());
@@ -164,7 +174,7 @@ public class JSONPopulator {
} else if (List.class.isAssignableFrom(arrayType)) {
newObject = convertToCollection(arrayType, type,
listValue, accessor);
} else {
- newObject = arrayType.newInstance();
+ newObject =
arrayType.getDeclaredConstructor().newInstance();
this.populateObject(newObject, (Map) listValue);
}
@@ -179,16 +189,15 @@ public class JSONPopulator {
}
@SuppressWarnings("unchecked")
- private Object convertToCollection(Class clazz, Type type, Object value,
Method accessor)
+ private Object convertToCollection(Class<?> clazz, Type type, Object
value, Method accessor)
throws JSONException, IllegalArgumentException,
IllegalAccessException,
InvocationTargetException, InstantiationException,
NoSuchMethodException, IntrospectionException {
if (value == null)
return null;
else if (value instanceof List) {
- Class itemClass = Object.class;
+ Class<?> itemClass = Object.class;
Type itemType = null;
- if ((type != null) && (type instanceof ParameterizedType)) {
- ParameterizedType ptype = (ParameterizedType) type;
+ if (type instanceof ParameterizedType ptype) {
itemType = ptype.getActualTypeArguments()[0];
if (itemType.getClass().equals(Class.class)) {
itemClass = (Class) itemType;
@@ -198,11 +207,8 @@ public class JSONPopulator {
}
List values = (List) value;
- Collection newCollection = null;
- try {
- newCollection = (Collection) clazz.newInstance();
- } catch (InstantiationException ex) {
- // fallback if clazz represents an interface or abstract class
+ Collection newCollection;
+ if (clazz.isInterface() ||
Modifier.isAbstract(clazz.getModifiers())) {
if (SortedSet.class.isAssignableFrom(clazz)) {
newCollection = new TreeSet();
} else if (Set.class.isAssignableFrom(clazz)) {
@@ -212,6 +218,8 @@ public class JSONPopulator {
} else {
newCollection = new ArrayList();
}
+ } else {
+ newCollection = (Collection)
clazz.getDeclaredConstructor().newInstance();
}
// create an object for each element
@@ -231,7 +239,7 @@ public class JSONPopulator {
newCollection.add(newObject);
} else if (listValue instanceof Map) {
// array of beans
- Object newObject = itemClass.newInstance();
+ Object newObject =
itemClass.getDeclaredConstructor().newInstance();
this.populateObject(newObject, (Map) listValue);
newCollection.add(newObject);
} else
@@ -244,16 +252,15 @@ public class JSONPopulator {
}
@SuppressWarnings("unchecked")
- private Object convertToMap(Class clazz, Type type, Object value, Method
accessor) throws JSONException,
+ private Object convertToMap(Class<?> clazz, Type type, Object value,
Method accessor) throws JSONException,
IllegalArgumentException, IllegalAccessException,
InvocationTargetException,
InstantiationException, NoSuchMethodException,
IntrospectionException {
if (value == null)
return null;
else if (value instanceof Map) {
- Class itemClass = Object.class;
+ Class<?> itemClass = Object.class;
Type itemType = null;
- if ((type != null) && (type instanceof ParameterizedType)) {
- ParameterizedType ptype = (ParameterizedType) type;
+ if (type instanceof ParameterizedType ptype) {
itemType = ptype.getActualTypeArguments()[1];
if (itemType.getClass().equals(Class.class)) {
itemClass = (Class) itemType;
@@ -264,11 +271,14 @@ public class JSONPopulator {
Map values = (Map) value;
Map newMap;
- try {
- newMap = (Map) clazz.newInstance();
- } catch (InstantiationException ex) {
- // fallback if clazz represents an interface or abstract class
- newMap = new HashMap();
+ if (clazz.isInterface() ||
Modifier.isAbstract(clazz.getModifiers())) {
+ if (SortedMap.class.isAssignableFrom(clazz)) {
+ newMap = new TreeMap();
+ } else {
+ newMap = new HashMap();
+ }
+ } else {
+ newMap = (Map) clazz.getDeclaredConstructor().newInstance();
}
// create an object for each element
@@ -291,7 +301,7 @@ public class JSONPopulator {
newMap.put(key, newObject);
} else if (v instanceof Map) {
// map of beans
- Object newObject = itemClass.newInstance();
+ Object newObject =
itemClass.getDeclaredConstructor().newInstance();
this.populateObject(newObject, (Map) v);
newMap.put(key, newObject);
} else
@@ -305,7 +315,7 @@ public class JSONPopulator {
/**
* Converts numbers to the desired class, if possible
- *
+ *
* @throws JSONException
*/
@SuppressWarnings("unchecked")
@@ -361,17 +371,42 @@ public class JSONPopulator {
JSON json = method.getAnnotation(JSON.class);
DateFormat formatter = new SimpleDateFormat(
- (json != null) && (json.format().length() > 0) ?
json.format() : this.dateFormat);
+ (json != null) && (!json.format().isEmpty()) ?
json.format() : this.dateFormat);
return formatter.parse((String) value);
} catch (ParseException e) {
LOG.error("Unable to parse date from: {}", value, e);
throw new JSONException("Unable to parse date from: " + value);
}
+ } else if (Calendar.class.isAssignableFrom(clazz)) {
+ try {
+ JSON json = method.getAnnotation(JSON.class);
+
+ DateFormat formatter = new SimpleDateFormat(
+ (json != null) && (!json.format().isEmpty()) ?
json.format() : this.dateFormat);
+ Date date = formatter.parse((String) value);
+ Calendar cal = Calendar.getInstance();
+ cal.setTime(date);
+ return cal;
+ } catch (ParseException e) {
+ LOG.error("Unable to parse calendar from: {}", value, e);
+ throw new JSONException("Unable to parse calendar from: " +
value);
+ }
+ } else if (clazz.equals(LocalDate.class)) {
+ return parseTemporalFromString(value, method,
DateTimeFormatter.ISO_LOCAL_DATE, LocalDate::from);
+ } else if (clazz.equals(LocalDateTime.class)) {
+ return parseTemporalFromString(value, method,
DateTimeFormatter.ISO_LOCAL_DATE_TIME, LocalDateTime::from);
+ } else if (clazz.equals(LocalTime.class)) {
+ return parseTemporalFromString(value, method,
DateTimeFormatter.ISO_LOCAL_TIME, LocalTime::from);
+ } else if (clazz.equals(ZonedDateTime.class)) {
+ return parseTemporalFromString(value, method,
DateTimeFormatter.ISO_ZONED_DATE_TIME, ZonedDateTime::from);
+ } else if (clazz.equals(OffsetDateTime.class)) {
+ return parseTemporalFromString(value, method,
DateTimeFormatter.ISO_OFFSET_DATE_TIME, OffsetDateTime::from);
+ } else if (clazz.equals(Instant.class)) {
+ return parseInstantFromString(value, method);
} else if (clazz.isEnum()) {
String sValue = (String) value;
return Enum.valueOf(clazz, sValue);
- } else if (value instanceof String) {
- String sValue = (String) value;
+ } else if (value instanceof String sValue) {
if (Boolean.TYPE.equals(clazz))
return Boolean.parseBoolean(sValue);
else if (Boolean.class.equals(clazz))
@@ -402,7 +437,7 @@ public class JSONPopulator {
return Double.valueOf(sValue);
else if (Character.TYPE.equals(clazz) ||
Character.class.equals(clazz)) {
char charValue = 0;
- if (sValue.length() > 0) {
+ if (!sValue.isEmpty()) {
charValue = sValue.charAt(0);
}
if (Character.TYPE.equals(clazz))
@@ -424,4 +459,38 @@ public class JSONPopulator {
return value;
}
+ private <T> T parseTemporalFromString(Object value, Method method,
DateTimeFormatter defaultFormatter, TemporalQuery<T> query) throws
JSONException {
+ try {
+ String sValue = (String) value;
+ JSON json = method.getAnnotation(JSON.class);
+
+ DateTimeFormatter formatter;
+ if (json != null && !json.format().isEmpty()) {
+ formatter = DateTimeFormatter.ofPattern(json.format());
+ } else {
+ formatter = defaultFormatter;
+ }
+ return formatter.parse(sValue, query);
+ } catch (Exception e) {
+ LOG.error("Unable to parse temporal from: {}", value, e);
+ throw new JSONException("Unable to parse temporal from: " + value);
+ }
+ }
+
+ private Instant parseInstantFromString(Object value, Method method) throws
JSONException {
+ try {
+ String sValue = (String) value;
+ JSON json = method.getAnnotation(JSON.class);
+
+ if (json != null && !json.format().isEmpty()) {
+ DateTimeFormatter formatter =
DateTimeFormatter.ofPattern(json.format()).withZone(ZoneOffset.UTC);
+ return Instant.from(formatter.parse(sValue));
+ }
+ return Instant.parse(sValue);
+ } catch (Exception e) {
+ LOG.error("Unable to parse instant from: {}", value, e);
+ throw new JSONException("Unable to parse instant from: " + value);
+ }
+ }
+
}
diff --git
a/plugins/json/src/test/java/org/apache/struts2/json/DefaultJSONWriterTest.java
b/plugins/json/src/test/java/org/apache/struts2/json/DefaultJSONWriterTest.java
index 1f25cabbc..27b127c0f 100644
---
a/plugins/json/src/test/java/org/apache/struts2/json/DefaultJSONWriterTest.java
+++
b/plugins/json/src/test/java/org/apache/struts2/json/DefaultJSONWriterTest.java
@@ -19,20 +19,32 @@
package org.apache.struts2.json;
import org.apache.struts2.json.annotations.JSONFieldBridge;
-import org.apache.struts2.json.bridge.StringBridge;
-import org.apache.struts2.junit.StrutsTestCase;
import org.apache.struts2.junit.util.TestUtils;
import org.junit.Test;
import java.net.URL;
import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
import java.util.ArrayList;
+import java.util.Calendar;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;
-public class DefaultJSONWriterTest extends StrutsTestCase {
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class DefaultJSONWriterTest {
+
@Test
public void testWrite() throws Exception {
Bean bean1 = new Bean();
@@ -65,7 +77,7 @@ public class DefaultJSONWriterTest extends StrutsTestCase {
bean1.setEnumField(AnEnum.ValueA);
bean1.setEnumBean(AnEnumBean.Two);
- Map m = new LinkedHashMap();
+ Map<String, String> m = new LinkedHashMap<>();
m.put("a", "x");
m.put("b", null);
m.put("c", "z");
@@ -78,14 +90,14 @@ public class DefaultJSONWriterTest extends StrutsTestCase {
TestUtils.assertEquals(DefaultJSONWriter.class.getResource("jsonwriter-write-bean-03.txt"),
json);
}
- private class BeanWithMap extends Bean {
- private Map map;
+ private static class BeanWithMap extends Bean {
+ private Map<?, ?> map;
- public Map getMap() {
+ public Map<?, ?> getMap() {
return map;
}
- public void setMap(Map map) {
+ public void setMap(Map<?, ?> map) {
this.map = map;
}
}
@@ -123,7 +135,7 @@ public class DefaultJSONWriterTest extends StrutsTestCase {
bean1.setLongField(100);
bean1.setEnumField(AnEnum.ValueA);
bean1.setEnumBean(AnEnumBean.Two);
- List<String> errors = new ArrayList<String>();
+ List<String> errors = new ArrayList<>();
errors.add("Field is required");
bean1.setErrors(errors);
@@ -134,7 +146,7 @@ public class DefaultJSONWriterTest extends StrutsTestCase {
TestUtils.assertEquals(DefaultJSONWriter.class.getResource("jsonwriter-write-bean-04.txt"),
json);
}
- private class BeanWithList extends Bean {
+ private static class BeanWithList extends Bean {
private List<String> errors;
public List<String> getErrors() {
@@ -146,10 +158,10 @@ public class DefaultJSONWriterTest extends StrutsTestCase
{
}
}
- private class AnnotatedBean extends Bean {
+ private static class AnnotatedBean extends Bean {
private URL url;
- @JSONFieldBridge(impl = StringBridge.class)
+ @JSONFieldBridge()
public URL getUrl() {
return url;
}
@@ -188,4 +200,118 @@ public class DefaultJSONWriterTest extends StrutsTestCase
{
assertEquals("{\"date\":\"12-23-2012\"}", json);
}
+ @Test
+ public void testSerializeLocalDate() throws Exception {
+ TemporalBean bean = new TemporalBean();
+ bean.setLocalDate(LocalDate.of(2026, 2, 27));
+
+ JSONWriter jsonWriter = new DefaultJSONWriter();
+ String json = jsonWriter.write(bean);
+ assertTrue(json.contains("\"localDate\":\"2026-02-27\""));
+ }
+
+ @Test
+ public void testSerializeLocalDateTime() throws Exception {
+ TemporalBean bean = new TemporalBean();
+ bean.setLocalDateTime(LocalDateTime.of(2026, 2, 27, 12, 0, 0));
+
+ JSONWriter jsonWriter = new DefaultJSONWriter();
+ String json = jsonWriter.write(bean);
+ assertTrue(json.contains("\"localDateTime\":\"2026-02-27T12:00:00\""));
+ }
+
+ @Test
+ public void testSerializeLocalTime() throws Exception {
+ TemporalBean bean = new TemporalBean();
+ bean.setLocalTime(LocalTime.of(12, 0, 0));
+
+ JSONWriter jsonWriter = new DefaultJSONWriter();
+ String json = jsonWriter.write(bean);
+ assertTrue(json.contains("\"localTime\":\"12:00:00\""));
+ }
+
+ @Test
+ public void testSerializeZonedDateTime() throws Exception {
+ TemporalBean bean = new TemporalBean();
+ bean.setZonedDateTime(ZonedDateTime.of(2026, 2, 27, 12, 0, 0, 0,
ZoneId.of("Europe/Paris")));
+
+ JSONWriter jsonWriter = new DefaultJSONWriter();
+ String json = jsonWriter.write(bean);
+
assertTrue(json.contains("\"zonedDateTime\":\"2026-02-27T12:00:00+01:00[Europe\\/Paris]\""));
+ }
+
+ @Test
+ public void testSerializeOffsetDateTime() throws Exception {
+ TemporalBean bean = new TemporalBean();
+ bean.setOffsetDateTime(OffsetDateTime.of(2026, 2, 27, 12, 0, 0, 0,
ZoneOffset.ofHours(1)));
+
+ JSONWriter jsonWriter = new DefaultJSONWriter();
+ String json = jsonWriter.write(bean);
+
assertTrue(json.contains("\"offsetDateTime\":\"2026-02-27T12:00:00+01:00\""));
+ }
+
+ @Test
+ public void testSerializeInstant() throws Exception {
+ TemporalBean bean = new TemporalBean();
+ bean.setInstant(Instant.parse("2026-02-27T11:00:00Z"));
+
+ JSONWriter jsonWriter = new DefaultJSONWriter();
+ String json = jsonWriter.write(bean);
+ assertTrue(json.contains("\"instant\":\"2026-02-27T11:00:00Z\""));
+ }
+
+ @Test
+ public void testSerializeLocalDateWithCustomFormat() throws Exception {
+ TemporalBean bean = new TemporalBean();
+ bean.setCustomFormatDate(LocalDate.of(2026, 2, 27));
+
+ JSONWriter jsonWriter = new DefaultJSONWriter();
+ String json = jsonWriter.write(bean);
+ assertTrue(json.contains("\"customFormatDate\":\"27\\/02\\/2026\""));
+ }
+
+ @Test
+ public void testSerializeLocalDateTimeWithCustomFormat() throws Exception {
+ TemporalBean bean = new TemporalBean();
+ bean.setCustomFormatDateTime(LocalDateTime.of(2026, 2, 27, 14, 30));
+
+ JSONWriter jsonWriter = new DefaultJSONWriter();
+ String json = jsonWriter.write(bean);
+ assertTrue(json.contains("\"customFormatDateTime\":\"27\\/02\\/2026
14:30\""));
+ }
+
+ @Test
+ public void testSerializeNullTemporalField() throws Exception {
+ TemporalBean bean = new TemporalBean();
+
+ JSONWriter jsonWriter = new DefaultJSONWriter();
+ String json = jsonWriter.write(bean, null, null, true);
+ assertFalse(json.contains("\"localDate\""));
+ }
+
+ @Test
+ public void testSerializeInstantWithCustomFormat() throws Exception {
+ TemporalBean bean = new TemporalBean();
+ bean.setCustomFormatInstant(Instant.parse("2026-02-27T11:00:00Z"));
+
+ JSONWriter jsonWriter = new DefaultJSONWriter();
+ String json = jsonWriter.write(bean);
+ assertTrue(json.contains("\"customFormatInstant\":\"2026-02-27
11:00:00\""));
+ }
+
+ @Test
+ public void testSerializeCalendar() throws Exception {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z");
+ Calendar cal = Calendar.getInstance();
+ cal.setTime(sdf.parse("2012-12-23 10:10:10 GMT"));
+
+ TemporalBean bean = new TemporalBean();
+ bean.setCalendar(cal);
+
+ JSONWriter jsonWriter = new DefaultJSONWriter();
+ TimeZone.setDefault(TimeZone.getTimeZone("GMT"));
+ String json = jsonWriter.write(bean);
+ assertTrue(json.contains("\"calendar\":\"2012-12-23T10:10:10\""));
+ }
+
}
diff --git
a/plugins/json/src/test/java/org/apache/struts2/json/JSONPopulatorTest.java
b/plugins/json/src/test/java/org/apache/struts2/json/JSONPopulatorTest.java
index c3a2a3bfe..bf44b2ef9 100644
--- a/plugins/json/src/test/java/org/apache/struts2/json/JSONPopulatorTest.java
+++ b/plugins/json/src/test/java/org/apache/struts2/json/JSONPopulatorTest.java
@@ -18,24 +18,42 @@
*/
package org.apache.struts2.json;
+import org.apache.struts2.junit.util.TestUtils;
+import org.junit.Test;
+
import java.beans.IntrospectionException;
import java.io.StringReader;
import java.lang.reflect.InvocationTargetException;
import java.math.BigDecimal;
import java.math.BigInteger;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.Calendar;
import java.util.HashMap;
import java.util.Map;
+import java.util.TimeZone;
-import junit.framework.TestCase;
-import org.apache.struts2.junit.util.TestUtils;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
-public class JSONPopulatorTest extends TestCase {
+public class JSONPopulatorTest {
+ @Test
public void testNulls() throws IntrospectionException,
InvocationTargetException, NoSuchMethodException,
JSONException, InstantiationException, IllegalAccessException {
JSONPopulator populator = new JSONPopulator();
OtherBean bean = new OtherBean();
- Map jsonMap = new HashMap();
+ Map<String, ?> jsonMap = new HashMap<>();
jsonMap.put("intField", null);
jsonMap.put("booleanField", null);
@@ -54,13 +72,14 @@ public class JSONPopulatorTest extends TestCase {
assertNull(bean.getByteField());
}
+ @Test
public void testPrimitiveBean() throws Exception {
StringReader stringReader = new
StringReader(TestUtils.readContent(JSONInterceptorTest.class
.getResource("json-7.txt")));
Object json = JSONUtil.deserialize(stringReader);
assertNotNull(json);
assertTrue(json instanceof Map);
- Map jsonMap = (Map) json;
+ Map<?, ?> jsonMap = (Map<?, ?>) json;
JSONPopulator populator = new JSONPopulator();
Bean bean = new Bean();
populator.populateObject(bean, jsonMap);
@@ -70,29 +89,30 @@ public class JSONPopulatorTest extends TestCase {
assertEquals('s', bean.getCharField());
assertEquals(10.1d, bean.getDoubleField(), 0d);
assertEquals(3, bean.getByteField());
- assertEquals(new BigDecimal(111111.5d), bean.getBigDecimal());
+ assertEquals(BigDecimal.valueOf(111111.5d), bean.getBigDecimal());
assertEquals(new BigInteger("111111"), bean.getBigInteger());
}
+ @Test
public void testObjectBean() throws Exception {
String text =
TestUtils.readContent(JSONInterceptorTest.class.getResource("json-7.txt"));
Object json = JSONUtil.deserialize(text);
assertNotNull(json);
assertTrue(json instanceof Map);
- Map jsonMap = (Map) json;
+ Map<?, ?> jsonMap = (Map<?, ?>) json;
JSONPopulator populator = new JSONPopulator();
WrapperClassBean bean = new WrapperClassBean();
populator.populateObject(bean, jsonMap);
assertEquals(Boolean.TRUE, bean.getBooleanField());
- assertEquals(true, bean.isPrimitiveBooleanField1());
- assertEquals(false, bean.isPrimitiveBooleanField2());
- assertEquals(false, bean.isPrimitiveBooleanField3());
+ assertTrue(bean.isPrimitiveBooleanField1());
+ assertFalse(bean.isPrimitiveBooleanField2());
+ assertFalse(bean.isPrimitiveBooleanField3());
assertEquals("test\u000E\u000f", bean.getStringField());
- assertEquals(new Integer(10), bean.getIntField());
+ assertEquals(Integer.valueOf(10), bean.getIntField());
assertEquals(0, bean.getNullIntField());
- assertEquals(new Character('s'), bean.getCharField());
- assertEquals(10.1d, bean.getDoubleField());
- assertEquals(new Byte((byte) 3), bean.getByteField());
+ assertEquals(Character.valueOf('s'), bean.getCharField());
+ assertEquals(Double.valueOf(10.1d), bean.getDoubleField());
+ assertEquals(Byte.valueOf((byte) 3), bean.getByteField());
assertEquals(2, bean.getListField().size());
assertEquals("1", bean.getListField().get(0).getValue());
@@ -100,33 +120,33 @@ public class JSONPopulatorTest extends TestCase {
assertEquals(1, bean.getListMapField().size());
assertEquals(2, bean.getListMapField().get(0).size());
- assertEquals(new Long(2073501),
bean.getListMapField().get(0).get("id1"));
- assertEquals(new Long(3), bean.getListMapField().get(0).get("id2"));
+ assertEquals(Long.valueOf(2073501L),
bean.getListMapField().get(0).get("id1"));
+ assertEquals(Long.valueOf(3L),
bean.getListMapField().get(0).get("id2"));
assertEquals(2, bean.getMapListField().size());
assertEquals(3, bean.getMapListField().get("id1").size());
- assertEquals(new Long(2), bean.getMapListField().get("id1").get(1));
+ assertEquals(Long.valueOf(2L),
bean.getMapListField().get("id1").get(1));
assertEquals(4, bean.getMapListField().get("id2").size());
- assertEquals(new Long(3), bean.getMapListField().get("id2").get(1));
+ assertEquals(Long.valueOf(3L),
bean.getMapListField().get("id2").get(1));
assertEquals(1, bean.getArrayMapField().length);
assertEquals(2, bean.getArrayMapField()[0].size());
- assertEquals(new Long(2073501), bean.getArrayMapField()[0].get("id1"));
- assertEquals(new Long(3), bean.getArrayMapField()[0].get("id2"));
+ assertEquals(Long.valueOf(2073501L),
bean.getArrayMapField()[0].get("id1"));
+ assertEquals(Long.valueOf(3L), bean.getArrayMapField()[0].get("id2"));
assertEquals(3, bean.getSetField().size());
- assertEquals(true, bean.getSetField().contains("A"));
- assertEquals(true, bean.getSetField().contains("B"));
- assertEquals(true, bean.getSetField().contains("C"));
+ assertTrue(bean.getSetField().contains("A"));
+ assertTrue(bean.getSetField().contains("B"));
+ assertTrue(bean.getSetField().contains("C"));
assertEquals(3, bean.getSortedSetField().size());
assertEquals("A", bean.getSortedSetField().first());
- assertEquals(true, bean.getSortedSetField().contains("B"));
+ assertTrue(bean.getSortedSetField().contains("B"));
assertEquals("C", bean.getSortedSetField().last());
assertEquals(3, bean.getNavigableSetField().size());
assertEquals("A", bean.getNavigableSetField().first());
- assertEquals(true, bean.getNavigableSetField().contains("B"));
+ assertTrue(bean.getNavigableSetField().contains("B"));
assertEquals("C", bean.getNavigableSetField().last());
assertEquals(3, bean.getQueueField().size());
@@ -140,41 +160,43 @@ public class JSONPopulatorTest extends TestCase {
assertEquals("C", bean.getDequeField().pollFirst());
}
+ @Test
public void testObjectBeanWithStrings() throws Exception {
StringReader stringReader = new
StringReader(TestUtils.readContent(JSONInterceptorTest.class
.getResource("json-8.txt")));
Object json = JSONUtil.deserialize(stringReader);
assertNotNull(json);
assertTrue(json instanceof Map);
- Map jsonMap = (Map) json;
+ Map<?, ?> jsonMap = (Map<?, ?>) json;
JSONPopulator populator = new JSONPopulator();
WrapperClassBean bean = new WrapperClassBean();
populator.populateObject(bean, jsonMap);
assertEquals(Boolean.TRUE, bean.getBooleanField());
assertEquals("test", bean.getStringField());
- assertEquals(new Integer(10), bean.getIntField());
- assertEquals(new Character('s'), bean.getCharField());
- assertEquals(10.1d, bean.getDoubleField());
- assertEquals(new Byte((byte) 3), bean.getByteField());
+ assertEquals(Integer.valueOf(10), bean.getIntField());
+ assertEquals(Character.valueOf('s'), bean.getCharField());
+ assertEquals(Double.valueOf(10.1d), bean.getDoubleField());
+ assertEquals(Byte.valueOf((byte) 3), bean.getByteField());
- assertEquals(null, bean.getListField());
- assertEquals(null, bean.getListMapField());
- assertEquals(null, bean.getMapListField());
- assertEquals(null, bean.getArrayMapField());
+ assertNull(bean.getListField());
+ assertNull(bean.getListMapField());
+ assertNull(bean.getMapListField());
+ assertNull(bean.getArrayMapField());
}
- public void testInfiniteLoop() throws JSONException {
+ @Test
+ public void testInfiniteLoop() {
try {
JSONReader reader = new JSONReader();
reader.read("[1,\"a]");
fail("Should have thrown an exception");
} catch (JSONException e) {
- // I can't get JUnit to ignore the exception
- // @Test(expected = JSONException.class)
+ assertEquals("Input string is not well formed JSON (invalid char
\uFFFF)", e.getMessage());
}
}
- public void testParseBadInput() throws JSONException {
+ @Test
+ public void testParseBadInput() {
try {
JSONReader reader = new JSONReader();
reader.read("[1,\"a\"1]");
@@ -184,4 +206,127 @@ public class JSONPopulatorTest extends TestCase {
// @Test(expected = JSONException.class)
}
}
+
+ @Test
+ public void testDeserializeLocalDate() throws Exception {
+ JSONPopulator populator = new JSONPopulator();
+ TemporalBean bean = new TemporalBean();
+ Map<String, Object> jsonMap = new HashMap<>();
+ jsonMap.put("localDate", "2026-02-27");
+ populator.populateObject(bean, jsonMap);
+ assertEquals(LocalDate.of(2026, 2, 27), bean.getLocalDate());
+ }
+
+ @Test
+ public void testDeserializeLocalDateTime() throws Exception {
+ JSONPopulator populator = new JSONPopulator();
+ TemporalBean bean = new TemporalBean();
+ Map<String, Object> jsonMap = new HashMap<>();
+ jsonMap.put("localDateTime", "2026-02-27T12:00:00");
+ populator.populateObject(bean, jsonMap);
+ assertEquals(LocalDateTime.of(2026, 2, 27, 12, 0, 0),
bean.getLocalDateTime());
+ }
+
+ @Test
+ public void testDeserializeLocalTime() throws Exception {
+ JSONPopulator populator = new JSONPopulator();
+ TemporalBean bean = new TemporalBean();
+ Map<String, Object> jsonMap = new HashMap<>();
+ jsonMap.put("localTime", "12:00:00");
+ populator.populateObject(bean, jsonMap);
+ assertEquals(LocalTime.of(12, 0, 0), bean.getLocalTime());
+ }
+
+ @Test
+ public void testDeserializeZonedDateTime() throws Exception {
+ JSONPopulator populator = new JSONPopulator();
+ TemporalBean bean = new TemporalBean();
+ Map<String, Object> jsonMap = new HashMap<>();
+ jsonMap.put("zonedDateTime",
"2026-02-27T12:00:00+01:00[Europe/Paris]");
+ populator.populateObject(bean, jsonMap);
+ assertEquals(ZonedDateTime.of(2026, 2, 27, 12, 0, 0, 0,
ZoneId.of("Europe/Paris")), bean.getZonedDateTime());
+ }
+
+ @Test
+ public void testDeserializeOffsetDateTime() throws Exception {
+ JSONPopulator populator = new JSONPopulator();
+ TemporalBean bean = new TemporalBean();
+ Map<String, Object> jsonMap = new HashMap<>();
+ jsonMap.put("offsetDateTime", "2026-02-27T12:00:00+01:00");
+ populator.populateObject(bean, jsonMap);
+ assertEquals(OffsetDateTime.of(2026, 2, 27, 12, 0, 0, 0,
ZoneOffset.ofHours(1)), bean.getOffsetDateTime());
+ }
+
+ @Test
+ public void testDeserializeInstant() throws Exception {
+ JSONPopulator populator = new JSONPopulator();
+ TemporalBean bean = new TemporalBean();
+ Map<String, Object> jsonMap = new HashMap<>();
+ jsonMap.put("instant", "2026-02-27T11:00:00Z");
+ populator.populateObject(bean, jsonMap);
+ assertEquals(Instant.parse("2026-02-27T11:00:00Z"), bean.getInstant());
+ }
+
+ @Test
+ public void testDeserializeCalendar() throws Exception {
+ JSONPopulator populator = new JSONPopulator();
+ TemporalBean bean = new TemporalBean();
+ Map<String, Object> jsonMap = new HashMap<>();
+ jsonMap.put("calendar", "2012-12-23T10:10:10");
+ populator.populateObject(bean, jsonMap);
+ assertNotNull(bean.getCalendar());
+ Calendar expected = Calendar.getInstance();
+ expected.setTimeZone(TimeZone.getDefault());
+ expected.set(2012, Calendar.DECEMBER, 23, 10, 10, 10);
+ expected.set(Calendar.MILLISECOND, 0);
+ assertEquals(expected.getTimeInMillis() / 1000,
bean.getCalendar().getTimeInMillis() / 1000);
+ }
+
+ @Test
+ public void testDeserializeLocalDateWithCustomFormat() throws Exception {
+ JSONPopulator populator = new JSONPopulator();
+ TemporalBean bean = new TemporalBean();
+ Map<String, Object> jsonMap = new HashMap<>();
+ jsonMap.put("customFormatDate", "27/02/2026");
+ populator.populateObject(bean, jsonMap);
+ assertEquals(LocalDate.of(2026, 2, 27), bean.getCustomFormatDate());
+ }
+
+ @Test
+ public void testDeserializeInstantWithCustomFormat() throws Exception {
+ JSONPopulator populator = new JSONPopulator();
+ TemporalBean bean = new TemporalBean();
+ Map<String, Object> jsonMap = new HashMap<>();
+ jsonMap.put("customFormatInstant", "2026-02-27 11:00:00");
+ populator.populateObject(bean, jsonMap);
+ assertEquals(Instant.parse("2026-02-27T11:00:00Z"),
bean.getCustomFormatInstant());
+ }
+
+ @Test(expected = JSONException.class)
+ public void testDeserializeMalformedTemporalThrowsException() throws
Exception {
+ JSONPopulator populator = new JSONPopulator();
+ TemporalBean bean = new TemporalBean();
+ Map<String, Object> jsonMap = new HashMap<>();
+ jsonMap.put("localDate", "not-a-date");
+ populator.populateObject(bean, jsonMap);
+ }
+
+ @Test(expected = JSONException.class)
+ public void testDeserializeMalformedInstantThrowsException() throws
Exception {
+ JSONPopulator populator = new JSONPopulator();
+ TemporalBean bean = new TemporalBean();
+ Map<String, Object> jsonMap = new HashMap<>();
+ jsonMap.put("instant", "not-an-instant");
+ populator.populateObject(bean, jsonMap);
+ }
+
+ @Test
+ public void testDeserializeLocalDateTimeWithCustomFormat() throws
Exception {
+ JSONPopulator populator = new JSONPopulator();
+ TemporalBean bean = new TemporalBean();
+ Map<String, Object> jsonMap = new HashMap<>();
+ jsonMap.put("customFormatDateTime", "27/02/2026 14:30");
+ populator.populateObject(bean, jsonMap);
+ assertEquals(LocalDateTime.of(2026, 2, 27, 14, 30),
bean.getCustomFormatDateTime());
+ }
}
diff --git
a/plugins/json/src/test/java/org/apache/struts2/json/TemporalBean.java
b/plugins/json/src/test/java/org/apache/struts2/json/TemporalBean.java
new file mode 100644
index 000000000..5fced794f
--- /dev/null
+++ b/plugins/json/src/test/java/org/apache/struts2/json/TemporalBean.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.struts2.json;
+
+import org.apache.struts2.json.annotations.JSON;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.ZonedDateTime;
+import java.util.Calendar;
+
+public class TemporalBean {
+
+ private LocalDate localDate;
+ private LocalDateTime localDateTime;
+ private LocalTime localTime;
+ private ZonedDateTime zonedDateTime;
+ private OffsetDateTime offsetDateTime;
+ private Instant instant;
+ private Calendar calendar;
+ private LocalDate customFormatDate;
+
+ public LocalDate getLocalDate() {
+ return localDate;
+ }
+
+ public void setLocalDate(LocalDate localDate) {
+ this.localDate = localDate;
+ }
+
+ public LocalDateTime getLocalDateTime() {
+ return localDateTime;
+ }
+
+ public void setLocalDateTime(LocalDateTime localDateTime) {
+ this.localDateTime = localDateTime;
+ }
+
+ public LocalTime getLocalTime() {
+ return localTime;
+ }
+
+ public void setLocalTime(LocalTime localTime) {
+ this.localTime = localTime;
+ }
+
+ public ZonedDateTime getZonedDateTime() {
+ return zonedDateTime;
+ }
+
+ public void setZonedDateTime(ZonedDateTime zonedDateTime) {
+ this.zonedDateTime = zonedDateTime;
+ }
+
+ public OffsetDateTime getOffsetDateTime() {
+ return offsetDateTime;
+ }
+
+ public void setOffsetDateTime(OffsetDateTime offsetDateTime) {
+ this.offsetDateTime = offsetDateTime;
+ }
+
+ public Instant getInstant() {
+ return instant;
+ }
+
+ public void setInstant(Instant instant) {
+ this.instant = instant;
+ }
+
+ public Calendar getCalendar() {
+ return calendar;
+ }
+
+ public void setCalendar(Calendar calendar) {
+ this.calendar = calendar;
+ }
+
+ @JSON(format = "dd/MM/yyyy")
+ public LocalDate getCustomFormatDate() {
+ return customFormatDate;
+ }
+
+ @JSON(format = "dd/MM/yyyy")
+ public void setCustomFormatDate(LocalDate customFormatDate) {
+ this.customFormatDate = customFormatDate;
+ }
+
+ private LocalDateTime customFormatDateTime;
+
+ @JSON(format = "dd/MM/yyyy HH:mm")
+ public LocalDateTime getCustomFormatDateTime() {
+ return customFormatDateTime;
+ }
+
+ @JSON(format = "dd/MM/yyyy HH:mm")
+ public void setCustomFormatDateTime(LocalDateTime customFormatDateTime) {
+ this.customFormatDateTime = customFormatDateTime;
+ }
+
+ private Instant customFormatInstant;
+
+ @JSON(format = "yyyy-MM-dd HH:mm:ss")
+ public Instant getCustomFormatInstant() {
+ return customFormatInstant;
+ }
+
+ @JSON(format = "yyyy-MM-dd HH:mm:ss")
+ public void setCustomFormatInstant(Instant customFormatInstant) {
+ this.customFormatInstant = customFormatInstant;
+ }
+}
diff --git
a/thoughts/shared/research/2026-02-27-WW-4428-json-plugin-java-time-support.md
b/thoughts/shared/research/2026-02-27-WW-4428-json-plugin-java-time-support.md
new file mode 100644
index 000000000..42aa782a0
--- /dev/null
+++
b/thoughts/shared/research/2026-02-27-WW-4428-json-plugin-java-time-support.md
@@ -0,0 +1,154 @@
+---
+date: 2026-02-27T12:00:00+01:00
+topic: "WW-4428: Add java.time (LocalDate, LocalDateTime) support to JSON
plugin"
+tags: [research, codebase, json-plugin, java-time, localdate, localdatetime,
serialization, deserialization]
+status: complete
+git_commit: 4d2eb938351b0e84a393979045248e21b75766e9
+---
+
+# Research: WW-4428 — Java 8 Date/Time Support in JSON Plugin
+
+**Date**: 2026-02-27
+
+## Research Question
+
+What is the current state of Java 8 `java.time` support (LocalDate,
LocalDateTime, etc.) in the Struts JSON plugin, and what changes are needed to
implement WW-4428?
+
+## Summary
+
+The JSON plugin has **zero java.time support**. Only `java.util.Date` and
`java.util.Calendar` are handled. Java 8 date types like `LocalDate` and
`LocalDateTime` fall through to JavaBean introspection during serialization
(producing garbage like `{"dayOfMonth":23,"month":"DECEMBER",...}`) and throw
exceptions during deserialization. The core module already has comprehensive
java.time support via `DateConverter`, but none of it is wired into the JSON
plugin.
+
+## Detailed Findings
+
+### 1. Serialization — DefaultJSONWriter
+
+**File**:
[`plugins/json/src/main/java/org/apache/struts2/json/DefaultJSONWriter.java`](https://github.com/apache/struts/blob/4d2eb938351b0e84a393979045248e21b75766e9/plugins/json/src/main/java/org/apache/struts2/json/DefaultJSONWriter.java)
+
+The `process()` method (line ~163) dispatches on type:
+
+```java
+} else if (object instanceof Date) {
+ this.date((Date) object, method);
+} else if (object instanceof Calendar) {
+ this.date(((Calendar) object).getTime(), method);
+}
+```
+
+There is no branch for `java.time.temporal.TemporalAccessor` or any specific
java.time type. These objects fall through to `processCustom()` → `bean()`,
which introspects them as JavaBeans.
+
+The `date()` method (line ~335) only accepts `java.util.Date` and uses
`SimpleDateFormat`:
+
+```java
+protected void date(Date date, Method method) {
+ // uses SimpleDateFormat with JSONUtil.RFC3339_FORMAT default
+}
+```
+
+The `setDateFormatter()` method (line ~487) only creates a `SimpleDateFormat`.
+
+### 2. Deserialization — JSONPopulator
+
+**File**:
[`plugins/json/src/main/java/org/apache/struts2/json/JSONPopulator.java`](https://github.com/apache/struts/blob/4d2eb938351b0e84a393979045248e21b75766e9/plugins/json/src/main/java/org/apache/struts2/json/JSONPopulator.java)
+
+`isJSONPrimitive()` (line ~92) only recognizes `Date.class`:
+
+```java
+return clazz.isPrimitive() || clazz.equals(String.class) ||
clazz.equals(Date.class) ...
+```
+
+`convertPrimitive()` (line ~255) only handles `Date.class` via
`SimpleDateFormat.parse()`. Java.time types will throw
`JSONException("Incompatible types for property ...")`.
+
+### 3. Format Configuration
+
+**File**:
[`plugins/json/src/main/java/org/apache/struts2/json/JSONUtil.java`](https://github.com/apache/struts/blob/4d2eb938351b0e84a393979045248e21b75766e9/plugins/json/src/main/java/org/apache/struts2/json/JSONUtil.java)
+
+- `RFC3339_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"` (line 53) — the default format
+- The java.time equivalent is `DateTimeFormatter.ISO_LOCAL_DATE_TIME`
+
+### 4. @JSON Annotation
+
+**File**:
[`plugins/json/src/main/java/org/apache/struts2/json/annotations/JSON.java`](https://github.com/apache/struts/blob/4d2eb938351b0e84a393979045248e21b75766e9/plugins/json/src/main/java/org/apache/struts2/json/annotations/JSON.java)
+
+Has `format()` attribute for per-property date format overrides — currently
only used with `SimpleDateFormat`. Should be extended to work with
`DateTimeFormatter` for java.time types.
+
+### 5. @JSONFieldBridge Workaround
+
+**Directory**: `plugins/json/src/main/java/org/apache/struts2/json/bridge/`
+
+The `FieldBridge` interface provides a manual escape hatch (`objectToString`)
but only supports serialization, not deserialization.
+
+### 6. Core Module Already Has java.time Support
+
+**File**:
[`core/src/main/java/org/apache/struts2/conversion/impl/DateConverter.java`](https://github.com/apache/struts/blob/4d2eb938351b0e84a393979045248e21b75766e9/core/src/main/java/org/apache/struts2/conversion/impl/DateConverter.java)
+
+Handles `LocalDate`, `LocalDateTime`, `LocalTime`, `OffsetDateTime` using
`DateTimeFormatter.parseBest()`. This is not wired into the JSON plugin.
+
+### 7. Test Coverage
+
+- `DefaultJSONWriterTest.java` — only tests `java.util.Date` serialization
(lines 115-142)
+- `SingleDateBean.java` — test fixture with only a `java.util.Date` field
+- `JSONPopulatorTest.java` — no dedicated date deserialization test
+- Zero tests for any java.time type
+
+## Gap Analysis
+
+| Type | Serialization | Deserialization |
+|---|---|---|
+| `java.util.Date` | Supported | Supported |
+| `java.util.Calendar` | Supported (→ Date) | Not supported |
+| `java.time.LocalDate` | **Not supported** | **Not supported** |
+| `java.time.LocalDateTime` | **Not supported** | **Not supported** |
+| `java.time.LocalTime` | **Not supported** | **Not supported** |
+| `java.time.ZonedDateTime` | **Not supported** | **Not supported** |
+| `java.time.Instant` | **Not supported** | **Not supported** |
+| `java.time.OffsetDateTime` | **Not supported** | **Not supported** |
+
+## Implementation Points
+
+To implement WW-4428, changes are needed in:
+
+### DefaultJSONWriter.java
+1. Add `instanceof` checks in `process()` for `LocalDate`, `LocalDateTime`,
`LocalTime`, `ZonedDateTime`, `Instant`, `OffsetDateTime` (or a blanket
`TemporalAccessor` check)
+2. Add a new `temporal(TemporalAccessor, Method)` method using
`DateTimeFormatter`
+3. Use sensible defaults: `ISO_LOCAL_DATE` for `LocalDate`,
`ISO_LOCAL_DATE_TIME` for `LocalDateTime`, etc.
+4. Respect `@JSON(format=...)` annotation via `DateTimeFormatter.ofPattern()`
+
+### JSONPopulator.java
+1. Extend `isJSONPrimitive()` to recognize java.time classes
+2. Extend `convertPrimitive()` to parse java.time types from strings using
`DateTimeFormatter`
+3. Respect `@JSON(format=...)` for custom formats
+
+### JSONWriter.java (interface)
+1. Consider adding `setDateTimeFormatter(String)` or reusing
`setDateFormatter()` for both legacy and java.time
+
+### Tests
+1. Add test beans with java.time fields
+2. Add serialization tests for each supported java.time type
+3. Add deserialization tests for each type
+4. Test `@JSON(format=...)` with java.time types
+5. Test default format behavior
+
+## Architecture Insights
+
+- The JSON plugin was designed when Java 6 was the target, hence
`SimpleDateFormat` throughout
+- The `@JSON(format=...)` annotation is the natural extension point for
per-field formatting
+- The core module's `DateConverter` shows the established pattern for handling
java.time in Struts
+- Since Struts now requires Java 17+, there are no compatibility concerns with
using java.time directly
+
+## Historical Context
+
+- WW-4428 was filed in December 2014 (Struts 2.3.20 era, targeting Java 6/7)
+- Original constraint: couldn't add java.time directly due to Java 6/7
compatibility
+- Related ticket: WW-5016 — Support java 8 date time in the date tag (already
implemented in `components/Date.java`)
+- No prior research documents in thoughts/ for this topic
+
+## Related Research
+
+None found in thoughts/shared/research/.
+
+## Open Questions
+
+1. Should `Instant` be serialized as epoch millis (number) or ISO-8601 string?
+2. Should `ZonedDateTime` include the zone info in the default format?
+3. Should the implementation use a blanket `TemporalAccessor` check or
individual type checks?
+4. Should `Calendar` deserialization also be added while we're at it?
\ No newline at end of file