diff --git a/build.gradle.kts b/build.gradle.kts index d56b5360..7ca5c5c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { testImplementation("io.mockk:mockk:1.13.3") testImplementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml") + testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") } kotlin { diff --git a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/InternalCommons.kt b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/InternalCommons.kt index ee0c9e24..64554938 100644 --- a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/InternalCommons.kt +++ b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/InternalCommons.kt @@ -14,6 +14,9 @@ import java.lang.reflect.Constructor import java.lang.reflect.Field import java.lang.reflect.Method +internal typealias JavaDuration = java.time.Duration +internal typealias KotlinDuration = kotlin.time.Duration + internal fun Class<*>.isUnboxableValueClass() = this.getAnnotation(JvmInline::class.java) != null internal fun Class<*>.toKmClass(): KmClass? = this.getAnnotation(Metadata::class.java) diff --git a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/KotlinFeature.kt b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/KotlinFeature.kt index cd262481..97384c57 100644 --- a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/KotlinFeature.kt +++ b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/KotlinFeature.kt @@ -73,7 +73,16 @@ public enum class KotlinFeature(internal val enabledByDefault: Boolean) { * * @see KotlinClassIntrospector */ - CopySyntheticConstructorParameterAnnotations(enabledByDefault = false); + CopySyntheticConstructorParameterAnnotations(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/io/github/projectmapk/jackson/module/kogera/KotlinModule.kt b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/KotlinModule.kt index 6e2c729d..105530f7 100644 --- a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/KotlinModule.kt +++ b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/KotlinModule.kt @@ -8,6 +8,7 @@ import io.github.projectmapk.jackson.module.kogera.KotlinFeature.NullToEmptyColl import io.github.projectmapk.jackson.module.kogera.KotlinFeature.NullToEmptyMap import io.github.projectmapk.jackson.module.kogera.KotlinFeature.SingletonSupport import io.github.projectmapk.jackson.module.kogera.KotlinFeature.StrictNullChecks +import io.github.projectmapk.jackson.module.kogera.KotlinFeature.UseJavaDurationConversion import io.github.projectmapk.jackson.module.kogera.annotation_introspector.KotlinFallbackAnnotationIntrospector import io.github.projectmapk.jackson.module.kogera.annotation_introspector.KotlinPrimaryAnnotationIntrospector import io.github.projectmapk.jackson.module.kogera.deser.deserializers.KotlinDeserializers @@ -32,6 +33,8 @@ import java.util.* * 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]. */ // Do not delete default arguments, // as this will cause an error during initialization by Spring's Jackson2ObjectMapperBuilder. @@ -43,7 +46,8 @@ public class KotlinModule private constructor( public val singletonSupport: Boolean = SingletonSupport.enabledByDefault, public val strictNullChecks: Boolean = StrictNullChecks.enabledByDefault, public val copySyntheticConstructorParameterAnnotations: Boolean = - CopySyntheticConstructorParameterAnnotations.enabledByDefault + CopySyntheticConstructorParameterAnnotations.enabledByDefault, + public val useJavaDurationConversion: Boolean = UseJavaDurationConversion.enabledByDefault ) : SimpleModule(KotlinModule::class.java.name, kogeraVersion) { // kogeraVersion is generated by building. private constructor(builder: Builder) : this( builder.reflectionCacheSize, @@ -52,7 +56,8 @@ public class KotlinModule private constructor( builder.isEnabled(NullIsSameAsDefault), builder.isEnabled(SingletonSupport), builder.isEnabled(StrictNullChecks), - builder.isEnabled(CopySyntheticConstructorParameterAnnotations) + builder.isEnabled(CopySyntheticConstructorParameterAnnotations), + builder.isEnabled(UseJavaDurationConversion) ) @Deprecated( @@ -87,13 +92,15 @@ public class KotlinModule private constructor( context.insertAnnotationIntrospector( KotlinPrimaryAnnotationIntrospector(nullToEmptyCollection, nullToEmptyMap, cache) ) - context.appendAnnotationIntrospector(KotlinFallbackAnnotationIntrospector(strictNullChecks, cache)) + context.appendAnnotationIntrospector( + KotlinFallbackAnnotationIntrospector(strictNullChecks, useJavaDurationConversion, cache) + ) if (copySyntheticConstructorParameterAnnotations) { context.setClassIntrospector(KotlinClassIntrospector) } - context.addDeserializers(KotlinDeserializers(cache)) + context.addDeserializers(KotlinDeserializers(cache, useJavaDurationConversion)) context.addKeyDeserializers(KotlinKeyDeserializers) context.addSerializers(KotlinSerializers()) context.addKeySerializers(KotlinKeySerializers()) diff --git a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/annotation_introspector/KotlinFallbackAnnotationIntrospector.kt b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/annotation_introspector/KotlinFallbackAnnotationIntrospector.kt index 4008b2b7..eb2a2ef4 100644 --- a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/annotation_introspector/KotlinFallbackAnnotationIntrospector.kt +++ b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/annotation_introspector/KotlinFallbackAnnotationIntrospector.kt @@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.introspect.AnnotatedParameter import com.fasterxml.jackson.databind.introspect.NopAnnotationIntrospector import com.fasterxml.jackson.databind.type.TypeFactory import com.fasterxml.jackson.databind.util.Converter +import io.github.projectmapk.jackson.module.kogera.KotlinDuration import io.github.projectmapk.jackson.module.kogera.ReflectionCache import io.github.projectmapk.jackson.module.kogera.deser.CollectionValueStrictNullChecksConverter import io.github.projectmapk.jackson.module.kogera.deser.MapValueStrictNullChecksConverter @@ -18,6 +19,8 @@ import io.github.projectmapk.jackson.module.kogera.deser.ValueClassUnboxConverte import io.github.projectmapk.jackson.module.kogera.isNullable import io.github.projectmapk.jackson.module.kogera.isUnboxableValueClass import io.github.projectmapk.jackson.module.kogera.reconstructClassOrNull +import io.github.projectmapk.jackson.module.kogera.ser.KotlinDurationValueToJavaDurationConverter +import io.github.projectmapk.jackson.module.kogera.ser.KotlinToJavaDurationConverter import io.github.projectmapk.jackson.module.kogera.ser.SequenceToIteratorConverter import kotlinx.metadata.KmTypeProjection import kotlinx.metadata.KmValueParameter @@ -31,6 +34,7 @@ import java.lang.reflect.Modifier // Original name: KotlinNamesAnnotationIntrospector internal class KotlinFallbackAnnotationIntrospector( private val strictNullChecks: Boolean, + private val useJavaDurationConversion: Boolean, private val cache: ReflectionCache ) : NopAnnotationIntrospector() { private fun findKotlinParameter(param: AnnotatedParameter): KmValueParameter? = @@ -73,12 +77,24 @@ internal class KotlinFallbackAnnotationIntrospector( 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 -> cache.findValueClassReturnType(a)?.let { + if (useJavaDurationConversion && it == KotlinDuration::class.java) { + if (a.rawReturnType == KotlinDuration::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) + KotlinDuration::class.java == a.rawType -> KotlinToJavaDurationConverter.takeIf { useJavaDurationConversion } else -> null } diff --git a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/deser/Converters.kt b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/deser/Converters.kt index a5e821dc..3189fcb0 100644 --- a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/deser/Converters.kt +++ b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/deser/Converters.kt @@ -1,10 +1,14 @@ package io.github.projectmapk.jackson.module.kogera.deser import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.deser.std.StdDelegatingDeserializer import com.fasterxml.jackson.databind.exc.MismatchedInputException import com.fasterxml.jackson.databind.type.TypeFactory import com.fasterxml.jackson.databind.util.Converter import com.fasterxml.jackson.databind.util.StdConverter +import io.github.projectmapk.jackson.module.kogera.JavaDuration +import io.github.projectmapk.jackson.module.kogera.KotlinDuration +import kotlin.time.toKotlinDuration internal class ValueClassUnboxConverter(private val valueClass: Class) : StdConverter() { private val unboxMethod = valueClass.getDeclaredMethod("unbox-impl").apply { @@ -73,3 +77,16 @@ internal class MapValueStrictNullChecksConverter( override fun getInputType(typeFactory: TypeFactory): JavaType = type override fun getOutputType(typeFactory: TypeFactory): JavaType = type } + +/** + * 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) + } +} diff --git a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/deser/deserializers/KotlinDeserializers.kt b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/deser/deserializers/KotlinDeserializers.kt index f4309a4d..cf4fda3b 100644 --- a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/deser/deserializers/KotlinDeserializers.kt +++ b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/deser/deserializers/KotlinDeserializers.kt @@ -10,7 +10,9 @@ import com.fasterxml.jackson.databind.deser.Deserializers import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.exc.InvalidDefinitionException import io.github.projectmapk.jackson.module.kogera.JmClass +import io.github.projectmapk.jackson.module.kogera.KotlinDuration import io.github.projectmapk.jackson.module.kogera.ReflectionCache +import io.github.projectmapk.jackson.module.kogera.deser.JavaToKotlinDurationConverter import io.github.projectmapk.jackson.module.kogera.hasCreatorAnnotation import io.github.projectmapk.jackson.module.kogera.isUnboxableValueClass import io.github.projectmapk.jackson.module.kogera.toSignature @@ -127,7 +129,10 @@ private fun findValueCreator(type: JavaType, clazz: Class<*>, jmClass: JmClass): return primaryConstructor } -internal class KotlinDeserializers(private val cache: ReflectionCache) : Deserializers.Base() { +internal class KotlinDeserializers( + private val cache: ReflectionCache, + private val useJavaDurationConversion: Boolean +) : Deserializers.Base() { override fun findBeanDeserializer( type: JavaType, config: DeserializationConfig?, @@ -142,6 +147,8 @@ internal class KotlinDeserializers(private val cache: ReflectionCache) : Deseria rawClass == UShort::class.java -> UShortDeserializer rawClass == UInt::class.java -> UIntDeserializer rawClass == ULong::class.java -> ULongDeserializer + rawClass == KotlinDuration::class.java -> + JavaToKotlinDurationConverter.takeIf { useJavaDurationConversion }?.delegatingDeserializer rawClass.isUnboxableValueClass() -> findValueCreator(type, rawClass, cache.getJmClass(rawClass)!!) ?.let { ValueClassBoxDeserializer(it, rawClass) } else -> null diff --git a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/ser/Converters.kt b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/ser/Converters.kt index b4265073..6a329a83 100644 --- a/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/ser/Converters.kt +++ b/src/main/kotlin/io/github/projectmapk/jackson/module/kogera/ser/Converters.kt @@ -4,6 +4,9 @@ import com.fasterxml.jackson.databind.JavaType import com.fasterxml.jackson.databind.ser.std.StdDelegatingSerializer import com.fasterxml.jackson.databind.type.TypeFactory import com.fasterxml.jackson.databind.util.StdConverter +import io.github.projectmapk.jackson.module.kogera.JavaDuration +import io.github.projectmapk.jackson.module.kogera.KotlinDuration +import kotlin.time.toJavaDuration internal class SequenceToIteratorConverter(private val input: JavaType) : StdConverter, Iterator<*>>() { override fun convert(value: Sequence<*>): Iterator<*> = value.iterator() @@ -31,3 +34,13 @@ internal class ValueClassBoxConverter( val delegatingSerializer: StdDelegatingSerializer by lazy { StdDelegatingSerializer(this) } } + +internal object KotlinDurationValueToJavaDurationConverter : StdConverter() { + private val boxConverter by lazy { ValueClassBoxConverter(Long::class.java, KotlinDuration::class.java) } + + override fun convert(value: Long): JavaDuration = KotlinToJavaDurationConverter.convert(boxConverter.convert(value)) +} + +internal object KotlinToJavaDurationConverter : StdConverter() { + override fun convert(value: KotlinDuration) = value.toJavaDuration() +} diff --git a/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/_integration/DurationTest.kt b/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/_integration/DurationTest.kt new file mode 100644 index 00000000..c763aee6 --- /dev/null +++ b/src/test/kotlin/io/github/projectmapk/jackson/module/kogera/_integration/DurationTest.kt @@ -0,0 +1,211 @@ +package io.github.projectmapk.jackson.module.kogera._integration + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.annotation.JsonFormat.Shape +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 io.github.projectmapk.jackson.module.kogera.JavaDuration +import io.github.projectmapk.jackson.module.kogera.KotlinDuration +import io.github.projectmapk.jackson.module.kogera.KotlinFeature +import io.github.projectmapk.jackson.module.kogera.KotlinModule +import io.github.projectmapk.jackson.module.kogera.kotlinModule +import io.github.projectmapk.jackson.module.kogera.readValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.Instant +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds + +class DurationTest { + private val objectMapper = jacksonObjectMapper { enable(KotlinFeature.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"]""") + + assertEquals(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 = 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 = Shape.STRING) + val shapeAnnotation: JavaDuration = JavaDuration.ofHours(1), + @field:JsonFormat(shape = Shape.STRING) + val optShapeAnnotation: JavaDuration? = JavaDuration.ofHours(1) + ) + + data class KDTO( + val plain: KotlinDuration = 1.hours, + val optPlain: KotlinDuration? = 1.hours, + @field:JsonFormat(shape = Shape.STRING) + val shapeAnnotation: KotlinDuration = 1.hours, + @field:JsonFormat(shape = 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)) +}