This is an automated email from the ASF dual-hosted git repository. lukaszlenart pushed a commit to branch feat/WW-4428-json-plugin-java-time-support in repository https://gitbox.apache.org/repos/asf/struts.git
commit 23de4c6355f718a200c78bb20548925a857180e5 Author: Lukasz Lenart <[email protected]> AuthorDate: Fri Feb 27 14:03:39 2026 +0100 feat(json): add java.time serialization and deserialization support Add support for Java 8+ temporal types in the JSON plugin: - LocalDate, LocalDateTime, LocalTime, ZonedDateTime, OffsetDateTime, Instant - Each type serializes/deserializes using its ISO-8601 default format - @JSON(format="...") annotation works for per-field custom formats - Calendar deserialization support added (was serialize-only) - Existing Date/Calendar serialization behavior unchanged 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --- .../org/apache/struts2/json/DefaultJSONWriter.java | 45 ++++++ .../org/apache/struts2/json/JSONPopulator.java | 79 ++++++++++- .../apache/struts2/json/DefaultJSONWriterTest.java | 94 +++++++++++++ .../org/apache/struts2/json/JSONPopulatorTest.java | 128 +++++++++++++++++ .../java/org/apache/struts2/json/TemporalBean.java | 107 ++++++++++++++ ...-02-27-WW-4428-json-plugin-java-time-support.md | 154 +++++++++++++++++++++ 6 files changed, 604 insertions(+), 3 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..8af778814 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,14 @@ 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.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; import java.util.Calendar; import java.util.Collection; import java.util.Date; @@ -191,6 +199,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) { @@ -512,6 +522,41 @@ public class DefaultJSONWriter implements JSONWriter { 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()); + } 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 */ 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..559ad18b0 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.*; /** @@ -89,7 +98,7 @@ public class JSONPopulator { if (paramTypes.length == 1) { Object convertedValue = this.convert(paramTypes[0], genericTypes[0], value, method); - method.invoke(object, new Object[] { convertedValue }); + method.invoke(object, new Object[]{convertedValue}); } } } @@ -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") @@ -305,7 +318,7 @@ public class JSONPopulator { /** * Converts numbers to the desired class, if possible - * + * * @throws JSONException */ @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..34f181853 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 @@ -26,7 +26,16 @@ 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; @@ -188,4 +197,89 @@ 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 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..132553484 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,122 @@ 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 testDeserializeNullTemporalValues() throws Exception { + JSONPopulator populator = new JSONPopulator(); + TemporalBean bean = new TemporalBean(); + Map<String, Object> jsonMap = new HashMap<>(); + jsonMap.put("localDate", null); + jsonMap.put("localDateTime", null); + jsonMap.put("instant", null); + populator.populateObject(bean, jsonMap); + assertNull(bean.getLocalDate()); + assertNull(bean.getLocalDateTime()); + assertNull(bean.getInstant()); + } + + public void testTemporalRoundTrip() throws Exception { + TemporalBean original = new TemporalBean(); + original.setLocalDate(LocalDate.of(2026, 2, 27)); + original.setLocalDateTime(LocalDateTime.of(2026, 2, 27, 12, 0, 0)); + original.setLocalTime(LocalTime.of(12, 0, 0)); + original.setOffsetDateTime(OffsetDateTime.of(2026, 2, 27, 12, 0, 0, 0, ZoneOffset.ofHours(1))); + original.setInstant(Instant.parse("2026-02-27T11:00:00Z")); + original.setCustomFormatDate(LocalDate.of(2026, 2, 27)); + + // Serialize + JSONWriter jsonWriter = new DefaultJSONWriter(); + String json = jsonWriter.write(original); + + // Deserialize + Object parsed = JSONUtil.deserialize(json); + assertTrue(parsed instanceof Map); + JSONPopulator populator = new JSONPopulator(); + TemporalBean restored = new TemporalBean(); + populator.populateObject(restored, (Map) parsed); + + assertEquals(original.getLocalDate(), restored.getLocalDate()); + assertEquals(original.getLocalDateTime(), restored.getLocalDateTime()); + assertEquals(original.getLocalTime(), restored.getLocalTime()); + assertEquals(original.getOffsetDateTime(), restored.getOffsetDateTime()); + assertEquals(original.getInstant(), restored.getInstant()); + assertEquals(original.getCustomFormatDate(), restored.getCustomFormatDate()); + } } 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..f476f988e --- /dev/null +++ b/plugins/json/src/test/java/org/apache/struts2/json/TemporalBean.java @@ -0,0 +1,107 @@ +/* + * 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; + } +} 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
