This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch release/struts-6-8-x
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/release/struts-6-8-x by this 
push:
     new bfebc3e4a WW-4428 feat(json): add java.time serialization and 
deserialization support (#1616)
bfebc3e4a is described below

commit bfebc3e4a1574b4025c9df6f7e7a99895d8262b0
Author: Lukasz Lenart <[email protected]>
AuthorDate: Thu Mar 12 08:43:08 2026 +0100

    WW-4428 feat(json): add java.time serialization and deserialization support 
(#1616)
    
    - 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
    - Add Calendar deserialization support (was serialize-only)
    - Add comprehensive tests including custom formats, null handling,
      malformed input, and round-trip serialization/deserialization
    
    Co-authored-by: Claude Opus 4.6 <[email protected]>
---
 .../org/apache/struts2/json/DefaultJSONWriter.java | 118 +++++++++------
 .../org/apache/struts2/json/JSONPopulator.java     |  75 +++++++++-
 .../apache/struts2/json/DefaultJSONWriterTest.java | 165 +++++++++++++++++++--
 .../org/apache/struts2/json/JSONPopulatorTest.java | 127 ++++++++++++++++
 .../java/org/apache/struts2/json/TemporalBean.java | 143 ++++++++++++++++++
 5 files changed, 573 insertions(+), 55 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 bd2ebe90e..16d13df3d 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
@@ -38,6 +38,15 @@ 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.Calendar;
 import java.util.Collection;
 import java.util.Date;
@@ -60,12 +69,12 @@ 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 final StringBuilder buf = new StringBuilder();
     private Stack<Object> stack = new Stack<>();
     private boolean ignoreHierarchy = true;
     private Object root;
@@ -73,10 +82,9 @@ public class DefaultJSONWriter implements JSONWriter {
     private String exprStack = "";
     private Collection<Pattern> excludeProperties;
     private Collection<Pattern> includeProperties;
-    private DateFormat formatter;
+    private DateFormat dateFormatter;
     private boolean enumAsBean = ENUM_AS_BEAN_DEFAULT;
     private boolean excludeNullProperties;
-    private boolean cacheBeanInfo = true;
     private boolean excludeProxyProperties;
 
     @Inject(value = JSONConstants.RESULT_EXCLUDE_PROXY_PROPERTIES, required = 
false)
@@ -95,14 +103,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
      */
@@ -128,7 +132,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 {
@@ -159,8 +162,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);
@@ -185,6 +187,8 @@ public class DefaultJSONWriter implements JSONWriter {
             this.date((Date) object, method);
         } else if (object instanceof Calendar) {
             this.date(((Calendar) object).getTime(), method);
+        } else if (object instanceof TemporalAccessor) {
+            this.temporal((TemporalAccessor) object, method);
         } else if (object instanceof Locale) {
             this.string(object);
         } else if (object instanceof Enum) {
@@ -201,8 +205,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);
@@ -212,8 +215,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("{");
@@ -334,23 +336,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;
@@ -361,8 +362,7 @@ 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 {
         if (enumAsBean) {
@@ -385,7 +385,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;
@@ -401,9 +401,7 @@ public class DefaultJSONWriter implements JSONWriter {
         if (this.excludeProperties != null) {
             for (Pattern pattern : this.excludeProperties) {
                 if (pattern.matcher(expr).matches()) {
-                    if (LOG.isDebugEnabled()) {
-                        LOG.debug("Ignoring property because of exclude rule: 
" + expr);
-                    }
+                    LOG.debug("Ignoring property because of exclude rule: {}", 
expr);
                     return true;
                 }
             }
@@ -415,9 +413,7 @@ public class DefaultJSONWriter implements JSONWriter {
                     return false;
                 }
             }
-            if (LOG.isDebugEnabled()){
-                LOG.debug("Ignoring property because of include rule:  " + 
expr);
-            }
+            LOG.debug("Ignoring property because of include rule:  {}", expr);
             return true;
         }
         return false;
@@ -498,14 +494,52 @@ 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.dateFormatter == null)
+            this.dateFormatter = new SimpleDateFormat(JSONUtil.RFC3339_FORMAT);
 
         DateFormat formatter = (json != null) && (json.format().length() > 0) 
? new SimpleDateFormat(json
-                .format()) : this.formatter;
+                .format()) : this.dateFormatter;
         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().length() > 0) {
+            formatter = DateTimeFormatter.ofPattern(json.format());
+            if (temporal instanceof Instant) {
+                formatter = formatter.withZone(ZoneOffset.UTC);
+            }
+        } else {
+            formatter = getDefaultDateTimeFormatter(temporal);
+        }
+        this.string(formatter.format(temporal));
+    }
+
+    private 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
      */
@@ -664,13 +698,13 @@ public class DefaultJSONWriter implements JSONWriter {
     @Override
     public void setDateFormatter(String defaultDateFormat) {
         if (defaultDateFormat != null) {
-            this.formatter = new SimpleDateFormat(defaultDateFormat);
+            this.dateFormatter = new SimpleDateFormat(defaultDateFormat);
         }
     }
-    
+
     @Override
     public void setCacheBeanInfo(boolean cacheBeanInfo) {
-       this.cacheBeanInfo = cacheBeanInfo;
+        // no-op
     }
 
     @Override
@@ -699,7 +733,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..2a2e65b31 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.*;
 
 /**
@@ -132,7 +141,11 @@ public class JSONPopulator {
                 || 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")
@@ -367,6 +380,32 @@ public class JSONPopulator {
                 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().length() > 0) ? 
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);
@@ -424,4 +463,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().length() > 0) {
+                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().length() > 0) {
+                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..886e717c5 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,13 +19,20 @@
 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.LinkedHashMap;
 import java.util.List;
@@ -33,7 +40,6 @@ import java.util.Map;
 import java.util.TimeZone;
 
 public class DefaultJSONWriterTest extends StrutsTestCase {
-    @Test
     public void testWrite() throws Exception {
         Bean bean1 = new Bean();
         bean1.setStringField("str");
@@ -52,7 +58,6 @@ public class DefaultJSONWriterTest extends StrutsTestCase {
         
TestUtils.assertEquals(DefaultJSONWriter.class.getResource("jsonwriter-write-bean-01.txt"),
 json);
     }
 
-    @Test
     public void testWriteExcludeNull() throws Exception {
         BeanWithMap bean1 = new BeanWithMap();
         bean1.setStringField("str");
@@ -78,7 +83,7 @@ public class DefaultJSONWriterTest extends StrutsTestCase {
         
TestUtils.assertEquals(DefaultJSONWriter.class.getResource("jsonwriter-write-bean-03.txt"),
 json);
     }
 
-    private class BeanWithMap extends Bean {
+    private static class BeanWithMap extends Bean {
         private Map map;
 
         public Map getMap() {
@@ -90,7 +95,6 @@ public class DefaultJSONWriterTest extends StrutsTestCase {
         }
     }
 
-    @Test
     public void testWriteAnnotatedBean() throws Exception {
         AnnotatedBean bean1 = new AnnotatedBean();
         bean1.setStringField("str");
@@ -111,7 +115,6 @@ public class DefaultJSONWriterTest extends StrutsTestCase {
         
TestUtils.assertEquals(DefaultJSONWriter.class.getResource("jsonwriter-write-bean-02.txt"),
 json);
     }
 
-    @Test
     public void testWriteBeanWithList() throws Exception {
         BeanWithList bean1 = new BeanWithList();
         bean1.setStringField("str");
@@ -134,7 +137,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 +149,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;
         }
@@ -159,7 +162,6 @@ public class DefaultJSONWriterTest extends StrutsTestCase {
         }
     }
 
-    @Test
     public void testCanSerializeADate() throws Exception {
         SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z");
 
@@ -174,7 +176,6 @@ public class DefaultJSONWriterTest extends StrutsTestCase {
         assertEquals("{\"date\":\"2012-12-23T10:10:10\"}", json);
     }
 
-    @Test
     public void testCanSetDefaultDateFormat() throws Exception {
         SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z");
 
@@ -188,4 +189,144 @@ public class DefaultJSONWriterTest extends StrutsTestCase 
{
         assertEquals("{\"date\":\"12-23-2012\"}", json);
     }
 
+    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\""));
+    }
+
+    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\""));
+    }
+
+    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\""));
+    }
+
+    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]\""));
+    }
+
+    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\""));
+    }
+
+    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\""));
+    }
+
+    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\""));
+    }
+
+    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\""));
+    }
+
+    public void testSerializeNullTemporalFields() throws Exception {
+        TemporalBean bean = new TemporalBean();
+
+        JSONWriter jsonWriter = new DefaultJSONWriter();
+        String json = jsonWriter.write(bean, null, null, true);
+        assertEquals("{}", json);
+    }
+
+    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\""));
+    }
+
+    public void testSerializeOffsetDateTimeWithCustomFormat() throws Exception 
{
+        TemporalBean bean = new TemporalBean();
+        bean.setCustomFormatOffsetDateTime(OffsetDateTime.of(2026, 2, 27, 14, 
30, 0, 0, ZoneOffset.ofHours(1)));
+
+        JSONWriter jsonWriter = new DefaultJSONWriter();
+        String json = jsonWriter.write(bean);
+        
assertTrue(json.contains("\"customFormatOffsetDateTime\":\"27\\/02\\/2026 
14:30:00+01:00\""));
+    }
+
+    public void testRoundTripLocalDate() throws Exception {
+        LocalDate original = LocalDate.of(2026, 2, 27);
+        TemporalBean writeBean = new TemporalBean();
+        writeBean.setLocalDate(original);
+
+        JSONWriter jsonWriter = new DefaultJSONWriter();
+        String json = jsonWriter.write(writeBean);
+
+        Object parsed = JSONUtil.deserialize(json);
+        TemporalBean readBean = new TemporalBean();
+        new JSONPopulator().populateObject(readBean, (Map) parsed);
+        assertEquals(original, readBean.getLocalDate());
+    }
+
+    public void testRoundTripInstant() throws Exception {
+        Instant original = Instant.parse("2026-02-27T11:00:00Z");
+        TemporalBean writeBean = new TemporalBean();
+        writeBean.setInstant(original);
+
+        JSONWriter jsonWriter = new DefaultJSONWriter();
+        String json = jsonWriter.write(writeBean);
+
+        Object parsed = JSONUtil.deserialize(json);
+        TemporalBean readBean = new TemporalBean();
+        new JSONPopulator().populateObject(readBean, (Map) parsed);
+        assertEquals(original, readBean.getInstant());
+    }
+
+    public void testRoundTripZonedDateTime() throws Exception {
+        ZonedDateTime original = ZonedDateTime.of(2026, 2, 27, 12, 0, 0, 0, 
ZoneId.of("Europe/Paris"));
+        TemporalBean writeBean = new TemporalBean();
+        writeBean.setZonedDateTime(original);
+
+        JSONWriter jsonWriter = new DefaultJSONWriter();
+        String json = jsonWriter.write(writeBean);
+
+        Object parsed = JSONUtil.deserialize(json);
+        TemporalBean readBean = new TemporalBean();
+        new JSONPopulator().populateObject(readBean, (Map) parsed);
+        assertEquals(original, readBean.getZonedDateTime());
+    }
+
 }
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..08d15e852 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
@@ -23,8 +23,18 @@ 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;
@@ -184,4 +194,121 @@ public class JSONPopulatorTest extends TestCase {
             // @Test(expected = JSONException.class)
         }
     }
+
+    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());
+    }
+
+    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());
+    }
+
+    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());
+    }
+
+    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());
+    }
+
+    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());
+    }
+
+    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());
+    }
+
+    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);
+    }
+
+    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());
+    }
+
+    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());
+    }
+
+    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());
+    }
+
+    public void testDeserializeOffsetDateTimeWithCustomFormat() throws 
Exception {
+        JSONPopulator populator = new JSONPopulator();
+        TemporalBean bean = new TemporalBean();
+        Map<String, Object> jsonMap = new HashMap<>();
+        jsonMap.put("customFormatOffsetDateTime", "27/02/2026 14:30:00+01:00");
+        populator.populateObject(bean, jsonMap);
+        assertEquals(OffsetDateTime.of(2026, 2, 27, 14, 30, 0, 0, 
ZoneOffset.ofHours(1)), bean.getCustomFormatOffsetDateTime());
+    }
+
+    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");
+        try {
+            populator.populateObject(bean, jsonMap);
+            fail("Should have thrown JSONException");
+        } catch (JSONException e) {
+            // expected
+        }
+    }
 }
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..a2f6ae324
--- /dev/null
+++ b/plugins/json/src/test/java/org/apache/struts2/json/TemporalBean.java
@@ -0,0 +1,143 @@
+/*
+ * 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;
+    }
+
+    private OffsetDateTime customFormatOffsetDateTime;
+
+    @JSON(format = "dd/MM/yyyy HH:mm:ssXXX")
+    public OffsetDateTime getCustomFormatOffsetDateTime() {
+        return customFormatOffsetDateTime;
+    }
+
+    @JSON(format = "dd/MM/yyyy HH:mm:ssXXX")
+    public void setCustomFormatOffsetDateTime(OffsetDateTime 
customFormatOffsetDateTime) {
+        this.customFormatOffsetDateTime = customFormatOffsetDateTime;
+    }
+}


Reply via email to