diff --git a/backend-services/feed-service/.gitignore b/backend-services/feed-service/.gitignore index dce73038415f7e68f232c49cefe21ad58a9056e7..28bb3ff68c708d613d3eab78cb7eee780612f77c 100644 --- a/backend-services/feed-service/.gitignore +++ b/backend-services/feed-service/.gitignore @@ -7,3 +7,11 @@ target /.project /.settings /RUNNING_PID + +# VSCode +.vscode + +# Metals (Scala extension) +.metals +.bloop +metals.sbt diff --git a/backend-services/feed-service/app/controllers/DailyController.scala b/backend-services/feed-service/app/controllers/DailyController.scala new file mode 100644 index 0000000000000000000000000000000000000000..a63fcf3a1d107f8d99c6abda5a07586094311108 --- /dev/null +++ b/backend-services/feed-service/app/controllers/DailyController.scala @@ -0,0 +1,206 @@ +package controllers + +import javax.inject._ +import play.api.mvc._ +import play.api.libs.json.{JsValue, JsLookupResult} + +import models.{Daily} +import models.actions.{AuthenticatedUserAction, AuthenticationRequest} +import models.exceptions.{ConflictException, NotFoundException, InvalidRequestBodyException, InvalidQueryParameterException} + +import scala.concurrent.TimeoutException +import org.bson.types.ObjectId + +/** + * This controller handles all the Daily endpoints. + */ +@Singleton +class DailyController @Inject()(val controllerComponents: ControllerComponents, authenticatedUserAction: AuthenticatedUserAction) + extends BaseController { + + /** + * Create an Action to fetch all the Dailies in the DB. + */ + def getAll() = authenticatedUserAction { + println("DailyController:getAll") + + try { + val result: Seq[Daily] = Daily.getAllDailiesAsync() + val jsonResult: JsValue = Daily.toJson(result) + Ok(jsonResult) + } catch { + case _: TimeoutException => BadRequest("Request timed out") + case _: Throwable => BadRequest("Exception raised") + } + } + + /** + * Create an Action to fetch the user's Dailies in the DB. + * + * @param userId The ID of the user to get the dailies for. + */ + def getUserDailies(userId: String) = authenticatedUserAction { + println("DailyController:getUserDailies") + + try { + if (!ObjectId.isValid(userId)) throw new InvalidQueryParameterException("Invalid query parameter ID format: userId") + + val result: Seq[Daily] = Daily.getUserDailiesAsync(new ObjectId(userId)) + val jsonResult: JsValue = Daily.toJson(result) + Ok(jsonResult) + } catch { + case _: TimeoutException => BadRequest("Request timed out") + case ex: InvalidQueryParameterException => BadRequest(ex.getMessage()) + case _: Throwable => BadRequest("Exception raised") + } + } + + /** + * Create an Action to fetch the user's Feed. + * + * @param userId The ID of the user to get the feed for. + */ + def getUserFeed(userId: String) = authenticatedUserAction { implicit request: AuthenticationRequest[AnyContent] => + println("DailyController:getUserFeed") + + try { + if (!ObjectId.isValid(userId)) throw new InvalidRequestBodyException("Invalid query parameter ID format: userId") + + val result: Seq[Daily] = Daily.getUserFeedAsync(new ObjectId(userId), request.jwt) + val jsonResult: JsValue = Daily.toJson(result) + Ok(jsonResult) + } catch { + case _: TimeoutException => BadRequest("Request timed out") + case ex: InvalidQueryParameterException => BadRequest(ex.getMessage()) + case _: Throwable => BadRequest("Exception raised") + } + } + + /** + * Create an Action to create a Daily. + */ + def create() = authenticatedUserAction { implicit request: AuthenticationRequest[AnyContent] => + println("DailyController:create") + + try { + val (userId, questionId, content) = fetchCreateRequestBody(request.body) + + val result: Daily = Daily.createDailyAsync(userId, questionId, content) + val jsonResult: JsValue = Daily.toJson(result) + Ok(jsonResult) + } catch { + case _: TimeoutException => BadRequest("Request timed out") + case ex: InvalidRequestBodyException => BadRequest(ex.getMessage()) + case _: Throwable => BadRequest("Exception raised") + } + } + + /** + * Create an Action to like a Daily. + */ + def like() = authenticatedUserAction { implicit request: AuthenticationRequest[AnyContent] => + println("DailyController:like") + + try { + val (dailyId, likerId) = fetchLikeRequestBody(request.body) + + Daily.likeAsync(dailyId, likerId, request.jwt) + Ok("Daily liked.") + } catch { + case _: TimeoutException => BadRequest("Request timed out") + case ex: InvalidRequestBodyException => BadRequest(ex.getMessage()) + case ex: ConflictException => BadRequest(ex.getMessage()) + case ex: NotFoundException => BadRequest(ex.getMessage()) + case _: Throwable => BadRequest("Exception raised") + } + } + + /** + * Create an Action to unlike a Daily. + */ + def unlike() = authenticatedUserAction { implicit request: AuthenticationRequest[AnyContent] => + println("DailyController:unlike") + + try { + val (dailyId, likerId) = fetchLikeRequestBody(request.body) + + Daily.unlikeAsync(dailyId, likerId, request.jwt) + Ok("Daily unliked.") + } catch { + case _: TimeoutException => BadRequest("Request timed out") + case ex: InvalidRequestBodyException => BadRequest(ex.getMessage()) + case ex: ConflictException => BadRequest(ex.getMessage()) + case ex: NotFoundException => BadRequest(ex.getMessage()) + case _: Throwable => BadRequest("Exception raised") + } + } + + /** + * Fetch the needed values from the request body for the creating a Daily endpoint. + * + * @param requestBody The request's body. + */ + def fetchCreateRequestBody(requestBody: AnyContent): (ObjectId, ObjectId, String) = { + if (!requestBody.asJson.isDefined) throw new InvalidRequestBodyException("Request body must be in JSON format.") + + val bodyJson = requestBody.asJson.get + + val userId: ObjectId = fetchJsonBodyObjectId(bodyJson, "userId") + val questionId: ObjectId = fetchJsonBodyObjectId(bodyJson, "questionId") + val content: String = fetchJsonBodyString(bodyJson, "content") + + (userId, questionId, content) + } + + /** + * Fetch the needed values from the request body for the liking/unliking a Daily endpoint. + * + * @param requestBody The request's body. + */ + def fetchLikeRequestBody(requestBody: AnyContent): (ObjectId, ObjectId) = { + if (!requestBody.asJson.isDefined) throw new InvalidRequestBodyException("Request body must be in JSON format.") + + val bodyJson = requestBody.asJson.get + + val dailyId: ObjectId = fetchJsonBodyObjectId(bodyJson, "dailyId") + val likerId: ObjectId = fetchJsonBodyObjectId(bodyJson, "likerId") + + (dailyId, likerId) + } + + /** + * Fetch the value of the given field name from the JSON. + * + * @param bodyJson The JSON. + * @param fieldName The field name. + */ + def fetchJsonBodyValue(bodyJson: JsValue, fieldName: String): JsValue = { + val value: JsLookupResult = (bodyJson \ fieldName) + if (!value.isDefined) throw new InvalidRequestBodyException("Missing parameter: " + fieldName) + value.get + } + + /** + * Fetch the String value of the field name from the JSON. + * + * @param bodyJson The JSON. + * @param fieldName The field name. + */ + def fetchJsonBodyString(bodyJson: JsValue, fieldName: String): String = { + fetchJsonBodyValue(bodyJson, fieldName).as[String] + } + + /** + * Fetch the ObjectId value of the field name from the JSON. + * + * @param bodyJson The JSON. + * @param fieldName The field name. + * + * @throws InvalidRequestBodyException if the value is not a valid ID. + */ + def fetchJsonBodyObjectId(bodyJson: JsValue, fieldName: String): ObjectId = { + val value: String = fetchJsonBodyValue(bodyJson, fieldName).as[String] + if (!ObjectId.isValid(value)) throw new InvalidRequestBodyException("Invalid ID format: " + fieldName) + new ObjectId(value) + } +} diff --git a/backend-services/feed-service/app/controllers/HomeController.scala b/backend-services/feed-service/app/controllers/HomeController.scala index 60a6e853619c672b573c0e1b974e44f16f86bf5d..690b3253a378e05dc38d82e2db65a68318237947 100644 --- a/backend-services/feed-service/app/controllers/HomeController.scala +++ b/backend-services/feed-service/app/controllers/HomeController.scala @@ -1,7 +1,6 @@ package controllers import javax.inject._ -import play.api._ import play.api.mvc._ /** @@ -18,7 +17,7 @@ class HomeController @Inject()(val controllerComponents: ControllerComponents) e * will be called when the application receives a `GET` request with * a path of `/`. */ - def index() = Action { implicit request: Request[AnyContent] => + def index() = Action { Ok("OK") } } diff --git a/backend-services/feed-service/app/controllers/MongoTestController.scala b/backend-services/feed-service/app/controllers/MongoTestController.scala deleted file mode 100644 index c086129a767005645bf5a1b040cb5375a3f0914e..0000000000000000000000000000000000000000 --- a/backend-services/feed-service/app/controllers/MongoTestController.scala +++ /dev/null @@ -1,49 +0,0 @@ -package controllers - -import javax.inject._ -import play.api._ -import play.api.mvc._ - -import models.MongoDBClient -import org.mongodb.scala.{Document} -import scala.concurrent.ExecutionContext.Implicits.global -import scala.concurrent.{Future, Await, TimeoutException} -import scala.concurrent.duration._ - -/** - * This controller creates an `Action` to handle HTTP MongoDB requests. - */ -@Singleton -class MongoTestController @Inject()(val controllerComponents: ControllerComponents) extends BaseController { - - /** - * Create an Action to test the Mongo Client. - */ - def index() = Action { implicit request: Request[AnyContent] => - println("MongoController") - - var database_name = "{DATABASE_NAME}" - var collection_name = "{COLLECTION_NAME}" - - var mongo = new MongoDBClient() - - // Sequentially waits for Future objects to complete before calling next method - val result: Future[Seq[Document]] = for { - db <- mongo.getDatabase(database_name) - collection <- mongo.getCollection(db, collection_name) - findResult <- mongo.find(collection) - } yield findResult - - - try { - // Wait for 10 seconds for the Future to complete - val resultCompleted = Await.result(result, 4.seconds) - - val jsonResult: Seq[String] = resultCompleted.map(doc => doc.toJson()) - Ok(jsonResult.toString()) - } catch { - case e: TimeoutException => - BadRequest("Request timed out") - } - } -} diff --git a/backend-services/feed-service/app/controllers/TestController.scala b/backend-services/feed-service/app/controllers/TestController.scala new file mode 100644 index 0000000000000000000000000000000000000000..123a9d0ebcd253ce5edbce811c69a9d8cc6e3b9d --- /dev/null +++ b/backend-services/feed-service/app/controllers/TestController.scala @@ -0,0 +1,39 @@ +package controllers + +import models.actions.AuthenticatedUserAction + +import javax.inject._ +import play.api.mvc._ +import play.api.libs.json.{JsString, JsArray} +import play.api.libs.json.JsBoolean + +/** + * This controller creates an `Action` to handle HTTP requests to the + * application's test page. + */ +@Singleton +class TestController @Inject()(val controllerComponents: ControllerComponents, authenticatedUserAction: AuthenticatedUserAction) + extends BaseController { + + def getFriends(userId: String) = authenticatedUserAction { + println("TestController:getFriends") + println(s"Fetching friends for User with ID {$userId}") + + val response = JsArray( + Seq( + JsString("641128f7e80bcd1ba39d04ae"), + JsString("641128f7e80bcd1ba39d04af"), + JsString("641128f7e80bcd1ba39d04aa") + ) + ) + + Ok(response) + } + + def verifyUser(userId: String) = authenticatedUserAction { + println("TestController:verifyUser") + println(s"Verifying User with ID {$userId}") + + Ok(JsBoolean(true)) + } +} diff --git a/backend-services/feed-service/app/models/Daily.scala b/backend-services/feed-service/app/models/Daily.scala new file mode 100644 index 0000000000000000000000000000000000000000..b4eda23e7fdcc1ffeb790a951e133087066f2f9d --- /dev/null +++ b/backend-services/feed-service/app/models/Daily.scala @@ -0,0 +1,200 @@ +package models + +import repositories.{DailyRepository} +import models.exceptions.{ConflictException, NotFoundException} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.{Future, Await} +import scala.concurrent.duration._ + +import org.bson.types.ObjectId +import java.util.Date +import java.time.Instant +import java.text.SimpleDateFormat + +import play.api.libs.json.{Json, JsValue, JsString, JsObject, JsArray} + +import org.bson.{BsonWriter, BsonReader, BsonType} +import org.bson.codecs.{Codec, EncoderContext, DecoderContext} +import org.bson.conversions.Bson +import org.mongodb.scala.model.Updates + +case class Daily( + id: Option[ObjectId], + userId: ObjectId, + questionId: ObjectId, + content: String, + usersLiked: Seq[ObjectId], + createdAt: Date, + updatedAt: Date +) + +object Daily { + val dailyRepo = new DailyRepository() + + def createDailyAsync( + userId: ObjectId, + questionId: ObjectId, + content: String, + timeout: Int = 4 + ): Daily = { + val now: Date = Date.from(Instant.now()) + val daily: Daily = Daily(None, userId, questionId, content, Seq.empty[ObjectId], now, now) + val future: Future[Daily] = dailyRepo.insertDaily(daily) + Await.result[Daily](future, timeout.seconds) + } + + def getAllDailiesAsync(timeout: Int = 4): Seq[Daily] = { + val future: Future[Seq[Daily]] = dailyRepo.getAll() + Await.result[Seq[Daily]](future, timeout.seconds) + } + + def getUserDailiesAsync(userId: ObjectId, timeout: Int = 4): Seq[Daily] = { + val future: Future[Seq[Daily]] = dailyRepo.getByValue("user_id", userId) + Await.result[Seq[Daily]](future, timeout.seconds) + } + + def getUserFeedAsync(userId: ObjectId, jwt: String, timeout: Int = 4): Seq[Daily] = { + // Sequentially waits for Future objects to complete before calling next method + val result: Future[Seq[Daily]] = for { + friends: Seq[ObjectId] <- User.getUserFriends(userId, jwt) + feed: Seq[Daily] <- dailyRepo.getByValues[ObjectId]("user_id", friends) + } yield feed + + Await.result[Seq[Daily]](result, timeout.seconds) + } + + def likeAsync(dailyId: ObjectId, likerId: ObjectId, jwt: String, timeout: Int = 4): Unit = { + val result: Future[Unit] = for { + // Fetch Daily from given ID + daily: Daily <- { + dailyRepo.getById(dailyId).map((oDaily: Option[Daily]) => { + if (oDaily.isEmpty) + throw new NotFoundException("No daily with given ID.") + else + oDaily.get + }) + } + + // Check user has not already liked the Daily + _ = if (daily.usersLiked.contains(likerId)) throw new ConflictException("User has already liked this Daily.") + + // Check user with given ID exists + _ <- User.userExists(likerId, jwt).map((exists: Boolean) => if (!exists) throw new NotFoundException("No user with given ID.")) + + like: Unit <- { + val updatedUsersLiked: Seq[ObjectId] = daily.usersLiked :+ likerId + val update: Bson = Updates.set("usersLiked", updatedUsersLiked) + + dailyRepo.updateOne(dailyId, Seq(update)) + } + } yield like + + Await.result[Unit](result, timeout.seconds) + } + + def unlikeAsync(dailyId: ObjectId, likerId: ObjectId, jwt: String, timeout: Int = 4): Unit = { + val result: Future[Unit] = for { + // Fetch Daily from given ID + daily: Daily <- { + dailyRepo.getById(dailyId).map((oDaily: Option[Daily]) => { + if (oDaily.isEmpty) + throw new NotFoundException("No daily with given ID.") + else + oDaily.get + }) + } + + // Check user with given ID exists + _ <- User.userExists(likerId, jwt).map((exists: Boolean) => if (!exists) throw new NotFoundException("No user with given ID.")) + + // Check user has liked the Daily + _ = if (!daily.usersLiked.contains(likerId)) throw new ConflictException("User has not liked this Daily.") + + unlike: Unit <- { + val updatedUsersLiked: Seq[ObjectId] = daily.usersLiked.filterNot(_ == likerId) + val update: Bson = Updates.set("usersLiked", updatedUsersLiked) + + dailyRepo.updateOne(dailyId, Seq(update)) + } + } yield unlike + + Await.result[Unit](result, timeout.seconds) + } + + // Convert from Daily object to JSON (serializing to JSON) + def toJson(daily: Daily): JsValue = { + val usersLikedAsJsStrings: Seq[JsString] = daily.usersLiked.map[JsString](id => JsString(id.toString())) + + val dateFormat: SimpleDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + val formattedCreatedAt: String = dateFormat.format(daily.createdAt) + val formattedUpdatedAt: String = dateFormat.format(daily.updatedAt) + + val dailyJson = Seq( + "id" -> JsString(daily.id.getOrElse("").toString()), + "userId" -> JsString(daily.userId.toString()), + "questionId" -> JsString(daily.questionId.toString()), + "content" -> JsString(daily.content), + "usersLiked" -> JsArray(usersLikedAsJsStrings), + "createdAt" -> JsString(formattedCreatedAt), + "updatedAt" -> JsString(formattedUpdatedAt) + ) + + Json.toJson[JsObject](JsObject(dailyJson)) + } + + // Convert from Daily set to JSON (serializing to JSON) + def toJson(dailies: Seq[Daily]): JsValue = { + val dailiesJson: Seq[JsValue] = dailies.map(daily => Daily.toJson(daily)) + + Json.toJson[JsArray](JsArray(dailiesJson)) + } + + def toString(daily: Daily): String = + return s"Daily(${daily.id.toString()}, ${daily.userId.toString()}, ${daily.questionId.toString()}, ${daily.content}, ${daily.usersLiked.toString()})" + + // Codec instance for serialising/deserialising type User to or from BSON. + // Implicit keyword lets Scala compiler automatically insert this into the code where it's needed. + implicit val codec: Codec[Daily] = new Codec[Daily] { + override def encode(writer: BsonWriter, value: Daily, encoderContext: EncoderContext): Unit = { + writer.writeStartDocument() + writer.writeObjectId("user_id", value.userId) + writer.writeObjectId("question_id", value.questionId) + writer.writeString("content", value.content) + writer.writeStartArray("usersLiked") + value.usersLiked.foreach(writer.writeObjectId) + writer.writeEndArray() + writer.writeDateTime("createdAt", value.createdAt.getTime()) + writer.writeDateTime("updatedAt", value.updatedAt.getTime()) + writer.writeEndDocument() + } + + override def decode(reader: BsonReader, decoderContext: DecoderContext): Daily = { + reader.readStartDocument() + val id = reader.readObjectId("_id") + val userId = reader.readObjectId("user_id") + val questionId = reader.readObjectId("question_id") + val content = reader.readString("content") + val usersLiked = { + reader.readName("usersLiked") + reader.readStartArray() + val buffer = collection.mutable.Buffer.empty[ObjectId] + while (reader.readBsonType() != BsonType.END_OF_DOCUMENT) { + buffer += reader.readObjectId() + } + reader.readEndArray() + buffer.toSeq + } + val createdAt = reader.readDateTime("createdAt") + val updatedAt = reader.readDateTime("updatedAt") + reader.readEndDocument() + + val createdAtDate: Date = Date.from(Instant.ofEpochMilli(createdAt)) + val updatedAtDate: Date = Date.from(Instant.ofEpochMilli(updatedAt)) + + Daily(Some(id), userId, questionId, content, usersLiked, createdAtDate, updatedAtDate) + } + + override def getEncoderClass: Class[Daily] = classOf[Daily] + } +} diff --git a/backend-services/feed-service/app/models/HttpCall.scala b/backend-services/feed-service/app/models/HttpCall.scala new file mode 100644 index 0000000000000000000000000000000000000000..cef4e4294d427aa8fe5e0b3f9937fae214ebbc69 --- /dev/null +++ b/backend-services/feed-service/app/models/HttpCall.scala @@ -0,0 +1,59 @@ +package models + +import akka.actor.ActorSystem +import akka.stream.{Materializer, SystemMaterializer} +import play.api.libs.ws.ahc.{StandaloneAhcWSClient} +import play.api.libs.ws.StandaloneWSRequest +import play.api.libs.json.JsValue + +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global + + +object HttpCall { + + /** + * Fetches the response of a GET request to the given URL. + * + * @param url The URL of the request. + * + * @return a Future containing the response body as a JSON. + */ + def get( + url: String, + queryStringParameters: Seq[(String, String)] = Seq.empty[(String, String)], + jwt: String = "" + ): Future[JsValue] = { + // Create ActorSystem for thread and streaming management + implicit val system: ActorSystem = ActorSystem() + + // Materializer ensures streams are executed correctly and resources are managed efficiently + implicit val materializer: Materializer = SystemMaterializer(system).materializer + + // Create the standalone WS client + val wsClient: StandaloneAhcWSClient = StandaloneAhcWSClient() + + // Create base request + var request: StandaloneWSRequest = wsClient.url(url) + + // Add query parameters to request + request = request.addQueryStringParameters(queryStringParameters: _*) + + // Add JWT header to request, if supplied + request = if (!jwt.isEmpty()) request.addHttpHeaders(("Authorization", s"Bearer $jwt")) else request + + // Call API and fetch response + val response: Future[JsValue] = request.get().map(response => { + if (response.status > 399) throw new RuntimeException() + + val statusText: String = response.statusText + println(s"Got a response: $statusText") + response.body[JsValue] + }) + + // Close WSClient and terminate ActorSystem + response + .andThen { case _ => wsClient.close() } + .andThen { case _ => system.terminate() } + } +} diff --git a/backend-services/feed-service/app/models/MongoDBClient.scala b/backend-services/feed-service/app/models/MongoDBClient.scala deleted file mode 100644 index 96c5dd630909f964630b16097b7fb77cb7820711..0000000000000000000000000000000000000000 --- a/backend-services/feed-service/app/models/MongoDBClient.scala +++ /dev/null @@ -1,96 +0,0 @@ -package models - -import utils.ConfigHelper - -import org.mongodb.scala.{MongoClient, MongoDatabase, MongoCollection, Document, FindObservable, Observer, Observable} -import org.mongodb.scala.model.{Filters, Projections, Sorts} -import org.bson.conversions.Bson - -import scala.concurrent.ExecutionContext.Implicits.global -import scala.concurrent.Future -import scala.util.{Success, Failure, Try} - - -/** - * A MongoDB client for connecting to and interacting with a MongoDB database. - * - * @constructor Creates a new instance of the MongoDBClient class. - */ -class MongoDBClient { - private val mongoUri = ConfigHelper.getString("mongodb.uri") - - // Connects to a MongoDB Client when class is constructed - private var client: MongoClient = this.connect() - - /** - * Connects to a MongoDB database using the default MongoDB connection string. - * - * @return A MongoClient instance. - */ - def connect(): MongoClient = { - MongoClient(mongoUri) - } - - /** - * Gets a reference to a MongoDB database. - * - * @param database The name of the database to retrieve. - * @return A Future containing a MongoDatabase instance. - */ - def getDatabase(database: String): Future[MongoDatabase] = Future { - client.getDatabase(database) - } - - /** - * Gets a reference to a MongoDB collection within a database. - * - * @param database The MongoDatabase instance containing the desired collection. - * @param collection The name of the collection to retrieve. - * @return A Future containing a MongoCollection instance. - */ - def getCollection(database: MongoDatabase, collection: String): Future[MongoCollection[Document]] = Future { - database.getCollection(collection) - } - - /** - * Finds documents in a MongoDB collection. - * - * @param collection The MongoCollection instance to search. - * @param filter A Bson filter to apply to the search. - * @param projection A Bson projection to apply to the search. - * @param sort A Bson sort to apply to the search. - * @return A Future containing a sequence of matching documents as Documents. - */ - def find( - collection: MongoCollection[Document], - filter: Bson = Filters.empty(), - projection: Bson = Projections.excludeId(), - sort: Bson = Sorts.ascending("_id") - ): Future[Seq[Document]] = { - collection.find(filter) - .projection(projection) - .sort(sort) - .toFuture() - } - - /** - * Inserts a document into a MongoDB collection. - * - * @param collection The MongoCollection instance to insert into. - * @param document The document to insert. - * @return A Future containing the ID of the inserted document. - * @throws RuntimeException if the insertion was not acknowledged by the database. - */ - def insertOne(collection: MongoCollection[Document], document: Document): Future[String] = { - val futureResult = collection.insertOne(document).toFuture() - - futureResult.map(result => { - if (result.wasAcknowledged()) { - // Grab the generated ID of the inserted document - result.getInsertedId().asObjectId.getValue().toString() - } else { - throw new RuntimeException("Insertion was not acknowledged") - } - }) - } -} diff --git a/backend-services/feed-service/app/models/User.scala b/backend-services/feed-service/app/models/User.scala new file mode 100644 index 0000000000000000000000000000000000000000..8556770ce2776d6ad9ca035ecba745f325d51bb7 --- /dev/null +++ b/backend-services/feed-service/app/models/User.scala @@ -0,0 +1,28 @@ +package models + +import models.HttpCall + +import play.api.libs.json.JsValue +import org.bson.types.ObjectId +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global + + +object User { + def getUserFriends(userId: ObjectId, jwt: String): Future[Seq[ObjectId]] = { + val url: String = "http://localhost:9000/test/getFriends" + val queryStringParameters: Seq[(String, String)] = Seq(("userId", userId.toString())) + + HttpCall.get(url, queryStringParameters, jwt).map[Seq[ObjectId]]((json: JsValue) => { + val sequence: Seq[String] = json.as[Seq[String]] + sequence.map[ObjectId](new ObjectId(_)) + }) + } + + def userExists(userId: ObjectId, jwt: String): Future[Boolean] = { + val url: String = "http://localhost:9000/test/verifyUser" + val queryStringParameters: Seq[(String, String)] = Seq(("userId", userId.toString())) + + HttpCall.get(url, queryStringParameters, jwt).map[Boolean](_.as[Boolean]) + } +} diff --git a/backend-services/feed-service/app/models/actions/AuthenticatedUserAction.scala b/backend-services/feed-service/app/models/actions/AuthenticatedUserAction.scala new file mode 100644 index 0000000000000000000000000000000000000000..b4351c78237b548275f97be4d85dd3d923479ea4 --- /dev/null +++ b/backend-services/feed-service/app/models/actions/AuthenticatedUserAction.scala @@ -0,0 +1,35 @@ +package models.actions + +import models.actions.AuthenticationRequest + +import play.api.mvc.{ActionBuilder, BodyParsers, Request, Result, AnyContent, Cookie} +import play.filters.csrf.CSRF + +import javax.inject.Inject +import scala.concurrent.{Future, ExecutionContext} + + +/** + * The authentication action builder that combines the request transformation and filtering. + */ +class AuthenticatedUserAction @Inject()(authenticationTransformer: AuthenticationTransformer, authenticationFilter: AuthenticationFilter) + (implicit val parser: BodyParsers.Default, val executionContext: ExecutionContext) + extends ActionBuilder[AuthenticationRequest, AnyContent] { + + /** + * Invoke the main controller block, with the transformations and filtering middleware, and the CSRF token injection. + * + * @param request The incoming request. + * @param block The block of code to invoke. + * @return A future of the result. + */ + override def invokeBlock[A](request: Request[A], block: AuthenticationRequest[A] => Future[Result]): Future[Result] = { + val result: Future[Result] = (authenticationTransformer andThen authenticationFilter).invokeBlock(request, block) + + (result).map(_result => { + // Add CSRF token to response + val token = CSRF.getToken(request).map(_.value).getOrElse("") + _result.withHeaders("Csrf-Token" -> token).withCookies(Cookie(name = "PLAY_CSRF_TOKEN", value = token)) + }) + } +} diff --git a/backend-services/feed-service/app/models/actions/AuthenticationFilter.scala b/backend-services/feed-service/app/models/actions/AuthenticationFilter.scala new file mode 100644 index 0000000000000000000000000000000000000000..8b7b752ae310c539ac8beaaddeb1db6f38ba1074 --- /dev/null +++ b/backend-services/feed-service/app/models/actions/AuthenticationFilter.scala @@ -0,0 +1,29 @@ +package models.actions + +import models.actions.AuthenticationRequest +import play.api.mvc.{ActionFilter, Result, Results} + +import scala.concurrent.{Future, ExecutionContext} + +import javax.inject.Inject + + +/** + * The authentication action filter that verifies the request contains a user ID. + */ +class AuthenticationFilter @Inject() (implicit val executionContext: ExecutionContext) extends ActionFilter[AuthenticationRequest] { + + /** + * Determines whether to process a request. + * Decides whether to immediately intercept the request or continue processing the request. + * + * @param request The incoming request. + * @return An optional Forbidden Result with which to abort the request. + */ + override def filter[A](request: AuthenticationRequest[A]): Future[Option[Result]] = Future.successful { + if (!request.requesterId.isDefined) + Some(Results.Forbidden("Invalid JWT Token")) + else + None + } +} diff --git a/backend-services/feed-service/app/models/actions/AuthenticationRequest.scala b/backend-services/feed-service/app/models/actions/AuthenticationRequest.scala new file mode 100644 index 0000000000000000000000000000000000000000..6573cd1596c44d8778c4bdf9ceae0c4389d8a42b --- /dev/null +++ b/backend-services/feed-service/app/models/actions/AuthenticationRequest.scala @@ -0,0 +1,66 @@ +package models.actions + +import com.typesafe.config.ConfigFactory + +import play.api.mvc.{ActionTransformer, Request, WrappedRequest} + +import scala.concurrent.{Future, ExecutionContext} +import scala.util.Try +import org.bson.types.ObjectId + +import pdi.jwt.{JwtJson, JwtAlgorithm, JwtClaim} +import play.api.libs.json.Json + +import javax.inject.Inject + + +class AuthenticationRequest[A](val jwt: String, val requesterId: Option[ObjectId], request: Request[A]) + extends WrappedRequest[A](request) + + +/** + * The authentication action transformer that transforms the incoming base request to a user request. + */ +class AuthenticationTransformer @Inject() (implicit val executionContext: ExecutionContext) extends ActionTransformer[Request, AuthenticationRequest] { + + /** + * Transforms the existing request from a Request to UserRequest. + * + * @param request The incoming request. + * @return The new parameter to pass to the Action block. + */ + override def transform[A](request: Request[A]) = Future.successful { + val (jwt: String, requesterId: Option[ObjectId]) = processJWT(request) + new AuthenticationRequest(jwt, requesterId, request) + } + + /** + * Processes the JWT token by decoding and validating it. + * + * @param request The incoming request. + * @return The user ID specified in the JWT's payload. + */ + def processJWT[A](request: Request[A]): (String, Option[ObjectId]) = { + val privateKey = ConfigFactory.load().getString("jwt.privateKey") + + try { + val authHeader = request.headers.get("Authorization").get + val token = authHeader.substring(7) + println(s"JWT Token Received: $token") + + val payload: Try[JwtClaim] = JwtJson.decode(token, privateKey, Seq(JwtAlgorithm.HS256)) + + val content = payload.get.content + val jsonContent = Json.parse(content) + val requesterId = (jsonContent \ "userId").as[String] + + (token, Some(new ObjectId(requesterId))) + } + catch { + case ex: Throwable => { + println(s"JWT Error: $ex") + ("", None) + } + } + } +} diff --git a/backend-services/feed-service/app/models/exceptions/ConflictException.scala b/backend-services/feed-service/app/models/exceptions/ConflictException.scala new file mode 100644 index 0000000000000000000000000000000000000000..bf99cc11e61694842f0f160c76a889a78aab8507 --- /dev/null +++ b/backend-services/feed-service/app/models/exceptions/ConflictException.scala @@ -0,0 +1,3 @@ +package models.exceptions + +case class ConflictException(message: String) extends Exception(message) diff --git a/backend-services/feed-service/app/models/exceptions/InvalidObjectIdValueException.scala b/backend-services/feed-service/app/models/exceptions/InvalidObjectIdValueException.scala new file mode 100644 index 0000000000000000000000000000000000000000..582699b04c3ba49a911bd3ba544071d98b87afd2 --- /dev/null +++ b/backend-services/feed-service/app/models/exceptions/InvalidObjectIdValueException.scala @@ -0,0 +1,3 @@ +package models.exceptions + +case class InvalidObjectIdValueException(message: String) extends Exception(message) diff --git a/backend-services/feed-service/app/models/exceptions/InvalidQueryParameterException.scala b/backend-services/feed-service/app/models/exceptions/InvalidQueryParameterException.scala new file mode 100644 index 0000000000000000000000000000000000000000..7acff65ebbbea9e3a2b960db92535a160a270826 --- /dev/null +++ b/backend-services/feed-service/app/models/exceptions/InvalidQueryParameterException.scala @@ -0,0 +1,3 @@ +package models.exceptions + +case class InvalidQueryParameterException(message: String) extends Exception(message) diff --git a/backend-services/feed-service/app/models/exceptions/InvalidRequestBodyException.scala b/backend-services/feed-service/app/models/exceptions/InvalidRequestBodyException.scala new file mode 100644 index 0000000000000000000000000000000000000000..744142c69e270ad8edff0ecdad6880502552fd7e --- /dev/null +++ b/backend-services/feed-service/app/models/exceptions/InvalidRequestBodyException.scala @@ -0,0 +1,3 @@ +package models.exceptions + +case class InvalidRequestBodyException(message: String) extends Exception(message) diff --git a/backend-services/feed-service/app/models/exceptions/NotFoundException.scala b/backend-services/feed-service/app/models/exceptions/NotFoundException.scala new file mode 100644 index 0000000000000000000000000000000000000000..edd62c3f6713f21512da8db56d2c2f543f233a43 --- /dev/null +++ b/backend-services/feed-service/app/models/exceptions/NotFoundException.scala @@ -0,0 +1,3 @@ +package models.exceptions + +case class NotFoundException(message: String) extends Exception(message) diff --git a/backend-services/feed-service/app/repositories/DailyRepository.scala b/backend-services/feed-service/app/repositories/DailyRepository.scala new file mode 100644 index 0000000000000000000000000000000000000000..514860f212551d4ba8f6a6cbd670a19707dcfa2d --- /dev/null +++ b/backend-services/feed-service/app/repositories/DailyRepository.scala @@ -0,0 +1,31 @@ +package repositories + +import models.Daily +import utils.MongoConnection + +import com.typesafe.config.ConfigFactory +import org.bson.types.ObjectId + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + + +class DailyRepository extends Repository[Daily] ( + ConfigFactory.load().getString("mongo.feedService.db"), + ConfigFactory.load().getString("mongo.dailies.collection") +) { + /** + * Inserts a Daily record into the database. + * + * @return A Future containing the inserted Daily object with the generated ID. + */ + def insertDaily(daily: Daily): Future[Daily] = { + val result: Future[String] = MongoConnection.insertOne[Daily](collection, daily) + + // Return a Daily entity with the generated ID + result.flatMap[Daily](id => { + val updatedDaily: Daily = daily.copy(id = Some(new ObjectId(id))) + Future.successful(updatedDaily) + }) + } +} diff --git a/backend-services/feed-service/app/repositories/Repository.scala b/backend-services/feed-service/app/repositories/Repository.scala new file mode 100644 index 0000000000000000000000000000000000000000..4cb446e36575e453b3b100ed5d77b54e9ef9ac90 --- /dev/null +++ b/backend-services/feed-service/app/repositories/Repository.scala @@ -0,0 +1,102 @@ +package repositories + +import utils.ConfigHelper + +import utils.MongoConnection + +import org.mongodb.scala.{MongoClient, MongoDatabase, MongoCollection} +import org.mongodb.scala.model.Filters +import org.bson.types.ObjectId +import org.bson.conversions.Bson + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.{Future, Await} +import scala.concurrent.duration._ +import scala.reflect.ClassTag + + +/** + * The base repository class that provides basic CRUD operations for a MongoDB collection. + * + * @tparam T The type of objects stored in the collection. + * @param databaseName The name of the MongoDB database to use. + * @param collectionName The name of the MongoDB collection to use. + */ +class Repository[T: ClassTag](databaseName: String, collectionName: String) { + + /** + * Returns a reference to a MongoDB collection within a database. + * Once Future completes, collection reference is returned. + * + * @return a MongoCollection[T] object representing the collection. + * @throws TimeoutException if the Future doesn't complete within the 3 second timeout. + */ + protected val collection: MongoCollection[T] = { + val mongoUri: String = ConfigHelper.getString("mongodb.uri") + + val futureCollection: Future[MongoCollection[T]] = for { + client: MongoClient <- MongoConnection.getClient(mongoUri) + database: MongoDatabase <- MongoConnection.getDatabase(client, databaseName) + collection: MongoCollection[T] <- MongoConnection.getCollection[T](database, collectionName) + } yield collection: MongoCollection[T] + + Await.result[MongoCollection[T]](futureCollection, 3.seconds) + } + + /** + * Gets all the records in the collection. + * + * @return A Future containing a sequence of the collection's records. + */ + def getAll(): Future[Seq[T]] = { + MongoConnection.find[T](collection) + } + + /** + * Gets a record with the given ID in the collection. + * + * @return A Future containing an optional matching document. + */ + def getById(id: ObjectId): Future[Option[T]] = { + val filter: Bson = Filters.equal[ObjectId]("_id", id) + MongoConnection.find[T](collection, filter).map(_.headOption) + } + + /** + * Retrieves records from the collection that have the specified value for the given field name. + * + * @tparam V The type of the query value. + * @param field_name The name of the field to search for. + * @param value The value to search for. + * + * @return A Future containing a sequence of matching documents. + */ + def getByValue[V](field_name: String, value: V): Future[Seq[T]] = { + val filter: Bson = Filters.equal[V](field_name, value) + MongoConnection.find[T](collection, filter) + } + + /** + * Retrieves records from the collection that contain any of the specified values for the given field name. + * + * @tparam V The type of the query values. + * @param field_name The name of the field to search for. + * @param value A sequence of values to search for. + * + * @return A Future containing a sequence of matching documents. + */ + def getByValues[V](field_name: String, values: Seq[V]): Future[Seq[T]] = { + val filter: Bson = Filters.in[V](field_name, values: _*) + MongoConnection.find[T](collection, filter) + } + + /** + * Updates a document in the collection. + * + * @param documentId The ID of the document to update. + * @param updates A sequence of Bson documents defining the updates. + */ + def updateOne(documentId: ObjectId, updates: Seq[Bson]): Future[Unit] = { + MongoConnection.updateOne[T](collection, documentId, updates) + } +} diff --git a/backend-services/feed-service/app/utils/MongoCodecs.scala b/backend-services/feed-service/app/utils/MongoCodecs.scala new file mode 100644 index 0000000000000000000000000000000000000000..f755d934f1c9b77a513fd8f0bf4d0664ec74a984 --- /dev/null +++ b/backend-services/feed-service/app/utils/MongoCodecs.scala @@ -0,0 +1,26 @@ +package utils + +import models.Daily +import org.bson.codecs.Codec +import org.bson.codecs.configuration.{CodecProvider, CodecRegistry, CodecRegistries} + +import org.mongodb.scala.MongoClient + +object MongoCodecs { + // Define a custom CodecProvider that returns a Codec for the case classes + val customCodecProvider = new CodecProvider { + override def get[T](clazz: Class[T], registry: CodecRegistry): Codec[T] = { + if (clazz == classOf[Daily]) { + // If the class is the Daily case class, return the Daily codec + Daily.codec.asInstanceOf[Codec[T]] + } + else { + // If the class is not the User case class, return null + null + } + } + } + + // Create a CodecRegistry that includes the custom CodecProvider and the default codecs + val codecRegistry: CodecRegistry = CodecRegistries.fromProviders(customCodecProvider, MongoClient.DEFAULT_CODEC_REGISTRY) +} diff --git a/backend-services/feed-service/app/utils/MongoConnection.scala b/backend-services/feed-service/app/utils/MongoConnection.scala new file mode 100644 index 0000000000000000000000000000000000000000..04ca348a8010d87e0ee83621819b28d8a6b3b3ab --- /dev/null +++ b/backend-services/feed-service/app/utils/MongoConnection.scala @@ -0,0 +1,118 @@ +package utils + +import org.mongodb.scala.{MongoClient, MongoDatabase, MongoCollection} +import org.mongodb.scala.model.{Filters, Sorts} +import com.mongodb.client.result.{InsertOneResult, UpdateResult} + +import org.bson.conversions.Bson +import org.bson.types.ObjectId + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.reflect.ClassTag + +import utils.MongoCodecs + + +/** + * Mongo helper functions for connecting to and interacting with MongoDB clients, databases, and collections. + * + * All functions return a Future. + */ +object MongoConnection { + /** + * Connects to and gets a reference to a MongoDB client. + * + * @param mongoUri The connection string. + * @return A Future containing the MongoClient instance. + */ + def getClient(mongoUri: String): Future[MongoClient] = Future { + MongoClient(mongoUri) + } + + /** + * Gets a reference to a MongoDB database. + * + * @param database The name of the database to retrieve. + * @return A Future containing a MongoDatabase instance. + */ + def getDatabase(client: MongoClient, database: String): Future[MongoDatabase] = Future { + client.getDatabase(database).withCodecRegistry(MongoCodecs.codecRegistry) + } + + /** + * Gets a reference to a MongoDB collection within a database. + * + * @param database The MongoDatabase instance containing the desired collection. + * @param collection The name of the collection to retrieve. + * @return A Future containing a MongoCollection instance. + */ + def getCollection[T: ClassTag](database: MongoDatabase, collection: String): Future[MongoCollection[T]] = Future { + database.getCollection[T](collection) + } + + /** + * Finds documents in a MongoDB collection. + * + * @param collection The MongoCollection instance to search. + * @param filter A Bson filter to apply to the search. + * @param projection A Bson projection to apply to the search. + * @param sort A Bson sort to apply to the search. + * @return A Future containing a sequence of matching documents as Documents. + */ + def find[T: ClassTag]( + collection: MongoCollection[T], + filter: Bson = Filters.empty(), + sort: Bson = Sorts.ascending("_id"), + projection: Option[Bson] = None + ): Future[Seq[T]] = { + var result = collection.find[T](filter).sort(sort) + + result = if (projection.isDefined == true) + result.projection(projection.get) + else + result + + result.toFuture() + } + + /** + * Inserts a document into a MongoDB collection. + * + * @param collection The MongoCollection instance to insert into. + * @param document The document to insert. + * @return A Future containing the ID of the inserted document. + * @throws RuntimeException if the insertion was not acknowledged by the database. + */ + def insertOne[T](collection: MongoCollection[T], document: T): Future[String] = { + val futureResult: Future[InsertOneResult] = collection.insertOne(document).toFuture() + + futureResult.map[String]((result: InsertOneResult) => { + if (result.wasAcknowledged()) { + // Grab the generated ID of the inserted document + result.getInsertedId().asObjectId.getValue().toString() + } else { + throw new RuntimeException("Insertion was not acknowledged") + } + }) + } + + /** + * Updates a document in a MongoDB collection. + * + * @param collection The MongoCollection instance the document is in. + * @param documentId The ID of the document to update. + * @param updates A sequence of Bson documents defining the updates. + * @throws RuntimeException if the update was not acknowledged by the database. + */ + def updateOne[T](collection: MongoCollection[T], documentId: ObjectId, updates: Seq[Bson]): Future[Unit] = { + val filter: Bson = Filters.equal[ObjectId]("_id", documentId) + val futureResult: Future[UpdateResult] = collection.updateOne(filter, updates).toFuture() + + futureResult.map[Unit]((result: UpdateResult) => { + if (!result.wasAcknowledged()) { + throw new RuntimeException("Update was not acknowledged") + } + }) + } +} diff --git a/backend-services/feed-service/build.sbt b/backend-services/feed-service/build.sbt index ad929924b648464b550c0b595083d5984381e964..8ac4696d90efd925ef9357082303b144d876854b 100644 --- a/backend-services/feed-service/build.sbt +++ b/backend-services/feed-service/build.sbt @@ -6,6 +6,8 @@ version := "1.0.0" lazy val root = (project in file(".")).enablePlugins(PlayScala) scalaVersion := "2.13.10" +scalacOptions ++= Seq("-Ywarn-unused") + libraryDependencies += guice libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "5.0.0" % Test @@ -16,3 +18,6 @@ libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "5.0.0 // Adds additional packages into conf/routes // play.sbt.routes.RoutesKeys.routesImport += "com.daily.binders._" libraryDependencies += "org.mongodb.scala" %% "mongo-scala-driver" % "4.3.0" +libraryDependencies += "com.typesafe.play" %% "play-ws" % "2.8.10" +libraryDependencies += "com.typesafe.play" %% "play-ahc-ws-standalone" % "2.1.10" +libraryDependencies += "com.github.jwt-scala" %% "jwt-play-json" % "9.2.0" diff --git a/backend-services/feed-service/conf/application.conf b/backend-services/feed-service/conf/application.conf index 852c11d6e6d10588b23cae3f2b3e8131c4827c11..cd190de63d1862ce68daa1d58dbd3993bc1c4e13 100644 --- a/backend-services/feed-service/conf/application.conf +++ b/backend-services/feed-service/conf/application.conf @@ -1,4 +1,7 @@ -# Default Configuration File - # MongoDB Connection Strings -mongodb.uri = "mongodb://localhost:27017/" +mongodb.uri="mongodb://localhost:27017/" +mongo.feedService.db = "feed-service" +mongo.dailies.collection = "dailies" + +# JWT Authenticationn +jwt.privateKey = "" diff --git a/backend-services/feed-service/conf/routes b/backend-services/feed-service/conf/routes index c987e3139a94547944c6f45cb7398242a2986f69..c858105f24e817c9cc22bec4ddfeb0d8348e6e95 100644 --- a/backend-services/feed-service/conf/routes +++ b/backend-services/feed-service/conf/routes @@ -7,6 +7,19 @@ GET / controllers.HomeController.index() # Map static resources from the /public folder to the /assets URL path -GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) +GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) -GET /mongo_test controllers.MongoTestController.index() +POST /daily/create controllers.DailyController.create() + +GET /user/dailies controllers.DailyController.getUserDailies(userId: String) + +GET /feed controllers.DailyController.getUserFeed(userId: String) + +PUT /daily/like controllers.DailyController.like() + +PUT /daily/unlike controllers.DailyController.unlike() + + +GET /test/getFriends controllers.TestController.getFriends(userId: String) + +GET /test/verifyUser controllers.TestController.verifyUser(userId: String)