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

Reply via email to