How to deserialize dates with offset ("2019-01-29+01:00") to `java.time` related classes? - kotlin

I've refactored some legacy code within Spring Boot (2.1.2) system and migrated from java.util.Date to java.time based classes (jsr310). The system expects the dates in a ISO8601 formated string, whereas some are complete timestamps with time information (e.g. "2019-01-29T15:29:34+01:00") while others are only dates with offset (e.g. "2019-01-29+01:00"). Here is the DTO (as Kotlin data class):
data class Dto(
// ...
#JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ssXXX")
#JsonProperty("processingTimestamp")
val processingTimestamp: OffsetDateTime,
// ...
#JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-ddXXX")
#JsonProperty("orderDate")
val orderDate: OffsetDateTime,
// ...
)
While Jackson perfectly deserializes processingTimestamp, it fails with orderDate:
Caused by: java.time.DateTimeException: Unable to obtain OffsetDateTime from TemporalAccessor: {OffsetSeconds=32400},ISO resolved to 2018-10-23 of type java.time.format.Parsed
at java.time.OffsetDateTime.from(OffsetDateTime.java:370) ~[na:1.8.0_152]
at com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer.deserialize(InstantDeserializer.java:207) ~[jackson-datatype-jsr310-2.9.8.jar:2.9.8]
This makes sense to me, since OffsetDateTime cannot find any time information necessary to construct the instant. If I change to val orderDate: LocalDate Jackson can successfully deserialize, but then the offset information is gone (which I need to convert to Instant later).
Question
My current workaround is to use OffsetDateTime, in combination with a custom deserializer (see below). But I'm wondering, if there is a better solution for this?
Also, I'd wish for a more appropriate data type like OffsetDate, but I cannot find it in java.time.
PS
I was asking myself if "2019-01-29+01:00" is a valid for ISO8601. However, since I found that java.time.DateTimeFormatter.ISO_DATE is can correctly parse it and I cannot change the format how the clients send data, I put aside this question.
Workaround
data class Dto(
// ...
#JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-ddXXX")
#JsonProperty("catchDate")
#JsonDeserialize(using = OffsetDateDeserializer::class)
val orderDate: OffsetDateTime,
// ...
)
class OffsetDateDeserializer(
private val formatter: DateTimeFormatter = DateTimeFormatter.ISO_DATE
) : JSR310DateTimeDeserializerBase<OffsetDateTime>(OffsetDateTime::class.java, formatter) {
override fun deserialize(parser: JsonParser, context: DeserializationContext): OffsetDateTime? {
if (parser.hasToken(JsonToken.VALUE_STRING)) {
val string = parser.text.trim()
if (string.isEmpty()) {
return null
}
val parsed: TemporalAccessor = formatter.parse(string)
val offset = if(parsed.isSupported(ChronoField.OFFSET_SECONDS)) ZoneOffset.from(parsed) else ZoneOffset.UTC
val localDate = LocalDate.from(parsed)
return OffsetDateTime.of(localDate.atStartOfDay(), offset)
}
throw context.wrongTokenException(parser, _valueClass, parser.currentToken, "date with offset must be contained in string")
}
override fun withDateFormat(otherFormatter: DateTimeFormatter?): JsonDeserializer<OffsetDateTime> = OffsetDateDeserializer(formatter)
}

As #JodaStephen explained in the comments, OffsetDate was not included in java.time to have a minimal set of classes. So, OffsetDateTime is the best option.
He also suggested to use DateTimeFormatterBuilder and parseDefaulting to create a DateTimeFormatter instance, to directly create OffsetDateTime from the formatters parsing result (TemporalAccessor). AFAIK, I still need to create a custom deserializer to use the formatter. Here is code, which solved my problem:
class OffsetDateDeserializer: JsonDeserializer<OffsetDateTime>() {
private val formatter = DateTimeFormatterBuilder()
.append(DateTimeFormatter.ISO_DATE)
.parseDefaulting(ChronoField.HOUR_OF_DAY, 0)
.parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
.parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0)
.parseDefaulting(ChronoField.MILLI_OF_SECOND, 0)
.parseDefaulting(ChronoField.OFFSET_SECONDS, 0)
.toFormatter()
override fun deserialize(parser: JsonParser, context: DeserializationContext): OffsetDateTime? {
if (parser.hasToken(JsonToken.VALUE_STRING)) {
val string = parser.text.trim()
if (string.isEmpty()) {
return null
}
try {
return OffsetDateTime.from(formatter.parse(string))
} catch (e: DateTimeException){
throw context.wrongTokenException(parser, OffsetDateTime::class.java, parser.currentToken, "error while parsing date: ${e.message}")
}
}
throw context.wrongTokenException(parser, OffsetDateTime::class.java, parser.currentToken, "date with offset must be contained in string")
}
}

