Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 1-based Month[De]serializer enabled with JavaTimeFeature.ONE_BASED_MONTHS option #292

Merged
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,16 @@ public enum JavaTimeFeature implements JacksonFeature
* stringified numbers are always accepted as timestamps regardless of
* this feature.
*/
ALWAYS_ALLOW_STRINGIFIED_DATE_TIMESTAMPS(false)
ALWAYS_ALLOW_STRINGIFIED_DATE_TIMESTAMPS(false),

/**
* Feature that determines whether {@link java.time.Month} is serialized
* and deserialized as using a zero-based index (FALSE) or a one-based index (TRUE).
* For example, "1" would be serialized/deserialized as Month.JANUARY if TRUE and Month.FEBRUARY if FALSE.
*<p>
* Default setting is false, meaning that Month is serialized/deserialized as a zero-based index.
*/
ONE_BASED_MONTHS(false)
cowtowncoder marked this conversation as resolved.
Show resolved Hide resolved
;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ public final class JavaTimeModule
public JavaTimeModule()
{
super(PackageVersion.VERSION);

_features = JacksonFeatureSet.fromDefaults(JavaTimeFeature.values());
}

Expand Down Expand Up @@ -142,6 +141,11 @@ public void setupModule(SetupContext context) {
desers.addDeserializer(ZoneOffset.class, JSR310StringParsableDeserializer.ZONE_OFFSET);

context.addDeserializers(desers);

final boolean oneBasedMonthEnabled = _features.isEnabled(JavaTimeFeature.ONE_BASED_MONTHS);

context.addBeanDeserializerModifier(new JavaTimeDeserializerModifier(oneBasedMonthEnabled));
context.addBeanSerializerModifier(new JavaTimeSerializerModifier(oneBasedMonthEnabled));
// 20-Nov-2023, tatu: [modules-java8#288]: someone may have directly
// added entries, need to add for backwards compatibility
if (_deserializers != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.fasterxml.jackson.datatype.jsr310.deser;

import java.time.Month;

import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;

/**
* @since 2.17
*/
public class JavaTimeDeserializerModifier extends BeanDeserializerModifier {
private static final long serialVersionUID = 1L;

private final boolean _oneBaseMonths;

public JavaTimeDeserializerModifier(boolean oneBaseMonths) {
_oneBaseMonths = oneBaseMonths;
}

@Override
public JsonDeserializer<?> modifyEnumDeserializer(DeserializationConfig config, JavaType type, BeanDescription beanDesc, JsonDeserializer<?> defaultDeserializer) {
if (_oneBaseMonths && type.hasRawClass(Month.class)) {
return new OneBasedMonthDeserializer(defaultDeserializer);
}
return defaultDeserializer;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.fasterxml.jackson.datatype.jsr310.deser;

import java.io.IOException;
import java.time.Month;
import java.util.regex.Pattern;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.deser.std.DelegatingDeserializer;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;

/**
* @since 2.17
*/
public class OneBasedMonthDeserializer extends DelegatingDeserializer {
private static final long serialVersionUID = 1L;

private static final Pattern HAS_ONE_OR_TWO_DIGITS = Pattern.compile("^\\d{1,2}$");

public OneBasedMonthDeserializer(JsonDeserializer<?> defaultDeserializer) {
super(defaultDeserializer);
}

@Override
public Object deserialize(JsonParser parser, DeserializationContext context) throws IOException {
JsonToken token = parser.currentToken();
Month zeroBaseMonth = (Month) getDelegatee().deserialize(parser, context);
if (!_isNumericValue(parser.getText(), token)) {
return zeroBaseMonth;
}
if (zeroBaseMonth == Month.JANUARY) {
throw new InvalidFormatException(parser, "Month.JANUARY value not allowed for 1-based Month.", zeroBaseMonth, Month.class);
}
return zeroBaseMonth.minus(1);
}

private boolean _isNumericValue(String text, JsonToken token) {
return token == JsonToken.VALUE_NUMBER_INT || _isNumberAsString(text, token);
}

private boolean _isNumberAsString(String text, JsonToken token) {
return token == JsonToken.VALUE_STRING && HAS_ONE_OR_TWO_DIGITS.matcher(text).matches();
}

@Override
protected JsonDeserializer<?> newDelegatingInstance(JsonDeserializer<?> newDelegatee) {
return new OneBasedMonthDeserializer(newDelegatee);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.fasterxml.jackson.datatype.jsr310.ser;

import java.time.Month;

import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializationConfig;
import com.fasterxml.jackson.databind.ser.BeanSerializerModifier;

/**
* @since 2.17
*/
public class JavaTimeSerializerModifier extends BeanSerializerModifier {
private static final long serialVersionUID = 1L;

private final boolean _oneBaseMonths;

public JavaTimeSerializerModifier(boolean oneBaseMonths) {
_oneBaseMonths = oneBaseMonths;
}

@Override
public JsonSerializer<?> modifyEnumSerializer(SerializationConfig config, JavaType valueType, BeanDescription beanDesc, JsonSerializer<?> serializer) {
if (_oneBaseMonths && valueType.hasRawClass(Month.class)) {
return new OneBasedMonthSerializer(serializer);
}
return serializer;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.fasterxml.jackson.datatype.jsr310.ser;

import java.io.IOException;
import java.time.Month;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.SerializerProvider;

/**
* @since 2.17
*/
public class OneBasedMonthSerializer extends JsonSerializer<Month> {
private final JsonSerializer<Object> _defaultSerializer;

@SuppressWarnings("unchecked")
public OneBasedMonthSerializer(JsonSerializer<?> defaultSerializer)
{
_defaultSerializer = (JsonSerializer<Object>) defaultSerializer;
}

@Override
public void serialize(Month value, JsonGenerator gen, SerializerProvider ctxt)
throws IOException
{
// 15-Jan-2024, tatu: [modules-java8#274] This is not really sufficient
// (see `jackson-databind` `EnumSerializer` for full logic), but has to
// do for now. May need to add `@JsonFormat.shape` handling in future.
if (ctxt.isEnabled(SerializationFeature.WRITE_ENUMS_USING_INDEX)) {
gen.writeNumber(value.ordinal() + 1);
return;
}
_defaultSerializer.serialize(value, gen, ctxt);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package com.fasterxml.jackson.datatype.jsr310.deser;

import java.time.Month;
import java.time.temporal.TemporalAccessor;

import org.junit.Test;
import org.junit.function.ThrowingRunnable;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.databind.cfg.CoercionAction;
import com.fasterxml.jackson.databind.cfg.CoercionInputShape;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
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.MockObjectConfiguration;
import com.fasterxml.jackson.datatype.jsr310.ModuleTestBase;

import static org.junit.Assert.*;

public class OneBasedMonthDeserTest extends ModuleTestBase
{
static class Wrapper {
public Month value;

public Wrapper(Month v) { value = v; }
public Wrapper() { }
}

@Test
public void testDeserializationAsString01_oneBased() throws Exception
{
assertEquals(Month.JANUARY, readerForOneBased().readValue("\"01\""));
}

@Test
public void testDeserializationAsString01_zeroBased() throws Exception
{
assertEquals(Month.FEBRUARY, readerForZeroBased().readValue("\"01\""));
}


@Test
public void testDeserializationAsString02_oneBased() throws Exception
{
assertEquals(Month.JANUARY, readerForOneBased().readValue("\"JANUARY\""));
}

@Test
public void testDeserializationAsString02_zeroBased() throws Exception
{
assertEquals(Month.JANUARY, readerForZeroBased().readValue("\"JANUARY\""));
}

@Test
public void testBadDeserializationAsString01_oneBased() {
assertError(
() -> readerForOneBased().readValue("\"notamonth\""),
InvalidFormatException.class,
"Cannot deserialize value of type `java.time.Month` from String \"notamonth\": not one of the values accepted for Enum class: [OCTOBER, SEPTEMBER, JUNE, MARCH, MAY, APRIL, JULY, JANUARY, FEBRUARY, DECEMBER, AUGUST, NOVEMBER]"
);
}

static void assertError(ThrowingRunnable codeToRun, Class<? extends Throwable> expectedException, String expectedMessage) {
try {
codeToRun.run();
fail(String.format("Expecting %s, but nothing was thrown!", expectedException.getName()));
} catch (Throwable actualException) {
if (!expectedException.isInstance(actualException)) {
fail(String.format("Expecting exception of type %s, but %s was thrown instead", expectedException.getName(), actualException.getClass().getName()));
}
if (actualException.getMessage() == null || !actualException.getMessage().contains(expectedMessage)) {
fail(String.format("Expecting exception with message containing:'%s', but the actual error message was:'%s'", expectedMessage, actualException.getMessage()));
}
}
}


@Test
public void testDeserialization01_zeroBased() throws Exception
{
assertEquals(Month.FEBRUARY, readerForZeroBased().readValue("1"));
}

@Test
public void testDeserialization01_oneBased() throws Exception
{
assertEquals(Month.JANUARY, readerForOneBased().readValue("1"));
}

@Test
public void testDeserialization02_zeroBased() throws Exception
{
assertEquals(Month.SEPTEMBER, readerForZeroBased().readValue("\"08\""));
}

@Test
public void testDeserialization02_oneBased() throws Exception
{
assertEquals(Month.AUGUST, readerForOneBased().readValue("\"08\""));
}

@Test
public void testDeserializationWithTypeInfo01_oneBased() throws Exception
{
ObjectMapper MAPPER = new ObjectMapper()
.registerModule(new JavaTimeModule().enable(JavaTimeFeature.ONE_BASED_MONTHS));
MAPPER.addMixIn(TemporalAccessor.class, MockObjectConfiguration.class);

TemporalAccessor value = MAPPER.readValue("[\"java.time.Month\",11]", TemporalAccessor.class);
assertEquals(Month.NOVEMBER, value);
}

@Test
public void testDeserializationWithTypeInfo01_zeroBased() throws Exception
{
ObjectMapper MAPPER = new ObjectMapper();
MAPPER.addMixIn(TemporalAccessor.class, MockObjectConfiguration.class);

TemporalAccessor value = MAPPER.readValue("[\"java.time.Month\",\"11\"]", TemporalAccessor.class);
assertEquals(Month.DECEMBER, value);
}

@Test
public void testFormatAnnotation_zeroBased() throws Exception
{
Wrapper output = readerForZeroBased().readValue("{\"value\":\"11\"}", Wrapper.class);
assertEquals(new Wrapper(Month.DECEMBER).value, output.value);
}

@Test
public void testFormatAnnotation_oneBased() throws Exception
{
Wrapper output = readerForOneBased().readValue("{\"value\":\"11\"}", Wrapper.class);
assertEquals(new Wrapper(Month.NOVEMBER).value, output.value);
}

/*
/**********************************************************
/* Tests for empty string handling
/**********************************************************
*/

@Test
public void testDeserializeFromEmptyString() throws Exception
{
final ObjectMapper mapper = newMapper();

// Nulls are handled in general way, not by deserializer so they are ok
Month m = mapper.readerFor(Month.class).readValue(" null ");
assertNull(m);

// But coercion from empty String not enabled for Enums by default:
try {
mapper.readerFor(Month.class).readValue("\"\"");
fail("Should not pass");
} catch (MismatchedInputException e) {
verifyException(e, "Cannot coerce empty String");
}
// But can allow coercion of empty String to, say, null
ObjectMapper emptyStringMapper = mapperBuilder()
.withCoercionConfig(Month.class,
h -> h.setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull))
.build();
m = emptyStringMapper.readerFor(Month.class).readValue("\"\"");
assertNull(m);
}

private ObjectReader readerForZeroBased() {
return JsonMapper.builder()
.addModule(new JavaTimeModule()
.disable(JavaTimeFeature.ONE_BASED_MONTHS))
.build()
.readerFor(Month.class);
}

private ObjectReader readerForOneBased() {
return JsonMapper.builder()
.addModule(new JavaTimeModule().enable(JavaTimeFeature.ONE_BASED_MONTHS))
.build()
.readerFor(Month.class);
}
}
Loading