Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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).
* <p>
* 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.
* <p>
* Default setting is disabled, for backwards compatibility.
*
* @since 2.23
*/
ALLOW_STRINGIFIED_DURATION_VALUES(false)
;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -69,10 +71,21 @@ public class DurationDeserializer extends JSR310DeserializerBase<Duration>
*/
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;
}

/**
Expand All @@ -82,6 +95,7 @@ protected DurationDeserializer(DurationDeserializer base, Boolean leniency) {
super(base, leniency);
_durationUnitConverter = base._durationUnitConverter;
_readTimestampsAsNanosOverride = base._readTimestampsAsNanosOverride;
_allowStringifiedDurationValues = base._allowStringifiedDurationValues;
}

/**
Expand All @@ -91,6 +105,7 @@ protected DurationDeserializer(DurationDeserializer base, DurationUnitConverter
super(base, base._isLenient);
_durationUnitConverter = converter;
_readTimestampsAsNanosOverride = base._readTimestampsAsNanosOverride;
_allowStringifiedDurationValues = base._allowStringifiedDurationValues;
}

/**
Expand All @@ -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<JavaTimeFeature> features)
{
super(base, base._isLenient);
_durationUnitConverter = base._durationUnitConverter;
_readTimestampsAsNanosOverride = base._readTimestampsAsNanosOverride;
_allowStringifiedDurationValues = features.isEnabled(JavaTimeFeature.ALLOW_STRINGIFIED_DURATION_VALUES);
}

@Override
Expand All @@ -114,6 +142,16 @@ protected DurationDeserializer withConverter(DurationUnitConverter converter) {
return new DurationDeserializer(this, converter);
}

/**
* @since 2.23
*/
public DurationDeserializer withFeatures(JacksonFeatureSet<JavaTimeFeature> 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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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")));
}
}
9 changes: 9 additions & 0 deletions release-notes/CREDITS-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 4 additions & 0 deletions release-notes/VERSION-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down