diff --git a/common/src/main/scala/com/gu/sfl/model/model.scala b/common/src/main/scala/com/gu/sfl/model/model.scala index 7e6bff8e..49593114 100644 --- a/common/src/main/scala/com/gu/sfl/model/model.scala +++ b/common/src/main/scala/com/gu/sfl/model/model.scala @@ -3,25 +3,47 @@ package com.gu.sfl.model import java.io.IOException import java.time.format.DateTimeFormatter import java.time.{Instant, LocalDateTime, ZoneOffset} - import com.fasterxml.jackson.annotation.JsonIgnore -import com.fasterxml.jackson.core.{JsonGenerator, JsonParser, JsonProcessingException} -import com.fasterxml.jackson.databind.annotation.{JsonDeserialize, JsonSerialize} +import com.fasterxml.jackson.core.{ + JsonGenerator, + JsonParser, + JsonProcessingException +} +import com.fasterxml.jackson.databind.annotation.{ + JsonDeserialize, + JsonSerialize +} import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.ser.std.StdSerializer -import com.fasterxml.jackson.databind.{DeserializationContext, JsonNode, SerializerProvider} +import com.fasterxml.jackson.databind.{ + DeserializationContext, + JsonNode, + SerializerProvider +} +import com.gu.sfl.lib.Jackson.mapper object SavedArticle { - implicit val localDateOrdering: Ordering[LocalDateTime] = Ordering.by(_.toEpochSecond(ZoneOffset.UTC)) - implicit val ordering: Ordering[SavedArticle] = Ordering.by[SavedArticle, LocalDateTime](_.date) + implicit val localDateOrdering: Ordering[LocalDateTime] = + Ordering.by(_.toEpochSecond(ZoneOffset.UTC)) + implicit val ordering: Ordering[SavedArticle] = + Ordering.by[SavedArticle, LocalDateTime](_.date) } - @JsonSerialize(using = classOf[SavedArticleSerializer]) -case class SavedArticle(id: String, shortUrl: String, date: LocalDateTime, read: Boolean) +case class SavedArticle( + id: String, + shortUrl: String, + date: LocalDateTime, + read: Boolean +) @JsonDeserialize(using = classOf[DirtySavedArticleDeserializer]) -case class DirtySavedArticle(id: Option[String], shortUrl: Option[String], date: Option[LocalDateTime], read: Boolean) +case class DirtySavedArticle( + id: Option[String], + shortUrl: Option[String], + date: Option[LocalDateTime], + read: Boolean +) case class SyncedPrefsResponse(status: String, syncedPrefs: SyncedPrefs) @@ -30,9 +52,9 @@ case class SavedArticlesResponse(status: String, savedArticles: SavedArticles) /*This is cribbed from a historic version of the Identity model: https://github.com/guardian/identity/blob/main/identity-model/src/main/scala/com/gu/identity/model/Model.scala This service was designed to sync various categories of data for a signed in user of which saved articles were one flavour - hence synced prefs. Because we need to preserve integrity with existing clients (ie apps) we need to maintain this model in order to render the same json -*/ -case class SyncedPrefs(userId: String, savedArticles: Option[SavedArticles]) { - def ordered: SyncedPrefs = copy( savedArticles = savedArticles.map(_.ordered) ) + */ +case class SyncedPrefs(userId: String, savedArticles: Option[SavedArticles]) { + def ordered: SyncedPrefs = copy(savedArticles = savedArticles.map(_.ordered)) } sealed trait SyncedPrefsData { @@ -43,38 +65,55 @@ sealed trait SyncedPrefsData { } object SavedArticles { - private val oldDate = LocalDateTime.of(2010,1,1,0,0,0) + private val oldDate = LocalDateTime.of(2010, 1, 1, 0, 0, 0) def nextVersion() = Instant.now().toEpochMilli.toString - def apply(articles: List[SavedArticle]) : SavedArticles = SavedArticles(nextVersion(), articles) - def apply(dirtySavedArticles: DirtySavedArticles) : SavedArticles = SavedArticles(dirtySavedArticles.version, buildArticlesWithDates(dirtySavedArticles)) + def apply(articles: List[SavedArticle]): SavedArticles = + SavedArticles(nextVersion(), articles) + def apply(dirtySavedArticles: DirtySavedArticles): SavedArticles = + SavedArticles( + dirtySavedArticles.version, + buildArticlesWithDates(dirtySavedArticles) + ) private def buildArticlesWithDates(dirtySavedArticles: DirtySavedArticles) = { - val startingDate = dirtySavedArticles.articles.flatMap(_.date).headOption.map(_.minusDays(1)).getOrElse(oldDate) - dirtySavedArticles.articles.foldLeft((startingDate, List.empty[SavedArticle])) { - case ((lastGoodDate, clean), dirtySavedArticle) => { - dirtySavedArticle match { - case DirtySavedArticle(Some(id), Some(shortUrl), date, read) => { - val thisDate = date.getOrElse(lastGoodDate.plusSeconds(1)) - (thisDate, SavedArticle(id, shortUrl, thisDate, read) :: clean) + val startingDate = dirtySavedArticles.articles + .flatMap(_.date) + .headOption + .map(_.minusDays(1)) + .getOrElse(oldDate) + dirtySavedArticles.articles + .foldLeft((startingDate, List.empty[SavedArticle])) { + case ((lastGoodDate, clean), dirtySavedArticle) => { + dirtySavedArticle match { + case DirtySavedArticle(Some(id), Some(shortUrl), date, read) => { + val thisDate = date.getOrElse(lastGoodDate.plusSeconds(1)) + (thisDate, SavedArticle(id, shortUrl, thisDate, read) :: clean) + } + case _ => (lastGoodDate, clean) } - case _ => (lastGoodDate, clean) } } - }._2.reverse + ._2 + .reverse } val empty = SavedArticles("1", List.empty) } -case class SavedArticles(version: String, articles: List[SavedArticle]) extends SyncedPrefsData { +case class SavedArticles(version: String, articles: List[SavedArticle]) + extends SyncedPrefsData { override def advanceVersion: SavedArticles = copy(version = nextVersion) @JsonIgnore lazy val numberOfArticles = articles.length def ordered: SavedArticles = copy(articles = articles.sorted) - def deduped: SavedArticles = copy( articles = articles.groupBy(_.id).map(_._2.max).toList.sorted ) - def mostRecent(limit: Int) = copy( articles = articles.sorted.takeRight(limit) ) + def deduped: SavedArticles = + copy(articles = articles.groupBy(_.id).map(_._2.max).toList.sorted) + def mostRecent(limit: Int) = copy(articles = articles.sorted.takeRight(limit)) } -case class DirtySavedArticles(version: String, articles: List[DirtySavedArticle]) +case class DirtySavedArticles( + version: String, + articles: List[DirtySavedArticle] +) case class ErrorResponse(status: String = "error", errors: List[Error]) case class Error(message: String, description: String) @@ -83,33 +122,49 @@ object SavedArticleDateSerializer { val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") } -class DirtySavedArticleDeserializer(t: Class[DirtySavedArticle]) extends StdDeserializer[DirtySavedArticle](t) { - def this () = this(null) +class DirtySavedArticleDeserializer(t: Class[DirtySavedArticle]) + extends StdDeserializer[DirtySavedArticle](t) { + def this() = this(null) @Override @throws(classOf[IOException]) @throws(classOf[JsonProcessingException]) - override def deserialize(p: JsonParser, ctxt: DeserializationContext): DirtySavedArticle = { + override def deserialize( + p: JsonParser, + ctxt: DeserializationContext + ): DirtySavedArticle = { val node: JsonNode = p.readValueAsTree() val id = Option(node.get("id")).filter(_.isTextual).map(_.asText()) - val shortUrl = Option(node.get("shortUrl")).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(LocalDateTime.parse(_, SavedArticleDateSerializer.formatter)) DirtySavedArticle(id, shortUrl, date, read.getOrElse(false)) } } -class SavedArticleSerializer(t:Class[SavedArticle]) extends StdSerializer[SavedArticle](t) { +class SavedArticleSerializer(t: Class[SavedArticle]) + extends StdSerializer[SavedArticle](t) { def this() = this(null) @Override @throws(classOf[IOException]) @throws(classOf[JsonProcessingException]) - def serialize(value: SavedArticle, gen: JsonGenerator, serializers: SerializerProvider) = { + def serialize( + value: SavedArticle, + gen: JsonGenerator, + serializers: SerializerProvider + ) = { gen.writeStartObject() gen.writeStringField("id", value.id) gen.writeStringField("shortUrl", value.shortUrl) - gen.writeStringField("date", SavedArticleDateSerializer.formatter.format(value.date)) + gen.writeStringField( + "date", + SavedArticleDateSerializer.formatter.format(value.date) + ) gen.writeBooleanField("read", value.read) gen.writeEndObject() } diff --git a/common/src/main/scala/com/gu/sfl/persistence/SavedArticlesPersistence.scala b/common/src/main/scala/com/gu/sfl/persistence/SavedArticlesPersistence.scala index c043845e..3a0646b5 100644 --- a/common/src/main/scala/com/gu/sfl/persistence/SavedArticlesPersistence.scala +++ b/common/src/main/scala/com/gu/sfl/persistence/SavedArticlesPersistence.scala @@ -1,13 +1,10 @@ package com.gu.sfl.persistence -import com.amazonaws.auth.DefaultAWSCredentialsProviderChain -import com.amazonaws.services.dynamodbv2.{AmazonDynamoDBAsync, AmazonDynamoDBAsyncClient} -import org.scanamo.{Scanamo, Table} -import org.scanamo.auto._ -import org.scanamo.syntax._ +import org.scanamo.{PutReturn, Scanamo, Table} import com.gu.sfl.Logging import com.gu.sfl.lib.Jackson._ import com.gu.sfl.model._ +import software.amazon.awssdk.services.dynamodb.DynamoDbClient import scala.util.{Failure, Success, Try} @@ -15,30 +12,55 @@ case class PersistenceConfig(app: String, stage: String) { val tableName = s"$app-$stage-articles" } -case class DynamoSavedArticles(userId: String, version: String, articles: String) +trait SavedArticlesPersistence { + def read(userId: String): Try[Option[SavedArticles]] -object DynamoSavedArticles { - def apply(userId: String, savedArticles: SavedArticles): DynamoSavedArticles = DynamoSavedArticles(userId, savedArticles.nextVersion, mapper.writeValueAsString(savedArticles.articles)) -} + def update( + userId: String, + savedArticles: SavedArticles + ): Try[Option[SavedArticles]] -trait SavedArticlesPersistence { - def read(userId: String) : Try[Option[SavedArticles]] + def write( + userId: String, + savedArticles: SavedArticles + ): Try[Option[SavedArticles]] +} - def update(userId: String, savedArticles: SavedArticles): Try[Option[SavedArticles]] +case class DynamoSavedArticles( + userId: String, + version: String, + articles: String +) - def write(userId: String, savedArticles: SavedArticles) : Try[Option[SavedArticles]] +object DynamoSavedArticles { + def apply(userId: String, savedArticles: SavedArticles): DynamoSavedArticles = + DynamoSavedArticles( + userId, + savedArticles.nextVersion, + mapper.writeValueAsString(savedArticles.articles) + ) } -class SavedArticlesPersistenceImpl(persistanceConfig: PersistenceConfig) extends SavedArticlesPersistence with Logging { - implicit def toSavedArticles(dynamoSavedArticles: DynamoSavedArticles): SavedArticles = { - val articles = mapper.readValue[List[SavedArticle]](dynamoSavedArticles.articles) +class SavedArticlesPersistenceImpl(persistanceConfig: PersistenceConfig) + extends SavedArticlesPersistence + with Logging { + implicit def toSavedArticles( + dynamoSavedArticles: DynamoSavedArticles + ): SavedArticles = { + val articles = + mapper.readValue[List[SavedArticle]](dynamoSavedArticles.articles) SavedArticles(dynamoSavedArticles.version, articles) } - private val client: AmazonDynamoDBAsync = AmazonDynamoDBAsyncClient.asyncBuilder().withCredentials(DefaultAWSCredentialsProviderChain.getInstance()).build() + // TODO - validate if default instance creds are needed here + private val client = DynamoDbClient.builder().build() //TODO confirm that it's ok to share the same client concurrently in all requests.. I guess if this is a lambda there won't be concurrent requests anyway ? private val scanamo = Scanamo(client) - private val table = Table[DynamoSavedArticles](persistanceConfig.tableName) + + import org.scanamo.syntax._ + import org.scanamo.generic.auto._ + + val table = Table[DynamoSavedArticles](persistanceConfig.tableName) override def read(userId: String): Try[Option[SavedArticles]] = { scanamo.exec(table.get("userId" -> userId)) match { @@ -55,14 +77,24 @@ class SavedArticlesPersistenceImpl(persistanceConfig: PersistenceConfig) extends } } - override def write(userId: String, savedArticles: SavedArticles): Try[Option[SavedArticles]] = { - scanamo.exec(table.put(DynamoSavedArticles(userId, savedArticles))) match { + override def write( + userId: String, + savedArticles: SavedArticles + ): Try[Option[SavedArticles]] = { + scanamo.exec( + table.putAndReturn(PutReturn.NewValue)( + DynamoSavedArticles(userId, savedArticles) + ) + ) match { case Some(Right(articles)) => logger.debug(s"Succcesfully saved articles for $userId") Success(Some(articles.ordered)) case Some(Left(error)) => val exception = new IllegalArgumentException(s"$error") - logger.debug(s"Exception Thrown saving articles for $userId:", exception) + logger.debug( + s"Exception Thrown saving articles for $userId:", + exception + ) Failure(exception) case None => { logger.debug(s"Successfully saved but none retrieved for $userId") @@ -71,17 +103,25 @@ class SavedArticlesPersistenceImpl(persistanceConfig: PersistenceConfig) extends } } - override def update(userId: String, savedArticles: SavedArticles): Try[Option[SavedArticles]] = { - scanamo.exec(table.update("userId" -> userId, + override def update( + userId: String, + savedArticles: SavedArticles + ): Try[Option[SavedArticles]] = { + scanamo.exec( + table.update( + "userId" -> userId, set("version" -> savedArticles.nextVersion) and - set("articles" -> mapper.writeValueAsString(savedArticles.articles))) + set("articles" -> mapper.writeValueAsString(savedArticles.articles)) + ) ) match { case Right(articles) => logger.debug("Updated articles") Success(Some(articles.ordered)) case Left(error) => val ex = new IllegalStateException(s"${error}") - logger.error(s"Error updating articles for userId ${userId}: ${ex.getMessage} ") + logger.error( + s"Error updating articles for userId ${userId}: ${ex.getMessage} " + ) Failure(ex) } } diff --git a/project/dependencies.scala b/project/dependencies.scala index 3e52e9f8..9f03738d 100644 --- a/project/dependencies.scala +++ b/project/dependencies.scala @@ -7,28 +7,35 @@ object Dependencies { val specsVersion = "4.0.3" val awsLambda = "com.amazonaws" % "aws-lambda-java-core" % "1.2.0" - val awsDynamo ="com.amazonaws" % "aws-java-sdk-dynamodb" % awsSdkVersion + val awsDynamo = "software.amazon.awssdk" % "dynamodb" % "2.24.11" val awsLambdaLog = "com.amazonaws" % "aws-lambda-java-log4j2" % "1.5.0" - val awsJavaSdk ="com.amazonaws" % "aws-java-sdk-ec2" % awsSdkVersion - val awsSqs ="com.amazonaws" % "aws-java-sdk-sqs" % awsSdkVersion + val awsJavaSdk = "com.amazonaws" % "aws-java-sdk-ec2" % awsSdkVersion + val awsSqs = "com.amazonaws" % "aws-java-sdk-sqs" % awsSdkVersion val awsLambdaEvent = "com.amazonaws" % "aws-lambda-java-events" % "2.2.2" - val jackson = "com.fasterxml.jackson.module" %% "jackson-module-scala" % jacksonVersion - val jacksonDataFormat = "com.fasterxml.jackson.dataformat" % "jackson-dataformat-cbor" % jacksonVersion - val jacksonJdk8DataType = "com.fasterxml.jackson.datatype" % "jackson-datatype-jdk8" % jacksonVersion - val jacksonJsrDataType = "com.fasterxml.jackson.datatype" % "jackson-datatype-jsr310" % jacksonVersion + val jackson = + "com.fasterxml.jackson.module" %% "jackson-module-scala" % jacksonVersion + val jacksonDataFormat = + "com.fasterxml.jackson.dataformat" % "jackson-dataformat-cbor" % jacksonVersion + val jacksonJdk8DataType = + "com.fasterxml.jackson.datatype" % "jackson-datatype-jdk8" % jacksonVersion + val jacksonJsrDataType = + "com.fasterxml.jackson.datatype" % "jackson-datatype-jsr310" % jacksonVersion val log4j = "org.apache.logging.log4j" % "log4j-slf4j-impl" % log4j2Version val commonsIo = "commons-io" % "commons-io" % "2.6" - val scanamo = "org.scanamo" %% "scanamo" % "1.0.0-M11" + val scanamo = "org.scanamo" %% "scanamo" % "1.0.0-M30" val okHttp = "com.squareup.okhttp3" % "okhttp" % "4.9.2" val specsCore = "org.specs2" %% "specs2-core" % specsVersion % "test" - val specsScalaCheck = "org.specs2" %% "specs2-scalacheck" % specsVersion % "test" + val specsScalaCheck = + "org.specs2" %% "specs2-scalacheck" % specsVersion % "test" val specsMock = "org.specs2" %% "specs2-mock" % specsVersion % "test" val identityAuthCore = "com.gu.identity" %% "identity-auth-core" % "4.17" //DependencyOverride val commonsLogging = "commons-logging" % "commons-logging" % "1.2" val slf4jApi = "org.slf4j" % "slf4j-api" % "1.7.25" - val apacheLog4JCore = "org.apache.logging.log4j" % "log4j-core" % log4j2Version - val apacheLog$jApi = "org.apache.logging.log4j" % "log4j-api" % log4j2Version % "provided" + val apacheLog4JCore = + "org.apache.logging.log4j" % "log4j-core" % log4j2Version + val apacheLog$jApi = + "org.apache.logging.log4j" % "log4j-api" % log4j2Version % "provided" }