Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions common/src/main/scala/com/gu/sfl/model/model.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package com.gu.sfl.model

import java.io.IOException
import java.time.format.DateTimeFormatter
import java.time.format.{DateTimeFormatter, DateTimeFormatterBuilder}
import java.time.temporal.ChronoField
import java.time.{Instant, LocalDateTime, ZoneOffset}

import com.fasterxml.jackson.annotation.JsonIgnore
Expand Down Expand Up @@ -80,7 +81,13 @@ case class ErrorResponse(status: String = "error", errors: List[Error])
case class Error(message: String, description: String)

object SavedArticleDateSerializer {
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
/** Accept optional 3-digit milliseconds in input parsing since it is a valid ISO-8601 component
* But drop them during output serialisation to ensure consistency in DB records and API responses
*/
val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss[.SSS]'Z'")
val outputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")

def parse(value: String): LocalDateTime = LocalDateTime.parse(value, inputFormatter)
}

class DirtySavedArticleDeserializer(t: Class[DirtySavedArticle]) extends StdDeserializer[DirtySavedArticle](t) {
Expand All @@ -93,7 +100,7 @@ class DirtySavedArticleDeserializer(t: Class[DirtySavedArticle]) extends StdDese
val id = Option(node.get("id")).filter(_.isTextual).map(_.asText())
val shortUrl = Option(node.get("shortUrl")).filter(_.isTextual).map(_.asText())
val read = Option(node.get("read")).filter(_.isBoolean).map(_.asBoolean())
val date = Option(node.get("date")).filter(_.isTextual).map(_.asText()).map(LocalDateTime.parse(_, SavedArticleDateSerializer.formatter))
val date = Option(node.get("date")).filter(_.isTextual).map(_.asText()).map(SavedArticleDateSerializer.parse)
DirtySavedArticle(id, shortUrl, date, read.getOrElse(false))
}
}
Expand All @@ -109,7 +116,7 @@ class SavedArticleSerializer(t:Class[SavedArticle]) extends StdSerializer[SavedA
gen.writeStartObject()
gen.writeStringField("id", value.id)
gen.writeStringField("shortUrl", value.shortUrl)
gen.writeStringField("date", SavedArticleDateSerializer.formatter.format(value.date))
gen.writeStringField("date", SavedArticleDateSerializer.outputFormatter.format(value.date))
gen.writeBooleanField("read", value.read)
gen.writeEndObject()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.gu.sfl.model

import java.time.LocalDateTime

import org.specs2.mutable.Specification

class SavedArticleDateSerializerTest extends Specification {
"SavedArticleDateSerializer.parse" should {
"parse a date without milliseconds" in {
SavedArticleDateSerializer.parse("2026-07-17T10:15:30Z") must beEqualTo(
LocalDateTime.of(2026, 7, 17, 10, 15, 30)
)
}

"parse a date with milliseconds" in {
SavedArticleDateSerializer.parse("2026-07-17T10:15:30.123Z") must beEqualTo(
LocalDateTime.of(2026, 7, 17, 10, 15, 30, 123000000)
)
}
}

"SavedArticleDateSerializer.outputFormatter" should {
"always write dates without milliseconds" in {
val date = LocalDateTime.of(2026, 7, 17, 10, 15, 30, 123000000)
SavedArticleDateSerializer.outputFormatter.format(date) must beEqualTo("2026-07-17T10:15:30Z")
}
}
}
Loading