From cf5281cd383a9753c831502d51a4e19aaecb7434 Mon Sep 17 00:00:00 2001 From: Krzysztof Kurczewski Date: Mon, 17 Jul 2023 15:35:11 +0200 Subject: [PATCH 01/16] Handle `kotlin.time.Duration` as `java.time.Duration` Adds converter and deserializer which bridges conversions between `kotlin.time.Duration` and `java.time.Duration`. Notes: * later Kotlin version adds dedicated method `kotlin.time.Duration.toJavaDuration()` which can be used instead ISO parsing (in converter) * ...and adds dedicated extension method `java.time.Duration.toKotlinDuration()` which can be used instead ISO parsing (in deserializer) --- pom.xml | 6 ++ .../jackson/module/kotlin/Converters.kt | 6 ++ .../kotlin/KotlinAnnotationIntrospector.kt | 12 ++- .../module/kotlin/KotlinDeserializers.kt | 8 ++ .../module/kotlin/test/DurationTests.kt | 79 +++++++++++++++++++ 5 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt diff --git a/pom.xml b/pom.xml index 0ef4d2936..accfd7e10 100644 --- a/pom.xml +++ b/pom.xml @@ -121,6 +121,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..7f5ea3efd 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt @@ -5,6 +5,8 @@ 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 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 +18,10 @@ internal class SequenceToIteratorConverter(private val input: JavaType) : StdCon ?: typeFactory.constructType(Iterator::class.java) } +internal object KotlinToJavaDurationConverter : StdConverter() { + override fun convert(value: KotlinDuration): JavaDuration = JavaDuration.parse(value.toIsoString()) +} + // 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..10e3f08f3 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 @@ -23,6 +22,7 @@ import kotlin.reflect.full.createType import kotlin.reflect.full.declaredMemberProperties import kotlin.reflect.full.memberProperties import kotlin.reflect.jvm.* +import kotlin.time.Duration internal class KotlinAnnotationIntrospector(private val context: Module.SetupContext, @@ -68,9 +68,13 @@ internal class KotlinAnnotationIntrospector(private val context: Module.SetupCon // 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 AnnotatedClass -> lookupKotlinTypeConverter(a) + else -> null + } + + private fun lookupKotlinTypeConverter(a: AnnotatedClass) = when { + Sequence::class.java.isAssignableFrom(a.rawType) -> SequenceToIteratorConverter(a.type) + Duration::class.java.isAssignableFrom(a.rawType) -> KotlinToJavaDurationConverter else -> null } 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..0006588c1 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 object SequenceDeserializer : StdDeserializer>(Sequence::class.java) { override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Sequence<*> { @@ -17,6 +18,12 @@ object SequenceDeserializer : StdDeserializer>(Sequence::class.java) } } +internal object DurationDeserializer : StdDeserializer(Duration::class.java) { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext) = ctxt + .readValue(p, java.time.Duration::class.java) + ?.let { Duration.parseIsoString(it.toString()) } +} + object RegexDeserializer : StdDeserializer(Regex::class.java) { override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Regex { val node = ctxt.readTree(p) @@ -94,6 +101,7 @@ 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 == Duration::class.java -> DurationDeserializer else -> null } } 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..5f7987e0a --- /dev/null +++ b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt @@ -0,0 +1,79 @@ +package com.fasterxml.jackson.module.kotlin.test + +import com.fasterxml.jackson.databind.SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.junit.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours + +class DurationTests { + @Test + fun `should serialize Kotlin duration using Java time module`() { + val mapper = jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .disable(WRITE_DURATIONS_AS_TIMESTAMPS) + + val result = mapper.writeValueAsString(1.hours) + + assertEquals("\"PT1H\"", result) + } + + @Test + fun `should deserialize Kotlin duration`() { + val mapper = jacksonObjectMapper().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 = jacksonObjectMapper() + .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 = jacksonObjectMapper().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 = jacksonObjectMapper() + .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 = jacksonObjectMapper().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) + } +} \ No newline at end of file From 7a61b3fcbdda228829101a33a3ad82cfd9fb6d5b Mon Sep 17 00:00:00 2001 From: Krzysztof Kurczewski Date: Mon, 17 Jul 2023 16:39:00 +0200 Subject: [PATCH 02/16] Add Duration tests for data class In order to properly deserialize data class with duration following hacks are needed: * class need to have explicit static factory annotated with ``@JsonCreator`, primary ctor won't cut it * in data class definition value-class getter has to be annotated with Duration converter --- .../jackson/module/kotlin/Converters.kt | 8 ++++ .../module/kotlin/test/DurationTests.kt | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+) 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 7f5ea3efd..03019a5ee 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt @@ -22,6 +22,14 @@ internal object KotlinToJavaDurationConverter : StdConverter() { + override fun convert(value: JavaDuration) = KotlinDuration.parseIsoString(value.toString()) +} + // S is nullable because value corresponds to a nullable value class // @see KotlinNamesAnnotationIntrospector.findNullSerializer internal class ValueClassBoxConverter( 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 index 5f7987e0a..58eb9a7cf 100644 --- a/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt +++ b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt @@ -1,10 +1,15 @@ package com.fasterxml.jackson.module.kotlin.test +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS import com.fasterxml.jackson.databind.SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS +import com.fasterxml.jackson.databind.annotation.JsonDeserialize import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.JavaToKotlinDurationConverter import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 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 @@ -76,4 +81,38 @@ class DurationTests { assertEquals(result["b"], 2.hours) assertEquals(result["c"], 3.hours) } + + data class Meeting( + val start: Instant, + @get:JsonDeserialize(converter = JavaToKotlinDurationConverter::class) + val duration: Duration, + ) { + companion object { + @JvmStatic + @JsonCreator + fun create(start: Instant, duration: Duration) = Meeting(start, duration) + } + } + + @Test + fun `should serialize Kotlin duration inside data class using Java time module`() { + val mapper = jacksonObjectMapper() + .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 = jacksonObjectMapper().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) + } } \ No newline at end of file From a1ba894c5af6cd965650c971bca023c1e785efa4 Mon Sep 17 00:00:00 2001 From: Krzysztof Kurczewski Date: Sun, 30 Jul 2023 10:03:42 +0200 Subject: [PATCH 03/16] Use conversion functions for Duration from stdlib-jdk8 --- pom.xml | 6 ++++++ .../com/fasterxml/jackson/module/kotlin/Converters.kt | 3 ++- .../jackson/module/kotlin/KotlinDeserializers.kt | 11 ++++++----- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/pom.xml b/pom.xml index accfd7e10..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 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 03019a5ee..ce24411b7 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt @@ -5,6 +5,7 @@ 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 java.time.Duration as JavaDuration import kotlin.time.Duration as KotlinDuration @@ -19,7 +20,7 @@ internal class SequenceToIteratorConverter(private val input: JavaType) : StdCon } internal object KotlinToJavaDurationConverter : StdConverter() { - override fun convert(value: KotlinDuration): JavaDuration = JavaDuration.parse(value.toIsoString()) + override fun convert(value: KotlinDuration) = value.toJavaDuration() } // this class is needed as workaround for deserialization 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 0006588c1..eb3c424ce 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinDeserializers.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinDeserializers.kt @@ -10,7 +10,9 @@ 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 +import kotlin.time.toKotlinDuration +import java.time.Duration as JavaDuration +import kotlin.time.Duration as KotlinDuration object SequenceDeserializer : StdDeserializer>(Sequence::class.java) { override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Sequence<*> { @@ -18,10 +20,9 @@ object SequenceDeserializer : StdDeserializer>(Sequence::class.java) } } -internal object DurationDeserializer : StdDeserializer(Duration::class.java) { +internal object DurationDeserializer : StdDeserializer(KotlinDuration::class.java) { override fun deserialize(p: JsonParser, ctxt: DeserializationContext) = ctxt - .readValue(p, java.time.Duration::class.java) - ?.let { Duration.parseIsoString(it.toString()) } + .readValue(p, JavaDuration::class.java)?.toKotlinDuration() } object RegexDeserializer : StdDeserializer(Regex::class.java) { @@ -101,7 +102,7 @@ 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 == Duration::class.java -> DurationDeserializer + type.rawClass == KotlinDuration::class.java -> DurationDeserializer else -> null } } From efeb17f223f5c14400e13f6d16444e179d0830c7 Mon Sep 17 00:00:00 2001 From: Krzysztof Kurczewski Date: Sun, 30 Jul 2023 11:49:37 +0200 Subject: [PATCH 04/16] Cleanup --- .../kotlin/KotlinAnnotationIntrospector.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) 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 10e3f08f3..035fa2b13 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt @@ -25,11 +25,13 @@ 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, +) : 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 @@ -106,7 +108,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) } @@ -126,7 +128,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, @@ -180,7 +182,7 @@ internal class KotlinAnnotationIntrospector(private val context: Module.SetupCon } private fun KFunction<*>.isMethodParameterRequired(index: Int): Boolean { - return isParameterRequired(index+1) + return isParameterRequired(index + 1) } private fun KFunction<*>.isParameterRequired(index: Int): Boolean { From 58ca164de499e50a0c7c547c85b6ca6f3910c0c6 Mon Sep 17 00:00:00 2001 From: Krzysztof Kurczewski Date: Sun, 30 Jul 2023 11:54:35 +0200 Subject: [PATCH 05/16] Serialize Kotlin Duration in same way as Java --- .../jackson/module/kotlin/Converters.kt | 2 + .../kotlin/KotlinAnnotationIntrospector.kt | 17 +++++--- .../module/kotlin/test/DurationTests.kt | 43 ++++++++++++++++--- 3 files changed, 50 insertions(+), 12 deletions(-) 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 ce24411b7..0d63c4519 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt @@ -21,6 +21,8 @@ internal class SequenceToIteratorConverter(private val input: JavaType) : StdCon internal object KotlinToJavaDurationConverter : StdConverter() { override fun convert(value: KotlinDuration) = value.toJavaDuration() + + val delegatingSerializer: StdDelegatingSerializer by lazy { StdDelegatingSerializer(this) } } // this class is needed as workaround for deserialization 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 035fa2b13..7dfb4f87a 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt @@ -68,8 +68,7 @@ internal class KotlinAnnotationIntrospector( 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 AnnotatedMethod -> a.ktClass()?.let { cache.getValueClassBoxConverter(a.rawReturnType, it) } is AnnotatedClass -> lookupKotlinTypeConverter(a) else -> null } @@ -87,10 +86,14 @@ internal class KotlinAnnotationIntrospector( // 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) + ?.ktClass() + ?.takeIf { it.requireRebox() } + ?.let { cache.getValueClassBoxConverter(am.rawReturnType, it).delegatingSerializer } + + override fun findSerializer(am: Annotated): Any? = when ((am as? AnnotatedMethod)?.ktClass()) { + Duration::class -> KotlinToJavaDurationConverter.delegatingSerializer + else -> super.findSerializer(am) } /** @@ -177,6 +180,8 @@ internal class KotlinAnnotationIntrospector( return requiredAnnotationOrNullability(byAnnotation, byNullability) } + private fun AnnotatedMethod.ktClass() = cache.findValueClassReturnType(this) + private fun KFunction<*>.isConstructorParameterRequired(index: Int): Boolean { return isParameterRequired(index) } 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 index 58eb9a7cf..40c436df3 100644 --- a/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt +++ b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt @@ -1,6 +1,8 @@ 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.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS import com.fasterxml.jackson.databind.SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS import com.fasterxml.jackson.databind.annotation.JsonDeserialize @@ -12,7 +14,8 @@ import org.junit.Test import java.time.Instant import kotlin.test.assertContentEquals import kotlin.test.assertEquals -import kotlin.time.Duration +import kotlin.time.Duration as KotlinDuration +import java.time.Duration as JavaDuration import kotlin.time.Duration.Companion.hours class DurationTests { @@ -31,7 +34,7 @@ class DurationTests { fun `should deserialize Kotlin duration`() { val mapper = jacksonObjectMapper().registerModule(JavaTimeModule()) - val result = mapper.readValue("\"PT1H\"") + val result = mapper.readValue("\"PT1H\"") assertEquals(1.hours, result) } @@ -51,7 +54,7 @@ class DurationTests { fun `should deserialize Kotlin duration inside list`() { val mapper = jacksonObjectMapper().registerModule(JavaTimeModule()) - val result = mapper.readValue>("""["PT1H","PT2H","PT3H"]""") + val result = mapper.readValue>("""["PT1H","PT2H","PT3H"]""") assertContentEquals(listOf(1.hours, 2.hours, 3.hours), result) } @@ -75,7 +78,7 @@ class DurationTests { fun `should deserialize Kotlin duration inside map`() { val mapper = jacksonObjectMapper().registerModule(JavaTimeModule()) - val result = mapper.readValue>("""{"a":"PT1H","b":"PT2H","c":"PT3H"}""") + val result = mapper.readValue>("""{"a":"PT1H","b":"PT2H","c":"PT3H"}""") assertEquals(result["a"], 1.hours) assertEquals(result["b"], 2.hours) @@ -85,12 +88,12 @@ class DurationTests { data class Meeting( val start: Instant, @get:JsonDeserialize(converter = JavaToKotlinDurationConverter::class) - val duration: Duration, + val duration: KotlinDuration, ) { companion object { @JvmStatic @JsonCreator - fun create(start: Instant, duration: Duration) = Meeting(start, duration) + fun create(start: Instant, duration: KotlinDuration) = Meeting(start, duration) } } @@ -115,4 +118,32 @@ class DurationTests { assertEquals(result.start, Instant.parse("2023-06-20T14:00:00Z")) assertEquals(result.duration, 1.5.hours) } + + 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 = jacksonObjectMapper().registerModule(JavaTimeModule()) + + val jdto = JDTO() + val kdto = KDTO() + + assertEquals(mapper.writeValueAsString(jdto), mapper.writeValueAsString(kdto)) + } } \ No newline at end of file From bc2a0b354694fa5a59ff095125a2fc12b909ceb5 Mon Sep 17 00:00:00 2001 From: Krzysztof Kurczewski Date: Sun, 30 Jul 2023 11:57:15 +0200 Subject: [PATCH 06/16] Remarks --- .../com/fasterxml/jackson/module/kotlin/test/DurationTests.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 40c436df3..7b5fdcaba 100644 --- a/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt +++ b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt @@ -146,4 +146,4 @@ class DurationTests { assertEquals(mapper.writeValueAsString(jdto), mapper.writeValueAsString(kdto)) } -} \ No newline at end of file +} From 79d8e4ebc404015ae6047e4d84b79ace5480d266 Mon Sep 17 00:00:00 2001 From: Krzysztof Kurczewski Date: Thu, 3 Aug 2023 18:34:54 +0200 Subject: [PATCH 07/16] Add feature toggle for Duration parsing --- .../jackson/module/kotlin/Converters.kt | 9 +++-- .../kotlin/KotlinAnnotationIntrospector.kt | 9 +++-- .../module/kotlin/KotlinDeserializers.kt | 8 ++-- .../jackson/module/kotlin/KotlinFeature.kt | 9 ++++- .../jackson/module/kotlin/KotlinModule.kt | 20 ++++++++-- .../module/kotlin/test/DurationTests.kt | 37 +++++++++++-------- 6 files changed, 61 insertions(+), 31 deletions(-) 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 0d63c4519..b96b1c829 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt @@ -25,10 +25,11 @@ internal object KotlinToJavaDurationConverter : StdConverter() { override fun convert(value: JavaDuration) = KotlinDuration.parseIsoString(value.toString()) } 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 7dfb4f87a..3aaf1a773 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt @@ -31,6 +31,7 @@ internal class KotlinAnnotationIntrospector( 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 @@ -75,7 +76,7 @@ internal class KotlinAnnotationIntrospector( private fun lookupKotlinTypeConverter(a: AnnotatedClass) = when { Sequence::class.java.isAssignableFrom(a.rawType) -> SequenceToIteratorConverter(a.type) - Duration::class.java.isAssignableFrom(a.rawType) -> KotlinToJavaDurationConverter + Duration::class.java.isAssignableFrom(a.rawType) -> KotlinToJavaDurationConverter.takeIf { useJavaDurationConversion } else -> null } @@ -92,9 +93,9 @@ internal class KotlinAnnotationIntrospector( ?.let { cache.getValueClassBoxConverter(am.rawReturnType, it).delegatingSerializer } override fun findSerializer(am: Annotated): Any? = when ((am as? AnnotatedMethod)?.ktClass()) { - Duration::class -> KotlinToJavaDurationConverter.delegatingSerializer - else -> super.findSerializer(am) - } + Duration::class -> KotlinToJavaDurationConverter.delegatingSerializer.takeIf { useJavaDurationConversion } + else -> null + } ?: super.findSerializer(am) /** * Subclasses can be detected automatically for sealed classes, since all possible subclasses are known 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 eb3c424ce..807dbaca8 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinDeserializers.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinDeserializers.kt @@ -89,11 +89,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 @@ -102,7 +104,7 @@ 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 -> DurationDeserializer + type.rawClass == KotlinDuration::class.java -> DurationDeserializer.takeIf { useJavaDurationConversion } 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..8990d33d2 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,14 @@ 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]. + */ + 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..aad2b9667 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, + private 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 index 7b5fdcaba..1445288e6 100644 --- a/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt +++ b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt @@ -3,27 +3,30 @@ 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.databind.annotation.JsonDeserialize import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.JavaToKotlinDurationConverter -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +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 as KotlinDuration -import java.time.Duration as JavaDuration import kotlin.time.Duration.Companion.hours +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 = jacksonObjectMapper() - .registerModule(JavaTimeModule()) - .disable(WRITE_DURATIONS_AS_TIMESTAMPS) + val mapper = objectMapper.registerModule(JavaTimeModule()).disable(WRITE_DURATIONS_AS_TIMESTAMPS) val result = mapper.writeValueAsString(1.hours) @@ -32,7 +35,7 @@ class DurationTests { @Test fun `should deserialize Kotlin duration`() { - val mapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + val mapper = objectMapper.registerModule(JavaTimeModule()) val result = mapper.readValue("\"PT1H\"") @@ -41,7 +44,7 @@ class DurationTests { @Test fun `should serialize Kotlin duration inside list using Java time module`() { - val mapper = jacksonObjectMapper() + val mapper = objectMapper .registerModule(JavaTimeModule()) .disable(WRITE_DURATIONS_AS_TIMESTAMPS) @@ -52,7 +55,7 @@ class DurationTests { @Test fun `should deserialize Kotlin duration inside list`() { - val mapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + val mapper = objectMapper.registerModule(JavaTimeModule()) val result = mapper.readValue>("""["PT1H","PT2H","PT3H"]""") @@ -61,7 +64,7 @@ class DurationTests { @Test fun `should serialize Kotlin duration inside map using Java time module`() { - val mapper = jacksonObjectMapper() + val mapper = objectMapper .registerModule(JavaTimeModule()) .disable(WRITE_DURATIONS_AS_TIMESTAMPS) @@ -76,7 +79,7 @@ class DurationTests { @Test fun `should deserialize Kotlin duration inside map`() { - val mapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + val mapper = objectMapper.registerModule(JavaTimeModule()) val result = mapper.readValue>("""{"a":"PT1H","b":"PT2H","c":"PT3H"}""") @@ -99,7 +102,7 @@ class DurationTests { @Test fun `should serialize Kotlin duration inside data class using Java time module`() { - val mapper = jacksonObjectMapper() + val mapper = objectMapper .registerModule(JavaTimeModule()) .disable(WRITE_DATES_AS_TIMESTAMPS) .disable(WRITE_DURATIONS_AS_TIMESTAMPS) @@ -111,7 +114,7 @@ class DurationTests { @Test fun `should deserialize Kotlin duration inside data class`() { - val mapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + val mapper = objectMapper.registerModule(JavaTimeModule()) val result = mapper.readValue("""{"start":"2023-06-20T14:00:00Z","duration":"PT1H30M"}""") @@ -139,11 +142,15 @@ class DurationTests { @Test fun `should serialize Kotlin duration exactly as Java duration`() { - val mapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + val mapper = objectMapper.registerModule(JavaTimeModule()) val jdto = JDTO() val kdto = KDTO() assertEquals(mapper.writeValueAsString(jdto), mapper.writeValueAsString(kdto)) } -} + + private fun jacksonObjectMapper( + configuration: KotlinModule.Builder.() -> Unit, + ) = ObjectMapper().registerModule(kotlinModule(configuration)) +} \ No newline at end of file From b56e13977a3ef07cc623cf2cdade72f943c42886 Mon Sep 17 00:00:00 2001 From: Krzysztof Kurczewski Date: Sat, 5 Aug 2023 07:49:51 +0200 Subject: [PATCH 08/16] Add doc note about current limitations Kotlin Duration is value class and as Jackson has limited support of such cases it falls under few limits: * data classes with Duration needs to have explicit creator method * Duration fields has to be annotated with explicit `@JsonDeserializer` annotation See `DurationTests` for details. --- .../com/fasterxml/jackson/module/kotlin/KotlinFeature.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 8990d33d2..09656aa55 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinFeature.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinFeature.kt @@ -63,7 +63,8 @@ enum class KotlinFeature(private val enabledByDefault: Boolean) { /** * 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]. + * This allows use Kotlin Duration type with [com.fasterxml.jackson.datatype.jsr310.JavaTimeModule] for serialization. + * For deserialization explicit creator method and annotation on Duration is needed. */ UseJavaDurationConversion(enabledByDefault = false); From 9709b83ebbc08ebbe39ad0931033b63de91bbe66 Mon Sep 17 00:00:00 2001 From: Krzysztof Kurczewski Date: Sat, 5 Aug 2023 08:05:10 +0200 Subject: [PATCH 09/16] Remarks --- .../module/kotlin/KotlinAnnotationIntrospector.kt | 10 +++++----- .../fasterxml/jackson/module/kotlin/KotlinModule.kt | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) 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 3aaf1a773..e225f6adc 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt @@ -69,14 +69,14 @@ internal class KotlinAnnotationIntrospector( 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 -> a.ktClass()?.let { cache.getValueClassBoxConverter(a.rawReturnType, it) } + is AnnotatedMethod -> a.findValueClassReturnType()?.let { 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.isAssignableFrom(a.rawType) -> KotlinToJavaDurationConverter.takeIf { useJavaDurationConversion } + Duration::class.java == a.rawType -> KotlinToJavaDurationConverter.takeIf { useJavaDurationConversion } else -> null } @@ -88,11 +88,11 @@ internal class KotlinAnnotationIntrospector( // 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) - ?.ktClass() + ?.findValueClassReturnType() ?.takeIf { it.requireRebox() } ?.let { cache.getValueClassBoxConverter(am.rawReturnType, it).delegatingSerializer } - override fun findSerializer(am: Annotated): Any? = when ((am as? AnnotatedMethod)?.ktClass()) { + override fun findSerializer(am: Annotated): Any? = when ((am as? AnnotatedMethod)?.findValueClassReturnType()) { Duration::class -> KotlinToJavaDurationConverter.delegatingSerializer.takeIf { useJavaDurationConversion } else -> null } ?: super.findSerializer(am) @@ -181,7 +181,7 @@ internal class KotlinAnnotationIntrospector( return requiredAnnotationOrNullability(byAnnotation, byNullability) } - private fun AnnotatedMethod.ktClass() = cache.findValueClassReturnType(this) + private fun AnnotatedMethod.findValueClassReturnType() = cache.findValueClassReturnType(this) private fun KFunction<*>.isConstructorParameterRequired(index: Int): Boolean { return isParameterRequired(index) 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 aad2b9667..f3bd57fb6 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinModule.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinModule.kt @@ -59,7 +59,7 @@ class KotlinModule @Deprecated( val singletonSupport: SingletonSupport = DISABLED, val strictNullChecks: Boolean = false, val useKotlinPropertyNameForGetter: Boolean = false, - private val useJavaDurationConversion: Boolean = false, + val useJavaDurationConversion: Boolean = false, ) : SimpleModule(KotlinModule::class.java.name, PackageVersion.VERSION) { init { if (!KotlinVersion.CURRENT.isAtLeast(1, 5)) { From 7161b2b1da86bbcbcf504d22ffb804a26574fd8c Mon Sep 17 00:00:00 2001 From: wrongwrong Date: Sun, 6 Aug 2023 14:44:10 +0900 Subject: [PATCH 10/16] Fixing converter usage --- .../jackson/module/kotlin/Converters.kt | 5 +++-- .../kotlin/KotlinAnnotationIntrospector.kt | 22 +++++++++++++++++++ .../module/kotlin/test/DurationTests.kt | 5 +---- 3 files changed, 26 insertions(+), 6 deletions(-) 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 b96b1c829..150fbb8d7 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt @@ -6,6 +6,7 @@ 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 @@ -30,8 +31,8 @@ internal object KotlinToJavaDurationConverter : StdConverter() { - override fun convert(value: JavaDuration) = KotlinDuration.parseIsoString(value.toString()) +internal object JavaToKotlinDurationConverter : StdConverter() { + override fun convert(value: JavaDuration) = value.toKotlinDuration() } // S is nullable because value corresponds to a nullable value class 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 e225f6adc..48e237920 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt @@ -13,6 +13,7 @@ import java.lang.reflect.AccessibleObject import java.lang.reflect.Constructor import java.lang.reflect.Field import java.lang.reflect.Method +import java.lang.reflect.Modifier import kotlin.reflect.KClass import kotlin.reflect.KFunction import kotlin.reflect.KMutableProperty1 @@ -21,6 +22,7 @@ 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 @@ -97,6 +99,26 @@ internal class KotlinAnnotationIntrospector( else -> null } ?: super.findSerializer(am) + 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 + } + } + } + /** * Subclasses can be detected automatically for sealed classes, since all possible subclasses are known * at compile-time to Kotlin. This makes [com.fasterxml.jackson.annotation.JsonSubTypes] redundant. 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 index 1445288e6..8e196ab45 100644 --- a/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt +++ b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt @@ -6,9 +6,7 @@ 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.databind.annotation.JsonDeserialize import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule -import com.fasterxml.jackson.module.kotlin.JavaToKotlinDurationConverter import com.fasterxml.jackson.module.kotlin.KotlinFeature.UseJavaDurationConversion import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.kotlinModule @@ -90,7 +88,6 @@ class DurationTests { data class Meeting( val start: Instant, - @get:JsonDeserialize(converter = JavaToKotlinDurationConverter::class) val duration: KotlinDuration, ) { companion object { @@ -153,4 +150,4 @@ class DurationTests { private fun jacksonObjectMapper( configuration: KotlinModule.Builder.() -> Unit, ) = ObjectMapper().registerModule(kotlinModule(configuration)) -} \ No newline at end of file +} From a0151d422267109a246c95c18242633b235c3ae7 Mon Sep 17 00:00:00 2001 From: wrongwrong Date: Sun, 6 Aug 2023 14:53:15 +0900 Subject: [PATCH 11/16] Modified to use DelegatingDeserializer --- .../com/fasterxml/jackson/module/kotlin/Converters.kt | 5 +++++ .../jackson/module/kotlin/KotlinDeserializers.kt | 10 ++-------- 2 files changed, 7 insertions(+), 8 deletions(-) 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 150fbb8d7..b0063d262 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt @@ -1,6 +1,7 @@ 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 @@ -33,6 +34,10 @@ internal object KotlinToJavaDurationConverter : 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 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 807dbaca8..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,8 +10,6 @@ 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.toKotlinDuration -import java.time.Duration as JavaDuration import kotlin.time.Duration as KotlinDuration object SequenceDeserializer : StdDeserializer>(Sequence::class.java) { @@ -20,11 +18,6 @@ object SequenceDeserializer : StdDeserializer>(Sequence::class.java) } } -internal object DurationDeserializer : StdDeserializer(KotlinDuration::class.java) { - override fun deserialize(p: JsonParser, ctxt: DeserializationContext) = ctxt - .readValue(p, JavaDuration::class.java)?.toKotlinDuration() -} - object RegexDeserializer : StdDeserializer(Regex::class.java) { override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Regex { val node = ctxt.readTree(p) @@ -104,7 +97,8 @@ internal class KotlinDeserializers( type.rawClass == UShort::class.java -> UShortDeserializer type.rawClass == UInt::class.java -> UIntDeserializer type.rawClass == ULong::class.java -> ULongDeserializer - type.rawClass == KotlinDuration::class.java -> DurationDeserializer.takeIf { useJavaDurationConversion } + type.rawClass == KotlinDuration::class.java -> + JavaToKotlinDurationConverter.takeIf { useJavaDurationConversion }?.delegatingDeserializer else -> null } } From bdc58b44a497d6b2d743a2eb12cf9d9a847292a2 Mon Sep 17 00:00:00 2001 From: Krzysztof Kurczewski Date: Sun, 6 Aug 2023 08:53:52 +0200 Subject: [PATCH 12/16] Add test cases involving mixins --- .../module/kotlin/test/DurationTests.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 index 8e196ab45..0fa4e2929 100644 --- a/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt +++ b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt @@ -91,12 +91,18 @@ class DurationTests { 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 @@ -119,6 +125,30 @@ class DurationTests { 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), From 4fba300d5a0199079d9d12a60306e8806917441f Mon Sep 17 00:00:00 2001 From: Krzysztof Kurczewski Date: Sun, 6 Aug 2023 09:03:42 +0200 Subject: [PATCH 13/16] Update feature java doc --- .../com/fasterxml/jackson/module/kotlin/KotlinFeature.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 09656aa55..bc6bb9d79 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinFeature.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinFeature.kt @@ -63,8 +63,8 @@ enum class KotlinFeature(private val enabledByDefault: Boolean) { /** * 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] for serialization. - * For deserialization explicit creator method and annotation on Duration is needed. + * 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`. */ UseJavaDurationConversion(enabledByDefault = false); From e650766b260848797545fe2dfbce5dd3fde021a1 Mon Sep 17 00:00:00 2001 From: wrongwrong Date: Sun, 6 Aug 2023 16:14:57 +0900 Subject: [PATCH 14/16] Fixed conversion to be done by converter only --- .../jackson/module/kotlin/Converters.kt | 8 ++++++-- .../kotlin/KotlinAnnotationIntrospector.kt | 17 ++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) 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 b0063d262..c27f82c69 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/Converters.kt @@ -21,10 +21,14 @@ 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() - - val delegatingSerializer: StdDelegatingSerializer by lazy { StdDelegatingSerializer(this) } } /** 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 48e237920..a06bd0142 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinAnnotationIntrospector.kt @@ -13,7 +13,6 @@ import java.lang.reflect.AccessibleObject import java.lang.reflect.Constructor import java.lang.reflect.Field import java.lang.reflect.Method -import java.lang.reflect.Modifier import kotlin.reflect.KClass import kotlin.reflect.KFunction import kotlin.reflect.KMutableProperty1 @@ -71,7 +70,16 @@ internal class KotlinAnnotationIntrospector( 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 -> a.findValueClassReturnType()?.let { cache.getValueClassBoxConverter(a.rawReturnType, it) } + 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 } @@ -94,11 +102,6 @@ internal class KotlinAnnotationIntrospector( ?.takeIf { it.requireRebox() } ?.let { cache.getValueClassBoxConverter(am.rawReturnType, it).delegatingSerializer } - override fun findSerializer(am: Annotated): Any? = when ((am as? AnnotatedMethod)?.findValueClassReturnType()) { - Duration::class -> KotlinToJavaDurationConverter.delegatingSerializer.takeIf { useJavaDurationConversion } - else -> null - } ?: super.findSerializer(am) - override fun findDeserializationConverter(a: Annotated): Any? { if (!useJavaDurationConversion) return null From f2e51b4f3aaab41df84c919ee9937fe69117dee8 Mon Sep 17 00:00:00 2001 From: Krzysztof Kurczewski Date: Sun, 6 Aug 2023 09:59:20 +0200 Subject: [PATCH 15/16] Add test for deserializing correct units from format annotation --- .../module/kotlin/test/DurationTests.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) 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 index 0fa4e2929..75db96297 100644 --- a/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt +++ b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/test/DurationTests.kt @@ -16,6 +16,7 @@ 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 @@ -177,6 +178,31 @@ class DurationTests { 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)) From 73bf6824825a88b9255c890d3ac4f13590c169ee Mon Sep 17 00:00:00 2001 From: kkurczewski Date: Sun, 6 Aug 2023 13:39:40 +0200 Subject: [PATCH 16/16] Add related issue reference in docs Co-authored-by: wrongwrong --- .../kotlin/com/fasterxml/jackson/module/kotlin/KotlinFeature.kt | 1 + 1 file changed, 1 insertion(+) 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 bc6bb9d79..93dba381f 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinFeature.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinFeature.kt @@ -65,6 +65,7 @@ enum class KotlinFeature(private val enabledByDefault: Boolean) { * * 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);