diff --git a/openai-java-core/src/main/kotlin/com/openai/core/ObjectMappers.kt b/openai-java-core/src/main/kotlin/com/openai/core/ObjectMappers.kt index 7be25df3..24bec380 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/ObjectMappers.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/ObjectMappers.kt @@ -4,12 +4,16 @@ package com.openai.core import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParseException +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.MapperFeature import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.cfg.CoercionAction import com.fasterxml.jackson.databind.cfg.CoercionInputShape +import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.type.LogicalType @@ -17,13 +21,23 @@ import com.fasterxml.jackson.datatype.jdk8.Jdk8Module import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.kotlinModule import java.io.InputStream +import java.time.DateTimeException +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoField fun jsonMapper(): JsonMapper = JsonMapper.builder() .addModule(kotlinModule()) .addModule(Jdk8Module()) .addModule(JavaTimeModule()) - .addModule(SimpleModule().addSerializer(InputStreamJsonSerializer)) + .addModule( + SimpleModule() + .addSerializer(InputStreamSerializer) + .addDeserializer(LocalDateTime::class.java, LenientLocalDateTimeDeserializer()) + ) .withCoercionConfig(LogicalType.Boolean) { it.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail) .setCoercion(CoercionInputShape.Float, CoercionAction.Fail) @@ -91,7 +105,10 @@ fun jsonMapper(): JsonMapper = .disable(MapperFeature.AUTO_DETECT_SETTERS) .build() -private object InputStreamJsonSerializer : BaseSerializer(InputStream::class) { +/** A serializer that serializes [InputStream] to bytes. */ +private object InputStreamSerializer : BaseSerializer(InputStream::class) { + + private fun readResolve(): Any = InputStreamSerializer override fun serialize( value: InputStream?, @@ -105,3 +122,46 @@ private object InputStreamJsonSerializer : BaseSerializer(InputStre } } } + +/** + * A deserializer that can deserialize [LocalDateTime] from datetimes, dates, and zoned datetimes. + */ +private class LenientLocalDateTimeDeserializer : + StdDeserializer(LocalDateTime::class.java) { + + companion object { + + private val DATE_TIME_FORMATTERS = + listOf( + DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ISO_LOCAL_DATE, + DateTimeFormatter.ISO_ZONED_DATE_TIME, + ) + } + + override fun logicalType(): LogicalType = LogicalType.DateTime + + override fun deserialize(p: JsonParser, context: DeserializationContext?): LocalDateTime { + val exceptions = mutableListOf() + + for (formatter in DATE_TIME_FORMATTERS) { + try { + val temporal = formatter.parse(p.text) + + return when { + !temporal.isSupported(ChronoField.HOUR_OF_DAY) -> + LocalDate.from(temporal).atStartOfDay() + !temporal.isSupported(ChronoField.OFFSET_SECONDS) -> + LocalDateTime.from(temporal) + else -> ZonedDateTime.from(temporal).toLocalDateTime() + } + } catch (e: DateTimeException) { + exceptions.add(e) + } + } + + throw JsonParseException(p, "Cannot parse `LocalDateTime` from value: ${p.text}").apply { + exceptions.forEach { addSuppressed(it) } + } + } +} diff --git a/openai-java-core/src/test/kotlin/com/openai/core/ObjectMappersTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/ObjectMappersTest.kt index 17fe45c3..2dbf1a4c 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/ObjectMappersTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/ObjectMappersTest.kt @@ -2,10 +2,15 @@ package com.openai.core import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.exc.MismatchedInputException +import com.fasterxml.jackson.module.kotlin.readValue +import java.time.LocalDateTime import kotlin.reflect.KClass import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.catchThrowable import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource import org.junitpioneer.jupiter.cartesian.CartesianTest internal class ObjectMappersTest { @@ -78,4 +83,20 @@ internal class ObjectMappersTest { assertThat(e).isInstanceOf(MismatchedInputException::class.java) } } + + enum class LenientLocalDateTimeTestCase(val string: String) { + DATE("1998-04-21"), + DATE_TIME("1998-04-21T04:00:00"), + ZONED_DATE_TIME_1("1998-04-21T04:00:00+03:00"), + ZONED_DATE_TIME_2("1998-04-21T04:00:00Z"), + } + + @ParameterizedTest + @EnumSource + fun readLocalDateTime_lenient(testCase: LenientLocalDateTimeTestCase) { + val jsonMapper = jsonMapper() + val json = jsonMapper.writeValueAsString(testCase.string) + + assertDoesNotThrow { jsonMapper().readValue(json) } + } }