Skip to content

Commit ac0fa47

Browse files
feat(client): make datetime deserialization more lenient (#105)
1 parent 0c69b0d commit ac0fa47

File tree

2 files changed

+83
-2
lines changed

2 files changed

+83
-2
lines changed

openlayer-java-core/src/main/kotlin/com/openlayer/api/core/ObjectMappers.kt

+62-2
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,40 @@ package com.openlayer.api.core
44

55
import com.fasterxml.jackson.annotation.JsonInclude
66
import com.fasterxml.jackson.core.JsonGenerator
7+
import com.fasterxml.jackson.core.JsonParseException
8+
import com.fasterxml.jackson.core.JsonParser
9+
import com.fasterxml.jackson.databind.DeserializationContext
710
import com.fasterxml.jackson.databind.DeserializationFeature
811
import com.fasterxml.jackson.databind.MapperFeature
912
import com.fasterxml.jackson.databind.SerializationFeature
1013
import com.fasterxml.jackson.databind.SerializerProvider
1114
import com.fasterxml.jackson.databind.cfg.CoercionAction
1215
import com.fasterxml.jackson.databind.cfg.CoercionInputShape
16+
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
1317
import com.fasterxml.jackson.databind.json.JsonMapper
1418
import com.fasterxml.jackson.databind.module.SimpleModule
1519
import com.fasterxml.jackson.databind.type.LogicalType
1620
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module
1721
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
1822
import com.fasterxml.jackson.module.kotlin.kotlinModule
1923
import java.io.InputStream
24+
import java.time.DateTimeException
25+
import java.time.LocalDate
26+
import java.time.LocalDateTime
27+
import java.time.ZonedDateTime
28+
import java.time.format.DateTimeFormatter
29+
import java.time.temporal.ChronoField
2030

2131
fun jsonMapper(): JsonMapper =
2232
JsonMapper.builder()
2333
.addModule(kotlinModule())
2434
.addModule(Jdk8Module())
2535
.addModule(JavaTimeModule())
26-
.addModule(SimpleModule().addSerializer(InputStreamJsonSerializer))
36+
.addModule(
37+
SimpleModule()
38+
.addSerializer(InputStreamSerializer)
39+
.addDeserializer(LocalDateTime::class.java, LenientLocalDateTimeDeserializer())
40+
)
2741
.withCoercionConfig(LogicalType.Boolean) {
2842
it.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)
2943
.setCoercion(CoercionInputShape.Float, CoercionAction.Fail)
@@ -91,7 +105,10 @@ fun jsonMapper(): JsonMapper =
91105
.disable(MapperFeature.AUTO_DETECT_SETTERS)
92106
.build()
93107

94-
private object InputStreamJsonSerializer : BaseSerializer<InputStream>(InputStream::class) {
108+
/** A serializer that serializes [InputStream] to bytes. */
109+
private object InputStreamSerializer : BaseSerializer<InputStream>(InputStream::class) {
110+
111+
private fun readResolve(): Any = InputStreamSerializer
95112

96113
override fun serialize(
97114
value: InputStream?,
@@ -105,3 +122,46 @@ private object InputStreamJsonSerializer : BaseSerializer<InputStream>(InputStre
105122
}
106123
}
107124
}
125+
126+
/**
127+
* A deserializer that can deserialize [LocalDateTime] from datetimes, dates, and zoned datetimes.
128+
*/
129+
private class LenientLocalDateTimeDeserializer :
130+
StdDeserializer<LocalDateTime>(LocalDateTime::class.java) {
131+
132+
companion object {
133+
134+
private val DATE_TIME_FORMATTERS =
135+
listOf(
136+
DateTimeFormatter.ISO_LOCAL_DATE_TIME,
137+
DateTimeFormatter.ISO_LOCAL_DATE,
138+
DateTimeFormatter.ISO_ZONED_DATE_TIME,
139+
)
140+
}
141+
142+
override fun logicalType(): LogicalType = LogicalType.DateTime
143+
144+
override fun deserialize(p: JsonParser, context: DeserializationContext?): LocalDateTime {
145+
val exceptions = mutableListOf<Exception>()
146+
147+
for (formatter in DATE_TIME_FORMATTERS) {
148+
try {
149+
val temporal = formatter.parse(p.text)
150+
151+
return when {
152+
!temporal.isSupported(ChronoField.HOUR_OF_DAY) ->
153+
LocalDate.from(temporal).atStartOfDay()
154+
!temporal.isSupported(ChronoField.OFFSET_SECONDS) ->
155+
LocalDateTime.from(temporal)
156+
else -> ZonedDateTime.from(temporal).toLocalDateTime()
157+
}
158+
} catch (e: DateTimeException) {
159+
exceptions.add(e)
160+
}
161+
}
162+
163+
throw JsonParseException(p, "Cannot parse `LocalDateTime` from value: ${p.text}").apply {
164+
exceptions.forEach { addSuppressed(it) }
165+
}
166+
}
167+
}

openlayer-java-core/src/test/kotlin/com/openlayer/api/core/ObjectMappersTest.kt

+21
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ package com.openlayer.api.core
22

33
import com.fasterxml.jackson.annotation.JsonProperty
44
import com.fasterxml.jackson.databind.exc.MismatchedInputException
5+
import com.fasterxml.jackson.module.kotlin.readValue
6+
import java.time.LocalDateTime
57
import kotlin.reflect.KClass
68
import org.assertj.core.api.Assertions.assertThat
79
import org.assertj.core.api.Assertions.catchThrowable
810
import org.junit.jupiter.api.Test
11+
import org.junit.jupiter.api.assertDoesNotThrow
12+
import org.junit.jupiter.params.ParameterizedTest
13+
import org.junit.jupiter.params.provider.EnumSource
914
import org.junitpioneer.jupiter.cartesian.CartesianTest
1015

1116
internal class ObjectMappersTest {
@@ -78,4 +83,20 @@ internal class ObjectMappersTest {
7883
assertThat(e).isInstanceOf(MismatchedInputException::class.java)
7984
}
8085
}
86+
87+
enum class LenientLocalDateTimeTestCase(val string: String) {
88+
DATE("1998-04-21"),
89+
DATE_TIME("1998-04-21T04:00:00"),
90+
ZONED_DATE_TIME_1("1998-04-21T04:00:00+03:00"),
91+
ZONED_DATE_TIME_2("1998-04-21T04:00:00Z"),
92+
}
93+
94+
@ParameterizedTest
95+
@EnumSource
96+
fun readLocalDateTime_lenient(testCase: LenientLocalDateTimeTestCase) {
97+
val jsonMapper = jsonMapper()
98+
val json = jsonMapper.writeValueAsString(testCase.string)
99+
100+
assertDoesNotThrow { jsonMapper().readValue<LocalDateTime>(json) }
101+
}
81102
}

0 commit comments

Comments
 (0)