Skip to content

Commit

Permalink
Merge pull request #264 from hmrc/ITSASU-2946
Browse files Browse the repository at this point in the history
[ITSASU-2946] - Create Session Data Repository
  • Loading branch information
AlexRimmerHMRC authored Mar 12, 2024
2 parents bfe5401 + 7d59bc4 commit 9586e97
Show file tree
Hide file tree
Showing 4 changed files with 338 additions and 2 deletions.
2 changes: 2 additions & 0 deletions app/config/MicroserviceAppConfig.scala
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ trait AppConfig {
val configuration: Configuration

val timeToLiveSeconds: Int
val sessionTimeToLiveSeconds: Int
val throttleTimeToLiveSeconds: Int

val mongoUri: String
Expand Down Expand Up @@ -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

}
163 changes: 163 additions & 0 deletions app/repositories/SessionDataRepository.scala
Original file line number Diff line number Diff line change
@@ -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"

}
5 changes: 3 additions & 2 deletions conf/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
170 changes: 170 additions & 0 deletions it/test/repositories/SessionDataRepositorySpec.scala
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}

0 comments on commit 9586e97

Please sign in to comment.