diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroInstantDeserializer.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroInstantDeserializer.java new file mode 100644 index 000000000..a041d9566 --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroInstantDeserializer.java @@ -0,0 +1,78 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.deser.ContextualDeserializer; +import com.fasterxml.jackson.databind.deser.std.StdScalarDeserializer; + +import java.io.IOException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.Temporal; +import java.util.function.BiFunction; + +/** + * A deserializer for variants of java.time classes that represent a specific instant on the timeline + * (Instant, OffsetDateTime, ZonedDateTime) which supports de-serialization from Avro long. + * + * See: http://avro.apache.org/docs/current/spec.html#Logical+Types + * + * Note: {@link AvroInstantDeserializer} does not support deserialization from string. + * + * @param The type of a instant class that can be deserialized. + */ +public class AvroInstantDeserializer extends StdScalarDeserializer + implements ContextualDeserializer { + + private static final long serialVersionUID = 1L; + + public static final AvroInstantDeserializer INSTANT = + new AvroInstantDeserializer<>(Instant.class, (instant, zoneID) -> instant); + + public static final AvroInstantDeserializer OFFSET_DATE_TIME = + new AvroInstantDeserializer<>(OffsetDateTime.class, OffsetDateTime::ofInstant); + + public static final AvroInstantDeserializer ZONED_DATE_TIME = + new AvroInstantDeserializer<>(ZonedDateTime.class, ZonedDateTime::ofInstant); + + protected final BiFunction fromInstant; + + protected AvroInstantDeserializer(Class t, BiFunction fromInstant) { + super(t); + this.fromInstant = fromInstant; + } + + @SuppressWarnings("unchecked") + @Override + public T deserialize(JsonParser p, DeserializationContext context) throws IOException, JsonProcessingException { + final ZoneId defaultZoneId = context.getTimeZone().toZoneId().normalized(); + switch (p.getCurrentToken()) { + case VALUE_NUMBER_INT: + return fromLong(p.getLongValue(), defaultZoneId); + default: + try { + return (T) context.handleUnexpectedToken(_valueClass, p); + } catch (JsonMappingException e) { + throw e; + } catch (IOException e) { + throw JsonMappingException.fromUnexpectedIOE(e); + } + } + } + + @Override + public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) { + return this; + } + + private T fromLong(long longValue, ZoneId defaultZoneId) { + return fromInstant.apply(Instant.ofEpochMilli(longValue), defaultZoneId); + } + +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroInstantSerializer.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroInstantSerializer.java new file mode 100644 index 000000000..8f60ddc4d --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroInstantSerializer.java @@ -0,0 +1,72 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonIntegerFormatVisitor; +import com.fasterxml.jackson.databind.ser.ContextualSerializer; +import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer; + +import java.io.IOException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.time.temporal.Temporal; +import java.util.function.Function; + +/** + * A serializer for variants of java.time classes that represent a specific instant on the timeline + * (Instant, OffsetDateTime, ZonedDateTime) which supports serialization to Avro long type and logicalType. + * + * See: http://avro.apache.org/docs/current/spec.html#Logical+Types + * + * Note: {@link AvroInstantSerializer} does not support serialization to string. + * + * @param The type of a instant class that can be serialized. + */ +public class AvroInstantSerializer extends StdScalarSerializer + implements ContextualSerializer { + + private static final long serialVersionUID = 1L; + + public static final AvroInstantSerializer INSTANT = + new AvroInstantSerializer<>(Instant.class, Function.identity()); + + public static final AvroInstantSerializer OFFSET_DATE_TIME = + new AvroInstantSerializer<>(OffsetDateTime.class, OffsetDateTime::toInstant); + + public static final AvroInstantSerializer ZONED_DATE_TIME = + new AvroInstantSerializer<>(ZonedDateTime.class, ZonedDateTime::toInstant); + + private final Function getInstant; + + protected AvroInstantSerializer(Class t, Function getInstant) { + super(t); + this.getInstant = getInstant; + } + + @Override + public void serialize(T value, JsonGenerator gen, SerializerProvider provider) throws IOException { + final Instant instant = getInstant.apply(value); + gen.writeNumber(instant.toEpochMilli()); + } + + @Override + public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) { + return this; + } + + @Override + public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType typeHint) throws JsonMappingException { + JsonIntegerFormatVisitor v2 = visitor.expectIntegerFormat(typeHint); + if (v2 != null) { + v2.numberType(JsonParser.NumberType.LONG); + } + } + +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule.java new file mode 100644 index 000000000..142f8a4c0 --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule.java @@ -0,0 +1,43 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310; + +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.core.json.PackageVersion; +import com.fasterxml.jackson.databind.module.SimpleModule; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; + +/** + * A module that installs a collection of serializers and deserializers for java.time classes. + */ +public class AvroJavaTimeModule extends SimpleModule { + + private static final long serialVersionUID = 1L; + + public AvroJavaTimeModule() { + super(PackageVersion.VERSION); + addSerializer(Instant.class, AvroInstantSerializer.INSTANT); + addSerializer(OffsetDateTime.class, AvroInstantSerializer.OFFSET_DATE_TIME); + addSerializer(ZonedDateTime.class, AvroInstantSerializer.ZONED_DATE_TIME); + + addDeserializer(Instant.class, AvroInstantDeserializer.INSTANT); + addDeserializer(OffsetDateTime.class, AvroInstantDeserializer.OFFSET_DATE_TIME); + addDeserializer(ZonedDateTime.class, AvroInstantDeserializer.ZONED_DATE_TIME); + } + + @Override + public String getModuleName() { + return getClass().getName(); + } + + @Override + public Version version() { + return PackageVersion.VERSION; + } + + @Override + public void setupModule(SetupContext context) { + super.setupModule(context); + } +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/DateTimeVisitor.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/DateTimeVisitor.java new file mode 100644 index 000000000..7d58dbdb4 --- /dev/null +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/DateTimeVisitor.java @@ -0,0 +1,87 @@ +package com.fasterxml.jackson.dataformat.avro.schema; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonIntegerFormatVisitor; +import org.apache.avro.LogicalType; +import org.apache.avro.Schema; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.util.Date; + +public class DateTimeVisitor extends JsonIntegerFormatVisitor.Base + implements SchemaBuilder { + + protected JsonParser.NumberType _type; + protected JavaType _hint; + + public DateTimeVisitor() { + } + + public DateTimeVisitor(JavaType typeHint) { + _hint = typeHint; + } + + @Override + public void numberType(JsonParser.NumberType type) { + _type = type; + } + + @Override + public Schema builtAvroSchema() { + if (_type == null) { + throw new IllegalStateException("No number type indicated"); + } + + Schema schema = AvroSchemaHelper.numericAvroSchema(_type); + if (_hint != null) { + String logicalType = logicalType(_hint); + if (logicalType != null) { + schema.addProp(LogicalType.LOGICAL_TYPE_PROP, logicalType); + } else { + schema.addProp(AvroSchemaHelper.AVRO_SCHEMA_PROP_CLASS, AvroSchemaHelper.getTypeId(_hint)); + } + } + return schema; + } + + private String logicalType(JavaType hint) { + Class clazz = hint.getRawClass(); + + if (Date.class.isAssignableFrom(clazz)) { + return TIMESTAMP_MILLIS; + } + if (OffsetDateTime.class.isAssignableFrom(clazz)) { + return TIMESTAMP_MILLIS; + } + if (ZonedDateTime.class.isAssignableFrom(clazz)) { + return TIMESTAMP_MILLIS; + } + if (Instant.class.isAssignableFrom(clazz)) { + return TIMESTAMP_MILLIS; + } + + if (LocalDate.class.isAssignableFrom(clazz)) { + return DATE; + } + if (LocalTime.class.isAssignableFrom(clazz)) { + return TIME_MILLIS; + } + if (LocalDateTime.class.isAssignableFrom(clazz)) { + return LOCAL_TIMESTAMP_MILLIS; + } + + return null; + } + + private static final String DATE = "date"; + private static final String TIME_MILLIS = "time-millis"; + private static final String TIMESTAMP_MILLIS = "timestamp-millis"; + private static final String LOCAL_TIMESTAMP_MILLIS = "local-timestamp-millis"; + +} diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/VisitorFormatWrapperImpl.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/VisitorFormatWrapperImpl.java index c180d3732..f0550df23 100644 --- a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/VisitorFormatWrapperImpl.java +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/VisitorFormatWrapperImpl.java @@ -11,11 +11,14 @@ import com.fasterxml.jackson.databind.jsonFormatVisitors.*; import com.fasterxml.jackson.dataformat.avro.AvroSchema; +import java.time.temporal.Temporal; +import java.util.Date; + public class VisitorFormatWrapperImpl implements JsonFormatVisitorWrapper { protected SerializerProvider _provider; - + protected final DefinedSchemas _schemas; /** @@ -28,7 +31,7 @@ public class VisitorFormatWrapperImpl * Schema for simple types that do not need a visitor. */ protected Schema _valueSchema; - + /* /********************************************************************** /* Construction @@ -39,7 +42,7 @@ public VisitorFormatWrapperImpl(DefinedSchemas schemas, SerializerProvider p) { _schemas = schemas; _provider = p; } - + @Override public SerializerProvider getProvider() { return _provider; @@ -67,7 +70,7 @@ public Schema getAvroSchema() { } return _builder.builtAvroSchema(); } - + /* /********************************************************************** /* Callbacks @@ -97,7 +100,7 @@ public JsonMapFormatVisitor expectMapFormat(JavaType mapType) { _builder = v; return v; } - + @Override public JsonArrayFormatVisitor expectArrayFormat(final JavaType convertedType) { // 22-Mar-2016, tatu: Actually we can detect byte[] quite easily here can't we? @@ -148,6 +151,13 @@ public JsonIntegerFormatVisitor expectIntegerFormat(JavaType type) { _valueSchema = s; return null; } + + if (isDateTimeType(type)) { + DateTimeVisitor v = new DateTimeVisitor(type); + _builder = v; + return v; + } + IntegerVisitor v = new IntegerVisitor(type); _builder = v; return v; @@ -186,4 +196,14 @@ protected T _throwUnsupported() { protected T _throwUnsupported(String msg) { throw new UnsupportedOperationException(msg); } + + private boolean isDateTimeType(JavaType type) { + if (Temporal.class.isAssignableFrom(type.getRawClass())) { + return true; + } + if (Date.class.isAssignableFrom(type.getRawClass())) { + return true; + } + return false; + } } diff --git a/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroInstantSerializer_schemaCreationTest.java b/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroInstantSerializer_schemaCreationTest.java new file mode 100644 index 000000000..c5321819c --- /dev/null +++ b/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroInstantSerializer_schemaCreationTest.java @@ -0,0 +1,61 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310; + +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.dataformat.avro.AvroMapper; +import com.fasterxml.jackson.dataformat.avro.schema.AvroSchemaGenerator; +import org.apache.avro.LogicalType; +import org.apache.avro.Schema; +import org.apache.avro.specific.SpecificData; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Collection; + +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(Parameterized.class) +public class AvroInstantSerializer_schemaCreationTest { + + @Parameter + public Class testClass; + + @Parameters(name = "With {0}") + public static Collection testData() { + return Arrays.asList( + Instant.class, + OffsetDateTime.class, + ZonedDateTime.class); + } + + @Test + public void testSchemaCreation() throws JsonMappingException { + // GIVEN + AvroMapper mapper = AvroMapper.builder() + .addModules(new AvroJavaTimeModule()) + .build(); + AvroSchemaGenerator gen = new AvroSchemaGenerator(); + + // WHEN + mapper.acceptJsonFormatVisitor(testClass, gen); + Schema actualSchema = gen.getGeneratedSchema().getAvroSchema(); + + System.out.println(actualSchema.toString(true)); + + // THEN + assertThat(actualSchema.getType()).isEqualTo(Schema.Type.LONG); + assertThat(actualSchema.getProp(LogicalType.LOGICAL_TYPE_PROP)).isEqualTo("timestamp-millis"); + /** + * Having logicalType and java-class is not valid according to + * {@link org.apache.avro.LogicalType#validate(Schema)} + */ + assertThat(actualSchema.getProp(SpecificData.CLASS_PROP)).isNull(); + } + +} diff --git a/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule_serialization_and_deserializationTest.java b/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule_serialization_and_deserializationTest.java new file mode 100644 index 000000000..a4013d4f8 --- /dev/null +++ b/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/jsr310/AvroJavaTimeModule_serialization_and_deserializationTest.java @@ -0,0 +1,78 @@ +package com.fasterxml.jackson.dataformat.avro.jsr310; + +import com.fasterxml.jackson.dataformat.avro.AvroMapper; +import com.fasterxml.jackson.dataformat.avro.AvroSchema; +import com.fasterxml.jackson.dataformat.avro.schema.AvroSchemaGenerator; +import org.junit.Test; + +import java.io.IOException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AvroJavaTimeModule_serialization_and_deserializationTest { + + private static AvroMapper newAvroMapper() { + return AvroMapper.builder() + .addModules(new AvroJavaTimeModule()) + .build(); + } + + @Test + public void testWithInstant() throws IOException { + // GIVEN + AvroMapper mapper = newAvroMapper(); + AvroSchemaGenerator gen = new AvroSchemaGenerator(); + mapper.acceptJsonFormatVisitor(Instant.class, gen); + AvroSchema schema = gen.getGeneratedSchema(); + + Instant expectedInstant = Instant.ofEpochMilli(0L); + + // WHEN + byte[] serialized = mapper.writer(schema).writeValueAsBytes(expectedInstant); + Instant deserInstant = mapper.readerFor(Instant.class).with(schema).readValue(serialized); + + // THEN + assertThat(deserInstant).isEqualTo(expectedInstant); + } + + @Test + public void testWithOffsetDateTime() throws IOException { + // GIVEN + AvroMapper mapper = newAvroMapper(); + AvroSchemaGenerator gen = new AvroSchemaGenerator(); + mapper.acceptJsonFormatVisitor(OffsetDateTime.class, gen); + AvroSchema schema = gen.getGeneratedSchema(); + + OffsetDateTime expectedOffsetDateTime = OffsetDateTime.of(2021, 6, 6, 12, 00, 30, 00, ZoneOffset.ofHours(2)); + + // WHEN + byte[] serialized = mapper.writer(schema).writeValueAsBytes(expectedOffsetDateTime); + OffsetDateTime deserOffsetDateTime = mapper.readerFor(OffsetDateTime.class).with(schema).readValue(serialized); + + // THEN + assertThat(deserOffsetDateTime.toInstant()).isEqualTo(expectedOffsetDateTime.toInstant()); + } + + @Test + public void testWithZonedDateTime() throws IOException { + // GIVEN + AvroMapper mapper = newAvroMapper(); + AvroSchemaGenerator gen = new AvroSchemaGenerator(); + mapper.acceptJsonFormatVisitor(ZonedDateTime.class, gen); + AvroSchema schema = gen.getGeneratedSchema(); + + ZonedDateTime expectedZonedDateTime = ZonedDateTime.of(2021, 6, 6, 12, 00, 30, 00, ZoneOffset.ofHours(2)); + + // WHEN + byte[] serialized = mapper.writer(schema).writeValueAsBytes(expectedZonedDateTime); + ZonedDateTime deserZonedDateTime = mapper.readerFor(ZonedDateTime.class).with(schema).readValue(serialized); + + // THEN + assertThat(deserZonedDateTime.toInstant()).isEqualTo(expectedZonedDateTime.toInstant()); + } + +}