Related

Jackson SNAKE_CASE How to generate underscore in field names before number

I have the next peace of code
#Test
fun `simple test`() {
val objectMapper = ObjectMapper()
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE)
.registerModule(KotlinModule())
val value = objectMapper.writeValueAsString(MyClass(myField1 = "something", myField2 = "something2"))
assertNotNull(value)
}
data class MyClass (
val myField1: String? = null,
#JsonProperty("my_field_2")
val myField2: String? = null,
)
the result of deserialization is next
{"my_field1":"something","my_field_2":"something2"}
Is it possible to configure objectMapper to automatically populate _ value, before digits in object property names, without specifying it in #JsonProperty?
Yes, this is possible using a PropertyNamingStrategy:
objectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
Note that you named your snake-case fields inconsistently, because there is my_field1 without a _ before the digit, and my_field_2 with a _ before the digit. The configuration above using PropertyNamingStrategies.SNAKE_CASE works fine for the first naming (like in my_field1).
If you want to use the second naming (like in my_field_2), then you would have to write your own naming strategy like this:
class MySnakeCaseStrategy : NamingBase() {
override fun translate(input: String?): String? =
if (input == null) null
else "([A-Z]+|[0-9]+)".toRegex().replace(input) { "_${it.groupValues[1]}".lowercase() }
}
That naming strategy can then be used to configure your object-mapper:
objectMapper.setPropertyNamingStrategy(MySnakeCaseStrategy())
I do not know if and how it would be possible to support both naming strategies at the same time.

How to get the first value from a list without exceptions in Kotlin?

I have a date value in format "2021-07-14T13:00:00.000+0300" (or similar). I want to convert it to Date. In this case I have to traverse a loop of different formats and check if they fail.
import java.text.*
import java.util.*
val formats = listOf(
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
"dd.MM.yyyy, EEEE, HH:mm" // And many others.
)
val date = "2021-07-14T13:00:00.000+0300"
val locale = Locale.getDefault()
for (format in formats) {
try {
return SimpleDateFormat(format, locale).parse(date)
} catch (e: ParseException) {
}
}
// If nothing found, return current date.
return Date()
How to convert this for-loop to something like map? So that we can get the first value without exception?
val result = formats.map { ... }
Another option, while still using firstNotNullOfOrNull(), is to use parse() with a ParsePosition object whose properties you can safely ignore when combined with setLenient(false)*.
The advantage of the parse​(String, ParsePosition) version over parse​(String) is that it returns null when it can't parse the date, instead of throwing an error, so the try-catch overhead per iteration can be avoided.
Along with that, since you're defaulting to the current date if all formats fail, you can avoid the nullable Date type result with an Elvis op at the very end.
val result: Date = formats.firstNotNullOfOrNull { format ->
with (SimpleDateFormat(format, locale)) {
setLenient(false) // may not be required, see below
parse(date, ParsePosition(0)) // is null or Date
}
} ?: Date()
Btw, setLenient(false) may not be required because on v15, there's no leniency for SimpleDateFormat.parse() in the docs...but it does behave leniently. Setting it to true above or leaving it out, and parsing a date of "2021-07-14T53:00:00.000+0300" (note the '53') produced Fri Jul 16 02:00:00 UTC 2021. With no leniency, it produces null. The leniency is mentioned on the abstract base class DateFormat.parse(String, ParsePosition) but not for SimpleDateFormat.parse(String, ParsePosition).
So if you're expecting non-pattern-matching dates rather than invalid-but-pattern-matching dates, the above loop could be reduced to:
val result: Date = formats.firstNotNullOfOrNull { format ->
SimpleDateFormat(format, locale).parse(date, ParsePosition(0))
} ?: Date()
Use firstNotNullOfOrNull().
val result: Date? = formats.firstNotNullOfOrNull { format ->
try {
SimpleDateFormat(format, locale).parse(date)
} catch (e: ParseException) {
null
}
}

How to add a column to query results in Google Dataflow

