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

lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/main by this push:
     new f8df2752a WW-5814 feat(conversion): opt-in offset-aware ISO date-times 
for java.util.Date
f8df2752a is described below

commit f8df2752ace0aeb9cd1edb40607be67dee643f5a
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Oct 25 07:24:46 2026 +0100

    WW-5814 feat(conversion): opt-in offset-aware ISO date-times for 
java.util.Date
    
    DateConverter reads the Z of an ISO date-time such as
    2026-10-25T05:14:42.000Z as a literal, so a UTC timestamp from a client
    is interpreted in the server's default time zone. Reading it correctly
    shifts every such value by the server's offset without a conversion
    error, so it is opt-in for 7.5.0: struts.converter.date.isoOffsetAware,
    default false. WW-5815 flips the default in 8.0.0.
    
    When enabled, a java.util.Date target (and Calendar, which converts
    through it) first tries DateTimeFormatter.ISO_OFFSET_DATE_TIME: Z means
    UTC, numeric offsets are honoured and 0-9 fractional digits are read as
    a fraction. Input without an offset falls through to the existing
    formats unchanged. A year outside Date's range is a conversion error.
    
    The Z tests pin Asia/Tokyo as the default zone, so they cannot pass
    vacuously on a UTC build machine.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../java/org/apache/struts2/StrutsConstants.java   |  9 +++
 .../struts2/conversion/impl/DateConverter.java     | 29 +++++++++
 .../org/apache/struts2/default.properties          |  5 ++
 .../struts2/conversion/impl/DateConverterTest.java | 75 +++++++++++++++++++++-
 4 files changed, 117 insertions(+), 1 deletion(-)

diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java 
b/core/src/main/java/org/apache/struts2/StrutsConstants.java
index 4c9ca1719..ae5d644af 100644
--- a/core/src/main/java/org/apache/struts2/StrutsConstants.java
+++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java
@@ -669,6 +669,15 @@ public final class StrutsConstants {
     public static final String STRUTS_CONVERTER_COLLECTION = 
"struts.converter.collection";
     public static final String STRUTS_CONVERTER_ARRAY = 
"struts.converter.array";
     public static final String STRUTS_CONVERTER_DATE = "struts.converter.date";
+
+    /**
+     * When true, {@link org.apache.struts2.conversion.impl.DateConverter} 
reads the offset of an ISO date-time such as
+     * {@code 2026-10-25T05:14:42.000Z}, so {@code Z} means UTC; when false 
(the default) it is read as server-local time.
+     *
+     * @since 7.5.0
+     */
+    public static final String STRUTS_CONVERTER_DATE_ISO_OFFSET_AWARE = 
"struts.converter.date.isoOffsetAware";
+
     public static final String STRUTS_CONVERTER_NUMBER = 
"struts.converter.number";
     public static final String STRUTS_CONVERTER_STRING = 
"struts.converter.string";
 
diff --git 
a/core/src/main/java/org/apache/struts2/conversion/impl/DateConverter.java 
b/core/src/main/java/org/apache/struts2/conversion/impl/DateConverter.java
index 44d835782..bd81d34d9 100644
--- a/core/src/main/java/org/apache/struts2/conversion/impl/DateConverter.java
+++ b/core/src/main/java/org/apache/struts2/conversion/impl/DateConverter.java
@@ -19,6 +19,8 @@
 package org.apache.struts2.conversion.impl;
 
 import org.apache.struts2.ActionContext;
+import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.inject.Inject;
 import org.apache.struts2.text.TextProvider;
 import org.apache.struts2.util.ValueStack;
 import org.apache.logging.log4j.LogManager;
@@ -47,6 +49,13 @@ public class DateConverter extends DefaultTypeConverter {
 
     private final static Logger LOG = 
LogManager.getLogger(DateConverter.class);
 
+    private boolean isoOffsetAware;
+
+    @Inject(value = StrutsConstants.STRUTS_CONVERTER_DATE_ISO_OFFSET_AWARE, 
required = false)
+    public void setIsoOffsetAware(String isoOffsetAware) {
+        this.isoOffsetAware = Boolean.parseBoolean(isoOffsetAware);
+    }
+
     @Override
     public Object convertValue(Map<String, Object> context, Object target, 
Member member, String propertyName,
             Object value, Class toType) {
@@ -67,6 +76,12 @@ public class DateConverter extends DefaultTypeConverter {
 
                 df = selectFormat(new DateFormat[] { fullfmt, dtfmt, dfmt }, 
sa);
             } else if (java.util.Date.class == toType) {
+                if (isoOffsetAware) {
+                    Date isoDate = parseIsoOffsetDateTime(sa);
+                    if (isoDate != null) {
+                        return isoDate;
+                    }
+                }
                 df = selectFormat(getDateFormats(ActionContext.of(context), 
locale), sa);
             } else if (java.time.LocalDateTime.class == toType || 
java.time.LocalDate.class == toType
                     || java.time.LocalTime.class == toType) {
@@ -132,6 +147,20 @@ public class DateConverter extends DefaultTypeConverter {
         return result;
     }
 
+    private static Date parseIsoOffsetDateTime(String input) {
+        OffsetDateTime dateTime;
+        try {
+            dateTime = OffsetDateTime.parse(input, 
DateTimeFormatter.ISO_OFFSET_DATE_TIME);
+        } catch (DateTimeParseException e) {
+            return null;
+        }
+        try {
+            return Date.from(dateTime.toInstant());
+        } catch (IllegalArgumentException e) {
+            throw new TypeConversionException("Could not parse date", e);
+        }
+    }
+
     /**
      * Picks the first format that strictly parses the whole input, so a 
format that only matches leniently, or only
      * matches a prefix, does not hide a later one that fits (an ISO date 
under a {@code dd-MM-y} locale, for example).
diff --git a/core/src/main/resources/org/apache/struts2/default.properties 
b/core/src/main/resources/org/apache/struts2/default.properties
index 05e0613b9..feb190714 100644
--- a/core/src/main/resources/org/apache/struts2/default.properties
+++ b/core/src/main/resources/org/apache/struts2/default.properties
@@ -356,6 +356,11 @@ struts.ognl.expressionMaxLength=256
 ### These formatters are using a slightly different patterns, please check 
JavaDocs of both and more details is in WW-5016
 struts.date.formatter=dateTimeFormatter
 
+### When true, ISO date-times converted to java.util.Date honour their offset, 
so a trailing Z means UTC
+### (e.g. 2026-10-25T05:14:42.000Z from JavaScript's Date.toISOString()). 
Defaults to false, which reads
+### the Z as server-local time.
+struts.converter.date.isoOffsetAware=false
+
 ### Defines which instance of QueryStringBuilder to use, Struts provides just 
one instance:
 ### - strutsQueryStringBuilder
 ### The builder is used by UrlHelp to create a proper Query String out of 
provided parameters map
diff --git 
a/core/src/test/java/org/apache/struts2/conversion/impl/DateConverterTest.java 
b/core/src/test/java/org/apache/struts2/conversion/impl/DateConverterTest.java
index 6f1e498e6..ebd1eee2c 100644
--- 
a/core/src/test/java/org/apache/struts2/conversion/impl/DateConverterTest.java
+++ 
b/core/src/test/java/org/apache/struts2/conversion/impl/DateConverterTest.java
@@ -22,6 +22,7 @@ import org.apache.struts2.ActionContext;
 import org.apache.struts2.text.StubTextProvider;
 import org.apache.struts2.StubValueStack;
 import org.apache.struts2.util.ValueStack;
+import org.apache.struts2.StrutsConstants;
 import org.apache.struts2.StrutsInternalTestCase;
 import org.apache.struts2.conversion.TypeConversionException;
 
@@ -29,6 +30,7 @@ import java.sql.Time;
 import java.sql.Timestamp;
 import java.text.DateFormat;
 import java.text.SimpleDateFormat;
+import java.time.Instant;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
@@ -37,10 +39,12 @@ import java.util.Date;
 import java.util.HashMap;
 import java.util.Locale;
 import java.util.Map;
+import java.util.TimeZone;
 
 public class DateConverterTest extends StrutsInternalTestCase {
 
     private String INPUT_TIME_STAMP_STR;
+    private TimeZone defaultTimeZone;
     private String INPUT_WHEN_LONG_CONSTRUCTOR_STR;
     private final Locale mxLocale = new Locale("es_MX", "MX");
     private final static String RES_TIME_STAMP_STR = "2020-03-20 00:00:00.0";
@@ -54,6 +58,7 @@ public class DateConverterTest extends StrutsInternalTestCase 
{
     private final static String LOCALTIME_STR = "01:59:10";
 
     private final static String INVALID_LOCALDATETIME = "2010-99-99T00:00";
+    private final static TimeZone TOKYO = TimeZone.getTimeZone("Asia/Tokyo");
     private final static Locale DUTCH = Locale.forLanguageTag("nl-NL");
     private final static String MESSAGE_PARSE_ERROR = "Could not parse date";
     private final static String MESSAGE_DEFAULT_CONSTRUCTOR_ERROR = "Couldn't 
create class null using default (long) constructor";
@@ -272,7 +277,68 @@ public class DateConverterTest extends 
StrutsInternalTestCase {
         }
     }
 
+    public void testIsoOffsetAwareIsOffByDefault() {
+        TimeZone.setDefault(TOKYO);
+        Object value = 
convertToDate(container.getInstance(DateConverter.class), 
"2026-10-24T10:15:30Z", Locale.US, null);
+
+        assertEquals(LocalDateTime.of(2026, 10, 24, 10, 15, 30), 
toLocalDateTime(value));
+    }
+
+    public void testIsoOffsetAwareConstantReadsZAsUtc() {
+        TimeZone.setDefault(TOKYO);
+        
initDispatcher(Map.of(StrutsConstants.STRUTS_CONVERTER_DATE_ISO_OFFSET_AWARE, 
"true"));
+
+        Object value = 
convertToDate(container.getInstance(DateConverter.class), 
"2026-10-24T10:15:30Z", Locale.US, null);
+
+        assertEquals(Instant.parse("2026-10-24T10:15:30Z"), ((Date) 
value).toInstant());
+    }
+
+    public void testIsoOffsetAwareReadsMillisAndZAsUtc() {
+        TimeZone.setDefault(TOKYO);
+        Object value = convertToDate(isoOffsetAwareConverter(), 
"2026-10-24T10:15:30.123Z", Locale.US, null);
+
+        assertEquals(Instant.parse("2026-10-24T10:15:30.123Z"), ((Date) 
value).toInstant());
+    }
+
+    public void testIsoOffsetAwareHonoursANumericOffset() {
+        Object value = convertToDate(isoOffsetAwareConverter(), 
"2026-10-24T12:15:30+02:00", Locale.US, null);
+
+        assertEquals(Instant.parse("2026-10-24T10:15:30Z"), ((Date) 
value).toInstant());
+    }
+
+    public void testIsoOffsetAwareAcceptsAnyFractionLength() {
+        TimeZone.setDefault(TOKYO);
+        Object value = convertToDate(isoOffsetAwareConverter(), 
"2026-10-24T10:15:30.5Z", Locale.US, null);
+
+        assertEquals(Instant.parse("2026-10-24T10:15:30.500Z"), ((Date) 
value).toInstant());
+    }
+
+    public void testIsoOffsetAwareKeepsTheLocalReadingWithoutAnOffset() {
+        Object value = convertToDate(isoOffsetAwareConverter(), 
"2026-10-24T10:15:30", Locale.US, null);
+
+        assertEquals(LocalDateTime.of(2026, 10, 24, 10, 15, 30), 
toLocalDateTime(value));
+    }
+
+    public void testIsoOffsetAwareRejectsAYearOutsideTheDateRange() {
+        try {
+            convertToDate(isoOffsetAwareConverter(), 
"+999999999-12-31T00:00:00Z", Locale.US, null);
+            fail("TypeConversionException expected");
+        } catch (TypeConversionException e) {
+            assertEquals(MESSAGE_PARSE_ERROR, e.getMessage());
+        }
+    }
+
+    private static DateConverter isoOffsetAwareConverter() {
+        DateConverter converter = new DateConverter();
+        converter.setIsoOffsetAware("true");
+        return converter;
+    }
+
     private Object convertToDate(String input, Locale locale, String 
globalFormat) {
+        return convertToDate(new DateConverter(), input, locale, globalFormat);
+    }
+
+    private Object convertToDate(DateConverter converter, String input, Locale 
locale, String globalFormat) {
         ValueStack stack = new StubValueStack();
         Map<String, String> texts = new HashMap<>();
         if (globalFormat != null) {
@@ -281,7 +347,7 @@ public class DateConverterTest extends 
StrutsInternalTestCase {
         stack.push(new StubTextProvider(texts));
         ActionContext context = 
ActionContext.of().withLocale(locale).withValueStack(stack);
 
-        return new DateConverter().convertValue(context.getContextMap(), null, 
null, null, input, Date.class);
+        return converter.convertValue(context.getContextMap(), null, null, 
null, input, Date.class);
     }
 
     private static String dutchDateTime(String time) {
@@ -335,6 +401,7 @@ public class DateConverterTest extends 
StrutsInternalTestCase {
     @Override
     protected void setUp() throws Exception {
         super.setUp();
+        defaultTimeZone = TimeZone.getDefault();
         // Due to JEP 252: Use CLDR Locale Data by Default
         DateFormat dFormat = DateFormat.getDateInstance(DateFormat.SHORT, 
mxLocale);
         if (dFormat.format(new Date()).contains("-")) { // Format when Java 9 
or greater
@@ -346,4 +413,10 @@ public class DateConverterTest extends 
StrutsInternalTestCase {
         INPUT_TIME_STAMP_STR = new SimpleDateFormat(dateTime.toPattern() + 
".SSS", mxLocale).format(Timestamp.valueOf(RES_TIME_STAMP_STR));
     }
 
+    @Override
+    protected void tearDown() throws Exception {
+        TimeZone.setDefault(defaultTimeZone);
+        super.tearDown();
+    }
+
 }

Reply via email to