diff --git a/pom.xml b/pom.xml index 0ef4d2936..41f9fef34 100644 --- a/pom.xml +++ b/pom.xml @@ -96,6 +96,12 @@ ${version.kotlin} provided + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${version.kotlin} + provided + org.jetbrains.kotlin kotlin-reflect @@ -121,6 +127,12 @@ jackson-dataformat-xml test + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + test + diff --git a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt index 716032518..c27f82c69 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt @@ -1,10 +1,15 @@ package com.fasterxml.jackson.module.kotlin import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.deser.std.StdDelegatingDeserializer import com.fasterxml.jackson.databind.ser.std.StdDelegatingSerializer import com.fasterxml.jackson.databind.type.TypeFactory import com.fasterxml.jackson.databind.util.StdConverter import kotlin.reflect.KClass +import kotlin.time.toJavaDuration +import kotlin.time.toKotlinDuration +import java.time.Duration as JavaDuration +import kotlin.time.Duration as KotlinDuration internal class SequenceToIteratorConverter(private val input: JavaType) : StdConverter, Iterator<*>>() { override fun convert(value: Sequence<*>): Iterator<*> = value.iterator() @@ -16,6 +21,29 @@ internal class SequenceToIteratorConverter(private val input: JavaType) : StdCon ?: typeFactory.constructType(Iterator::class.java) } +internal object KotlinDurationValueToJavaDurationConverter : StdConverter() { + private val boxConverter by lazy { ValueClassBoxConverter(Long::class.java, KotlinDuration::class) } + + override fun convert(value: Long): JavaDuration = KotlinToJavaDurationConverter.convert(boxConverter.convert(value)) +} + +internal object KotlinToJavaDurationConverter : StdConverter() { + override fun convert(value: KotlinDuration) = value.toJavaDuration() +} + +/** + * Currently it is not possible to deduce type of [kotlin.time.Duration] fields therefore explicit annotation is needed on fields in order to properly deserialize POJO. + * + * @see [com.fasterxml.jackson.module.kotlin.test.DurationTests] + */ +internal object JavaToKotlinDurationConverter : StdConverter() { + override fun convert(value: JavaDuration) = value.toKotlinDuration() + + val delegatingDeserializer: StdDelegatingDeserializer by lazy { + StdDelegatingDeserializer(this) + } +} + // S is nullable because value corresponds to a nullable value class // @see KotlinNamesAnnotationIntrospector.findNullSerializer internal class ValueClassBoxConverter( diff --git a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt index a9b53951d..a06bd0142 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt @@ -8,7 +8,6 @@ import com.fasterxml.jackson.databind.Module import com.fasterxml.jackson.databind.cfg.MapperConfig import com.fasterxml.jackson.databind.introspect.* import com.fasterxml.jackson.databind.jsontype.NamedType -import com.fasterxml.jackson.databind.ser.std.StdSerializer import com.fasterxml.jackson.databind.util.Converter import java.lang.reflect.AccessibleObject import java.lang.reflect.Constructor @@ -22,14 +21,19 @@ import kotlin.reflect.KType import kotlin.reflect.full.createType import kotlin.reflect.full.declaredMemberProperties import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.valueParameters import kotlin.reflect.jvm.* +import kotlin.time.Duration -internal class KotlinAnnotationIntrospector(private val context: Module.SetupContext, - private val cache: ReflectionCache, - private val nullToEmptyCollection: Boolean, - private val nullToEmptyMap: Boolean, - private val nullIsSameAsDefault: Boolean) : NopAnnotationIntrospector() { +internal class KotlinAnnotationIntrospector( + private val context: Module.SetupContext, + private val cache: ReflectionCache, + private val nullToEmptyCollection: Boolean, + private val nullToEmptyMap: Boolean, + private val nullIsSameAsDefault: Boolean, + private val useJavaDurationConversion: Boolean, +) : NopAnnotationIntrospector() { // TODO: implement nullIsSameAsDefault flag, which represents when TRUE that if something has a default value, it can be passed a null to default it // this likely impacts this class to be accurate about what COULD be considered required @@ -66,11 +70,23 @@ internal class KotlinAnnotationIntrospector(private val context: Module.SetupCon override fun findSerializationConverter(a: Annotated): Converter<*, *>? = when (a) { // Find a converter to handle the case where the getter returns an unboxed value from the value class. - is AnnotatedMethod -> cache.findValueClassReturnType(a) - ?.let { cache.getValueClassBoxConverter(a.rawReturnType, it) } - is AnnotatedClass -> a - .takeIf { Sequence::class.java.isAssignableFrom(it.rawType) } - ?.let { SequenceToIteratorConverter(it.type) } + is AnnotatedMethod -> a.findValueClassReturnType()?.let { + if (useJavaDurationConversion && it == Duration::class) { + if (a.rawReturnType == Duration::class.java) + KotlinToJavaDurationConverter + else + KotlinDurationValueToJavaDurationConverter + } else { + cache.getValueClassBoxConverter(a.rawReturnType, it) + } + } + is AnnotatedClass -> lookupKotlinTypeConverter(a) + else -> null + } + + private fun lookupKotlinTypeConverter(a: AnnotatedClass) = when { + Sequence::class.java.isAssignableFrom(a.rawType) -> SequenceToIteratorConverter(a.type) + Duration::class.java == a.rawType -> KotlinToJavaDurationConverter.takeIf { useJavaDurationConversion } else -> null } @@ -81,10 +97,29 @@ internal class KotlinAnnotationIntrospector(private val context: Module.SetupCon // Perform proper serialization even if the value wrapped by the value class is null. // If value is a non-null object type, it must not be reboxing. - override fun findNullSerializer(am: Annotated): JsonSerializer<*>? = (am as? AnnotatedMethod)?.let { _ -> - cache.findValueClassReturnType(am) - ?.takeIf { it.requireRebox() } - ?.let { cache.getValueClassBoxConverter(am.rawReturnType, it).delegatingSerializer } + override fun findNullSerializer(am: Annotated): JsonSerializer<*>? = (am as? AnnotatedMethod) + ?.findValueClassReturnType() + ?.takeIf { it.requireRebox() } + ?.let { cache.getValueClassBoxConverter(am.rawReturnType, it).delegatingSerializer } + + override fun findDeserializationConverter(a: Annotated): Any? { + if (!useJavaDurationConversion) return null + + return (a as? AnnotatedParameter)?.let { param -> + @Suppress("UNCHECKED_CAST") + val function: KFunction<*> = when (val owner = param.owner.member) { + is Constructor<*> -> cache.kotlinFromJava(owner as Constructor) + is Method -> cache.kotlinFromJava(owner) + else -> null + } ?: return@let null + val valueParameter = function.valueParameters[a.index] + + if (valueParameter.type.classifier == Duration::class) { + JavaToKotlinDurationConverter + } else { + null + } + } } /** @@ -102,7 +137,7 @@ internal class KotlinAnnotationIntrospector(private val context: Module.SetupCon private fun AnnotatedField.hasRequiredMarker(): Boolean? { val byAnnotation = (member as Field).isRequiredByAnnotation() - val byNullability = (member as Field).kotlinProperty?.returnType?.isRequired() + val byNullability = (member as Field).kotlinProperty?.returnType?.isRequired() return requiredAnnotationOrNullability(byAnnotation, byNullability) } @@ -122,7 +157,7 @@ internal class KotlinAnnotationIntrospector(private val context: Module.SetupCon } private fun Method.isRequiredByAnnotation(): Boolean? { - return (this.annotations.firstOrNull { it.annotationClass.java == JsonProperty::class.java } as? JsonProperty)?.required + return (this.annotations.firstOrNull { it.annotationClass.java == JsonProperty::class.java } as? JsonProperty)?.required } // Since Kotlin's property has the same Type for each field, getter, and setter, @@ -171,12 +206,14 @@ internal class KotlinAnnotationIntrospector(private val context: Module.SetupCon return requiredAnnotationOrNullability(byAnnotation, byNullability) } + private fun AnnotatedMethod.findValueClassReturnType() = cache.findValueClassReturnType(this) + private fun KFunction<*>.isConstructorParameterRequired(index: Int): Boolean { return isParameterRequired(index) } private fun KFunction<*>.isMethodParameterRequired(index: Int): Boolean { - return isParameterRequired(index+1) + return isParameterRequired(index + 1) } private fun KFunction<*>.isParameterRequired(index: Int): Boolean { diff --git a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinDeserializers.kt b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinDeserializers.kt index 0bceb64a0..0a927c9a0 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinDeserializers.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinDeserializers.kt @@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.JavaType import com.fasterxml.jackson.databind.JsonDeserializer import com.fasterxml.jackson.databind.deser.Deserializers import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import kotlin.time.Duration as KotlinDuration object SequenceDeserializer : StdDeserializer>(Sequence::class.java) { override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Sequence<*> { @@ -81,11 +82,13 @@ object ULongDeserializer : StdDeserializer(ULong::class.java) { ) } -internal class KotlinDeserializers : Deserializers.Base() { +internal class KotlinDeserializers( + private val useJavaDurationConversion: Boolean, +) : Deserializers.Base() { override fun findBeanDeserializer( type: JavaType, config: DeserializationConfig?, - beanDesc: BeanDescription? + beanDesc: BeanDescription?, ): JsonDeserializer<*>? { return when { type.isInterface && type.rawClass == Sequence::class.java -> SequenceDeserializer @@ -94,6 +97,8 @@ internal class KotlinDeserializers : Deserializers.Base() { type.rawClass == UShort::class.java -> UShortDeserializer type.rawClass == UInt::class.java -> UIntDeserializer type.rawClass == ULong::class.java -> ULongDeserializer + type.rawClass == KotlinDuration::class.java -> + JavaToKotlinDurationConverter.takeIf { useJavaDurationConversion }?.delegatingDeserializer else -> null } } diff --git a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinFeature.kt b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinFeature.kt index b6143fccf..93dba381f 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinFeature.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinFeature.kt @@ -58,7 +58,16 @@ enum class KotlinFeature(private val enabledByDefault: Boolean) { * In addition, the adjustment of behavior using get:JvmName is disabled. * Note also that this feature does not apply to setters. */ - KotlinPropertyNameAsImplicitName(enabledByDefault = false); + KotlinPropertyNameAsImplicitName(enabledByDefault = false), + + /** + * This feature represents whether to handle [kotlin.time.Duration] using [java.time.Duration] as conversion bridge. + * + * This allows use Kotlin Duration type with [com.fasterxml.jackson.datatype.jsr310.JavaTimeModule]. + * `@JsonFormat` annotations need to be declared either on getter using `@get:JsonFormat` or field using `@field:JsonFormat`. + * See [jackson-module-kotlin#651] for details. + */ + UseJavaDurationConversion(enabledByDefault = false); internal val bitSet: BitSet = (1 shl ordinal).toBitSet() diff --git a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinModule.kt b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinModule.kt index 5d75a1ade..f3bd57fb6 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinModule.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinModule.kt @@ -8,6 +8,7 @@ import com.fasterxml.jackson.module.kotlin.KotlinFeature.NullToEmptyCollection import com.fasterxml.jackson.module.kotlin.KotlinFeature.NullToEmptyMap import com.fasterxml.jackson.module.kotlin.KotlinFeature.StrictNullChecks import com.fasterxml.jackson.module.kotlin.KotlinFeature.KotlinPropertyNameAsImplicitName +import com.fasterxml.jackson.module.kotlin.KotlinFeature.UseJavaDurationConversion import com.fasterxml.jackson.module.kotlin.SingletonSupport.CANONICALIZE import com.fasterxml.jackson.module.kotlin.SingletonSupport.DISABLED import java.util.* @@ -33,6 +34,8 @@ fun Class<*>.isKotlinClass(): Boolean { * the default, collections which are typed to disallow null members * (e.g. List) may contain null values after deserialization. Enabling it * protects against this but has significant performance impact. + * @param useJavaDurationConversion Default: false. Whether to use [java.time.Duration] as a bridge for [kotlin.time.Duration]. + * This allows use Kotlin Duration type with [com.fasterxml.jackson.datatype.jsr310.JavaTimeModule]. */ class KotlinModule @Deprecated( level = DeprecationLevel.WARNING, @@ -55,7 +58,8 @@ class KotlinModule @Deprecated( val nullIsSameAsDefault: Boolean = false, val singletonSupport: SingletonSupport = DISABLED, val strictNullChecks: Boolean = false, - val useKotlinPropertyNameForGetter: Boolean = false + val useKotlinPropertyNameForGetter: Boolean = false, + val useJavaDurationConversion: Boolean = false, ) : SimpleModule(KotlinModule::class.java.name, PackageVersion.VERSION) { init { if (!KotlinVersion.CURRENT.isAtLeast(1, 5)) { @@ -105,7 +109,8 @@ class KotlinModule @Deprecated( else -> DISABLED }, builder.isEnabled(StrictNullChecks), - builder.isEnabled(KotlinPropertyNameAsImplicitName) + builder.isEnabled(KotlinPropertyNameAsImplicitName), + builder.isEnabled(UseJavaDurationConversion), ) companion object { @@ -132,7 +137,14 @@ class KotlinModule @Deprecated( } } - context.insertAnnotationIntrospector(KotlinAnnotationIntrospector(context, cache, nullToEmptyCollection, nullToEmptyMap, nullIsSameAsDefault)) + context.insertAnnotationIntrospector(KotlinAnnotationIntrospector( + context, + cache, + nullToEmptyCollection, + nullToEmptyMap, + nullIsSameAsDefault, + useJavaDurationConversion + )) context.appendAnnotationIntrospector( KotlinNamesAnnotationIntrospector( this, @@ -141,7 +153,7 @@ class KotlinModule @Deprecated( useKotlinPropertyNameForGetter) ) - context.addDeserializers(KotlinDeserializers()) + context.addDeserializers(KotlinDeserializers(useJavaDurationConversion)) context.addKeyDeserializers(KotlinKeyDeserializers) context.addSerializers(KotlinSerializers()) context.addKeySerializers(KotlinKeySerializers()) diff --git a/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt new file mode 100644 index 000000000..75db96297 --- /dev/null +++ b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt @@ -0,0 +1,209 @@ +package com.fasterxml.jackson.module.kotlin.test + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.annotation.JsonFormat.Shape.STRING +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS +import com.fasterxml.jackson.databind.SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinFeature.UseJavaDurationConversion +import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.fasterxml.jackson.module.kotlin.kotlinModule +import com.fasterxml.jackson.module.kotlin.readValue +import org.junit.Test +import java.time.Instant +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds +import java.time.Duration as JavaDuration +import kotlin.time.Duration as KotlinDuration + +class DurationTests { + private val objectMapper = jacksonObjectMapper { enable(UseJavaDurationConversion) } + + @Test + fun `should serialize Kotlin duration using Java time module`() { + val mapper = objectMapper.registerModule(JavaTimeModule()).disable(WRITE_DURATIONS_AS_TIMESTAMPS) + + val result = mapper.writeValueAsString(1.hours) + + assertEquals("\"PT1H\"", result) + } + + @Test + fun `should deserialize Kotlin duration`() { + val mapper = objectMapper.registerModule(JavaTimeModule()) + + val result = mapper.readValue("\"PT1H\"") + + assertEquals(1.hours, result) + } + + @Test + fun `should serialize Kotlin duration inside list using Java time module`() { + val mapper = objectMapper + .registerModule(JavaTimeModule()) + .disable(WRITE_DURATIONS_AS_TIMESTAMPS) + + val result = mapper.writeValueAsString(listOf(1.hours, 2.hours, 3.hours)) + + assertEquals("""["PT1H","PT2H","PT3H"]""", result) + } + + @Test + fun `should deserialize Kotlin duration inside list`() { + val mapper = objectMapper.registerModule(JavaTimeModule()) + + val result = mapper.readValue>("""["PT1H","PT2H","PT3H"]""") + + assertContentEquals(listOf(1.hours, 2.hours, 3.hours), result) + } + + @Test + fun `should serialize Kotlin duration inside map using Java time module`() { + val mapper = objectMapper + .registerModule(JavaTimeModule()) + .disable(WRITE_DURATIONS_AS_TIMESTAMPS) + + val result = mapper.writeValueAsString(mapOf( + "a" to 1.hours, + "b" to 2.hours, + "c" to 3.hours + )) + + assertEquals("""{"a":"PT1H","b":"PT2H","c":"PT3H"}""", result) + } + + @Test + fun `should deserialize Kotlin duration inside map`() { + val mapper = objectMapper.registerModule(JavaTimeModule()) + + val result = mapper.readValue>("""{"a":"PT1H","b":"PT2H","c":"PT3H"}""") + + assertEquals(result["a"], 1.hours) + assertEquals(result["b"], 2.hours) + assertEquals(result["c"], 3.hours) + } + + data class Meeting( + val start: Instant, + val duration: KotlinDuration, + ) { + companion object { + @Suppress("unused") + @JvmStatic + @JsonCreator + fun create(start: Instant, duration: KotlinDuration) = Meeting(start, duration) + } + } + + abstract class MeetingMixin( + @Suppress("unused") @field:JsonFormat(shape = STRING) + val duration: KotlinDuration, + ) + + @Test + fun `should serialize Kotlin duration inside data class using Java time module`() { + val mapper = objectMapper + .registerModule(JavaTimeModule()) + .disable(WRITE_DATES_AS_TIMESTAMPS) + .disable(WRITE_DURATIONS_AS_TIMESTAMPS) + + val result = mapper.writeValueAsString(Meeting(Instant.parse("2023-06-20T14:00:00Z"), 1.5.hours)) + + assertEquals("""{"start":"2023-06-20T14:00:00Z","duration":"PT1H30M"}""", result) + } + + @Test + fun `should deserialize Kotlin duration inside data class`() { + val mapper = objectMapper.registerModule(JavaTimeModule()) + + val result = mapper.readValue("""{"start":"2023-06-20T14:00:00Z","duration":"PT1H30M"}""") + + assertEquals(result.start, Instant.parse("2023-06-20T14:00:00Z")) + assertEquals(result.duration, 1.5.hours) + } + + @Test + fun `should deserialize Kotlin duration inside data class using mixin`() { + val mapper = objectMapper + .registerModule(JavaTimeModule()) + .addMixIn(Meeting::class.java, MeetingMixin::class.java) + + val meeting = mapper.readValue("""{"start":"2023-06-20T14:00:00Z","duration":"PT1H30M"}""") + + assertEquals(Instant.parse("2023-06-20T14:00:00Z"), meeting.start) + assertEquals(1.5.hours, meeting.duration) + } + + @Test + fun `should serialize Kotlin duration inside data class using Java time module and mixin`() { + val mapper = objectMapper + .registerModule(JavaTimeModule()) + .disable(WRITE_DATES_AS_TIMESTAMPS) + .addMixIn(Meeting::class.java, MeetingMixin::class.java) + + val result = mapper.writeValueAsString(Meeting(Instant.parse("2023-06-20T14:00:00Z"), 1.5.hours)) + + assertEquals("""{"start":"2023-06-20T14:00:00Z","duration":"PT1H30M"}""", result) + } + + data class JDTO( + val plain: JavaDuration = JavaDuration.ofHours(1), + val optPlain: JavaDuration? = JavaDuration.ofHours(1), + @field:JsonFormat(shape = STRING) + val shapeAnnotation: JavaDuration = JavaDuration.ofHours(1), + @field:JsonFormat(shape = STRING) + val optShapeAnnotation: JavaDuration? = JavaDuration.ofHours(1), + ) + + data class KDTO( + val plain: KotlinDuration = 1.hours, + val optPlain: KotlinDuration? = 1.hours, + @field:JsonFormat(shape = STRING) + val shapeAnnotation: KotlinDuration = 1.hours, + @field:JsonFormat(shape = STRING) + val optShapeAnnotation: KotlinDuration? = 1.hours, + ) + + @Test + fun `should serialize Kotlin duration exactly as Java duration`() { + val mapper = objectMapper.registerModule(JavaTimeModule()) + + val jdto = JDTO() + val kdto = KDTO() + + assertEquals(mapper.writeValueAsString(jdto), mapper.writeValueAsString(kdto)) + } + + data class DurationWithFormattedUnits( + @field:JsonFormat(pattern = "HOURS") val formatted: KotlinDuration, + val default: KotlinDuration, + ) { + companion object { + @Suppress("unused") + @JvmStatic + @JsonCreator + fun create( + formatted: KotlinDuration, + default: KotlinDuration, + ) = DurationWithFormattedUnits(formatted, default) + } + } + + @Test + fun `should deserialize using custom units specified by format annotation`() { + val mapper = objectMapper.registerModule(JavaTimeModule()) + + val actual = mapper.readValue("""{"formatted":1,"default":1}""") + + assertEquals(1.hours, actual.formatted) + assertEquals(1.seconds, actual.default) + } + + private fun jacksonObjectMapper( + configuration: KotlinModule.Builder.() -> Unit, + ) = ObjectMapper().registerModule(kotlinModule(configuration)) +}