I am trying to read a query from BigQuery and then with Apache Beam / Dataflow in Kotlin I want to add a column with the current date as a timestamp. I don't want to do it inside the query itself because I want to reuse this code for a big amount of queries and it looks like a better design.
This is the pipeline code I wrote:
val pipeline = Pipeline.create(options)
.apply("Retrieve query", BigQueryIO.readTableRows().fromQuery(query).usingStandardSql())
.apply("Add date", ParDo.of(AddDate()))
.apply("Store data", BigQueryIO.writeTableRows().withSchema(tableSchema)
.withCreateDisposition(BigQueryIO.Write.CreateDisposition.CREATE_IF_NEEDED)
.withWriteDisposition(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE)
.to(TableReference().setProjectId(gcpProject).setDatasetId(datasetId).setTableId(tableId))
For some reason it does not advance from the Add date transformation.
This is the code which is most likely to have the bug / error:
class AddDate : DoFn<TableRow, TableRow>() {
#ProcessElement
fun processElement(context: ProcessContext) {
val tableRow = context.element() as TableRow
tableRow.set("process_date", Instant.now())
context.output(tableRow)
}
}
I also tried with this code instead inside processElement, but still does not work.
context.outputWithTimestamp(context.element(), Instant.now())
The error is the following:
Input values must not be mutated in any way.
The problem is solved by using a new object and being careful with the type used for the date (for DATE or TIMESTAMP types)
#ProcessElement
fun processElement(context: ProcessContext) {
val tableRow = TableRow()
tableRow.set("process_date", Instant.now().toString())
val input = context.element() as TableRow
input.keys.forEach { tableRow.set(it, input[it]) }
context.output(tableRow)
}

How to get the string in Kotlin to readable format?

I don't know how to get the string in readable format in my app. My code is:
val allergyList = recipeItem.allergies
allergyList.joinToString()
var allergyString: String = ""
for (allergy in allergyList) {
allergyList[1]
allergyString += " ${allergy}"
println(allergy.toString())
}
holder.recipeSearchPageAllergies.text = allergyString
When I print this I get the allergy string memory space?
Result for each one is something like this:
Allergy#4e8f238
How do I 'decode' it into something readable for a human? It should say 'nut allergy'.
you have some options. If you have the control over the Allergy source code,
you could override toString method:
class Allergy(val name: String) {
override fun toString(): String = "Allergy[name=${name}]"
}
println(Allergy("flowers"))
// Allergy[name=flowers]
also, you can make a data class of it. Data class has sane toString by default. It also has a few nice perks, like by default equals/hashCode generation, and deconstruction to components, allowing you to use it in destructing:
data class Allergy(val name: String)
println(Allergy("peanuts"))
// Allergy(name=peanuts)
otherwise, if you can't modify the source of the Allregy, you can make up an extension method for that:
class Allergy(val name: String)
fun Allergy.readable() = "Allergy[name=${name}]"
println(Allergy("cats").readable())
// Allergy[name=cats]
in your case, you could also make an extension method for collections of allergies to have the format you need:
fun Collection<Allergy>.readable() = joinToString { "Allergy[name=${it.name}]" }
println(
listOf(Allergy("cats"), Allergy("peanuts"), Allergy("flowers")).readable()
)
// Allergy[name=cats], Allergy[name=peanuts], Allergy[name=flowers]
// in your case:
holder.recipeSearchPageAllergies.text = recipeItem.allergies.readable()
// or just
holder.recipeSearchPageAllergies.text = recipeItem.allergies.joinToString { "Allergy[name=${it.name}]" }
You can make it simplier:
val allergiesStr = recipeItem.allergies.map { allergy ->
// your allergy name from allergy variable
}.joinToString(separator = " ")

Serializing a Map with custom MapAdapter in moshi

I am trying to write a custom MapAdapter in moshi My requirement is to ignore any bad elements in the map. I have successfully written deserialization method(fromJson()), however, I am facing trouble with toJson. here is my toJson() method.
override fun toJson(writer: JsonWriter, value: Map<Any?, Any?>?) {
if (value == null) {
writer.nullValue()
} else {
writer.beginObject()
value.forEach {
writer.name(elementKeyAdapter.toJsonValue(it.key).toString())
.value(elementValueAdapter.toJson(it.value))
}
writer.endObject()
}
}
problem with this code is that it always is writing values in map as string in final Json. For example consider this code
enum class VehicleType2 {
#Json(name ="type1")
TYPE1,
#Json(name ="type2")
TYPE2,
#Json(name ="type3")
TYPE3,
#Json(name ="type4")
TYPE4,
}
and
val map = mutableMapOf<VehicleType2, Int>()
map[VehicleType2.TYPE1] = 1
map[VehicleType2.TYPE2] = 2
val adapter: JsonAdapter<Map<VehicleType2, Int>> =
moshi.adapter(Types.newParameterizedType(Map::class.java, VehicleType2::class.java, Integer::class.java))
Log.i("test", adapter.toJson(map))
this results in following Json
{"type1":"1","type2":"2"}
notice how 1 and 2 are strings and not integers. I have tried many permutations bit no success so far.
Here is more complete sample which reproduces this issue.
After few more trials and errors I found that this is working fine
writer.name(elementKeyAdapter.toJsonValue(it.key).toString())
elementValueAdapter.toJson(writer, it.value)
instead of
writer.name(elementKeyAdapter.toJsonValue(it.key).toString())
.value(elementValueAdapter.toJson(it.value))
I am still unsure about the reasoning behind it though. Thanks!