From 66a62016c5d58f572c8d54fdb65ceda9df4e8353 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 27 Feb 2026 14:25:06 +0100 Subject: [PATCH] WW-4428 feat(json): add java.time serialization and deserialization support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../struts2/json/DefaultJSONWriter.java | 150 +++++++----- .../apache/struts2/json/JSONPopulator.java | 145 ++++++++--- .../struts2/json/DefaultJSONWriterTest.java | 120 +++++++++- .../struts2/json/JSONPopulatorTest.java | 226 +++++++++++++++--- .../org/apache/struts2/json/TemporalBean.java | 107 +++++++++ ...7-WW-4428-json-plugin-java-time-support.md | 154 ++++++++++++ 6 files changed, 757 insertions(+), 145 deletions(-) create mode 100644 plugins/json/src/test/java/org/apache/struts2/json/TemporalBean.java create mode 100644 thoughts/shared/research/2026-02-27-WW-4428-json-plugin-java-time-support.md 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 df4ba2dec4..41cf07589c 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,29 @@ 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.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 +70,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, BeanInfo> BEAN_INFO_CACHE_IGNORE_HIERARCHY = new ConcurrentHashMap<>(); private static final ConcurrentMap, BeanInfo> BEAN_INFO_CACHE = new ConcurrentHashMap<>(); - private StringBuilder buf = new StringBuilder(); - private Stack stack = new Stack<>(); + private final StringBuilder buf = new StringBuilder(); + private final Deque stack = new ArrayDeque<>(); private boolean ignoreHierarchy = true; private Object root; private boolean buildExpr = true; private String exprStack = ""; private Collection excludeProperties; private Collection 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 +110,10 @@ public String write(Object object) throws JSONException { } /** - * @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 +139,6 @@ public String write(Object object, Collection excludeProperties, * * @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 +148,7 @@ protected void value(Object object, Method method) throws JSONException { } if (this.stack.contains(object)) { - Class clazz = object.getClass(); + Class clazz = object.getClass(); // cyclic reference if (clazz.isPrimitive() || clazz.equals(String.class)) { @@ -165,8 +169,7 @@ protected void value(Object object, Method method) throws JSONException { * * @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 +184,22 @@ protected void process(Object object, Method method) throws JSONException { 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 +212,7 @@ protected void process(Object object, Method method) throws JSONException { * * @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 +222,7 @@ protected void processCustom(Object object, Method method) throws JSONException * 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 +282,7 @@ protected void bean(Object object) throws JSONException { // 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 +312,11 @@ protected BeanInfo getBeanInfo(final Class clazz) throws IntrospectionExcepti 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 params = new HashMap<>(fieldBridgeAnn.params().length); @@ -327,7 +330,7 @@ protected Object getBridgedValue(Method baseAccessor, Object value) throws Insta 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 +343,22 @@ protected Method findBaseAccessor(Class clazz, Method accessor) { } 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 +369,9 @@ protected Method findBaseAccessor(Class clazz, Method accessor) { * 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 +379,7 @@ protected void enumeration(Enum enumeration) throws JSONException { } } - 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 +392,7 @@ protected String expandExpr(int i) { } protected String expandExpr(String property) { - if (this.exprStack.length() == 0) { + if (this.exprStack.isEmpty()) { return property; } return this.exprStack + "." + property; @@ -421,7 +422,7 @@ protected boolean shouldExcludeProperty(String expr) { return false; } } - if (LOG.isDebugEnabled()){ + if (LOG.isDebugEnabled()) { LOG.debug("Ignoring property because of include rule: " + expr); } return true; @@ -449,15 +450,15 @@ protected boolean add(String name, Object value, Method method, boolean hasData) /* * 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 +505,53 @@ protected void date(Date date, Method method) { 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()); + } 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 +706,13 @@ public void setEnumAsBean(boolean enumAsBean) { @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 +722,7 @@ public void setExcludeProxyProperties(boolean excludeProxyProperties) { protected static class JSONAnnotationFinder { private boolean serialize = true; - private Method accessor; + private final Method accessor; private String name; public JSONAnnotationFinder(Method accessor) { @@ -705,7 +741,7 @@ public String getName() { 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 ef1ac77bdd..2b330631f1 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.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 void setDateFormat(String dateFormat) { 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 void populateObject(Object object, final Map elements) throws IllegalAcce // 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 void populateObject(Object object, final Map elements) throws IllegalAcce } } - @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 @@ else if (clazz.isArray()) 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 @@ else if (value instanceof Map) { 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 @@ else if (value instanceof List) { } 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 @@ else if (value instanceof List) { } @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 @@ else if (value instanceof List) { } 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 @@ else if (value instanceof List) { } else { newCollection = new ArrayList(); } + } else { + newCollection = (Collection) clazz.getDeclaredConstructor().newInstance(); } // create an object for each element @@ -231,7 +239,7 @@ else if (value instanceof List) { 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 @@ else if (value instanceof List) { } @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 @@ else if (value instanceof Map) { 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 @@ else if (value instanceof Map) { 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 @@ else if (value instanceof Map) { /** * Converts numbers to the desired class, if possible - * + * * @throws JSONException */ @SuppressWarnings("unchecked") @@ -361,17 +371,42 @@ else if (String.class.equals(clazz)) 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 @@ else if (Double.class.equals(clazz)) 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 @@ else if (Character.TYPE.equals(clazz) || Character.class.equals(clazz)) { return value; } + private T parseTemporalFromString(Object value, Method method, DateTimeFormatter defaultFormatter, TemporalQuery 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 1f25cabbc8..0b4f87c983 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,31 @@ 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.assertTrue; + +public class DefaultJSONWriterTest { + @Test public void testWrite() throws Exception { Bean bean1 = new Bean(); @@ -65,7 +76,7 @@ public void testWriteExcludeNull() throws Exception { bean1.setEnumField(AnEnum.ValueA); bean1.setEnumBean(AnEnumBean.Two); - Map m = new LinkedHashMap(); + Map m = new LinkedHashMap<>(); m.put("a", "x"); m.put("b", null); m.put("c", "z"); @@ -78,14 +89,14 @@ public void testWriteExcludeNull() throws Exception { 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 +134,7 @@ public void testWriteBeanWithList() throws Exception { bean1.setLongField(100); bean1.setEnumField(AnEnum.ValueA); bean1.setEnumBean(AnEnumBean.Two); - List errors = new ArrayList(); + List errors = new ArrayList<>(); errors.add("Field is required"); bean1.setErrors(errors); @@ -134,7 +145,7 @@ public void testWriteBeanWithList() throws Exception { TestUtils.assertEquals(DefaultJSONWriter.class.getResource("jsonwriter-write-bean-04.txt"), json); } - private class BeanWithList extends Bean { + private static class BeanWithList extends Bean { private List errors; public List getErrors() { @@ -146,10 +157,10 @@ public void setErrors(List errors) { } } - 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 +199,89 @@ public void testCanSetDefaultDateFormat() throws Exception { 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 c3a2a3bfe7..3893e4750a 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 jsonMap = new HashMap<>(); jsonMap.put("intField", null); jsonMap.put("booleanField", null); @@ -54,13 +72,14 @@ public void testNulls() throws IntrospectionException, InvocationTargetException 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 void testPrimitiveBean() throws Exception { 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 void testObjectBean() throws Exception { 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 void testObjectBean() throws Exception { 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,132 @@ public void testParseBadInput() throws JSONException { // @Test(expected = JSONException.class) } } + + @Test + public void testDeserializeLocalDate() throws Exception { + JSONPopulator populator = new JSONPopulator(); + TemporalBean bean = new TemporalBean(); + Map 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 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 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 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 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 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 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 jsonMap = new HashMap<>(); + jsonMap.put("customFormatDate", "27/02/2026"); + populator.populateObject(bean, jsonMap); + assertEquals(LocalDate.of(2026, 2, 27), bean.getCustomFormatDate()); + } + + @Test + public void testDeserializeNullTemporalValues() throws Exception { + JSONPopulator populator = new JSONPopulator(); + TemporalBean bean = new TemporalBean(); + Map 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()); + } + + @Test + 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 0000000000..f476f988e1 --- /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 0000000000..42aa782a01 --- /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