diff --git a/.scalafmt.conf b/.scalafmt.conf index cd07f06..8bdb038 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,25 +1,48 @@ version = "3.7.15" runner.dialect = scala3 -align.preset = more + +style = defaultWithAlign assumeStandardLibraryStripMargin = false -align.stripMargin = true -style = defaultWithAlign +docstrings.style = Asterisk +docstrings.oneline = unfold +align = more +align.stripMargin = true +align.arrowEnumeratorGenerator = true +align.multiline = true +align.inInterpolation = true align.openParenCallSite = false align.openParenDefnSite = false -# align.tokens = [{code = "->"}, {code = "<-"}, {code = "=>", owner = "Case"}] +align.tokens = [ + {code = "->"}, + {code = "<-"}, + {code = "=>", owner = "Case"}, + {code = ")"}, + {code = "="}, + {code = "%"}, + {code = "%%"}, + {code = ":", owners = [{regex = "Term\\.Param", parents = [ "Ctor\\.Primary" ]}]} +] + continuationIndent.callSite = 2 continuationIndent.defnSite = 2 + danglingParentheses.preset = true -# verticalMultiline.atDefnSite = true -# newlines.implicitParamListModifierForce = [before] + verticalMultiline.arityThreshold = 1 + +newlines.inInterpolation = oneline newlines.source = keep +newlines.topLevelStatements = [before, after] + indentOperator.preset = spray + maxColumn = 120 + project.excludeFilters = [".*\\.sbt"] + rewrite.rules = [RedundantParens, SortModifiers, AsciiSortImports] -spaces.inImportCurlyBraces = false -# newlines.topLevelStatements = [before] \ No newline at end of file + +spaces.inImportCurlyBraces = false \ No newline at end of file diff --git a/build.sbt b/build.sbt index d4de074..79b51b2 100644 --- a/build.sbt +++ b/build.sbt @@ -32,9 +32,12 @@ lazy val root = (project in file(".")) http4sDSL, pureconfig.cross(CrossVersion.for3Use2_13), akkaStream.cross(CrossVersion.for3Use2_13), - // akkaCluster.cross(CrossVersion.for3Use2_13) - // akkaActor.cross(CrossVersion.for3Use2_13), + akkaCluster.cross(CrossVersion.for3Use2_13), + akkaDistributedData.cross(CrossVersion.for3Use2_13), + akkaActor.cross(CrossVersion.for3Use2_13), // akkaTest.cross(CrossVersion.for3Use2_13), // docker ) ) + +resolvers += "Akka library repository".at("https://repo.akka.io/maven") diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 3c71078..da1e4b8 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -1,10 +1,9 @@ import sbt._ object Dependencies { - lazy val akkaVersion = "2.7.0" - lazy val akkaHttpVersion = "10.4.0" + lazy val akkaVersion = "2.10.0" lazy val scalaTestVersion = "3.2.15" - lazy val scalaLogVersion = "1.2.11" + lazy val scalaLogVersion = "1.4.6" lazy val dockerVersion = "8.9.0" lazy val catsEffectVersion = "3.3.11" lazy val circeVersion = "0.14.5" @@ -12,21 +11,22 @@ object Dependencies { lazy val http4sVersion = "0.23.29" lazy val log4catsVersion = "2.7.0" - lazy val akkaActor = "com.typesafe.akka" %% "akka-actor-typed" % akkaVersion - lazy val akkaStream = "com.typesafe.akka" %% "akka-stream" % akkaVersion - lazy val akkaCluster = "com.typesafe.akka" %% "akka-cluster" % akkaVersion - lazy val scalaLogging = "ch.qos.logback" % "logback-classic" % scalaLogVersion - lazy val log4cats = "org.typelevel" %% "log4cats-core" % log4catsVersion - lazy val docker = "com.spotify" % "docker-client" % dockerVersion - lazy val catsEffect = "org.typelevel" %% "cats-effect" % catsEffectVersion - lazy val circeCore = "io.circe" %% "circe-core" % circeVersion - lazy val circeGeneric = "io.circe" %% "circe-generic" % circeVersion - lazy val circeParser = "io.circe" %% "circe-parser" % circeVersion - lazy val pureconfig = "com.github.pureconfig" %% "pureconfig" % pureconfigVersion - lazy val http4sEmberClient = "org.http4s" %% "http4s-ember-client" % http4sVersion - lazy val http4sEmberServer = "org.http4s" %% "http4s-ember-server" % http4sVersion - lazy val http4sCirce = "org.http4s" %% "http4s-circe" % http4sVersion - lazy val http4sDSL = "org.http4s" %% "http4s-dsl" % http4sVersion - lazy val akkaTest = "com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test - lazy val scalaTest = "org.scalatest" %% "scalatest" % scalaTestVersion % Test + lazy val akkaActor = "com.typesafe.akka" %% "akka-actor-typed" % akkaVersion + lazy val akkaStream = "com.typesafe.akka" %% "akka-stream-typed" % akkaVersion + lazy val akkaCluster = "com.typesafe.akka" %% "akka-cluster-typed" % akkaVersion + lazy val akkaDistributedData = "com.typesafe.akka" %% "akka-distributed-data" % akkaVersion + lazy val scalaLogging = "ch.qos.logback" % "logback-classic" % scalaLogVersion + lazy val log4cats = "org.typelevel" %% "log4cats-core" % log4catsVersion + lazy val docker = "com.spotify" % "docker-client" % dockerVersion + lazy val catsEffect = "org.typelevel" %% "cats-effect" % catsEffectVersion + lazy val circeCore = "io.circe" %% "circe-core" % circeVersion + lazy val circeGeneric = "io.circe" %% "circe-generic" % circeVersion + lazy val circeParser = "io.circe" %% "circe-parser" % circeVersion + lazy val pureconfig = "com.github.pureconfig" %% "pureconfig" % pureconfigVersion + lazy val http4sEmberClient = "org.http4s" %% "http4s-ember-client" % http4sVersion + lazy val http4sEmberServer = "org.http4s" %% "http4s-ember-server" % http4sVersion + lazy val http4sCirce = "org.http4s" %% "http4s-circe" % http4sVersion + lazy val http4sDSL = "org.http4s" %% "http4s-dsl" % http4sVersion + lazy val akkaTest = "com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test + lazy val scalaTest = "org.scalatest" %% "scalatest" % scalaTestVersion % Test } diff --git a/src/main/scala/api/Endpoints.scala b/src/main/scala/api/Endpoints.scala index 26d79ac..22acb08 100644 --- a/src/main/scala/api/Endpoints.scala +++ b/src/main/scala/api/Endpoints.scala @@ -11,6 +11,7 @@ import io.circe.syntax.EncoderOps import io.circe.generic.auto._ class Endpoints { + private val healthRoute: HttpRoutes[IO] = HttpRoutes.of[IO] { case GET -> Root / "health" => Ok("Health check response") @@ -44,4 +45,5 @@ class Endpoints { "/api" -> (healthRoute <+> getReactionRoute <+> postReactionRoute <+> deleteReactionRoute) ) ) + } diff --git a/src/main/scala/api/ErrorHandler.scala b/src/main/scala/api/ErrorHandler.scala index 881571f..bf34ec3 100644 --- a/src/main/scala/api/ErrorHandler.scala +++ b/src/main/scala/api/ErrorHandler.scala @@ -9,6 +9,7 @@ import org.http4s.circe.CirceEntityEncoder._ final case class ErrorResponse(error: String, message: String) object ErrorHandler { + def apply(routes: HttpRoutes[IO]): HttpRoutes[IO] = HttpRoutes.of[IO] { request => routes(request).getOrElseF( IO(Response(Status.NotFound).withEntity(ErrorResponse( @@ -35,4 +36,5 @@ object ErrorHandler { ).asJson)) } } + } diff --git a/src/main/scala/api/ServerBuilder.scala b/src/main/scala/api/ServerBuilder.scala index 24b5390..2d145cd 100644 --- a/src/main/scala/api/ServerBuilder.scala +++ b/src/main/scala/api/ServerBuilder.scala @@ -2,16 +2,13 @@ package api import cats.effect.{IO, Resource} import com.comcast.ip4s.{Host, Port} -import cats.syntax.flatMap.toFlatMapOps import org.http4s.server.Server import org.http4s.ember.server.EmberServerBuilder -import org.typelevel.log4cats.Logger class ServerBuilder( - implicit - endpoints: Endpoints, - logger: Logger[IO] + implicit endpoints: Endpoints ) { + def startServer( host: Host, port: Port @@ -22,6 +19,6 @@ class ServerBuilder( .withPort(port) .withHttpApp(ErrorHandler(endpoints.routes).orNotFound) .build - .flatTap { server => Resource.eval(logger.info("Press ENTER to terminate...")) } } + } diff --git a/src/main/scala/app/Main.scala b/src/main/scala/app/Main.scala index aa16ca6..27adeaf 100644 --- a/src/main/scala/app/Main.scala +++ b/src/main/scala/app/Main.scala @@ -10,6 +10,7 @@ import org.typelevel.log4cats.Logger import scala.concurrent.ExecutionContext object Main extends IOApp { + def actorSystemResource( implicit ec: ExecutionContext, @@ -50,6 +51,7 @@ object Main extends IOApp { _ <- Resource.eval(logger.info("Creating ServerBuilder resource")) serverBuilder <- serverBuilderResource _ <- serverBuilder.startServer(host, port) + _ <- Resource.eval(logger.info("Press ENTER to terminate...")) _ <- Resource.eval(IO(scala.io.StdIn.readLine)) } yield () @@ -66,4 +68,5 @@ object Main extends IOApp { runApp(httpConfig.host, httpConfig.port).use(_ => IO.unit).as(ExitCode.Success) } + } diff --git a/src/main/scala/config/ConfigLoader.scala b/src/main/scala/config/ConfigLoader.scala index 002cc54..6547d1f 100644 --- a/src/main/scala/config/ConfigLoader.scala +++ b/src/main/scala/config/ConfigLoader.scala @@ -5,61 +5,108 @@ import com.typesafe.config.{Config, ConfigFactory} import pureconfig.{ConfigReader, ConfigSource} import pureconfig.error.CannotConvert import java.io.File +import scala.concurrent.duration.FiniteDuration case class KafkaTopics( - reactions: String, + reactions: String, mechanisms: String ) -object KafkaTopics -implicit val kafkaTopicsReader: ConfigReader[KafkaTopics] = - ConfigReader.forProduct2("reactions", "mechanisms")(KafkaTopics.apply) +object KafkaTopics { + + implicit val kafkaTopicsReader: ConfigReader[KafkaTopics] = + ConfigReader.forProduct2("reactions", "mechanisms")(KafkaTopics.apply) + +} case class KafkaConfig( bootstrapServers: String, - topic: KafkaTopics + topic: KafkaTopics ) -object KafkaConfig -implicit val kafkaConfigReader: ConfigReader[KafkaConfig] = - ConfigReader.forProduct2("bootstrapServers", "topic")(KafkaConfig.apply) +object KafkaConfig { + + implicit val kafkaConfigReader: ConfigReader[KafkaConfig] = + ConfigReader.forProduct2("bootstrapServers", "topic")(KafkaConfig.apply) + +} case class HttpConfig( host: Host, port: Port ) -implicit val hostReader: ConfigReader[Host] = ConfigReader.fromString { str => - Host.fromString(str).toRight(CannotConvert(str, "Host", "Invalid host format")) -} +object HttpConfig { -implicit val portReader: ConfigReader[Port] = ConfigReader.fromString { str => - Port.fromString(str).toRight(CannotConvert(str, "Port", "Invalid port format")) -} + implicit val hostReader: ConfigReader[Host] = ConfigReader.fromString { str => + Host.fromString(str).toRight(CannotConvert(str, "Host", "Invalid host format")) + } -object HttpConfig -implicit val httpConfigReader: ConfigReader[HttpConfig] = - ConfigReader.forProduct2("host", "port")(HttpConfig.apply) + implicit val portReader: ConfigReader[Port] = ConfigReader.fromString { str => + Port.fromString(str).toRight(CannotConvert(str, "Port", "Invalid port format")) + } + + implicit val httpConfigReader: ConfigReader[HttpConfig] = + ConfigReader.forProduct2("host", "port")(HttpConfig.apply) + +} case class DatabaseConfig( - url: String, - user: String, + url: String, + user: String, password: String ) -object DatabaseConfig -implicit val databaseConfigReader: ConfigReader[DatabaseConfig] = - ConfigReader.forProduct3("url", "user", "password")(DatabaseConfig.apply) +object DatabaseConfig { + + implicit val databaseConfigReader: ConfigReader[DatabaseConfig] = + ConfigReader.forProduct3("url", "user", "password")(DatabaseConfig.apply) + +} + +case class HttpClientConfig( + baseUri: String, + timeout: HttpClientTimeout, + retries: Int, + pool: HttpClientPool +) + +case class HttpClientTimeout( + connect: FiniteDuration, + request: FiniteDuration +) + +case class HttpClientPool( + maxConnections: Int, + maxIdleTime: FiniteDuration +) + +object HttpClientConfig { + + implicit val httpClientTimeoutReader: ConfigReader[HttpClientTimeout] = + ConfigReader.forProduct2("connect", "request")(HttpClientTimeout.apply) + + implicit val httpClientPoolReader: ConfigReader[HttpClientPool] = + ConfigReader.forProduct2("max-connections", "max-idle-time")(HttpClientPool.apply) + + implicit val httpClientConfigReader: ConfigReader[HttpClientConfig] = + ConfigReader.forProduct4("baseUri", "timeout", "retries", "pool")(HttpClientConfig.apply) + +} case class AppConfig( - kafka: KafkaConfig, - http: HttpConfig, - database: DatabaseConfig + kafka: KafkaConfig, + http: HttpConfig, + database: DatabaseConfig, + httpClient: HttpClientConfig ) -object AppConfig -implicit val appConfigReader: ConfigReader[AppConfig] = - ConfigReader.forProduct3("kafka", "http", "database")(AppConfig.apply) +object AppConfig { + + implicit val appConfigReader: ConfigReader[AppConfig] = + ConfigReader.forProduct4("kafka", "http", "database", "httpClient")(AppConfig.apply) + +} object ConfigLoader { System.setProperty("logback.configurationFile", "src/main/scala/resource/logback.xml") @@ -73,8 +120,9 @@ object ConfigLoader { private val config: Config = appConf.withFallback(refConf).resolve() - val appConfig: AppConfig = ConfigSource.fromConfig(config).loadOrThrow[AppConfig] - val kafkaConfig: KafkaConfig = appConfig.kafka - val httpConfig: HttpConfig = appConfig.http - val databaseConfig: DatabaseConfig = appConfig.database + val appConfig: AppConfig = ConfigSource.fromConfig(config).loadOrThrow[AppConfig] + val kafkaConfig: KafkaConfig = appConfig.kafka + val httpConfig: HttpConfig = appConfig.http + val databaseConfig: DatabaseConfig = appConfig.database + val httpClientConfig: HttpClientConfig = appConfig.httpClient } diff --git a/src/main/scala/core/domain/Chemical.scala b/src/main/scala/core/domain/Chemical.scala index 69dd3b3..bc5bd3b 100644 --- a/src/main/scala/core/domain/Chemical.scala +++ b/src/main/scala/core/domain/Chemical.scala @@ -1,32 +1,38 @@ -package chemical +package core.domain import io.circe.{Decoder, Encoder} import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} +type MoleculeId = Int + +type ReactionId = Int + +type CatalystId = Int + case class Molecule( - moleculeId: Int, - moleculeSmiles: String, - moleculeIupacName: String + id: MoleculeId, + smiles: String, + iupacName: String ) case class Reaction( - reactionId: Int, - reactionName: String + id: ReactionId, + name: String ) case class Catalyst( - catalystId: Int, - catalystSmiles: String, - catalystName: Option[String] + id: CatalystId, + smiles: String, + name: Option[String] ) -case class ProductFrom(productAmount: Float) +case class PRODUCT_FROM(productAmount: Float) -case class ReagentIn(reagentAmount: Float) +case class REAGENT_IN(reagentAmount: Float) -case class Accelerate( +case class ACCELERATE( temperature: List[Float], - pressure: List[Float] + pressure: List[Float] ) object Molecule { @@ -44,17 +50,17 @@ object Catalyst { implicit val catalystDecoder: Decoder[Catalyst] = deriveDecoder[Catalyst] } -object ProductFrom { - implicit val productFromEncoder: Encoder[ProductFrom] = deriveEncoder[ProductFrom] - implicit val productFromDecoder: Decoder[ProductFrom] = deriveDecoder[ProductFrom] +object PRODUCT_FROM { + implicit val productFromEncoder: Encoder[PRODUCT_FROM] = deriveEncoder[PRODUCT_FROM] + implicit val productFromDecoder: Decoder[PRODUCT_FROM] = deriveDecoder[PRODUCT_FROM] } -object ReagentIn { - implicit val reagentInEncoder: Encoder[ReagentIn] = deriveEncoder[ReagentIn] - implicit val reagentInDecoder: Decoder[ReagentIn] = deriveDecoder[ReagentIn] +object REAGENT_IN { + implicit val reagentInEncoder: Encoder[REAGENT_IN] = deriveEncoder[REAGENT_IN] + implicit val reagentInDecoder: Decoder[REAGENT_IN] = deriveDecoder[REAGENT_IN] } -object Accelerate { - implicit val accelerateEncoder: Encoder[Accelerate] = deriveEncoder[Accelerate] - implicit val accelerateDecoder: Decoder[Accelerate] = deriveDecoder[Accelerate] +object ACCELERATE { + implicit val accelerateEncoder: Encoder[ACCELERATE] = deriveEncoder[ACCELERATE] + implicit val accelerateDecoder: Decoder[ACCELERATE] = deriveDecoder[ACCELERATE] } diff --git a/src/main/scala/core/domain/Interactant.scala b/src/main/scala/core/domain/Interactant.scala index bde8cc4..d42d46f 100644 --- a/src/main/scala/core/domain/Interactant.scala +++ b/src/main/scala/core/domain/Interactant.scala @@ -3,13 +3,59 @@ package core.domain import io.circe.{Decoder, Encoder} import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} -case class Interactant( - id: String, - name: String, - role: String -) +sealed trait Interactant object Interactant { - implicit val interactantEncoder: Encoder[Interactant] = deriveEncoder[Interactant] - implicit val interactantDecoder: Decoder[Interactant] = deriveDecoder[Interactant] + implicit val interactantEncoder: Encoder[Interactant] = deriveEncoder + implicit val interactantDecoder: Decoder[Interactant] = deriveDecoder +} + +case class IAccelerate(accelerate: ACCELERATE) extends Interactant +case class ICatalyst(catalyst: Catalyst) extends Interactant +case class IMolecule(molecule: Molecule) extends Interactant +case class IProductFrom(productFrom: PRODUCT_FROM) extends Interactant +case class IReagentIn(reagentIn: REAGENT_IN) extends Interactant +case class IReaction(reaction: Reaction) extends Interactant + +object IAccelerate { + implicit val iAccelerateEncoder: Encoder[IAccelerate] = deriveEncoder + implicit val iAccelerateDecoder: Decoder[IAccelerate] = deriveDecoder +} + +object ICatalyst { + implicit val iCatalystEncoder: Encoder[ICatalyst] = deriveEncoder + implicit val iCatalystDecoder: Decoder[ICatalyst] = deriveDecoder +} + +object IMolecule { + implicit val iMoleculeEncoder: Encoder[IMolecule] = deriveEncoder + implicit val iMoleculeDecoder: Decoder[IMolecule] = deriveDecoder +} + +object IProductFrom { + implicit val iProductFromEncoder: Encoder[IProductFrom] = deriveEncoder + implicit val iProductFromDecoder: Decoder[IProductFrom] = deriveDecoder +} + +object IReagentIn { + implicit val iReagentInEncoder: Encoder[IReagentIn] = deriveEncoder + implicit val iReagentInDecoder: Decoder[IReagentIn] = deriveDecoder +} + +object IReaction { + implicit val iReactionEncoder: Encoder[IReaction] = deriveEncoder + implicit val iReactionDecoder: Decoder[IReaction] = deriveDecoder +} + +sealed trait Explain + +case class EMechanism(mechanism: Mechanism) extends Explain +case class EStage(stage: Stage) extends Explain + +object Explain { + implicit val eMechanismEncoder: Encoder[EMechanism] = deriveEncoder + implicit val eMechanismDecoder: Decoder[EMechanism] = deriveDecoder + + implicit val eStageEncoder: Encoder[EStage] = deriveEncoder + implicit val eStageDecoder: Decoder[EStage] = deriveDecoder } diff --git a/src/main/scala/core/domain/Mechenism.scala b/src/main/scala/core/domain/Mechenism.scala new file mode 100644 index 0000000..22061d8 --- /dev/null +++ b/src/main/scala/core/domain/Mechenism.scala @@ -0,0 +1,47 @@ +package core.domain + +import io.circe.{Decoder, Encoder} +import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} + +type MechanismId = Int +type StageID = Int + +case class Mechanism( + id: MechanismId, + name: String, + family: String, + activationEnergy: Float +) + +object Mechanism { + implicit val mechanismEncoder: Encoder[Mechanism] = deriveEncoder[Mechanism] + implicit val mechanismDecoder: Decoder[Mechanism] = deriveDecoder[Mechanism] +} + +case class FOLLOW( + description: String +) + +object FOLLOW { + implicit val followEncoder: Encoder[FOLLOW] = deriveEncoder[FOLLOW] + implicit val followDecoder: Decoder[FOLLOW] = deriveDecoder[FOLLOW] +} + +case class Stage( + order: StageID, + name: String, + description: String, + products: List[String] +) + +object Stage { + implicit val stageEncoder: Encoder[Stage] = deriveEncoder[Stage] + implicit val stageDecoder: Decoder[Stage] = deriveDecoder[Stage] +} + +case class INCLUDE() + +object INCLUDE { + implicit val includeEncoder: Encoder[INCLUDE] = deriveEncoder[INCLUDE] + implicit val includeDecoder: Decoder[INCLUDE] = deriveDecoder[INCLUDE] +} diff --git a/src/main/scala/core/domain/Process.scala b/src/main/scala/core/domain/Process.scala index f7bb0ec..bf82ea9 100644 --- a/src/main/scala/core/domain/Process.scala +++ b/src/main/scala/core/domain/Process.scala @@ -1,17 +1,36 @@ -package resource.models +package core.domain import io.circe.{Decoder, Encoder} import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} -import chemical.{Accelerate, Catalyst, Molecule, ProductFrom, Reaction, ReagentIn} case class ReactionDetails( - reaction: Reaction, - inboundReagents: List[(ReagentIn, Molecule)], - outboundProducts: List[(ProductFrom, Molecule)], - conditions: List[(Accelerate, Catalyst)] + reaction: Reaction, + inboundReagents: List[(REAGENT_IN, Molecule)], + outboundProducts: List[(PRODUCT_FROM, Molecule)], + conditions: List[(ACCELERATE, Catalyst)] ) object ReactionDetails { implicit val reactionDetailsEncoder: Encoder[ReactionDetails] = deriveEncoder[ReactionDetails] implicit val reactionDetailsDecoder: Decoder[ReactionDetails] = deriveDecoder[ReactionDetails] } + +case class MechanismDetails( + mechanismContext: (Mechanism, FOLLOW), + stageInteractants: List[(Stage, List[Interactant])] +) + +object MechanismDetails { + implicit val mechanismDetailsEncoder: Encoder[MechanismDetails] = deriveEncoder[MechanismDetails] + implicit val mechanismDetailsDecoder: Decoder[MechanismDetails] = deriveDecoder[MechanismDetails] +} + +case class ProcessDetails( + reactionDetails: ReactionDetails, + mechanismDetails: MechanismDetails +) + +object ProcessDetails { + implicit val processDetailsEncoder: Encoder[ProcessDetails] = deriveEncoder[ProcessDetails] + implicit val processDetailsDecoder: Decoder[ProcessDetails] = deriveDecoder[ProcessDetails] +} diff --git a/src/main/scala/core/domain/Reaction.scala b/src/main/scala/core/domain/Reaction.scala deleted file mode 100644 index 288c38b..0000000 --- a/src/main/scala/core/domain/Reaction.scala +++ /dev/null @@ -1,17 +0,0 @@ -package core.domain - -import io.circe.{Decoder, Encoder} -import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} - -case class Reaction( - id: String, - name: String, - description: String, - reactants: List[Interactant], - products: List[Interactant] -) - -object Reaction { - implicit val reactionEncoder: Encoder[Reaction] = deriveEncoder[Reaction] - implicit val reactionDecoder: Decoder[Reaction] = deriveDecoder[Reaction] -} diff --git a/src/main/scala/core/errors/http/MechanismError.scala b/src/main/scala/core/errors/http/MechanismError.scala new file mode 100644 index 0000000..17db9b7 --- /dev/null +++ b/src/main/scala/core/errors/http/MechanismError.scala @@ -0,0 +1,11 @@ +package core.errors.http + +sealed trait MechanismError extends Throwable { + def message: String +} + +object MechanismError { + case class NotFoundError(message: String) extends MechanismError + case class CreationError(message: String) extends MechanismError + case class DeletionError(message: String) extends MechanismError +} diff --git a/src/main/scala/core/errors/http/ReactionError.scala b/src/main/scala/core/errors/http/ReactionError.scala new file mode 100644 index 0000000..7ae87eb --- /dev/null +++ b/src/main/scala/core/errors/http/ReactionError.scala @@ -0,0 +1,11 @@ +package core.errors.http + +sealed trait ReactionError extends Throwable { + def message: String +} + +object ReactionError { + case class NotFoundError(message: String) extends ReactionError + case class CreationError(message: String) extends ReactionError + case class DeletionError(message: String) extends ReactionError +} diff --git a/src/main/scala/core/repositories/InMemoryMechanismRepository.scala b/src/main/scala/core/repositories/InMemoryMechanismRepository.scala new file mode 100644 index 0000000..a5812ee --- /dev/null +++ b/src/main/scala/core/repositories/InMemoryMechanismRepository.scala @@ -0,0 +1,102 @@ +package core.repositories + +import cats.effect.{Ref, Sync} +import core.domain.{Mechanism, MechanismId} +import core.errors.http.MechanismError +import core.errors.http.MechanismError.CreationError +import cats.implicits.toFunctorOps +import types.MechanismRepository + +/** + * InMemoryMechanismRepository is analogous to a Haskell stateful data structure that holds a `Map` within a monadic + * context. This class abstracts over an effect type `F`, which can be seen as a Haskell monad that supports side + * effects and state management. + * + * @param state + * Ref[F, Map[MechanismId, Mechanism]] + * - `Ref` in Scala is similar to `IORef` or `MVar` in Haskell, representing mutable state within a monad. + * - `Map[MechanismId, Mechanism]` represents an immutable key-value data structure, comparable to `Data.Map` in + * Haskell. + * - `F[_]: Sync` constraint in Scala corresponds to a Haskell `MonadIO` constraint, enabling us to manage effects in + * a functional way. + * + * @tparam F + * The abstract effect type, which could be likened to an effectful monad in Haskell (e.g., `IO`, `StateT`). + */ +class InMemoryMechanismRepository[F[_]: Sync](state: Ref[F, Map[MechanismId, Mechanism]]) + extends MechanismRepository[F] { + + /** + * Generates a new unique `MechanismId` based on the current state. + * + * This function is analogous to a pure function in Haskell: + * + * `generateId` :: `Map MechanismId Mechanism` -> `MechanismId` + * + * It takes an immutable `Map` and returns a new unique ID by finding the maximum key and adding 1. Using `maxOption`, + * it safely handles the case of an empty map by defaulting to 0. + * + * In Haskell, `Map` is also immutable by default, so this function would work on a snapshot of the state there as + * well. + */ + private def generateId(currentState: Map[MechanismId, Mechanism]): MechanismId = + currentState.keys.maxOption.getOrElse(0) + 1 + + /** + * Retrieves a Mechanism by its identifier. + * + * This function’s signature in Haskell might look like: + * + * get :: Monad m => MechanismId -> StateT (Map MechanismId Mechanism) m (Maybe Mechanism) + * + * - The `Option[Mechanism]` is analogous to `Maybe Mechanism` in Haskell. + * - The monadic context `F` represents the effect type (like `StateT` or `IO`), enabling access to the mutable + * state `Ref`. + */ + def get(id: MechanismId): F[Option[Mechanism]] = + state.get.map(_.get(id)) + + /** + * Creates a new Mechanism entry, assigning it a unique identifier, and updates the state. + * + * Haskell equivalent signature: + * + * create :: Monad m => Mechanism -> StateT (Map MechanismId Mechanism) m Mechanism + * + * - This function modifies the state, analogous to Haskell’s `StateT` monad transformer with `modify`. + * - `state.modify` here acts like `modify` in Haskell’s `State` monad, updating the map with the new Mechanism. + * - The `copy` method in Scala can be thought of as `record syntax` in Haskell, creating a new `Mechanism` with an + * updated `id`. + */ + def create(mechanism: Mechanism): F[Either[MechanismError, Mechanism]] = { + state.modify { mechanisms => + val id = generateId(mechanisms) + if (mechanisms.values.exists(_.name == mechanism.name)) { + // Returns Left if a mechanism with the same name already exists + (mechanisms, Left(CreationError(s"Mechanism with name '${mechanism.name}' already exists"))) + } else { + val newMechanism = mechanism.copy(id) + (mechanisms + (id -> newMechanism), Right(newMechanism)) + } + } + } + + /** + * Deletes a Mechanism from the state by its identifier. + * + * Equivalent Haskell signature: + * + * delete :: Monad m => MechanismId -> StateT (Map MechanismId Mechanism) m Bool + * + * - `Option` here would be represented by `Maybe` in Haskell, where the result is either `True` (for success) or + * `False`. + * - The `modify` function again resembles Haskell’s `StateT modify`, allowing safe state updates within an + * effectful context. + */ + def delete(id: MechanismId): F[Boolean] = + state.modify { mechanisms => + if (mechanisms.contains(id)) (mechanisms - id, true) + else (mechanisms, false) + } + +} diff --git a/src/main/scala/core/repositories/InMemoryReactionRepository.scala b/src/main/scala/core/repositories/InMemoryReactionRepository.scala new file mode 100644 index 0000000..4d161bb --- /dev/null +++ b/src/main/scala/core/repositories/InMemoryReactionRepository.scala @@ -0,0 +1,36 @@ +package core.repositories + +import cats.effect.{Ref, Sync} +import cats.implicits.toFunctorOps +import core.domain.{Reaction, ReactionId} +import types.ReactionRepository +import core.errors.http.ReactionError +import core.errors.http.ReactionError.CreationError + +class InMemoryReactionRepository[F[_]: Sync](state: Ref[F, Map[ReactionId, Reaction]]) extends ReactionRepository[F] { + + private def generateId(currentState: Map[ReactionId, Reaction]): Int = + currentState.keys.maxOption.getOrElse(0) + 1 + + def get(id: ReactionId): F[Option[Reaction]] = + state.get.map(_.get(id)) + + def create(reaction: Reaction): F[Either[ReactionError, Reaction]] = { + state.modify { reactions => + val id = generateId(reactions) + if (reactions.values.exists(_.name == reaction.name)) { + (reactions, Left(CreationError(s"Reaction with name '${reaction.name}' already exists"))) + } else { + val newReaction = reaction.copy(id) + (reactions + (id -> newReaction), Right(newReaction)) + } + } + } + + def delete(id: ReactionId): F[Boolean] = + state.modify { reactions => + if (reactions.contains(id)) (reactions - id, true) + else (reactions, false) + } + +} diff --git a/src/main/scala/core/repositories/Neo4jReactionRepository.scala b/src/main/scala/core/repositories/Neo4jReactionRepository.scala new file mode 100644 index 0000000..2790126 --- /dev/null +++ b/src/main/scala/core/repositories/Neo4jReactionRepository.scala @@ -0,0 +1,90 @@ +package core.repositories + +import cats.effect.Sync +import cats.implicits.{toFlatMapOps, toFunctorOps} +import core.domain.{Reaction, ReactionId} +import core.errors.http.ReactionError +import core.errors.http.ReactionError.CreationError +import io.circe.parser.decode +import io.circe.syntax.EncoderOps +import infrastructure.http.HttpClient +import org.http4s.Uri +import types.ReactionRepository + +/** + * ADDITIONAL MODULE + * + * Neo4jReactionRepository provides a direct interface to the Chemist service for managing reactions. This + * implementation bypasses any caching or additional service logic, directly interacting with the Neo4j-backed Chemist + * service through HTTP requests. + * + * @param client + * The HttpClient used to communicate with the Chemist service. + */ +class Neo4jReactionRepository[F[_]: Sync](client: HttpClient[F]) extends ReactionRepository[F] { + + /** + * Fetches a reaction by ID from the Chemist service. + * + * @param id + * The ReactionId of the reaction to fetch. + * @return + * An F-wrapped Option of Reaction. If the reaction is found, it returns Some(Reaction), otherwise None. + */ + def get(id: ReactionId): F[Option[Reaction]] = + client + .get(Uri.Path.unsafeFromString(s"/reaction/$id")) + .map(decode[Option[Reaction]]) + .flatMap(Sync[F].fromEither) + + /** + * Creates a new reaction in the Chemist service. + * + * @param reaction + * The Reaction object to be created. + * @return + * An F-wrapped Either with ReactionError on the left in case of a failure, or the created Reaction on the right if + * successful. + */ + def create(reaction: Reaction): F[Either[ReactionError, Reaction]] = + client + .post(Uri.Path.unsafeFromString("/reaction"), reaction.asJson) + .flatMap { response => + io.circe.parser.decode[Reaction](response) match { + case Right(createdReaction) => Sync[F].pure(Right(createdReaction)) + case Left(_) => Sync[F].pure(Left(CreationError(s"Failed to create reaction: ${reaction.name}"))) + } + } + + /** + * Updates an existing reaction by ID in the Chemist service. + * + * @param id + * The ID of the reaction to update. + * @param reaction + * The updated Reaction object. + * @return + * An F-wrapped Option of Reaction. Returns Some(updatedReaction) if successful, otherwise None if the reaction does + * not exist or update fails. + */ + def update(id: Int, reaction: Reaction): F[Option[Reaction]] = + client + .put(Uri.Path.unsafeFromString(s"/reaction/$id"), reaction) + .map(decode[Option[Reaction]]) + .flatMap(Sync[F].fromEither) + + /** + * Deletes a reaction by ID from the Chemist service. + * + * @param id + * The ID of the reaction to delete. + * @return + * An F-wrapped Boolean. Returns true if deletion is successful, false otherwise. + */ + def delete(id: Int): F[Boolean] = + client + .delete(Uri.Path.unsafeFromString(s"/reaction/$id")) + .map(decode[Boolean]) + .flatMap(Sync[F].fromEither) + +} diff --git a/src/main/scala/core/repositories/types/MechanismRepository.scala b/src/main/scala/core/repositories/types/MechanismRepository.scala new file mode 100644 index 0000000..a790724 --- /dev/null +++ b/src/main/scala/core/repositories/types/MechanismRepository.scala @@ -0,0 +1,10 @@ +package core.repositories.types + +import core.domain.{Mechanism, MechanismId} +import core.errors.http.MechanismError + +trait MechanismRepository[F[_]] { + def get(id: MechanismId): F[Option[Mechanism]] + def create(mechanism: Mechanism): F[Either[MechanismError, Mechanism]] + def delete(id: MechanismId): F[Boolean] +} diff --git a/src/main/scala/core/repositories/types/ReactionRepository.scala b/src/main/scala/core/repositories/types/ReactionRepository.scala new file mode 100644 index 0000000..1c73467 --- /dev/null +++ b/src/main/scala/core/repositories/types/ReactionRepository.scala @@ -0,0 +1,10 @@ +package core.repositories.types + +import core.domain.{Reaction, ReactionId} +import core.errors.http.ReactionError + +trait ReactionRepository[F[_]] { + def get(id: ReactionId): F[Option[Reaction]] + def create(reaction: Reaction): F[Either[ReactionError, Reaction]] + def delete(id: ReactionId): F[Boolean] +} diff --git a/src/main/scala/core/services/CacheService.scala b/src/main/scala/core/services/CacheService.scala new file mode 100644 index 0000000..a312d70 --- /dev/null +++ b/src/main/scala/core/services/CacheService.scala @@ -0,0 +1,60 @@ +package core.services + +import scala.concurrent.duration._ +import core.domain.{Mechanism, MechanismId, Reaction, ReactionId} +import scala.collection.concurrent.TrieMap +import scala.concurrent.duration.FiniteDuration +import cats.effect.kernel.Sync + +class CacheService[F[_]: Sync] { + private val mechanismCache: TrieMap[MechanismId, (Mechanism, Long)] = TrieMap.empty + private val reactionCache: TrieMap[ReactionId, (Reaction, Long)] = TrieMap.empty + private val ttl: FiniteDuration = 5.minutes + + private def currentTime: Long = System.currentTimeMillis + + private def isExpired(entryTime: Long): Boolean = currentTime - entryTime > ttl.toMillis + + def getMechanism(id: MechanismId): F[Option[Mechanism]] = Sync[F].delay { + mechanismCache.get(id).collect { + case (mechanism, timestamp) if !isExpired(timestamp) => mechanism + } + } + + def putMechanism(id: MechanismId, mechanism: Mechanism): F[Unit] = Sync[F].delay { + mechanismCache.update(id, (mechanism, currentTime)) + } + + def createMechanism(id: MechanismId, mechanism: Mechanism): F[Either[String, Mechanism]] = Sync[F].delay { + if (mechanismCache.contains(id)) Left(s"Mechanism with ID $id already exists in cache.") + else { + mechanismCache.update(id, (mechanism, currentTime)) + Right(mechanism) + } + } + + def getReaction(id: ReactionId): F[Option[Reaction]] = Sync[F].delay { + reactionCache.get(id).collect { + case (reaction, timestamp) if !isExpired(timestamp) => reaction + } + } + + def createReaction(id: ReactionId, reaction: Reaction): F[Either[String, Reaction]] = Sync[F].delay { + if (reactionCache.contains(id)) Left(s"Reaction with ID $id already exists in cache.") + else { + reactionCache.update(id, (reaction, currentTime)) + Right(reaction) + } + } + + def putReaction(id: ReactionId, reaction: Reaction): F[Unit] = Sync[F].delay { + reactionCache.update(id, (reaction, currentTime)) + } + + def cleanExpiredEntries: F[Unit] = Sync[F].delay { + val now = currentTime + mechanismCache.filterInPlace { case (_, (_, timestamp)) => now - timestamp <= ttl.toMillis } + reactionCache.filterInPlace { case (_, (_, timestamp)) => now - timestamp <= ttl.toMillis } + } + +} diff --git a/src/main/scala/core/services/DistributedCacheService.scala b/src/main/scala/core/services/DistributedCacheService.scala new file mode 100644 index 0000000..f2cf9f6 --- /dev/null +++ b/src/main/scala/core/services/DistributedCacheService.scala @@ -0,0 +1,65 @@ +// WORK WITH SCALA 2.13 + +// package core.services + +// import akka.actor.{ActorSystem, Scheduler} +// import akka.cluster.ddata.Replicator.{Get, Update, WriteLocal} +// import akka.cluster.ddata.scaladsl.{DistributedData, LWWMapKey, Replicator} +// // import akka.cluster.ddata._ +// // import akka.util.Timeout +// import cats.effect.Sync +// import cats.implicits._ +// import core.domain.{Mechanism, MechanismId, Reaction, ReactionId} +// import scala.concurrent.duration._ +// import cats.effect.kernel.Sync + +// class DistributedCacheService[F[_]: Sync](system: ActorSystem) { +// private val replicator = DistributedData(system).replicator + +// // implicit private val timeout: Timeout = 3.seconds +// implicit private val scheduler: Scheduler = system.scheduler + +// // Keys for distributed data maps +// private val mechanismCacheKey = LWWMapKey[MechanismId, Mechanism]("mechanismCache") +// private val reactionCacheKey = LWWMapKey[ReactionId, Reaction]("reactionCache") + +// def getMechanism(id: MechanismId): F[Option[Mechanism]] = Sync[F].delay { +// val getRequest = Get(mechanismCacheKey, Replicator.readLocal) +// replicator.ask[Replicator.GetResponse[LWWMapKey[MechanismId, Mechanism]]](replyTo => +// getRequest.copy(replyTo = replyTo) +// ) +// .map { +// case response: Replicator.GetSuccess[LWWMapKey[MechanismId, Mechanism]] => +// response.dataValue.get(id) +// case _ => None +// } +// } + +// def putMechanism(id: MechanismId, mechanism: Mechanism): F[Unit] = Sync[F].delay { +// val update = Update(mechanismCacheKey, Replicator.writeLocal)(_ + (id -> mechanism)) +// replicator.ask[Replicator.UpdateResponse[LWWMapKey[MechanismId, Mechanism]]](replyTo => +// update.copy(replyTo = replyTo) +// ) +// } + +// def getReaction(id: ReactionId): F[Option[Reaction]] = Sync[F].delay { +// val getRequest = Get(reactionCacheKey, Replicator.readLocal) +// replicator.ask[Replicator.GetResponse[LWWMapKey[ReactionId, Reaction]]](replyTo => +// getRequest.copy(replyTo = replyTo) +// ) +// .map { +// case response: Replicator.GetSuccess[LWWMapKey[ReactionId, Reaction]] => +// response.dataValue.get(id) +// case _ => None +// } +// } + +// def putReaction(id: ReactionId, reaction: Reaction): F[Unit] = Sync[F].delay { +// val update = Update(reactionCacheKey, Replicator.writeLocal)(_ + (id -> reaction)) +// replicator.ask[Replicator.UpdateResponse[LWWMapKey[ReactionId, Reaction]]](replyTo => +// update.copy(replyTo = replyTo) +// ) +// } + +// def cleanExpiredEntries: F[Unit] = Sync[F].unit +// } diff --git a/src/main/scala/core/services/MechanismService.scala b/src/main/scala/core/services/MechanismService.scala new file mode 100644 index 0000000..2c73ccc --- /dev/null +++ b/src/main/scala/core/services/MechanismService.scala @@ -0,0 +1,72 @@ +package core.services + +import cats.effect.Concurrent +import cats.implicits.{catsSyntaxApplicativeError, catsSyntaxApplicativeId, catsSyntaxApplyOps, toFlatMapOps} +import core.domain.{Mechanism, MechanismId} +import core.errors.http.MechanismError +import core.errors.http.MechanismError.{CreationError, DeletionError, NotFoundError} +import org.http4s.client.Client +import org.http4s.{Method, Request, Status, Uri} +import io.circe.syntax.EncoderOps +import org.http4s.circe.jsonEncoder +import org.http4s.circe.toMessageSyntax +import org.http4s.implicits.uri + +class MechanismService[F[_]: Concurrent]( + client: Client[F], + cacheService: CacheService[F] +) { + private val baseUri = uri"http://localhost:8080/mechanism" + + def getMechanism(id: MechanismId): F[Mechanism] = + cacheService + .getMechanism(id) + .flatMap { + case Some(cachedMechanism) => + Concurrent[F].pure(cachedMechanism) + case None => + client + .run(Request[F](Method.GET, baseUri / id.toString)) + .use { response => + response + .decodeJson[Mechanism] + .attempt + .flatMap { + case Right(mechanism) if response.status.isSuccess => + cacheService.putMechanism(id, mechanism) *> Concurrent[F].pure(mechanism) + case _ if response.status == Status.NotFound => + Concurrent[F].raiseError(new NotFoundError(s"Mechanism with ID $id not found")) + case _ => + Concurrent[F].raiseError(new RuntimeException(s"Failed to fetch Mechanism with ID $id")) + } + } + } + + def createMechanism(mechanism: Mechanism): F[Mechanism] = + client + .run(Request[F](Method.POST, baseUri).withEntity(mechanism.asJson)) + .use { response => + response + .decodeJson[Mechanism] + .attempt + .flatMap { + case Right(createdMechanism) if response.status.isSuccess => + cacheService.putMechanism(createdMechanism.id, createdMechanism) + *> Concurrent[F].pure(createdMechanism) + case Right(_) => Concurrent[F].raiseError(CreationError("Mechanism could not be created")) + case Left(error) => Concurrent[F].raiseError( + CreationError(s"Failed to create Mechanism: ${error.getMessage}") + ) + } + } + + def deleteMechanism(id: MechanismId): F[Either[MechanismError, Boolean]] = + client.run(Request[F](Method.DELETE, baseUri / id.toString)).use { response => + if (response.status == Status.NoContent) { + cacheService.cleanExpiredEntries *> Right(true).pure[F] + } else { + Left(DeletionError(s"Failed to delete Mechanism with ID: $id")).pure[F] + } + } + +} diff --git a/src/main/scala/core/services/ReactionService.scala b/src/main/scala/core/services/ReactionService.scala new file mode 100644 index 0000000..0f85665 --- /dev/null +++ b/src/main/scala/core/services/ReactionService.scala @@ -0,0 +1,70 @@ +package core.services + +import cats.effect.Concurrent +import cats.implicits.{catsSyntaxApplicativeError, catsSyntaxApplicativeId, catsSyntaxApplyOps, toFlatMapOps} +import core.domain.{Reaction, ReactionId} +import core.errors.http.ReactionError +import core.errors.http.ReactionError.{CreationError, DeletionError, NotFoundError} +import org.http4s.client.Client +import org.http4s.{Method, Request, Status, Uri} +import io.circe.syntax.EncoderOps +import org.http4s.circe.jsonEncoder +import org.http4s.circe.toMessageSyntax +import org.http4s.implicits.uri + +class ReactionService[F[_]: Concurrent]( + client: Client[F], + cacheService: CacheService[F] +) { + private val baseUri = uri"http://localhost:8080/reaction" + + def getReaction(id: ReactionId): F[Reaction] = + cacheService + .getReaction(id) + .flatMap { + case Some(cachedReaction) => + Concurrent[F].pure(cachedReaction) + case None => + client.run(Request[F](Method.GET, baseUri / id.toString)).use { response => + response + .decodeJson[Reaction] + .attempt + .flatMap { + case Right(reaction) if response.status.isSuccess => + cacheService.putReaction(id, reaction) *> Concurrent[F].pure(reaction) + case _ if response.status == Status.NotFound => + Concurrent[F].raiseError(new NotFoundError(s"Reaction with ID $id not found")) + case _ => + Concurrent[F].raiseError(new RuntimeException(s"Failed to fetch Reaction with ID $id")) + } + } + } + + def createReaction(Reaction: Reaction): F[Reaction] = + client + .run(Request[F](Method.POST, baseUri).withEntity(Reaction.asJson)) + .use { response => + response + .decodeJson[Reaction] + .attempt + .flatMap { + case Right(createdReaction) if response.status.isSuccess => + cacheService.putReaction(createdReaction.id, createdReaction) + *> Concurrent[F].pure(createdReaction) + case Right(_) => Concurrent[F].raiseError(CreationError("Reaction could not be created")) + case Left(error) => Concurrent[F].raiseError( + CreationError(s"Failed to create Reaction: ${error.getMessage}") + ) + } + } + + def deleteReaction(id: ReactionId): F[Either[ReactionError, Boolean]] = + client.run(Request[F](Method.DELETE, baseUri / id.toString)).use { response => + if (response.status == Status.NoContent) { + cacheService.cleanExpiredEntries *> Right(true).pure[F] + } else { + Left(DeletionError(s"Failed to delete Reaction with ID: $id")).pure[F] + } + } + +} diff --git a/src/main/scala/infrastructure/http/HttpClient.scala b/src/main/scala/infrastructure/http/HttpClient.scala new file mode 100644 index 0000000..e5a87d0 --- /dev/null +++ b/src/main/scala/infrastructure/http/HttpClient.scala @@ -0,0 +1,57 @@ +package infrastructure.http + +import cats.effect.Async +import cats.implicits.{catsSyntaxApplicativeId, toFlatMapOps} +import org.http4s.{Request, Uri} +import org.http4s.circe.CirceEntityEncoder.circeEntityEncoder +import org.http4s.client.Client +import org.http4s.client.dsl.Http4sClientDsl +import org.http4s.dsl.io.{DELETE, GET, POST, PUT} +import io.circe.{Decoder, Encoder} +import io.circe.syntax.EncoderOps +import io.circe.parser.decode + +class HttpClient[F[_]: Async](client: Client[F], baseUri: Uri) extends Http4sClientDsl[F] { + + private def sendRequest(request: Request[F]): F[String] = + client.run(request).use { response => + response.as[String].flatMap { body => + if (response.status.isSuccess) body.pure[F] + else Async[F].raiseError(new Exception(s"Request failed: $body")) + } + } + + def get(endpoint: Uri.Path): F[String] = { + val request = Request[F](method = GET, uri = baseUri.withPath(endpoint)) + sendRequest(request) + } + + def post[T: Encoder](endpoint: Uri.Path, payload: T): F[String] = { + val request = Request[F](method = POST, uri = baseUri.withPath(endpoint)) + .withEntity(payload.asJson) + sendRequest(request) + } + + def put[T: Encoder](endpoint: Uri.Path, payload: T): F[String] = { + val request = Request[F](method = PUT, uri = baseUri.withPath(endpoint)) + .withEntity(payload.asJson) + sendRequest(request) + } + + def delete(endpoint: Uri.Path): F[String] = { + val request = Request[F](method = DELETE, uri = baseUri.withPath(endpoint)) + sendRequest(request) + } + + def decodeResponse[T: Decoder](response: String): F[T] = + Async[F].fromEither(decode[T](response).left.map(err => new Exception(s"Decoding failed: $err"))) + +} + +object HttpClient { + + def resource[F[_]: Async](client: Client[F], baseUri: Uri): HttpClient[F] = { + new HttpClient[F](client, baseUri) + } + +} diff --git a/src/main/scala/resource/application.conf b/src/main/scala/resource/application.conf index ceb6dec..b2438cc 100644 --- a/src/main/scala/resource/application.conf +++ b/src/main/scala/resource/application.conf @@ -1,4 +1,3 @@ -# Akka configuration akka { actor { provider = "cluster" # Use Akka cluster @@ -22,7 +21,6 @@ akka { } } -# Kafka configuration kafka { bootstrapServers = "localhost:9092" # Kafka broker address client.id = "chemist-flow-client" @@ -36,7 +34,6 @@ kafka { } } -# Database configuration database { url = "jdbc:postgresql://localhost:5432/chemist_db" user = "chemist_user" @@ -47,9 +44,20 @@ database { } } -# HTTP server configuration http { host = "0.0.0.0" # Host for HTTP server port = 8081 # Port for HTTP server } +httpClient { + baseUri = "http://localhost:8080" # Default base URI for external requests + timeout { + connect = 5s # Connection timeout + request = 10s # Request timeout + } + retries = 3 # Number of retries on failure + pool { + max-connections = 50 # Max number of connections + max-idle-time = 30s # Max idle time for connections + } +} diff --git a/src/main/scala/resource/logback.xml b/src/main/scala/resource/logback.xml index d0818ca..b822b63 100644 --- a/src/main/scala/resource/logback.xml +++ b/src/main/scala/resource/logback.xml @@ -3,7 +3,7 @@ - %d{yyyy-MM-dd HH:mm:ss} │ %-5level │ %-20.20logger{36} │ %-15.15thread │ %msg%n + %d{yyyy-MM-dd HH:mm:ss} │ %-4level │ %-45.45logger{36} │ %-35.35thread │ %msg%n diff --git a/src/test/scala/app/MainSpec.scala b/src/test/scala/app/MainSpec.scala index d663bad..e209182 100644 --- a/src/test/scala/app/MainSpec.scala +++ b/src/test/scala/app/MainSpec.scala @@ -9,19 +9,16 @@ import com.comcast.ip4s.Port import org.scalatest.BeforeAndAfterAll import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AsyncWordSpec -import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger class MainSpec extends AsyncWordSpec with Matchers with BeforeAndAfterAll { "Main" should { "start the http4s server as a Resource" in { - implicit val logger: Logger[IO] = Slf4jLogger.getLogger[IO] - implicit val endpoints = new Endpoints - val serverBuilder = new ServerBuilder - val maybeHost = Host.fromString("0.0.0.0") - val maybePort = Port.fromInt(8081) - val bindingResource = serverBuilder.startServer(maybeHost.get, maybePort.get) + implicit val endpoints = new Endpoints + val serverBuilder = new ServerBuilder + val maybeHost = Host.fromString("0.0.0.0") + val maybePort = Port.fromInt(8081) + val bindingResource = serverBuilder.startServer(maybeHost.get, maybePort.get) bindingResource .use { server =>