diff --git a/src/main/g8/backend/.scalafmt.conf b/src/main/g8/backend/.scalafmt.conf new file mode 100644 index 0000000..755b21e --- /dev/null +++ b/src/main/g8/backend/.scalafmt.conf @@ -0,0 +1,2 @@ +version = "3.7.3" +runner.dialect = scala213 \ No newline at end of file diff --git a/src/main/g8/backend/README.md b/src/main/g8/backend/README.md new file mode 100644 index 0000000..ecd0bf0 --- /dev/null +++ b/src/main/g8/backend/README.md @@ -0,0 +1,105 @@ +# Scalajs backend app + +## Routes + +### GET /todos + +Returns a list of todos tasks + +_Response example:_ + +```json +[ + { + "id": 1, + "title": "Todo 1", + "completed": false + }, + { + "id": 2, + "title": "Todo 2", + "completed": false + }, + { + "id": 3, + "title": "Todo 3", + "completed": false + } +] +``` + +### GET /todos/:id + +Returns a todo task + +_Response example:_ + +```json +{ + "id": 1, + "title": "Todo 1", + "completed": false +} +``` + +### POST /todos?title=string + +Creates a todo task + +_Response example:_ + +```json +{ + "id": 1, + "title": "Todo 1", + "completed": false +} +``` + +### PUT /todos/:id?title=string + +Updates a todo task by changing the title field + +_Response example:_ + +```json +{ + "id": 1, + "title": "Todo 1", + "completed": false +} +``` + +### POST /todos/:id/completed + +Updates a todo task by changing the completed status + +_Response example:_ + +```json +{ + "id": 1, + "title": "Todo 1", + "completed": true +} +``` + +### DELETE /todos/:id + +Deletes a todo task + +_Response example:_ + +```text +Task 1 has been deleted +``` + +### DELETE /todos/completed + +Deletes all completed todo tasks + +_Response example:_ + +```text +All completed tasks have been deleted +``` diff --git a/src/main/g8/backend/build.sbt b/src/main/g8/backend/build.sbt index da76952..79c9122 100644 --- a/src/main/g8/backend/build.sbt +++ b/src/main/g8/backend/build.sbt @@ -1,9 +1,18 @@ // give the user a nice default project! ThisBuild / organization := "com.do" -ThisBuild / scalaVersion := "2.12.8" +ThisBuild / scalaVersion := "2.13.10" -lazy val root = (project in file(".")). - settings( - name := "Fullstack Scaffhold", - mainClass := Some("com.do.Main") +lazy val root = (project in file(".")).settings( + name := "Fullstack Scaffhold", + mainClass := Some("com.do.Main"), + libraryDependencies ++= Seq( + "dev.zio" %% "zio" % "2.0.14", + "dev.zio" %% "zio-http" % "3.0.0-RC1", + "dev.zio" %% "zio-sql" % "0.1.2", + "dev.zio" %% "zio-sql-postgres" % "0.1.2", + "dev.zio" %% "zio-streams" % "1.0.12", + "dev.zio" %% "zio-interop-cats" % "3.1.1.0", + "org.mongodb.scala" %% "mongo-scala-driver" % "4.9.1", + "org.mongodb.scala" %% "mongo-scala-bson" % "4.9.1" ) +) diff --git a/src/main/g8/backend/data.json b/src/main/g8/backend/data.json new file mode 100644 index 0000000..c0c2d3e --- /dev/null +++ b/src/main/g8/backend/data.json @@ -0,0 +1,12 @@ +[ + { + "id": 1, + "title": "Faire du sale", + "completed": false + }, + { + "id": 2, + "title": "Manger du poulet", + "completed": false + } +] diff --git a/src/main/g8/backend/docker-compose.yaml b/src/main/g8/backend/docker-compose.yaml new file mode 100644 index 0000000..586b4e0 --- /dev/null +++ b/src/main/g8/backend/docker-compose.yaml @@ -0,0 +1,17 @@ +# Use root/example as user/password credentials +version: '3.1' + +services: + mongo: + image: mongo + ports: + - 27018:27017 + environment: + - MONGO_INITDB_ROOT_USERNAME=root + - MONGO_INITDB_ROOT_PASSWORD=root + - MONGO_INITDB_DATABASE=todo_app + volumes: + - mongodb_data_todo_app:/data/db + +volumes: + mongodb_data_todo_app: diff --git a/src/main/g8/backend/project/metals.sbt b/src/main/g8/backend/project/metals.sbt new file mode 100644 index 0000000..05fd2b3 --- /dev/null +++ b/src/main/g8/backend/project/metals.sbt @@ -0,0 +1,6 @@ +// DO NOT EDIT! This file is auto-generated. + +// This file enables sbt-bloop to create bloop config files. + +addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.5.6") + diff --git a/src/main/g8/backend/project/project/metals.sbt b/src/main/g8/backend/project/project/metals.sbt new file mode 100644 index 0000000..05fd2b3 --- /dev/null +++ b/src/main/g8/backend/project/project/metals.sbt @@ -0,0 +1,6 @@ +// DO NOT EDIT! This file is auto-generated. + +// This file enables sbt-bloop to create bloop config files. + +addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.5.6") + diff --git a/src/main/g8/backend/src/main/scala/DB.scala b/src/main/g8/backend/src/main/scala/DB.scala new file mode 100644 index 0000000..166bf47 --- /dev/null +++ b/src/main/g8/backend/src/main/scala/DB.scala @@ -0,0 +1,30 @@ +package todo + +import org.bson.codecs.configuration.CodecRegistry +import org.bson.codecs.configuration.CodecRegistries.{ + fromProviders, + fromRegistries +} +import org.mongodb.scala.{MongoClient, MongoCollection} +import org.mongodb.scala.MongoClient.DEFAULT_CODEC_REGISTRY +import org.mongodb.scala.bson.codecs.Macros._ + +object DB { + private val databaseURL: String = + Option(System.getenv("MONGO_URL")) + .getOrElse("mongodb://root:root@localhost:27018") + + private val customCodecs: CodecRegistry = + fromProviders(classOf[Todo]) + + private val codecRegistry: CodecRegistry = + fromRegistries(customCodecs, DEFAULT_CODEC_REGISTRY) + + private val database = + MongoClient(databaseURL) + .getDatabase("todoapp") + .withCodecRegistry(codecRegistry) + + val todosCollection: MongoCollection[Todo] = + database.getCollection[Todo]("todos") +} diff --git a/src/main/g8/backend/src/main/scala/Main.scala b/src/main/g8/backend/src/main/scala/Main.scala index 54ccbec..1a60181 100644 --- a/src/main/g8/backend/src/main/scala/Main.scala +++ b/src/main/g8/backend/src/main/scala/Main.scala @@ -1,5 +1,20 @@ -object Main { - def main(args: Array[String]): Unit = { - println("Hello, world") - } -} \ No newline at end of file +package todo + +import zio._ +import zio.http._ + +object TodoApp extends ZIOAppDefault { + val port: Int = + sys.env + .get("PORT") + .filter(_.nonEmpty) + .map(_.toInt) + .getOrElse(8080) + + private val config = Server.Config.default.port(port) + private val configLayer = ZLayer.succeed(config) + + printf("Server listening on http://localhost:%d\n", port) + override val run = + Server.serve(TodoController.routes).provide(configLayer, Server.live) +} diff --git a/src/main/g8/backend/src/main/scala/Todo.scala b/src/main/g8/backend/src/main/scala/Todo.scala new file mode 100644 index 0000000..e42ebd6 --- /dev/null +++ b/src/main/g8/backend/src/main/scala/Todo.scala @@ -0,0 +1,14 @@ +package todo + +import zio.json._ + +case class Todo( + id: Int, + title: String, + completed: Boolean +) + +object Todo { + implicit val todoEncoder: JsonEncoder[Todo] = DeriveJsonEncoder.gen[Todo] + implicit val todoDecoder: JsonDecoder[Todo] = DeriveJsonDecoder.gen[Todo] +} diff --git a/src/main/g8/backend/src/main/scala/TodoController.scala b/src/main/g8/backend/src/main/scala/TodoController.scala new file mode 100644 index 0000000..57cf796 --- /dev/null +++ b/src/main/g8/backend/src/main/scala/TodoController.scala @@ -0,0 +1,121 @@ +package todo + +import zio._ +import zio.http._ +import zio.json._ + +object TodoController { + + val BasePath = !! / "todos" + + val routes: Http[Any, Nothing, Request, Response] = + Http.collectZIO[Request] { + case Method.GET -> BasePath => { + TodoService + .getTodos() + .map(_.toJson) + .map(Response.text(_)) + .orElse( + ZIO.succeed( + Response.fromHttpError( + HttpError.NotFound("No todos found") + ) + ) + ) + } + case Method.GET -> BasePath / id => { + if (id.forall(_.isDigit)) { + TodoService + .getTodoById(id.toInt) + .map(_.toJson) + .map(Response.text(_)) + .orElse( + ZIO.succeed( + Response.fromHttpError( + HttpError.NotFound(s"Todo with ID $id not found") + ) + ) + ) + } else { + ZIO.succeed( + Response.fromHttpError(HttpError.BadRequest("Invalid ID format")) + ) + } + } + case req @ Method.POST -> BasePath => { + (for { + queryParams <- ZIO + .fromOption(Option(req.url.queryParams)) + .orElseFail(HttpError.BadRequest("Missing query parameters")) + title <- ZIO + .fromOption(queryParams.get("title").collect(_.head)) + .orElseFail(HttpError.BadRequest("Missing 'title' parameter")) + createdTodo <- TodoService.createTodo(title) + } yield createdTodo) + .fold( + error => Response.fromHttpError(HttpError.InternalServerError()), + todo => Response.text(todo.toJson) + ) + } + case req @ Method.PUT -> BasePath / id => { + (for { + queryParams <- ZIO + .fromOption(Option(req.url.queryParams)) + .orElseFail(HttpError.BadRequest("Missing query parameters")) + title <- ZIO + .fromOption(queryParams.get("title").collect(_.head)) + .orElseFail(HttpError.BadRequest("Missing 'title' parameter")) + updatedTodo <- TodoService.updateTodoTitleField(id.toInt, title) + } yield updatedTodo) + .fold( + error => Response.fromHttpError(HttpError.InternalServerError()), + todo => Response.text(todo.toJson) + ) + } + case Method.POST -> BasePath / id / "completed" => { + if (id.forall(_.isDigit)) { + TodoService + .updateTodoCompletedField(id.toInt) + .map(_.toJson) + .map(Response.text(_)) + .orElse( + ZIO.succeed( + Response.fromHttpError( + HttpError.NotFound(s"Todo with ID $id not found") + ) + ) + ) + } else { + ZIO.succeed(Response.fromHttpError(HttpError.BadRequest())) + } + } + case Method.DELETE -> BasePath / "completed" => { + TodoService + .deleteCompletedTodo() + .map(_ => Response.text("All completed tasks have been deleted")) + .orElse( + ZIO.succeed( + Response.fromHttpError( + HttpError.InternalServerError("Error deleting completed tasks") + ) + ) + ) + } + case Method.DELETE -> BasePath / id => { + if (id.forall(_.isDigit)) { + TodoService + .deleteTodoById(id.toInt) + .map(_ => Response.text(s"Task $id has been deleted")) + .orElse( + ZIO.succeed( + Response.fromHttpError( + HttpError.NotFound(s"Todo with ID $id not found") + ) + ) + ) + } else { + ZIO.succeed(Response.fromHttpError(HttpError.BadRequest())) + } + } + } +} diff --git a/src/main/g8/backend/src/main/scala/TodoService.scala b/src/main/g8/backend/src/main/scala/TodoService.scala new file mode 100644 index 0000000..8f5fbbe --- /dev/null +++ b/src/main/g8/backend/src/main/scala/TodoService.scala @@ -0,0 +1,96 @@ +package todo + +import zio.Task +import zio._ + +import org.mongodb.scala.model.Filters._ +import org.mongodb.scala.model.Updates._ + +import org.mongodb.scala.MongoCollection + +object TodoService { + private val todosCollection: MongoCollection[Todo] = DB.todosCollection + + def getTodos(): Task[Seq[Todo]] = + ZIO.fromFuture(_ => todosCollection.find().toFuture()) + + def getTodoById(id: Int): Task[Option[Todo]] = + ZIO.fromFuture(_ => todosCollection.find(equal("id", id)).headOption()) + + def createTodo(title: String): Task[Todo] = + for { + todos <- getTodos() + newId = todos.map(_.id).max + 1 + newTodo = Todo(newId, title, false) + _ <- ZIO + .fromFuture(_ => todosCollection.insertOne(newTodo).toFuture()) + .unit + } yield newTodo + + def updateTodoTitleField(todoId: Int, title: String): Task[Todo] = + for { + taskToChange <- getTodoById(todoId) + updatedTodo <- taskToChange match { + case Some(todo) => + val updated = todo.copy(title = title) + ZIO + .fromFuture(_ => + todosCollection + .updateOne(equal("id", todoId), set("title", updated.title)) + .toFuture() + ) + .map(_ => updated) + case None => + ZIO.fail( + new NoSuchElementException(s"Todo with ID $todoId not found") + ) + } + } yield updatedTodo + + def updateTodoCompletedField(todoId: Int): Task[Todo] = + for { + taskToChange <- getTodoById(todoId) + updatedTodo <- taskToChange match { + case Some(todo) => + val updated = todo.copy(completed = !todo.completed) + ZIO + .fromFuture(_ => + todosCollection + .updateOne( + equal("id", todoId), + set("completed", updated.completed) + ) + .toFuture() + ) + .map(_ => updated) + case None => + ZIO.fail( + new NoSuchElementException(s"Todo with ID $todoId not found") + ) + } + } yield updatedTodo + + def deleteTodoById(id: Int): Task[Unit] = + ZIO + .fromFuture(_ => todosCollection.deleteOne(equal("id", id)).toFuture()) + .flatMap { result => + if (result.wasAcknowledged() && result.getDeletedCount == 0) { + ZIO.fail(new Exception("Todo not found")) + } else { + ZIO.unit + } + } + + def deleteCompletedTodo(): Task[Unit] = + ZIO + .fromFuture(_ => + todosCollection.deleteMany(equal("completed", true)).toFuture() + ) + .flatMap { result => + if (result.wasAcknowledged() && result.getDeletedCount == 0) { + ZIO.fail(new Exception("No todo deleted")) + } else { + ZIO.unit + } + } +}