diff --git a/build.sbt b/build.sbt index 2cb95bd..eadfb98 100644 --- a/build.sbt +++ b/build.sbt @@ -27,11 +27,12 @@ lazy val quizz = library.doobieCore, library.doobiePostgres, library.doobieQuill, - library.doobieScalatest % Test + library.doobieScalatest % Test, // library.tapirHttp4s, // library.tapirJson, // library.bazelServer, // library.bazelClient + library.betterFiles ) ) @@ -52,6 +53,7 @@ lazy val library = val doobie = "0.9.0" val sttp = "2.2.8" val sttpTapirJsonCirce = "0.16.16" + val betterFiles = "3.9.1" } val scalaCheck = "org.scalacheck" %% "scalacheck" % Version.scalaCheck val scalaTest = "org.scalatest" %% "scalatest" % Version.scalaTest @@ -67,6 +69,7 @@ lazy val library = val sttpClient = "com.softwaremill.sttp.client" %% "core" % Version.sttp val sttpClientCirce = "com.softwaremill.sttp.client" %% "circe" % Version.sttp val tapirJsonCirce = "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % Version.sttpTapirJsonCirce + val betterFiles = "com.github.pathikrit" %% "better-files" % Version.betterFiles val logback = "ch.qos.logback" % "logback-classic" % Version.logback val doobieCore = "org.tpolecat" %% "doobie-core" % Version.doobie diff --git a/docker-compose.yml b/docker-compose.yml index 2134ef3..de05fe1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,13 +4,35 @@ services: quizz: image: otrebski/quizz:latest volumes: - - '/tmp/mindmups/:/tmp/mindmups/' + - '/tmp/mindmups/:/mindmups/' environment: - LOAD_FROM_DIR: "/tmp/mindmups/" - USE_SLACK: "false" SLACK_TOKEN: "" + FEEDBACK_USE_SLACK: "false" + FEEDBACK_USE_DB: "false" + MINDMUP_STORAGE: "file" #use file, memory or database + FILESTORAGE_DIR: "/mindmups" +# DB_HOST: "db" +# DB_PORT: "5432" +# DB_NAME: "quizz" +# DB_USERNAME: "postgres" +# DB_PASSWORD: "password" + links: + - db + gui: image: otrebski/quizz-gui:latest ports: - 8080:80 + links: + - quizz + + db: + image: library/postgres:13 + # ports: + # - 5432:15432 + environment: + POSTGRES_PASSWORD: "password" + POSTGRES_DB: "quizz" + volumes: + - ./postgres-data:/var/lib/postgresql/data diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 0b8853f..c4340b1 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -1,30 +1,35 @@ -quizz { - loader { - dir = "" - dir = ${?LOAD_FROM_DIR} - } -} - feedback { slack { use = false - use = ${?USE_SLACK} + use = ${?FEEDBACK_USE_SLACK} token = "" token = ${?SLACK_TOKEN} } + database { + use = false + use = ${?FEEDBACK_USE_DB} + } +} + +mindmup { + store-type = "file" //file, database or memory + store-type = ${?MINDMUP_STORAGE} +} + +filestorage { + dir = "mindmups" + dir = ${?FILESTORAGE_DIR} } database { - use = false - use = ${?USE_DB} - host = "localhost" - host = ${?DB_HOST} - port = 5432 - port = ${?DB_PORT} - dbname = "quizz" - dbname = ${?DB_NAME} - user = "postgres" - user = ${?DB_USERNAME} - password = "password" - password = ${?DB_PASSWORD} + host = "localhost" + host = ${?DB_HOST} + port = 5432 + port = ${?DB_PORT} + dbname = "quizz" + dbname = ${?DB_NAME} + user = "postgres" + user = ${?DB_USERNAME} + password = "password" + password = ${?DB_PASSWORD} } \ No newline at end of file diff --git a/src/main/scala/mindmup/Parser.scala b/src/main/scala/mindmup/Parser.scala index 452c094..9863d92 100644 --- a/src/main/scala/mindmup/Parser.scala +++ b/src/main/scala/mindmup/Parser.scala @@ -22,7 +22,9 @@ object Parser extends LazyLogging { val r: Either[Error, V3IdString.Mindmap] = parsedJson.flatMap(mindMupDecoder.decodeJson) r match { case Left(e) => - logger.info(s"Parsing was not successful due to ${e.getMessage}, will try different parser") + logger.debug( + s"Parsing was not successful due to ${e.getMessage}, will try different parser" + ) parsedJson.flatMap(mindMupDecoderInt.decodeJson).map(_.toV3IdString) match { case Left(_) => logger.info( diff --git a/src/main/scala/mindmup/package.scala b/src/main/scala/mindmup/package.scala index f43751d..c352e91 100644 --- a/src/main/scala/mindmup/package.scala +++ b/src/main/scala/mindmup/package.scala @@ -84,7 +84,6 @@ package object mindmup extends LazyLogging { label.getOrElse("?") -> toStep(v) } Question(id, title, stringToStep) - } } diff --git a/src/main/scala/quizz/data/ExamplesData.scala b/src/main/scala/quizz/data/ExamplesData.scala index be61b09..e6de661 100644 --- a/src/main/scala/quizz/data/ExamplesData.scala +++ b/src/main/scala/quizz/data/ExamplesData.scala @@ -21,11 +21,11 @@ import cats.syntax.show._ import com.typesafe.scalalogging.LazyLogging import mindmup.Parser import quizz.model.{ FailureStep, Question, Quizz, SuccessStep } -import quizz.web.WebApp.Api.{ Answer, HistoryStep, QuizzState, Step } +import quizz.web.Api.{ Answer, HistoryStep, QuizzState, Step } object ExamplesData extends LazyLogging { - val quiz = Question( + val quiz: Question = Question( "root", "What kind of problem do you have", Map( @@ -117,7 +117,7 @@ object ExamplesData extends LazyLogging { object Fake { - val exampleStateInProgress = QuizzState( + val exampleStateInProgress: QuizzState = QuizzState( path = "root", currentStep = Step( "a", @@ -154,12 +154,12 @@ object ExamplesData extends LazyLogging { ) ) ) - val exampleStateFinalSuccess = QuizzState( + val exampleStateFinalSuccess: QuizzState = QuizzState( path = "asdfsdf", currentStep = Step("a", "I co dalej?", List.empty, success = Some(true)), history = List() ) - val exampleStateFinalFailure = QuizzState( + val exampleStateFinalFailure: QuizzState = QuizzState( path = "asdfsdf", currentStep = Step("a", "I co dalej?", List.empty, success = Some(false)), history = List() diff --git a/src/main/scala/quizz/data/MindmupStore.scala b/src/main/scala/quizz/data/MindmupStore.scala new file mode 100644 index 0000000..5ef9791 --- /dev/null +++ b/src/main/scala/quizz/data/MindmupStore.scala @@ -0,0 +1,142 @@ +package quizz.data + +import cats.effect.{ Async, ContextShift, IO, Sync } + +import scala.language.higherKinds +import better.files._ +import cats.{ Applicative, FlatMap } +import cats.effect.concurrent.Ref +import doobie.Transactor +import doobie.util.transactor.Transactor.Aux +import quizz.db.DatabaseConfig + +trait MindmupStore[F[_]] { + + def store(name: String, content: String): F[Unit] + + def listNames(): F[Set[String]] + + def load(name: String): F[String] + + def delete(name: String): F[Unit] +} + +object FileMindmupStore { + def apply[F[_]](dir: File)(implicit ev: Sync[F]): F[FileMindmupStore[F]] = + Sync[F].delay { + dir.createDirectoryIfNotExists(createParents = true) + new FileMindmupStore(dir) + } +} + +class FileMindmupStore[F[_]: Sync](dir: File) extends MindmupStore[F] { + + override def store(name: String, content: String): F[Unit] = + Sync[F].delay { + (dir / name).overwrite(content) + } + + override def listNames(): F[Set[String]] = + Sync[F].delay { + dir.list + .filter(_.isRegularFile) + .map(_.name) + .toSet + } + + override def load(name: String): F[String] = + Sync[F].delay { + (dir / name).contentAsString + } + + override def delete(name: String): F[Unit] = + Sync[F].delay { + val toDelete = dir / name + toDelete.delete(swallowIOExceptions = false) + } +} + +object MemoryMindmupStore { + def apply[F[_]]()(implicit ev: Sync[F]): F[MemoryMindmupStore[F]] = { + val ref: F[Ref[F, Map[String, String]]] = + Ref.of[F, Map[String, String]](Map.empty[String, String]) + Applicative[F].map(ref)(r => new MemoryMindmupStore[F](r)) + } + +} + +class MemoryMindmupStore[F[_]: Sync](ref: Ref[F, Map[String, String]]) extends MindmupStore[F] { + override def store(name: String, content: String): F[Unit] = + ref.update(x => x.updated(name, content)) + + override def listNames(): F[Set[String]] = + FlatMap[F].flatMap(ref.get)(m => Applicative[F].pure(m.toList.map(_._1).toSet)) + + override def load(name: String): F[String] = + FlatMap[F].flatMap(ref.get)(m => Applicative[F].pure(m(name))) + + override def delete(name: String): F[Unit] = + FlatMap[F].flatMap(ref.update(v => v.removed(name)))(_ => Applicative[F].pure(())) +} + +object DbMindMupStore { + def apply[F[_]]( + dbConfig: DatabaseConfig + )(implicit ec2: Async[F], ev3: ContextShift[F]): F[DbMindMupStore[F]] = + Async[F].delay { + + implicit val cs: ContextShift[IO] = IO.contextShift(scala.concurrent.ExecutionContext.global) + + val xa: Aux[F, Unit] = Transactor.fromDriverManager[F]( + driver = "org.postgresql.Driver", + url = s"jdbc:postgresql://${dbConfig.host}:${dbConfig.port}/${dbConfig.database}", + user = dbConfig.user, + pass = dbConfig.password + ) + + new DbMindMupStore(xa) + } +} + +class DbMindMupStore[F[_]: Async: ContextShift](xa: Aux[F, Unit]) extends MindmupStore[F] { + + case class Mindmup(id: String, json: String) + + import doobie.quill.DoobieContext + import io.getquill.Literal + + val dc = new DoobieContext.Postgres(Literal) // Literal naming scheme + + import dc._ + import doobie.implicits._ + + override def store(name: String, json: String): F[Unit] = { + val q: dc.Quoted[dc.Insert[Mindmup]] = quote { + query[Mindmup] + .insert(lift(Mindmup(name, json))) + .onConflictUpdate(_.id)((t, e) => t.json -> e.json) + } + Applicative[F].map(run(q).transact(xa))(_ => ()) + } + + override def listNames(): F[Set[String]] = { + val q = quote { + query[Mindmup].map(_.id) + } + Applicative[F].map(run(q).transact(xa))(_.toSet) + } + + override def load(name: String): F[String] = { + val q = quote { + query[Mindmup].map(x => x.json) + } + Applicative[F].map(run(q).transact(xa))(_.head) + } + + override def delete(name: String): F[Unit] = { + val q = quote { + query[Mindmup].filter(_.id == lift(name)).delete + } + Applicative[F].map(run(q).transact(xa))(_ => ()) + } +} diff --git a/src/main/scala/quizz/db/DatabaseInitializer.scala b/src/main/scala/quizz/db/DatabaseInitializer.scala index c641cad..12330d0 100644 --- a/src/main/scala/quizz/db/DatabaseInitializer.scala +++ b/src/main/scala/quizz/db/DatabaseInitializer.scala @@ -1,14 +1,15 @@ package quizz.db import cats.effect.IO +import cats.implicits._ object DatabaseInitializer { def initDatabase(cfg: DatabaseConfig): IO[Int] = { + import cats.effect._ import doobie._ import doobie.implicits._ import doobie.util.ExecutionContexts - import cats.effect._ implicit val cs: ContextShift[IO] = IO.contextShift(ExecutionContexts.synchronous) val xa = Transactor.fromDriverManager[IO]( @@ -17,18 +18,27 @@ object DatabaseInitializer { cfg.user, // user cfg.password // password ) - val create = - sql""" CREATE TABLE IF NOT EXISTS feedback - ( - id SERIAL PRIMARY KEY, - timestamp timestamp NOT NULL, - quizzId varchar(300) NOT NULL, - path varchar(2000) NOT NULL, - comment varchar(5000) NOT NULL, - rate INT NOT NULL - ); - """.update.run - create.transact(xa) + + val createFeedback: doobie.ConnectionIO[Int] = + sql"""CREATE TABLE IF NOT EXISTS feedback + |( + | id SERIAL PRIMARY KEY, + | timestamp timestamp NOT NULL, + | quizzId varchar(300) NOT NULL, + | path varchar(2000) NOT NULL, + | comment varchar(5000) NOT NULL, + | rate INT NOT NULL + |); + | """.stripMargin.update.run + val createMindmup: doobie.ConnectionIO[Int] = + sql"""CREATE TABLE IF NOT EXISTS mindmup + |( + | id varchar(500) primary key, + | json varchar(100000) + |)""".stripMargin.update.run + + (createFeedback, createMindmup).mapN(_ + _).transact(xa) + } } diff --git a/src/main/scala/quizz/feedback/FeedbackSender.scala b/src/main/scala/quizz/feedback/FeedbackSender.scala index 763a80b..0f6a4bc 100644 --- a/src/main/scala/quizz/feedback/FeedbackSender.scala +++ b/src/main/scala/quizz/feedback/FeedbackSender.scala @@ -6,7 +6,7 @@ import com.typesafe.scalalogging.LazyLogging import doobie.quill.DoobieContext import io.getquill.Literal import quizz.db.{ DatabaseConfig, Feedback } -import quizz.web.WebApp.Api.{ FeedbackSend, QuizzState } +import quizz.web.Api.{ FeedbackSend, QuizzState } import scala.language.higherKinds diff --git a/src/main/scala/quizz/web/Api.scala b/src/main/scala/quizz/web/Api.scala new file mode 100644 index 0000000..babc4dc --- /dev/null +++ b/src/main/scala/quizz/web/Api.scala @@ -0,0 +1,43 @@ +package quizz.web + +object Api { + + case class QuizzQuery(id: String, path: String) + + case class QuizzId(id: String) + + case class QuizzState(path: String, currentStep: Step, history: List[HistoryStep] = List.empty) + + case class Step( + id: String, + question: String, + answers: List[Answer] = List.empty, + success: Option[Boolean] = None + ) + + case class HistoryStep( + id: String, + question: String, + answers: List[Answer] = List.empty, + path: List[String] = List.empty, + success: Option[Boolean] = None + ) + + case class Answer(id: String, text: String, selected: Option[Boolean] = None) + + case class QuizzInfo(id: String, title: String) + + case class QuizzErrorInfoInfo(id: String, error: String) + + case class Quizzes(quizzes: List[QuizzInfo], errorQuizzes: List[QuizzErrorInfoInfo]) + + case class FeedbackSend(quizzId: String, path: String, rate: Int, comment: String) + + case class FeedbackResponse(status: String) + + case class AddQuizz(id: String, mindmupSource: String) + + case class AddQuizzResponse(status: String) + + case class ValidationResult(valid: Boolean, errors: List[String]) +} diff --git a/src/main/scala/quizz/web/Endpoints.scala b/src/main/scala/quizz/web/Endpoints.scala new file mode 100644 index 0000000..8f0af67 --- /dev/null +++ b/src/main/scala/quizz/web/Endpoints.scala @@ -0,0 +1,42 @@ +package quizz.web + +import io.circe.generic.auto._ +import tapir.json.circe._ +import tapir.{ path, _ } + +object Endpoints { + val routeEndpoint: Endpoint[Api.QuizzQuery, String, Api.QuizzState, Nothing] = endpoint.get + .in( + ("api" / "quiz" / path[String]("id") / "path" / path[String]("quizPath")) + .mapTo(Api.QuizzQuery) + ) + .errorOut(stringBody) + .out(jsonBody[Api.QuizzState]) + + val routeEndpointStart: Endpoint[Api.QuizzId, String, Api.QuizzState, Nothing] = endpoint.get + .in(("api" / "quiz" / path[String]("id") / "path").mapTo(Api.QuizzId)) + .errorOut(stringBody) + .out(jsonBody[Api.QuizzState]) + + val listQuizzes: Endpoint[Unit, Unit, Api.Quizzes, Nothing] = endpoint.get + .in("api" / "quiz") + .out(jsonBody[Api.Quizzes]) + + val addQuizz: Endpoint[Api.AddQuizz, Unit, Api.AddQuizzResponse, Nothing] = endpoint.put + .in("api" / "quizz" / path[String](name = "id").description("Id of quizz to add/replace")) + .in(stringBody("UTF-8")) + .mapIn(idAndContent => Api.AddQuizz(idAndContent._1, idAndContent._2))(a => + (a.id, a.mindmupSource) + ) + .out(jsonBody[Api.AddQuizzResponse]) + + val feedback: Endpoint[Api.FeedbackSend, Unit, Api.FeedbackResponse, Nothing] = endpoint.post + .in("api" / "feedback") + .in(jsonBody[Api.FeedbackSend].description("Feedback from user")) + .out(jsonBody[Api.FeedbackResponse]) + + val validateEndpoint: Endpoint[String, Unit, Api.ValidationResult, Nothing] = endpoint.post + .in("api" / "quizz" / "validate" / "mindmup") + .in(stringBody("UTF-8")) + .out(jsonBody[Api.ValidationResult]) +} diff --git a/src/main/scala/quizz/web/Logic.scala b/src/main/scala/quizz/web/Logic.scala index 5fedb92..04730db 100644 --- a/src/main/scala/quizz/web/Logic.scala +++ b/src/main/scala/quizz/web/Logic.scala @@ -4,14 +4,13 @@ import cats.syntax.option._ import quizz.engine.QuizzEngine import quizz.model import quizz.model.{ FailureStep, Question, Quizz, SuccessStep } -import quizz.web.WebApp.Api -import quizz.web.WebApp.Api.HistoryStep +import quizz.web.Api.HistoryStep object Logic { def calculateStateOnPath( request: Api.QuizzQuery, - quizzes: Map[String, Quizz] + quizzes: Map[String, Quizz] //TODO replace map with single Quizz ): Either[String, Api.QuizzState] = { val path = request.path val pathList = path.split(";").toList.reverse diff --git a/src/main/scala/quizz/web/RouteProviders.scala b/src/main/scala/quizz/web/RouteProviders.scala new file mode 100644 index 0000000..f25acb2 --- /dev/null +++ b/src/main/scala/quizz/web/RouteProviders.scala @@ -0,0 +1,115 @@ +package quizz.web + +import cats.effect.IO +import cats.implicits._ +import io.circe +import mindmup.Parser +import quizz.data.MindmupStore +import quizz.feedback.FeedbackSender +import quizz.model.Quizz +import quizz.web.Api.{ AddQuizzResponse, FeedbackResponse, Quizzes } + +import scala.concurrent.Future + +object RouteProviders { + + def routeWithoutPathProvider( + store: MindmupStore[IO] + )(request: Api.QuizzId): Future[Either[String, Api.QuizzState]] = { + import mindmup._ + val a: IO[Either[String, Api.QuizzState]] = for { + quizzString <- store.load(request.id) + quizzOrError = Parser.parseInput(quizzString).map(_.toQuizz).left.map(_.getMessage) + result = quizzOrError.flatMap(q => Logic.calculateStateOnPathStart(q.firstStep)) + } yield result + + a.unsafeToFuture() + } + + def routeWithPathProvider( + store: MindmupStore[IO] + )(request: Api.QuizzQuery): Future[Either[String, Api.QuizzState]] = { + import mindmup._ + val r: IO[Either[String, Api.QuizzState]] = for { + q <- + store + .load(request.id) + .map(s => Parser.parseInput(s).map(_.toQuizz).left.map(_.getMessage)) + result = q.flatMap(quizzes => Logic.calculateStateOnPath(request, Map(request.id -> quizzes))) + } yield result + r.unsafeToFuture() + } + + def quizListProvider(quizzStore: MindmupStore[IO]): Unit => Future[Either[Unit, Api.Quizzes]] = { + _ => + import mindmup._ + val r: IO[Quizzes] = for { + ids <- quizzStore.listNames() + errorOrQuizzList <- + ids.toList + .traverse(id => + quizzStore + .load(id) + .map(string => Parser.parseInput(string).map(_.toQuizz)) + .map { + case Left(error) => Left(Api.QuizzErrorInfoInfo(id, error.getMessage)) + case Right(value) => Right(Api.QuizzInfo(id, value.name)) + } + ) + (errors, quizzes) = errorOrQuizzList.partitionMap(identity) + } yield Quizzes(quizzes, errors) + r.redeem(error => Left(()), v => Right(v)) + .unsafeToFuture() + } + + def addQuizzProvider( + store: MindmupStore[IO] + )(request: Api.AddQuizz): Future[Either[Unit, AddQuizzResponse]] = { + import mindmup._ + val newQuizzOrError: Either[circe.Error, Quizz] = + Parser.parseInput(request.mindmupSource).map(_.toQuizz.copy(id = request.id)) + + newQuizzOrError match { + case Left(error) => Future.failed(new Exception(error.toString)) + case Right(_) => + store + .store(request.id, request.mindmupSource) + .map(_ => AddQuizzResponse("added").asRight[Unit]) + .unsafeToFuture() + } + } + + def feedbackProvider(store: MindmupStore[IO], feedbackSenders: List[FeedbackSender[IO]])( + feedback: Api.FeedbackSend + ): Future[Either[Unit, FeedbackResponse]] = { + + val request = Api.QuizzQuery(feedback.quizzId, feedback.path) + val quizzState: IO[Either[String, Api.QuizzState]] = store + .load(request.id) + .map(string => + Parser + .parseInput(string) + .map(_.toQuizz) + .left + .map(_.toString) + .flatMap(quizz => Logic.calculateStateOnPath(request, Map(request.id -> quizz))) + ) + + val p: IO[Either[Unit, FeedbackResponse]] = quizzState.flatMap { + case Right(quizzState) => + feedbackSenders + .traverse(_.send(feedback, quizzState)) + .map(_ => FeedbackResponse("OK").asRight) + case Left(error) => IO.raiseError(new Exception(s"Can't process feedback: $error")) + } + p.unsafeToFuture() + } + + val validateProvider: String => Future[Either[Unit, Api.ValidationResult]] = { s => + val result = mindmup.Parser.parseInput(s) match { + case Left(error) => Api.ValidationResult(valid = false, List(error.getMessage)) + case Right(_) => Api.ValidationResult(valid = true, List.empty[String]) + } + Future.successful(Right(result)) + } +} diff --git a/src/main/scala/quizz/web/WebApp.scala b/src/main/scala/quizz/web/WebApp.scala index d3bfcba..e4ad061 100644 --- a/src/main/scala/quizz/web/WebApp.scala +++ b/src/main/scala/quizz/web/WebApp.scala @@ -16,203 +16,21 @@ package quizz.web -import java.io.File - -import cats.effect.concurrent.Ref -import cats.effect.{Clock, ExitCode, IO, IOApp} +import akka.http.scaladsl.Http +import cats.effect.{ Clock, ExitCode, IO, IOApp } import cats.implicits._ - -import scala.concurrent.{ExecutionContextExecutor, Future} -import com.typesafe.config.{Config, ConfigFactory} +import com.typesafe.config.{ Config, ConfigFactory } import com.typesafe.scalalogging.LazyLogging -import io.circe -import io.circe.generic.auto._ -import quizz.data.{ExamplesData, Loader} +import quizz.data.{ DbMindMupStore, FileMindmupStore, MemoryMindmupStore, MindmupStore } import quizz.db.DatabaseInitializer -import quizz.feedback.{FeedbackDBSender, FeedbackSender, LogFeedbackSender, SlackFeedbackSender} -import quizz.model.Quizz -import quizz.web.WebApp.Api.{AddQuizzResponse, FeedbackResponse, Quizzes} -import tapir.json.circe._ +import quizz.feedback.{ FeedbackDBSender, FeedbackSender, LogFeedbackSender, SlackFeedbackSender } import tapir.server.akkahttp._ -import tapir.{path, _} - -object WebApp extends IOApp with LazyLogging { - - object Api { - - case class QuizzQuery(id: String, path: String) - - case class QuizzId(id: String) - - case class QuizzState(path: String, currentStep: Step, history: List[HistoryStep] = List.empty) - - case class Step( - id: String, - question: String, - answers: List[Answer] = List.empty, - success: Option[Boolean] = None - ) - - case class HistoryStep( - id: String, - question: String, - answers: List[Answer] = List.empty, - path: List[String] = List.empty, - success: Option[Boolean] = None - ) - - case class Answer(id: String, text: String, selected: Option[Boolean] = None) - - case class QuizzInfo(id: String, title: String) - - case class Quizzes(quizzes: List[QuizzInfo]) - - case class FeedbackSend(quizzId: String, path: String, rate: Int, comment: String) - - case class FeedbackResponse(status: String) - - case class AddQuizz(id: String, mindmupSource: String) - - case class AddQuizzResponse(status: String) - - case class ValidationResult(valid: Boolean, errors: List[String]) - - } - - private val stateCodec = jsonBody[Api.QuizzState] - private val codecQuizInfo = jsonBody[Api.Quizzes] - private val codecFeedback = jsonBody[Api.FeedbackSend] - private val codecFeedbackResponse = jsonBody[Api.FeedbackResponse] - - private val config: Config = ConfigFactory.load() - private val dirWithQuizzes: String = config.getString("quizz.loader.dir") - logger.info(s"""Loading from "$dirWithQuizzes" """) - - private val routeEndpoint = endpoint.get - .in( - ("api" / "quiz" / path[String]("id") / "path" / path[String]("quizPath")) - .mapTo(Api.QuizzQuery) - ) - .errorOut(stringBody) - .out(jsonBody[Api.QuizzState]) - - private val routeEndpointStart = endpoint.get - .in(("api" / "quiz" / path[String]("id") / "path").mapTo(Api.QuizzId)) - .errorOut(stringBody) - .out(jsonBody[Api.QuizzState]) - - private val listQuizzes = endpoint.get - .in("api" / "quiz") - .out(jsonBody[Api.Quizzes]) - - private val addQuizz = endpoint.put - .in("api" / "quizz" / path[String](name = "id").description("Id of quizz to add/replace")) - .in(stringBody("UTF-8")) - .mapIn(idAndContent => Api.AddQuizz(idAndContent._1, idAndContent._2))(a => - (a.id, a.mindmupSource) - ) - .out(jsonBody[Api.AddQuizzResponse]) - - private val feedback = endpoint.post - .in("api" / "feedback") - .in(jsonBody[Api.FeedbackSend].description("Feedback from user")) - .out(jsonBody[Api.FeedbackResponse]) - - private val validateEndpoint = endpoint.post - .in("api" / "quizz" / "validate" / "mindmup") - .in(stringBody("UTF-8")) - .out(jsonBody[Api.ValidationResult]) - import akka.actor.ActorSystem - import akka.http.scaladsl.Http - import akka.stream.ActorMaterializer +import scala.concurrent.{ ExecutionContextExecutor, Future } - private def routeWithoutPathProvider( - quizzes: Ref[IO, Either[String, Map[String, Quizz]]] - )(request: Api.QuizzId): Future[Either[String, Api.QuizzState]] = { - val a: IO[Either[String, Api.QuizzState]] = for { - quizzesMap <- quizzes.get - quizz = quizzesMap.flatMap(q => - q.get(request.id).map(_.asRight[String]).getOrElse("Not found".asLeft[Quizz]) - ) - step = quizz.flatMap(q => Logic.calculateStateOnPathStart(q.firstStep)) - } yield step - - a.unsafeToFuture() - } - - private def routeWithPathProvider( - quizzes: Ref[IO, Either[String, Map[String, Quizz]]] - )(request: Api.QuizzQuery) = { - val r: IO[Either[String, Api.QuizzState]] = for { - q <- quizzes.get - result = q.flatMap(quizzes => Logic.calculateStateOnPath(request, quizzes)) - } yield result - r.unsafeToFuture() - } - - def quizListProvider( - quizzes: Ref[IO, Either[String, Map[String, Quizz]]] - ): Unit => Future[Either[Unit, Api.Quizzes]] = { _ => - (for { - quizzesOrError <- quizzes.get - quizzesInfo = - quizzesOrError - .map(q => q.values.toList.map(q1 => Api.QuizzInfo(q1.id, q1.name))) - .map(Api.Quizzes) - .leftMap(_ => ()) - } yield quizzesInfo).unsafeToFuture() - } - - def addQuizzProvider( - quizzes: Ref[IO, Either[String, Map[String, Quizz]]] - )(request: Api.AddQuizz): Future[Either[Nothing, AddQuizzResponse]] = { - import mindmup._ - val newQuizzOrError: Either[circe.Error, Quizz] = - Parser.parseInput(request.mindmupSource).map(_.toQuizz.copy(id = request.id)) - - newQuizzOrError match { - case Left(error) => Future.failed(new Exception(error.toString)) - case Right(quizz) => - val io: IO[Either[String, Map[String, Quizz]]] = - quizzes.getAndUpdate(old => old.map(_.updated(request.id, quizz))) - io.map(_ => AddQuizzResponse("Added").asRight) - .unsafeToFuture() - } - } - - def feedbackProvider( - quizzes: Ref[IO, Either[String, Map[String, Quizz]]], - feedbackSenders: List[FeedbackSender[IO]] - )(feedback: Api.FeedbackSend): Future[Either[Unit, FeedbackResponse]] = { - // val log = new LogFeedbackSender[IO] - // val useSlack = config.getBoolean("feedback.slack.use") - // val useDb = config.getBoolean("database.use") - val request = Api.QuizzQuery(feedback.quizzId, feedback.path) - val quizzState: IO[Either[String, Api.QuizzState]] = for { - q <- quizzes.get - result = q.flatMap(quizzes => Logic.calculateStateOnPath(request, quizzes)) - } yield result - - val p = quizzState.flatMap { - case Right(quizzState) => - feedbackSenders - .traverse(_.send(feedback, quizzState)) - - case Left(error) => IO.raiseError(new Exception(s"Can't process feedback: $error")) - } +object WebApp extends IOApp with LazyLogging { - implicit val ec: ExecutionContextExecutor = scala.concurrent.ExecutionContext.global - p.unsafeToFuture().map(_ => Right(FeedbackResponse("OK"))) - } - - val validateProvider: String => Future[Either[Unit, Api.ValidationResult]] = { s => - val result = mindmup.Parser.parseInput(s) match { - case Left(error) => Api.ValidationResult(valid = false, List(error.getMessage)) - case Right(_) => Api.ValidationResult(valid = true, List.empty[String]) - } - Future.successful(Right(result)) - } + private val config: Config = ConfigFactory.load() override def run(args: List[String]): IO[ExitCode] = { @@ -222,10 +40,11 @@ object WebApp extends IOApp with LazyLogging { Some(new SlackFeedbackSender[IO](config.getString("feedback.slack.token"))) else none[FeedbackSender[IO]] + val databaseConfig = quizz.db.databaseConfig(config) val databaseFeedbackSender: Option[FeedbackDBSender] = { - if (config.getBoolean("database.use")) { + if (config.getBoolean("feedback.database.use")) { val a: Clock[IO] = implicitly[Clock[IO]] - new FeedbackDBSender(quizz.db.databaseConfig(config))(a).some + new FeedbackDBSender(databaseConfig)(a).some } else None } @@ -234,15 +53,21 @@ object WebApp extends IOApp with LazyLogging { import akka.http.scaladsl.server.Directives._ val port = 8080 - def bindingFuture( - quizzes: Ref[IO, Either[String, Map[String, Quizz]]] - ): IO[Future[Http.ServerBinding]] = + def bindingFuture(store: MindmupStore[IO]): IO[Future[Http.ServerBinding]] = { + import better.files.File.home + val dir = home / "tmp" / "mindmups" + dir.createDirectoryIfNotExists(createParents = true) + import Endpoints._ + import RouteProviders._ + import akka.actor.ActorSystem + import akka.stream.ActorMaterializer + IO { - val route = routeEndpoint.toRoute(routeWithPathProvider(quizzes)) - val routeStart = routeEndpointStart.toRoute(routeWithoutPathProvider(quizzes)) - val routeList = listQuizzes.toRoute(quizListProvider(quizzes)) - val routeFeedback = feedback.toRoute(feedbackProvider(quizzes, feedbackSenders)) - val add = addQuizz.toRoute(addQuizzProvider(quizzes)) + val route = routeEndpoint.toRoute(routeWithPathProvider(store)) + val routeStart = routeEndpointStart.toRoute(routeWithoutPathProvider(store)) + val routeList = listQuizzes.toRoute(quizListProvider(store)) + val routeFeedback = feedback.toRoute(feedbackProvider(store, feedbackSenders)) + val add = addQuizz.toRoute(addQuizzProvider(store)) val validateRoute = validateEndpoint.toRoute(validateProvider) implicit val system: ActorSystem = ActorSystem("my-system") implicit val materializer: ActorMaterializer = ActorMaterializer() @@ -253,30 +78,34 @@ object WebApp extends IOApp with LazyLogging { port ) } + } - def loadQuizzes(): IO[Either[String, Map[String, Quizz]]] = - IO { - if (dirWithQuizzes.nonEmpty) { - val list = Loader.fromFolder(new File(dirWithQuizzes)) - list.map(errorOr => errorOr.map(q => q.id -> q).toMap) - } else - Right(ExamplesData.quizzes) - } - + val mindmupStoreType = config.getString("mindmup.store-type") val initDb = - if (config.getBoolean("database.use")) + if (config.getBoolean("feedback.database.use") || mindmupStoreType == "database") DatabaseInitializer.initDatabase(quizz.db.databaseConfig(config)) else IO.unit + val createMindmupStore: IO[MindmupStore[IO]] = + IO(logger.info(s"Will use $mindmupStoreType for storing mindmups")) *> { + mindmupStoreType match { + case "file" => + import better.files._ + import better.files.File.currentWorkingDirectory + val str = config.getString("filestorage.dir") + val dir = if (str.startsWith("/")) File.apply(str) else currentWorkingDirectory / str + FileMindmupStore[IO](dir) + case "memory" => MemoryMindmupStore[IO] + case "database" => DbMindMupStore[IO](databaseConfig) + } + } + logger.info(s"Server is online on port $port") for { - _ <- initDb - quizzesRef <- - Ref.of[IO, Either[String, Map[String, Quizz]]]("Not yet loaded".asLeft[Map[String, Quizz]]) - quizzesOrError <- loadQuizzes() - _ <- quizzesRef.set(quizzesOrError) - _ <- bindingFuture(quizzesRef) + _ <- initDb + mindmupStore <- createMindmupStore + _ <- bindingFuture(mindmupStore) } yield ExitCode.Success } } diff --git a/src/test/scala/quizz/data/FileMindmupStoreTest.scala b/src/test/scala/quizz/data/FileMindmupStoreTest.scala new file mode 100644 index 0000000..aa38d7b --- /dev/null +++ b/src/test/scala/quizz/data/FileMindmupStoreTest.scala @@ -0,0 +1,75 @@ +package quizz.data + +import cats.effect.IO +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import better.files.File + +class FileMindmupStoreTest extends AnyFlatSpec with Matchers { + + "FileMindmupStoreTest" should "save mindmup" in { + val dir = File.newTemporaryDirectory() + (for { + store <- FileMindmupStore[IO](dir) + _ <- store.store("n", "content") + } yield ()).unsafeRunSync() + (dir / "n").contentAsString shouldBe "content" + } + + "FileMindmupStoreTest" should "update mindmup" in { + val dir = File.newTemporaryDirectory() + (for { + store <- FileMindmupStore[IO](dir) + _ <- store.store("n", "content") + } yield ()).unsafeRunSync() + (dir / "n").contentAsString shouldBe "content" + + (for { + store <- FileMindmupStore[IO](dir) + _ <- store.store("n", "something else") + } yield ()).unsafeRunSync() + (dir / "n").contentAsString shouldBe "something else" + } + + "FileMindmupStoreTest" should "delete mindmup" in { + val dir = File.newTemporaryDirectory() + (dir / "n").write("content") + (dir / "n").exists shouldBe true + + (for { + store <- FileMindmupStore[IO](dir) + _ <- store.delete("n") + } yield ()).unsafeRunSync() + (dir / "n").exists shouldBe false + } + + "FileMindmupStoreTest" should "list all mindmups" in { + val dir = File.newTemporaryDirectory() + (dir / "n1").write("content") + (dir / "n2").write("content") + (dir / "n3").write("content") + + val names = (for { + store <- FileMindmupStore[IO](dir) + names <- store.listNames() + } yield names).unsafeRunSync() + + names shouldBe Set("n1", "n2", "n3") + } + + "FileMindmupStoreTest" should "load mindmup" in { + val dir = File.newTemporaryDirectory() + (dir / "n1").write("content") + (dir / "n2").write("content") + (dir / "n3").write("content") + + val store = FileMindmupStore[IO](dir) + val content = (for { + store <- FileMindmupStore[IO](dir) + content <- store.load("n1") + } yield content).unsafeRunSync() + + content shouldBe "content" + } + +} diff --git a/src/test/scala/quizz/web/LogicTest.scala b/src/test/scala/quizz/web/LogicTest.scala index af4b19c..5c25644 100644 --- a/src/test/scala/quizz/web/LogicTest.scala +++ b/src/test/scala/quizz/web/LogicTest.scala @@ -3,7 +3,6 @@ package quizz.web import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import quizz.data.ExamplesData -import quizz.web.WebApp.Api class LogicTest extends AnyFlatSpec with Matchers {