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