From 7d59bc41ce8fddf1ee3540e1bd8c8b56e2225fac Mon Sep 17 00:00:00 2001 From: srikanthsunkara79 Date: Tue, 12 Mar 2024 14:58:01 +0000 Subject: [PATCH] Create Session Data Repository --- app/config/MicroserviceAppConfig.scala | 2 + app/repositories/SessionDataRepository.scala | 163 +++++++++++++++++ conf/application.conf | 5 +- .../SessionDataRepositorySpec.scala | 170 ++++++++++++++++++ 4 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 app/repositories/SessionDataRepository.scala create mode 100644 it/test/repositories/SessionDataRepositorySpec.scala diff --git a/app/config/MicroserviceAppConfig.scala b/app/config/MicroserviceAppConfig.scala index d30ce7c3..38e6a7aa 100644 --- a/app/config/MicroserviceAppConfig.scala +++ b/app/config/MicroserviceAppConfig.scala @@ -26,6 +26,7 @@ trait AppConfig { val configuration: Configuration val timeToLiveSeconds: Int + val sessionTimeToLiveSeconds: Int val throttleTimeToLiveSeconds: Int val mongoUri: String @@ -95,6 +96,7 @@ class MicroserviceAppConfig @Inject()(servicesConfig: ServicesConfig, val config lazy val mongoUri: String = loadConfig("mongodb.uri") lazy val timeToLiveSeconds: Int = loadConfig("mongodb.timeToLiveSeconds").toInt + lazy val sessionTimeToLiveSeconds: Int = loadConfig("mongodb.sessionTimeToLiveSeconds").toInt lazy val throttleTimeToLiveSeconds: Int = loadConfig("mongodb.throttleTimeToLiveSeconds").toInt } diff --git a/app/repositories/SessionDataRepository.scala b/app/repositories/SessionDataRepository.scala new file mode 100644 index 00000000..8c98a67e --- /dev/null +++ b/app/repositories/SessionDataRepository.scala @@ -0,0 +1,163 @@ +/* + * Copyright 2023 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package repositories + +import com.mongodb.client.model.{FindOneAndUpdateOptions, IndexOptions} +import com.mongodb.client.result.DeleteResult +import config.AppConfig +import org.bson.Document +import org.bson.conversions.Bson +import org.mongodb.scala.model.IndexModel +import org.mongodb.scala.result.InsertOneResult +import play.api.libs.json.Json.JsValueWrapper +import play.api.libs.json._ +import uk.gov.hmrc.http.InternalServerException +import uk.gov.hmrc.mongo.MongoComponent +import uk.gov.hmrc.mongo.play.json.PlayMongoRepository +import utils.JsonUtils.JsObjectUtil + +import java.time.Instant +import java.util.UUID +import java.util.concurrent.TimeUnit +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} +import scala.language.implicitConversions + + +@Singleton +class SessionDataRepositoryConfig @Inject()(val appConfig: AppConfig) { + + import SessionDataRepository._ + + private val ttlLengthSeconds = appConfig.sessionTimeToLiveSeconds + + def mongoComponent: MongoComponent = MongoComponent(appConfig.mongoUri) + + def indexes: Seq[IndexModel] = + Seq(ttlIndex(ttlLengthSeconds), sessionIdIndex) +} + +@Singleton +class SessionDataRepository @Inject()(config: SessionDataRepositoryConfig)(implicit ec: ExecutionContext) + extends PlayMongoRepository[JsObject]( + collectionName = "sessionData", + mongoComponent = config.mongoComponent, + domainFormat = implicitly[Format[JsObject]], + indexes = config.indexes, + replaceIndexes = false + ) { + + def findOneAndUpdateOptions(upsert: Boolean): FindOneAndUpdateOptions = new FindOneAndUpdateOptions().upsert(upsert) + + import SessionDataRepository._ + + private val removeIdProjection = toBson(Json.obj(_Id -> 0)) + + def find(selector: JsObject, projection: Option[JsObject]): Future[Seq[JsValue]] = { + collection + .find(selector) + .projection(projection.map(toBson).getOrElse(removeIdProjection)) + .toFuture() + } + + def getSessionData(sessionId: String): Future[Option[JsValue]] = { + val selector = Json.obj("session-id" -> sessionId) + val projection = Json.obj(_Id -> 0) + find(selector, Some(projection)) map (_.headOption) + } + + def getDataFromSession(sessionId: String, dataId: String): Future[Option[JsValue]] = { + getSessionData(sessionId) map { optData => + optData.flatMap { json => + (json \ dataId).asOpt[JsValue] + } + } + } + + def insertDataWithSession(sessionId: String, dataId: String, data: JsValue): Future[Option[JsValue]] = { + val selector: JsObject = Json.obj("session-id" -> sessionId) + val set: JsValue = selector ++ Json.obj(dataId -> data) ++ Json.obj( + "lastUpdatedTimestamp" -> Json.obj( + "$date" -> Instant.now.toEpochMilli + ).as[JsValue] + ) + val update: JsObject = Json.obj(f"$$set" -> set) + findAndUpdate(selector, update, fetchNewObject = true, upsert = true) + } + + def deleteDataWithSession(sessionId: String, dataId: String): Future[Option[JsValue]] = { + val selector: JsObject = Json.obj("session-id" -> sessionId) + val unset: JsValue = Json.obj(dataId -> "") + val update: JsObject = Json.obj(f"$$unset" -> unset) + findAndUpdate(selector, update) + } + + + private def findAndUpdate(selector: JsObject, update: JsObject, fetchNewObject: Boolean = false, upsert: Boolean = false) = { + collection + .findOneAndUpdate(selector, update, findOneAndUpdateOptions(upsert)) + .toFuture() + .map(asOption) + } + + def insert(document: JsObject): Future[InsertOneResult] = { + collection + .insertOne(document) + .toFuture() + } + + def drop(): Future[Void] = { + collection + .drop() + .toFuture() + } + +} + + +object SessionDataRepository { + + object IndexType { + def ascending: Int = 1 + + def descending: Int = -1 + } + + def asOption(o: JsObject): Option[JsValue] = o.result.toOption.flatMap(Option(_)) + + implicit def toBson(doc: JsObject): Bson = Document.parse(doc.toString()) + + val lastUpdatedTimestampKey = "lastUpdatedTimestamp" + + val sessionIdIndex: IndexModel = IndexModel( + Json.obj("session-id" -> IndexType.ascending), + new IndexOptions() + .name("sessionIdIndex") + .unique(true) + ) + + def ttlIndex(ttlLengthSeconds: Long): IndexModel = new IndexModel( + Json.obj(lastUpdatedTimestampKey -> IndexType.ascending), + new IndexOptions() + .name("sessionDataExpires") + .unique(false) + .expireAfter(ttlLengthSeconds, TimeUnit.SECONDS) + ) + + val _Id = "_id" + +} diff --git a/conf/application.conf b/conf/application.conf index 3c3a171e..43076f9b 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -92,8 +92,9 @@ play.http.errorHandler = "uk.gov.hmrc.play.bootstrap.backend.http.JsonErrorHandl mongodb { uri = "mongodb://localhost:27017/itsa" - timeToLiveSeconds = 2592000 - throttleTimeToLiveSeconds = 3600 + timeToLiveSeconds = 2592000 # 30 Days + sessionTimeToLiveSeconds = 43200 # 12 Hours + throttleTimeToLiveSeconds = 3600 # 1 Hour } feature-switching { diff --git a/it/test/repositories/SessionDataRepositorySpec.scala b/it/test/repositories/SessionDataRepositorySpec.scala new file mode 100644 index 00000000..1210e764 --- /dev/null +++ b/it/test/repositories/SessionDataRepositorySpec.scala @@ -0,0 +1,170 @@ +/* + * Copyright 2018 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package repositories + +import helpers.IntegrationTestConstants.testArn +import org.scalatest.concurrent.ScalaFutures.convertScalaFuture +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpecLike +import org.scalatest.{BeforeAndAfterEach, OptionValues} +import org.scalatestplus.play.guice.GuiceOneAppPerSuite +import play.api.libs.json.{JsObject, JsValue, Json} +import play.api.test.Helpers.{await, defaultAwaitTimeout} +import uk.gov.hmrc.mongo.MongoUtils + +import scala.concurrent.ExecutionContext.Implicits._ +import scala.concurrent.Future + +class SessionDataRepositorySpec extends AnyWordSpecLike with Matchers with OptionValues with GuiceOneAppPerSuite with BeforeAndAfterEach { + + val testSessionDataRepository: SessionDataRepository = app.injector.instanceOf[SessionDataRepository] + + override def beforeEach(): Unit = { + await(testSessionDataRepository.drop()) + await(MongoUtils.ensureIndexes(testSessionDataRepository.collection, testSessionDataRepository.indexes, replaceIndexes = true)) + } + + val testSessionId: String = "testSessionIdOne" + val testDataId: String = "testDataId" + val testData: JsObject = Json.obj("testDataIdKey" -> "testDataIdValue") + + + def testDocument(sessionId : String = testSessionId): JsObject = Json.obj( + "session-id" -> sessionId, + "testDataIdOne" -> Json.obj( + "testDataIdOneKey" -> "testDataIdOneValue" + ), + "testDataIdTwo" -> Json.obj( + "testDataIdTwoKey" -> "testDataIdTwoValue" + ) + ) + + class Setup(documents: JsObject*) { + await(Future.sequence(documents.map(testSessionDataRepository.insert))) + } + + "getDataFromSession" should { + "return the data relating to the dataId" when { + "a document with the session and dataId is found" in new Setup(testDocument()) { + testSessionDataRepository.getDataFromSession(testSessionId, "testDataIdOne").futureValue shouldBe Some(Json.obj( + "testDataIdOneKey" -> "testDataIdOneValue" + )) + } + } + "return none" when { + "no document with the session was found" in new Setup(testDocument("testSessionIdTwo")) { + testSessionDataRepository.getDataFromSession(testSessionId, "testDataIdOne").futureValue shouldBe None + } + "a document with the session was found but did not contain dataId" in new Setup(testDocument()) { + testSessionDataRepository.getDataFromSession(testSessionId, "testDataIdThree").futureValue shouldBe None + } + } + } + + "getSessionData" should { + "return the data relating to the session" when { + "a document with the session is found" in new Setup(testDocument()) { + testSessionDataRepository.getSessionData(testSessionId).futureValue shouldBe Some(testDocument()) + } + } + "return None" when { + "no document with the session was found" in new Setup(testDocument("testSessionIdTwo")) { + testSessionDataRepository.getSessionData(testSessionId).futureValue shouldBe None + } + } + } + + "insertDataWithSession" should { + "upsert the data relating to the session" when { + "there is no document with the session" in new Setup(testDocument("testSessionIdTwo")) { + await(testSessionDataRepository.insertDataWithSession(testSessionId, testDataId, testData)) + + val optionalData: Option[JsValue] = testSessionDataRepository.getSessionData(testSessionId).futureValue + optionalData.isDefined shouldBe true + val data: JsValue = optionalData.get + (data \ "session-id").asOpt[String] shouldBe Some(testSessionId) + (data \ testDataId).asOpt[JsObject] shouldBe Some(testData) + (data \ "lastUpdatedTimestamp").isDefined shouldBe true + } + "there is a document with the session but does not contain the dataId" in new Setup(testDocument()) { + await(testSessionDataRepository.insertDataWithSession(testSessionId, testDataId, testData)) + + val optionalData: Option[JsValue] = testSessionDataRepository.getSessionData(testSessionId).futureValue + optionalData.isDefined shouldBe true + val data: JsValue = optionalData.get + (data \ "session-id").asOpt[String] shouldBe Some(testSessionId) + (data \ testDataId).asOpt[JsObject] shouldBe Some(testData) + (data \ "lastUpdatedTimestamp").isDefined shouldBe true + (data \ "testDataIdOne" \ "testDataIdOneKey").asOpt[String] shouldBe Some("testDataIdOneValue") + (data \ "testDataIdTwo" \ "testDataIdTwoKey").asOpt[String] shouldBe Some("testDataIdTwoValue") + } + + "there is a document with the session and the dataId" in new Setup(testDocument()) { + await(testSessionDataRepository.insertDataWithSession(testSessionId, "testDataIdOne", testData)) + + val optionalData: Option[JsValue] = testSessionDataRepository.getSessionData(testSessionId).futureValue + optionalData.isDefined shouldBe true + val data: JsValue = optionalData.get + (data \ "session-id").asOpt[String] shouldBe Some(testSessionId) + (data \ "lastUpdatedTimestamp").isDefined shouldBe true + (data \ "testDataIdOne").asOpt[JsObject] shouldBe Some(testData) + (data \ "testDataIdTwo" \ "testDataIdTwoKey").asOpt[String] shouldBe Some("testDataIdTwoValue") + } + } + } + + "deleteDataWithSession" when { + "the document was found" should { + "update the document so that it no longer contains the specified key" when { + "The document contains the requested key" in new Setup(testDocument()) { + testSessionDataRepository.deleteDataWithSession(testSessionId, "testDataIdOne").futureValue.isDefined shouldBe true + + val optionalData: Option[JsValue] = testSessionDataRepository.getSessionData(testSessionId).futureValue + optionalData.isDefined shouldBe true + val data: JsValue = optionalData.get + (data \ "session-id").asOpt[String] shouldBe Some(testSessionId) + (data \ "testDataIdOne").isDefined shouldBe false + (data \ "testDataIdTwo").isDefined shouldBe true + } + "The document does not contain the requested key" in new Setup(testDocument()) { + testSessionDataRepository.deleteDataWithSession(testSessionId, "testDataIdThree").futureValue.isDefined shouldBe true + + val optionalData: Option[JsValue] = testSessionDataRepository.getSessionData(testSessionId).futureValue + optionalData.isDefined shouldBe true + val data: JsValue = optionalData.get + (data \ "session-id").asOpt[String] shouldBe Some(testSessionId) + (data \ "testDataIdOne").isDefined shouldBe true + (data \ "testDataIdTwo").isDefined shouldBe true + (data \ "testDataIdThree").isDefined shouldBe false + } + } + } + + "the document is not found" should { + "return no document" in new Setup(testDocument()) { + testSessionDataRepository.deleteDataWithSession(testSessionId + "-2", "testDataIdOne").futureValue shouldBe None + + val optionalData: Option[JsValue] = testSessionDataRepository.getSessionData(testSessionId).futureValue + optionalData.isDefined shouldBe true + val data: JsValue = optionalData.get + (data \ "session-id").asOpt[String] shouldBe Some(testSessionId) + (data \ "testDataIdOne").isDefined shouldBe true + (data \ "testDataIdTwo").isDefined shouldBe true + } + } + } +}