From 942eac98c0275f7b4920ad392407d3bf0f565c64 Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:35:34 +0700 Subject: [PATCH] Add JavaTimeFeature.ALLOW_STRINGIFIED_DURATION_VALUES (#232) Opt-in Duration deserialization from JSON stringified numbers such as "3600", using the same numeric path as JSON numbers. Default remains ISO-8601 Duration.parse only. --- .../datatype/jsr310/JavaTimeFeature.java | 24 +++- .../datatype/jsr310/JavaTimeModule.java | 2 +- .../jsr310/deser/DurationDeserializer.java | 80 +++++++++++++ .../jsr310/deser/DurationDeser232Test.java | 112 ++++++++++++++++++ release-notes/CREDITS-2.x | 9 ++ release-notes/VERSION-2.x | 4 + 6 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeser232Test.java diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeFeature.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeFeature.java index 36bebe27..19c684ee 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeFeature.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeFeature.java @@ -84,7 +84,29 @@ public enum JavaTimeFeature implements JacksonFeature * * @since 2.23 */ - ALWAYS_WRITE_SUBSECOND_DIGITS(false) + ALWAYS_WRITE_SUBSECOND_DIGITS(false), + + /** + * Feature that controls whether stringified numbers (JSON Strings that + * without quotes would be legal JSON Numbers) may be deserialized as + * {@link java.time.Duration} values (enabled) or not (disabled). + *

+ * When disabled (the default), JSON Strings are parsed with + * {@link java.time.Duration#parse} and must be ISO-8601 duration + * representations such as {@code "PT1H"} -- an int-like String such as + * {@code "3600"} fails. + * When enabled, integer and decimal numeric Strings are handled the same + * as JSON numbers: integers use {@link com.fasterxml.jackson.annotation.JsonFormat} + * pattern unit conversion (or + * {@link com.fasterxml.jackson.databind.DeserializationFeature#READ_DATE_TIMESTAMPS_AS_NANOSECONDS}), + * and decimals are treated as seconds with fractional nanos. + * ISO-8601 duration Strings remain accepted either way. + *

+ * Default setting is disabled, for backwards compatibility. + * + * @since 2.23 + */ + ALLOW_STRINGIFIED_DURATION_VALUES(false) ; /** diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeModule.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeModule.java index 80ede099..75d8b7ea 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeModule.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeModule.java @@ -130,7 +130,7 @@ public void setupModule(SetupContext context) { InstantDeserializer.ZONED_DATE_TIME.withFeatures(_features)); // // Other deserializers - desers.addDeserializer(Duration.class, DurationDeserializer.INSTANCE); + desers.addDeserializer(Duration.class, DurationDeserializer.INSTANCE.withFeatures(_features)); desers.addDeserializer(LocalDateTime.class, LocalDateTimeDeserializer.INSTANCE.withFeatures(_features)); desers.addDeserializer(LocalDate.class, LocalDateDeserializer.INSTANCE.withFeatures(_features)); desers.addDeserializer(LocalTime.class, LocalTimeDeserializer.INSTANCE); diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeserializer.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeserializer.java index 79a1fc25..92270582 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeserializer.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeserializer.java @@ -29,10 +29,12 @@ import com.fasterxml.jackson.core.JsonTokenId; import com.fasterxml.jackson.core.StreamReadCapability; import com.fasterxml.jackson.core.io.NumberInput; +import com.fasterxml.jackson.core.util.JacksonFeatureSet; import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.deser.ContextualDeserializer; import com.fasterxml.jackson.datatype.jsr310.DecimalUtils; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature; import com.fasterxml.jackson.datatype.jsr310.util.DurationUnitConverter; /** @@ -69,10 +71,21 @@ public class DurationDeserializer extends JSR310DeserializerBase */ protected final Boolean _readTimestampsAsNanosOverride; + /** + * Flag for {@link JavaTimeFeature#ALLOW_STRINGIFIED_DURATION_VALUES} + * + * @since 2.23 + */ + protected final boolean _allowStringifiedDurationValues; + + private final static boolean DEFAULT_ALLOW_STRINGIFIED_DURATION_VALUES + = JavaTimeFeature.ALLOW_STRINGIFIED_DURATION_VALUES.enabledByDefault(); + public DurationDeserializer() { super(Duration.class); _durationUnitConverter = null; _readTimestampsAsNanosOverride = null; + _allowStringifiedDurationValues = DEFAULT_ALLOW_STRINGIFIED_DURATION_VALUES; } /** @@ -82,6 +95,7 @@ protected DurationDeserializer(DurationDeserializer base, Boolean leniency) { super(base, leniency); _durationUnitConverter = base._durationUnitConverter; _readTimestampsAsNanosOverride = base._readTimestampsAsNanosOverride; + _allowStringifiedDurationValues = base._allowStringifiedDurationValues; } /** @@ -91,6 +105,7 @@ protected DurationDeserializer(DurationDeserializer base, DurationUnitConverter super(base, base._isLenient); _durationUnitConverter = converter; _readTimestampsAsNanosOverride = base._readTimestampsAsNanosOverride; + _allowStringifiedDurationValues = base._allowStringifiedDurationValues; } /** @@ -103,6 +118,19 @@ protected DurationDeserializer(DurationDeserializer base, super(base, leniency); _durationUnitConverter = converter; _readTimestampsAsNanosOverride = readTimestampsAsNanosOverride; + _allowStringifiedDurationValues = base._allowStringifiedDurationValues; + } + + /** + * @since 2.23 + */ + protected DurationDeserializer(DurationDeserializer base, + JacksonFeatureSet features) + { + super(base, base._isLenient); + _durationUnitConverter = base._durationUnitConverter; + _readTimestampsAsNanosOverride = base._readTimestampsAsNanosOverride; + _allowStringifiedDurationValues = features.isEnabled(JavaTimeFeature.ALLOW_STRINGIFIED_DURATION_VALUES); } @Override @@ -114,6 +142,16 @@ protected DurationDeserializer withConverter(DurationUnitConverter converter) { return new DurationDeserializer(this, converter); } + /** + * @since 2.23 + */ + public DurationDeserializer withFeatures(JacksonFeatureSet features) { + if (_allowStringifiedDurationValues == features.isEnabled(JavaTimeFeature.ALLOW_STRINGIFIED_DURATION_VALUES)) { + return this; + } + return new DurationDeserializer(this, features); + } + @Override public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException @@ -197,6 +235,26 @@ protected Duration _fromString(JsonParser parser, DeserializationContext ctxt, && _isValidTimestampString(value)) { return _fromTimestamp(ctxt, NumberInput.parseLong(value)); } + // [modules-java8#232]: optionally accept JSON stringified numbers + if (_allowStringifiedDurationValues) { + int dots = _countPeriods(value); + if (dots >= 0) { // negative if not simple number + try { + if (dots == 0) { + _validateTimestampLength(parser, value, false); + return _fromTimestamp(ctxt, NumberInput.parseLong(value)); + } + if (dots == 1) { + _validateTimestampLength(parser, value, true); + return DecimalUtils.extractSecondsAndNanos( + NumberInput.parseBigDecimal(value, false), + Duration::ofSeconds, false); + } + } catch (NumberFormatException e) { + // fall through to ISO-8601 handling, to get error there + } + } + } try { return Duration.parse(value); @@ -205,6 +263,28 @@ && _isValidTimestampString(value)) { } } + // Helper to find Strings of form "all digits" and "digits.digits" + protected int _countPeriods(String str) + { + int commas = 0; + int i = 0; + int ch = str.charAt(i); + if (ch == '-') { + ++i; + } + for (int end = str.length(); i < end; ++i) { + ch = str.charAt(i); + if (ch < '0' || ch > '9') { + if (ch == '.') { + ++commas; + } else { + return -1; + } + } + } + return commas; + } + protected Duration _fromTimestamp(DeserializationContext ctxt, long ts) { if (_durationUnitConverter != null) { return _durationUnitConverter.convert(ts); diff --git a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeser232Test.java b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeser232Test.java new file mode 100644 index 00000000..5baec3c2 --- /dev/null +++ b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeser232Test.java @@ -0,0 +1,112 @@ +package com.fasterxml.jackson.datatype.jsr310.deser; + +import java.time.Duration; +import java.util.Locale; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.ModuleTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for [modules-java8#232]: {@link JavaTimeFeature#ALLOW_STRINGIFIED_DURATION_VALUES}. + */ +public class DurationDeser232Test extends ModuleTestBase +{ + static class SecondsWrapper { + @JsonFormat(pattern = "SECONDS") + public Duration value; + } + + // NOTE: cannot use `ModuleTestBase.mapperBuilder()` here, since it already registers a + // plain `JavaTimeModule` and duplicate registrations of the same module are ignored + private static JsonMapper.Builder builderWithFeature() { + return JsonMapper.builder() + .defaultLocale(Locale.ENGLISH) + .addModule(new JavaTimeModule() + .enable(JavaTimeFeature.ALLOW_STRINGIFIED_DURATION_VALUES)); + } + + private final ObjectMapper MAPPER = builderWithFeature().build(); + private final ObjectReader READER = MAPPER.readerFor(Duration.class); + private final ObjectReader DEFAULT_READER = newMapper().readerFor(Duration.class); + + @Test + public void testStringifiedIntegerFailsByDefault() throws Exception + { + assertThrows(InvalidFormatException.class, + () -> DEFAULT_READER.readValue(q("3600"))); + } + + @Test + public void testIsoStringWorksByDefault() throws Exception + { + assertEquals(Duration.ofSeconds(3600L), DEFAULT_READER.readValue(q("PT3600S"))); + } + + @Test + public void testStringifiedIntegerWhenEnabled() throws Exception + { + assertEquals(Duration.ofSeconds(3600L), READER.readValue(q("3600"))); + } + + @Test + public void testStringifiedNegativeIntegerWhenEnabled() throws Exception + { + assertEquals(Duration.ofSeconds(-3600L), READER.readValue(q("-3600"))); + } + + @Test + public void testStringifiedIntegerMillisWhenNanosDisabled() throws Exception + { + Duration value = READER + .without(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS) + .readValue(q("60000")); + assertEquals(Duration.ofSeconds(60L), value); + } + + @Test + public void testStringifiedDecimalWhenEnabled() throws Exception + { + assertEquals(Duration.ofSeconds(60L, 500_000_000), + READER.readValue(q("60.5"))); + } + + @Test + public void testIsoStringStillWorksWhenEnabled() throws Exception + { + assertEquals(Duration.ofSeconds(25L), READER.readValue(q("PT25S"))); + } + + @Test + public void testJsonFormatSecondsAppliesToStringifiedInteger() throws Exception + { + SecondsWrapper w = MAPPER.readerFor(SecondsWrapper.class) + .readValue("{\"value\":\"3600\"}"); + assertEquals(Duration.ofSeconds(3600L), w.value); + } + + @Test + public void testJsonFormatSecondsDoesNotApplyWhenDisabled() throws Exception + { + assertThrows(InvalidFormatException.class, + () -> newMapper().readerFor(SecondsWrapper.class) + .readValue("{\"value\":\"3600\"}")); + } + + @Test + public void testNonNumericStringStillFailsWhenEnabled() throws Exception + { + assertThrows(InvalidFormatException.class, + () -> READER.readValue(q("not-a-duration"))); + } +} diff --git a/release-notes/CREDITS-2.x b/release-notes/CREDITS-2.x index c8693d31..13b1d5ce 100644 --- a/release-notes/CREDITS-2.x +++ b/release-notes/CREDITS-2.x @@ -243,3 +243,12 @@ Seonwoo Jung (@seonwooj0810) * Contributed fix for #76: Missing milliseconds, when serializing Java 8 date-time, if they are zeros (2.23.0) + +Jakub Bocheński (@jakub-bochenski) + * Reported #232: Deserialize Duration from int-like String (e.g. `"3600"`) + (2.23.0) + +Fardan An (@arimu1) + * Contributed #232: Add `JavaTimeFeature.ALLOW_STRINGIFIED_DURATION_VALUES` + to deserialize `Duration` from stringified numbers + (2.23.0) diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index 6bcaa93e..dd565302 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -14,6 +14,10 @@ Modules: `JavaTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS`) (reported by @rycler) (fix contributed by Seonwoo J) +#232: Add `JavaTimeFeature.ALLOW_STRINGIFIED_DURATION_VALUES` to deserialize + `Duration` from stringified numbers (e.g. `"3600"`) + (reported by @jakub-bochenski) + (contributed by @arimu1) 2.22.2 (16-Aug-2026) 2.22.1 (07-Jul-2026)