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();
+ }
+
}