diff --git a/Dockerfile b/Dockerfile index 8dc4940..3c6deea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ RUN sbt update COPY src/ ./src/ -RUN sbt compile +RUN sbt clean compile COPY . . diff --git a/build.sbt b/build.sbt index 79b51b2..4ce8d84 100644 --- a/build.sbt +++ b/build.sbt @@ -17,7 +17,7 @@ Test / scalaSource := baseDirectory.value / "src" / "test" / "scala" lazy val root = (project in file(".")) .settings( - name := "chemist-flow", + name := ".", libraryDependencies ++= Seq( scalaLogging, scalaTest, @@ -40,4 +40,13 @@ lazy val root = (project in file(".")) ) ) -resolvers += "Akka library repository".at("https://repo.akka.io/maven") +scalacOptions ++= Seq( + "-Xmax-inlines", "64" +) + +resolvers ++= Seq( + "Akka library repository" at "https://repo.akka.io/maven", + "Sonatype OSS Releases" at "https://oss.sonatype.org/content/repositories/releases/", + "Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots/", + "Maven Central" at "https://repo1.maven.org/maven2/" +) diff --git a/examples/.env.example b/examples/.env.example new file mode 100644 index 0000000..0650fde --- /dev/null +++ b/examples/.env.example @@ -0,0 +1,13 @@ +AKKA_LICENSE_KEY=... + +CHEMIST_FLOW_HOST=localhost +CHEMIST_FLOW_PORT=8081 + +POSTGRE_URL=jdbc:postgresql://localhost:5432/chemist_db +POSTGRE_USER=chemist +POSTGRE_PASSWORD=chemist_password +POSTGRE_DRIVER=org.postgresql.Driver + +CHEMIST_PREPROCESSOR_BASE_URI=http://localhost:8080 + +CHEMIST_ENGINE_BASE_URI=http://localhost:8082 diff --git a/project/plugins.sbt b/project/plugins.sbt index 406354d..63660ce 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,4 +1,11 @@ addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.10.4") addSbtPlugin("org.http4s" % "sbt-http4s-org" % "0.17.5") -resolvers += "Scalafmt Releases" at "https://oss.sonatype.org/content/repositories/releases" + +resolvers ++= Seq( + "Scalafmt Releases" at "https://oss.sonatype.org/content/repositories/releases", + "Sonatype OSS Releases" at "https://oss.sonatype.org/content/repositories/releases", + "Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots/", + "Maven Central" at "https://repo1.maven.org/maven2/", + "Akka repository" at "https://repo.akka.io/maven" +) diff --git a/src/main/scala/api/Endpoints.scala b/src/main/scala/api/Endpoints.scala deleted file mode 100644 index 22acb08..0000000 --- a/src/main/scala/api/Endpoints.scala +++ /dev/null @@ -1,49 +0,0 @@ -package api - -import cats.effect.IO -import cats.syntax.semigroupk.toSemigroupKOps -import org.http4s.HttpRoutes -import org.http4s.dsl.io._ -import org.http4s.server.Router -import org.http4s.server.middleware.Logger -import org.http4s.circe.CirceEntityEncoder.circeEntityEncoder -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") - } - - private val getReactionRoute: HttpRoutes[IO] = HttpRoutes.of[IO] { - case GET -> Root / "reaction" / id => - validateId(id) match { - case Some(validId) => Ok(s"Get reaction details for ID: $validId") - case None => BadRequest(ErrorResponse("BadRequest", "ID must be an integer").asJson) - } - } - - private val postReactionRoute: HttpRoutes[IO] = HttpRoutes.of[IO] { - case POST -> Root / "reaction" => - Ok("Create new reaction") - } - - private val deleteReactionRoute: HttpRoutes[IO] = HttpRoutes.of[IO] { - case DELETE -> Root / "reaction" / id => - validateId(id) match { - case Some(validId) => Ok(s"Delete reaction with ID: $validId") - case None => BadRequest(ErrorResponse("BadRequest", "ID must be an integer").asJson) - } - } - - private def validateId(id: String): Option[Int] = id.toIntOption - - val routes: HttpRoutes[IO] = Logger.httpRoutes(logHeaders = false, logBody = true)( - Router( - "/api" -> (healthRoute <+> getReactionRoute <+> postReactionRoute <+> deleteReactionRoute) - ) - ) - -} diff --git a/src/main/scala/api/ServerBuilder.scala b/src/main/scala/api/ServerBuilder.scala index 2d145cd..cc35813 100644 --- a/src/main/scala/api/ServerBuilder.scala +++ b/src/main/scala/api/ServerBuilder.scala @@ -1,12 +1,15 @@ package api import cats.effect.{IO, Resource} + import com.comcast.ip4s.{Host, Port} + import org.http4s.server.Server import org.http4s.ember.server.EmberServerBuilder +import org.http4s.HttpRoutes class ServerBuilder( - implicit endpoints: Endpoints + routes: HttpRoutes[IO] ) { def startServer( @@ -17,7 +20,7 @@ class ServerBuilder( .default[IO] .withHost(host) .withPort(port) - .withHttpApp(ErrorHandler(endpoints.routes).orNotFound) + .withHttpApp(ErrorHandler(routes).orNotFound) .build } diff --git a/src/main/scala/api/endpoints/flow/ReaktoroEndpoints.scala b/src/main/scala/api/endpoints/flow/ReaktoroEndpoints.scala new file mode 100644 index 0000000..73894a5 --- /dev/null +++ b/src/main/scala/api/endpoints/flow/ReaktoroEndpoints.scala @@ -0,0 +1,54 @@ +package api.endpoints.flow + +import cats.effect.IO + +import org.http4s.HttpRoutes +import org.http4s.dsl.io._ +import org.http4s.server.Router +import org.http4s.server.middleware.Logger +import org.http4s.circe.CirceEntityEncoder.circeEntityEncoder +import org.http4s.circe.CirceSensitiveDataEntityDecoder.circeEntityDecoder + +import io.circe.syntax._ +import io.circe.generic.auto.deriveEncoder + +import core.services.flow.ReaktoroService +import core.domain.preprocessor.ReactionId +import core.domain.flow.{DataBase, MoleculeAmountList} + +case class ComputePropsRequest( + reactionId: ReactionId, + database: DataBase, + amounts: MoleculeAmountList +) + +object ComputePropsRequest { + import io.circe.{Decoder, Encoder} + import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} + + implicit val decoder: Decoder[ComputePropsRequest] = deriveDecoder + implicit val encoder: Encoder[ComputePropsRequest] = deriveEncoder +} + +class ReaktoroEndpoints( + reaktoroService: ReaktoroService[IO] +) { + + private val computeSystemPropsForReactionRoute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> Root / "system" / "properties" => + req.as[ComputePropsRequest].flatMap { + case ComputePropsRequest(reactionId, database, amounts) => + reaktoroService + .computeSystemPropsForReaction(reactionId, database, amounts) + .flatMap(result => Ok(result.asJson)) + .handleErrorWith(ex => InternalServerError(("InternalError", ex.getMessage).asJson)) + } + } + + val routes: HttpRoutes[IO] = Logger.httpRoutes(logHeaders = true, logBody = true)( + Router( + "/api" -> computeSystemPropsForReactionRoute + ) + ) + +} diff --git a/src/main/scala/api/endpoints/preprocessor/PreprocessorEndpoints.scala b/src/main/scala/api/endpoints/preprocessor/PreprocessorEndpoints.scala new file mode 100644 index 0000000..fe962f1 --- /dev/null +++ b/src/main/scala/api/endpoints/preprocessor/PreprocessorEndpoints.scala @@ -0,0 +1,84 @@ +package api.endpoints.preprocessor + +import cats.effect.IO +import cats.syntax.semigroupk.toSemigroupKOps + +import io.circe.syntax.EncoderOps + +import org.http4s.HttpRoutes +import org.http4s.dsl.io._ +import org.http4s.server.Router +import org.http4s.server.middleware.Logger +import org.http4s.circe.CirceEntityEncoder.circeEntityEncoder +import org.http4s.circe.CirceSensitiveDataEntityDecoder.circeEntityDecoder + +import core.services.preprocessor.{MechanismService, ReactionService} +import core.domain.preprocessor.{MechanismDetails, Reaction, ReactionDetails} +import core.errors.http.preprocessor.ReactionError + +class PreprocessorEndpoints( + reactionService: ReactionService[IO], + mechanismService: MechanismService[IO] +) { + + private val getReactionRoute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case GET -> Root / "reaction" / id => + validateId(id) match { + case Some(validId) => + reactionService.getReaction(validId).flatMap { + (reactionDetails: ReactionDetails) => Ok(reactionDetails.asJson) + }.handleErrorWith { + case _: ReactionError.NotFoundError => NotFound(("NotFound", s"Reaction with ID $validId not found")) + case ex => InternalServerError(("InternalError", ex.getMessage)) + } + case None => BadRequest(("BadRequest", "ID must be an integer")) + } + } + + private val postReactionRoute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> Root / "reaction" => + req.as[Reaction].flatMap { reaction => + reactionService.createReaction(reaction).flatMap { + createdReaction => Created(createdReaction.asJson) + }.handleErrorWith { + case _: ReactionError.CreationError => BadRequest(("CreationError", "Failed to create reaction")) + case ex => InternalServerError(("InternalError", ex.getMessage)) + } + } + } + + private val deleteReactionRoute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case DELETE -> Root / "reaction" / id => + validateId(id) match { + case Some(validId) => + reactionService.deleteReaction(validId).flatMap { + case Right(_) => NoContent() + case Left(error) => BadRequest(("DeletionError", error.message)) + } + case None => BadRequest(("BadRequest", "ID must be an integer")) + } + } + + private val getMechanismRoute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case GET -> Root / "mechanism" / id => + validateId(id) match { + case Some(validId) => + mechanismService.getMechanism(validId).flatMap { + (mechanismDetails: MechanismDetails) => Ok(mechanismDetails.asJson) + }.handleErrorWith { + case _: ReactionError.NotFoundError => NotFound(("NotFound", s"Mechanism with ID $validId not found")) + case ex => InternalServerError(("InternalError", ex.getMessage)) + } + case None => BadRequest(("BadRequest", "ID must be an integer")) + } + } + + private def validateId(id: String): Option[Int] = id.toIntOption + + val routes: HttpRoutes[IO] = Logger.httpRoutes(logHeaders = false, logBody = true)( + Router( + "/api" -> (getReactionRoute <+> postReactionRoute <+> deleteReactionRoute <+> getMechanismRoute) + ) + ) + +} diff --git a/src/main/scala/app/Main.scala b/src/main/scala/app/Main.scala index 27adeaf..e3d0495 100644 --- a/src/main/scala/app/Main.scala +++ b/src/main/scala/app/Main.scala @@ -1,12 +1,27 @@ package app +import api.ServerBuilder +import api.endpoints.preprocessor.PreprocessorEndpoints +import api.endpoints.flow.ReaktoroEndpoints + import akka.actor.ActorSystem + import cats.effect.{ExitCode, IO, IOApp, Resource} -import com.comcast.ip4s.{Host, Port} -import api.{Endpoints, ServerBuilder} +import cats.implicits.toSemigroupKOps + +import core.services.cache.CacheService +import core.services.flow.ReaktoroService +import core.services.preprocessor.{MechanismService, ReactionService} + import config.ConfigLoader +import config.ConfigLoader.DefaultConfigLoader + import org.typelevel.log4cats.slf4j.Slf4jLogger import org.typelevel.log4cats.Logger +import org.http4s.client.Client +import org.http4s.ember.client.EmberClientBuilder +import org.http4s.{HttpRoutes, Uri} + import scala.concurrent.ExecutionContext object Main extends IOApp { @@ -17,56 +32,149 @@ object Main extends IOApp { system: ActorSystem, logger: Logger[IO] ): Resource[IO, ActorSystem] = - Resource.make(IO(system)) { system => + Resource.make( + logger.info("Creating Actor System") *> + IO(system) + )(system => IO.fromFuture(IO(system.terminate())).attempt.flatMap { case Right(_) => logger.info("Actor system terminated successfully") case Left(ex) => logger.error(s"Actor system termination failed: ${ex.getMessage}") } - } + ) def serverBuilderResource( - implicit - endpoints: Endpoints, - serverBuilder: ServerBuilder, - logger: Logger[IO] + routes: HttpRoutes[IO] + )( + implicit logger: Logger[IO] ): Resource[IO, ServerBuilder] = - Resource.make(IO(serverBuilder))(endpoints => + Resource.make( + logger.info("Creating Server Builder") *> + IO(new ServerBuilder(routes)) + )(endpoints => logger.info("Shutting down ServerBuilder").handleErrorWith(_ => IO.unit) ) + def preprocessorEndpointsResource( + reactionService: ReactionService[IO], + mechanismService: MechanismService[IO] + )( + implicit logger: Logger[IO] + ): Resource[IO, PreprocessorEndpoints] = + Resource.make( + logger.info("Creating Endpoints") *> + IO(new PreprocessorEndpoints(reactionService, mechanismService)) + )(endpoints => + logger.info("Shutting down Endpoints").handleErrorWith(_ => IO.unit) + ) + + def reaktoroEndpointsResource( + reaktoroService: ReaktoroService[IO] + )( + implicit logger: Logger[IO] + ): Resource[IO, ReaktoroEndpoints] = + Resource.make( + logger.info("Creating Reaktoro Endpoints") *> + IO(new ReaktoroEndpoints(reaktoroService)) + )(endpoints => + logger.info("Shutting down Reaktoro Endpoints").handleErrorWith(_ => IO.unit) + ) + + def clientResource( + implicit logger: Logger[IO] + ): Resource[IO, Client[IO]] = + EmberClientBuilder.default[IO].build + + def mechanismServiceResource( + cacheService: CacheService[IO], + client: Client[IO], + baseUri: Uri + )( + implicit logger: Logger[IO] + ): Resource[IO, MechanismService[IO]] = + Resource.make( + logger.info("Creating Mechanism Service") *> + IO(new MechanismService[IO](cacheService, client, baseUri)) + )(_ => + logger.info("Shutting down Mechanism Service").handleErrorWith(_ => IO.unit) + ) + + def reactionServiceResource( + cacheService: CacheService[IO], + client: Client[IO], + baseUri: Uri + )( + implicit logger: Logger[IO] + ): Resource[IO, ReactionService[IO]] = + Resource.make( + logger.info("Creating Reaction Service") *> + IO(new ReactionService[IO](cacheService, client, baseUri)) + )(_ => + logger.info("Shutting down Reaction Service").handleErrorWith(_ => IO.unit) + ) + + def reaktoroServiceResource( + reactionService: ReactionService[IO], + client: Client[IO], + baseUri: Uri + )( + implicit logger: Logger[IO] + ): Resource[IO, ReaktoroService[IO]] = + Resource.make( + logger.info("Creating Reaktoro Service") *> + IO(new ReaktoroService[IO](reactionService, client, baseUri)) + )(_ => + logger.info("Shutting down Reaktoro Service").handleErrorWith(_ => IO.unit) + ) + + def cacheServiceResource( + implicit logger: Logger[IO] + ): Resource[IO, CacheService[IO]] = + Resource.make( + logger.info("Creating Cache Service") *> IO(new CacheService[IO]) + )(_ => + logger.info("Shutting down Cache Service").handleErrorWith(_ => IO.unit) + ) + def runApp( - host: Host, - port: Port + config: ConfigLoader )( implicit ec: ExecutionContext, system: ActorSystem, - endpoints: Endpoints, - serverBuilder: ServerBuilder, logger: Logger[IO] ): Resource[IO, Unit] = + val preprocessorBaseUri = config.preprocessorHttpClientConfig.baseUri + val engineBaseUri = config.engineHttpClientConfig.baseUri + val host = config.httpConfig.host + val port = config.httpConfig.port + for { - _ <- Resource.eval(logger.info("Creating Actor system resource")) - system <- actorSystemResource - _ <- 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)) + system <- actorSystemResource + client <- clientResource + cacheService <- cacheServiceResource + mechanismService <- mechanismServiceResource(cacheService, client, preprocessorBaseUri / "mechanism") + reactionService <- reactionServiceResource(cacheService, client, preprocessorBaseUri / "reaction") + reaktoroService <- reaktoroServiceResource(reactionService, client, engineBaseUri) + preprocessorEndpoints <- preprocessorEndpointsResource(reactionService, mechanismService) + reaktoroEndpoints <- reaktoroEndpointsResource(reaktoroService) + serverBuilder <- serverBuilderResource(preprocessorEndpoints.routes <+> reaktoroEndpoints.routes) + server <- serverBuilder.startServer(host, port) + _ <- Resource.eval(logger.info("Press ENTER to terminate...")) + _ <- Resource.eval(IO(scala.io.StdIn.readLine)) } yield () override def run( args: List[String] ): IO[ExitCode] = { - val httpConfig = ConfigLoader.httpConfig + val config = DefaultConfigLoader - implicit val logger: Logger[IO] = Slf4jLogger.getLogger[IO] - implicit val endpoints: Endpoints = new Endpoints - implicit val serverBuilder: ServerBuilder = new ServerBuilder - implicit val system: ActorSystem = ActorSystem("ChemistFlowActorSystem") - implicit val ec: ExecutionContext = system.dispatcher + implicit val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + implicit val system: ActorSystem = ActorSystem("ChemistFlowActorSystem") + implicit val ec: ExecutionContext = system.dispatcher - runApp(httpConfig.host, httpConfig.port).use(_ => IO.unit).as(ExitCode.Success) + runApp(config) + .use(_ => IO.unit) + .as(ExitCode.Success) } } diff --git a/src/main/scala/config/ConfigLoader.scala b/src/main/scala/config/ConfigLoader.scala index 6547d1f..21389a1 100644 --- a/src/main/scala/config/ConfigLoader.scala +++ b/src/main/scala/config/ConfigLoader.scala @@ -6,6 +6,7 @@ import pureconfig.{ConfigReader, ConfigSource} import pureconfig.error.CannotConvert import java.io.File import scala.concurrent.duration.FiniteDuration +import org.http4s.Uri case class KafkaTopics( reactions: String, @@ -64,8 +65,8 @@ object DatabaseConfig { } -case class HttpClientConfig( - baseUri: String, +case class ChemistPreprocessorHttpClient( + baseUri: Uri, timeout: HttpClientTimeout, retries: Int, pool: HttpClientPool @@ -81,7 +82,7 @@ case class HttpClientPool( maxIdleTime: FiniteDuration ) -object HttpClientConfig { +object ChemistPreprocessorHttpClient { implicit val httpClientTimeoutReader: ConfigReader[HttpClientTimeout] = ConfigReader.forProduct2("connect", "request")(HttpClientTimeout.apply) @@ -89,23 +90,69 @@ object HttpClientConfig { 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) + implicit val baseUriReader: ConfigReader[Uri] = ConfigReader.fromString { str => + Uri.fromString(str).left.map(failure => CannotConvert(str, "Uri", failure.sanitized)) + } + + implicit val httpClientConfigReader: ConfigReader[ChemistPreprocessorHttpClient] = + ConfigReader.forProduct4("baseUri", "timeout", "retries", "pool")(ChemistPreprocessorHttpClient.apply) + +} + +case class ChemistEngineHttpClient( + baseUri: Uri, + timeout: HttpClientTimeout, + retries: Int, + pool: HttpClientPool +) + +object ChemistEngineHttpClient { + + 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 baseUriReader: ConfigReader[Uri] = ConfigReader.fromString { str => + Uri.fromString(str).left.map(failure => CannotConvert(str, "Uri", failure.sanitized)) + } + + implicit val httpClientConfigReader: ConfigReader[ChemistEngineHttpClient] = + ConfigReader.forProduct4("baseUri", "timeout", "retries", "pool")(ChemistEngineHttpClient.apply) } case class AppConfig( - kafka: KafkaConfig, - http: HttpConfig, - database: DatabaseConfig, - httpClient: HttpClientConfig + kafka: KafkaConfig, + http: HttpConfig, + database: DatabaseConfig, + preprocessorHttpClient: ChemistPreprocessorHttpClient, + engineHttpClient: ChemistEngineHttpClient ) object AppConfig { implicit val appConfigReader: ConfigReader[AppConfig] = - ConfigReader.forProduct4("kafka", "http", "database", "httpClient")(AppConfig.apply) + ConfigReader.forProduct5( + "kafka", + "http", + "database", + "chemistPreprocessorHttpClient", + "chemistEngineHttpClient" + )( + AppConfig.apply + ) + +} +sealed trait ConfigLoader { + def appConfig: AppConfig + def kafkaConfig: KafkaConfig + def httpConfig: HttpConfig + def databaseConfig: DatabaseConfig + def preprocessorHttpClientConfig: ChemistPreprocessorHttpClient + def engineHttpClientConfig: ChemistEngineHttpClient } object ConfigLoader { @@ -120,9 +167,15 @@ 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 httpClientConfig: HttpClientConfig = appConfig.httpClient + private lazy val loadedAppConfig: AppConfig = ConfigSource.fromConfig(config).loadOrThrow[AppConfig] + + case object DefaultConfigLoader extends ConfigLoader { + override val appConfig: AppConfig = loadedAppConfig + override val kafkaConfig: KafkaConfig = appConfig.kafka + override val httpConfig: HttpConfig = appConfig.http + override val databaseConfig: DatabaseConfig = appConfig.database + override val preprocessorHttpClientConfig: ChemistPreprocessorHttpClient = appConfig.preprocessorHttpClient + override val engineHttpClientConfig: ChemistEngineHttpClient = appConfig.engineHttpClient + } + } diff --git a/src/main/scala/core/domain/Chemical.scala b/src/main/scala/core/domain/Chemical.scala deleted file mode 100644 index bc5bd3b..0000000 --- a/src/main/scala/core/domain/Chemical.scala +++ /dev/null @@ -1,66 +0,0 @@ -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( - id: MoleculeId, - smiles: String, - iupacName: String -) - -case class Reaction( - id: ReactionId, - name: String -) - -case class Catalyst( - id: CatalystId, - smiles: String, - name: Option[String] -) - -case class PRODUCT_FROM(productAmount: Float) - -case class REAGENT_IN(reagentAmount: Float) - -case class ACCELERATE( - temperature: List[Float], - pressure: List[Float] -) - -object Molecule { - implicit val moleculeEncoder: Encoder[Molecule] = deriveEncoder[Molecule] - implicit val moleculeDecoder: Decoder[Molecule] = deriveDecoder[Molecule] -} - -object Reaction { - implicit val reactionEncoder: Encoder[Reaction] = deriveEncoder[Reaction] - implicit val reactionDecoder: Decoder[Reaction] = deriveDecoder[Reaction] -} - -object Catalyst { - implicit val catalystEncoder: Encoder[Catalyst] = deriveEncoder[Catalyst] - implicit val catalystDecoder: Decoder[Catalyst] = deriveDecoder[Catalyst] -} - -object PRODUCT_FROM { - implicit val productFromEncoder: Encoder[PRODUCT_FROM] = deriveEncoder[PRODUCT_FROM] - implicit val productFromDecoder: Decoder[PRODUCT_FROM] = deriveDecoder[PRODUCT_FROM] -} - -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] -} diff --git a/src/main/scala/core/domain/Interactant.scala b/src/main/scala/core/domain/Interactant.scala deleted file mode 100644 index d42d46f..0000000 --- a/src/main/scala/core/domain/Interactant.scala +++ /dev/null @@ -1,61 +0,0 @@ -package core.domain - -import io.circe.{Decoder, Encoder} -import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} - -sealed trait Interactant - -object 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/flow/SystemProps.scala b/src/main/scala/core/domain/flow/SystemProps.scala new file mode 100644 index 0000000..9bdcbba --- /dev/null +++ b/src/main/scala/core/domain/flow/SystemProps.scala @@ -0,0 +1,78 @@ +package core.domain.flow + +import io.circe.{Decoder, Encoder} +import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} + +case class Phase(name: String) + +object Phase { + val AqueousPhase = Phase("AqueousPhase") + val GaseousPhase = Phase("GaseousPhase") + val LiquidPhase = Phase("LiquidPhase") + val SolidPhase = Phase("SolidPhase") + val MineralPhase = Phase("MineralPhase") + val CondensedPhase = Phase("CondensedPhase") + val IonExchangePhase = Phase("IonExchangePhase") +} + +case class Property(value: Double, unit: String) +case class Amount(name: String, value: Double, unit: String) +case class ActivityInfo(name: String, value: Double, unit: String) +case class EnergyInfo(name: String, value: Double, unit: String) +case class MoleFraction(name: String, value: Double, unit: String) +case class HeatCapacity(name: String, value: Double, unit: String) + +case class SystemProps( + generalProperties: Map[String, Property], + elementAmounts: List[Amount], + speciesAmounts: List[Amount], + moleFractions: List[MoleFraction], + activityCoefficients: List[ActivityInfo], + activities: List[ActivityInfo], + logActivities: List[ActivityInfo], + lnActivities: List[ActivityInfo], + chemicalPotentials: List[EnergyInfo], + standardVolumes: List[Amount], + standardGibbsEnergies: List[EnergyInfo], + standardEnthalpies: List[EnergyInfo], + standardEntropies: List[EnergyInfo], + standardInternalEnergies: List[EnergyInfo], + standardHelmholtzEnergies: List[EnergyInfo], + standardHeatCapacitiesP: List[HeatCapacity], + standardHeatCapacitiesV: List[HeatCapacity] +) + +object Property { + implicit val encoder: Encoder[Property] = deriveEncoder + implicit val decoder: Decoder[Property] = deriveDecoder +} + +object Amount { + implicit val encoder: Encoder[Amount] = deriveEncoder + implicit val decoder: Decoder[Amount] = deriveDecoder +} + +object ActivityInfo { + implicit val encoder: Encoder[ActivityInfo] = deriveEncoder + implicit val decoder: Decoder[ActivityInfo] = deriveDecoder +} + +object EnergyInfo { + implicit val encoder: Encoder[EnergyInfo] = deriveEncoder + implicit val decoder: Decoder[EnergyInfo] = deriveDecoder +} + +object MoleFraction { + implicit val encoder: Encoder[MoleFraction] = deriveEncoder + implicit val decoder: Decoder[MoleFraction] = deriveDecoder +} + +object HeatCapacity { + implicit val encoder: Encoder[HeatCapacity] = deriveEncoder + implicit val decoder: Decoder[HeatCapacity] = deriveDecoder +} + +object SystemProps { + implicit val encoder: Encoder[SystemProps] = deriveEncoder + implicit val decoder: Decoder[SystemProps] = deriveDecoder +} diff --git a/src/main/scala/core/domain/flow/SystemState.scala b/src/main/scala/core/domain/flow/SystemState.scala new file mode 100644 index 0000000..2aa7179 --- /dev/null +++ b/src/main/scala/core/domain/flow/SystemState.scala @@ -0,0 +1,41 @@ +package core.domain.flow + +import io.circe.{Decoder, Encoder} +import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} +import core.domain.preprocessor.Molecule +import core.domain.preprocessor.Molecule._ + +case class DataBase(name: String) + +object DataBase { + val thermoFunDatabaseSlop = DataBase("slop98") + val phreeqcDatabase = DataBase("phreeqc.dat") + val thermoFunDatabaseCemdata = DataBase("cemdata18") + + def custom(name: String): DataBase = DataBase(name) + + implicit val encoder: Encoder[DataBase] = deriveEncoder + implicit val decoder: Decoder[DataBase] = deriveDecoder +} + +case class SystemState( + temperature: Double, + pressure: Double, + database: DataBase, + moleculeAmounts: Map[Molecule, Double] +) + +object SystemState { + implicit val encoder: Encoder[SystemState] = deriveEncoder + implicit val decoder: Decoder[SystemState] = deriveDecoder +} + +case class MoleculeAmountList( + inboundReagentAmounts: List[Double], + outboundProductAmounts: List[Double] +) + +object MoleculeAmountList { + implicit val encoder: Encoder[MoleculeAmountList] = deriveEncoder + implicit val decoder: Decoder[MoleculeAmountList] = deriveDecoder +} diff --git a/src/main/scala/core/domain/preprocessor/Chemical.scala b/src/main/scala/core/domain/preprocessor/Chemical.scala new file mode 100644 index 0000000..077f6e1 --- /dev/null +++ b/src/main/scala/core/domain/preprocessor/Chemical.scala @@ -0,0 +1,107 @@ +package core.domain.preprocessor + +import io.circe.{Decoder, Encoder} +import io.circe.{KeyDecoder, KeyEncoder} +import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} + +type MoleculeId = Int +type ReactionId = Int +type CatalystId = Int + +case class Molecule( + moleculeId: MoleculeId, + moleculeSmiles: String, + moleculeIupacName: String +) + +case class Reaction( + reactionId: ReactionId, + reactionName: String +) + +case class Catalyst( + catalystId: CatalystId, + catalystSmiles: String, + catalystName: Option[String] +) + +case class PRODUCT_FROM(productAmount: Float) + +case class REAGENT_IN(reagentAmount: Float) + +case class ACCELERATE( + temperature: List[Float], + pressure: List[Float] +) + +case class InboundReagent( + reagentIn: REAGENT_IN, + molecule: Molecule +) + +case class OutboundProduct( + productFrom: PRODUCT_FROM, + molecule: Molecule +) + +case class Condition( + accelerate: ACCELERATE, + catalyst: Catalyst +) + +object Molecule { + implicit val encoder: Encoder[Molecule] = deriveEncoder[Molecule] + implicit val decoder: Decoder[Molecule] = deriveDecoder[Molecule] + + implicit val keyEncoder: KeyEncoder[Molecule] = KeyEncoder.instance { molecule => + s"${molecule.moleculeId}-${molecule.moleculeSmiles}" + } + + implicit val keyDecoder: KeyDecoder[Molecule] = KeyDecoder.instance { key => + key.split("-", 2) match { + case Array(id, smiles) => Some(Molecule(id.toInt, smiles, "")) // Assuming empty IUPAC name + case _ => None + } + } + +} + +object Reaction { + implicit val encoder: Encoder[Reaction] = deriveEncoder[Reaction] + implicit val decoder: Decoder[Reaction] = deriveDecoder[Reaction] +} + +object Catalyst { + implicit val encoder: Encoder[Catalyst] = deriveEncoder[Catalyst] + implicit val decoder: Decoder[Catalyst] = deriveDecoder[Catalyst] +} + +object PRODUCT_FROM { + implicit val encoder: Encoder[PRODUCT_FROM] = deriveEncoder[PRODUCT_FROM] + implicit val decoder: Decoder[PRODUCT_FROM] = deriveDecoder[PRODUCT_FROM] +} + +object REAGENT_IN { + implicit val encoder: Encoder[REAGENT_IN] = deriveEncoder[REAGENT_IN] + implicit val decoder: Decoder[REAGENT_IN] = deriveDecoder[REAGENT_IN] +} + +object ACCELERATE { + implicit val encoder: Encoder[ACCELERATE] = deriveEncoder[ACCELERATE] + implicit val decoder: Decoder[ACCELERATE] = deriveDecoder[ACCELERATE] +} + +object InboundReagent { + implicit val encoder: Encoder[InboundReagent] = deriveEncoder[InboundReagent] + implicit val decoder: Decoder[InboundReagent] = deriveDecoder[InboundReagent] +} + +object OutboundProduct { + implicit val encoder: Encoder[OutboundProduct] = deriveEncoder[OutboundProduct] + implicit val decoder: Decoder[OutboundProduct] = deriveDecoder[OutboundProduct] +} + +object Condition { + implicit val encoder: Encoder[Condition] = deriveEncoder[Condition] + implicit val decoder: Decoder[Condition] = deriveDecoder[Condition] +} diff --git a/src/main/scala/core/domain/preprocessor/Interactant.scala b/src/main/scala/core/domain/preprocessor/Interactant.scala new file mode 100644 index 0000000..4446484 --- /dev/null +++ b/src/main/scala/core/domain/preprocessor/Interactant.scala @@ -0,0 +1,99 @@ +package core.domain.preprocessor + +import io.circe.{Decoder, DecodingFailure, Encoder, Json} +import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} +import core.domain.preprocessor.Stage.stageDecoder + +sealed trait Interactant + +case class IMolecule(molecule: Molecule) extends Interactant +case class ICatalyst(catalyst: Catalyst) extends Interactant +case class IAccelerate(accelerate: ACCELERATE) 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 Interactant { + + implicit val interactantEncoder: Encoder[Interactant] = Encoder.instance { + case i: IMolecule => IMolecule.iMoleculeEncoder(i) + case c: ICatalyst => ICatalyst.iCatalystEncoder(c) + case a: IAccelerate => IAccelerate.iAccelerateEncoder(a) + case pf: IProductFrom => IProductFrom.iProductFromEncoder(pf) + case ri: IReagentIn => IReagentIn.iReagentInEncoder(ri) + case r: IReaction => IReaction.iReactionEncoder(r) + } + + implicit val interactantDecoder: Decoder[Interactant] = Decoder.instance { cursor => + for { + tag <- cursor.downField("tag").as[String] + contents <- cursor.downField("contents").focus.map(_.as[Json]).getOrElse(Left(DecodingFailure( + "Missing contents", + cursor.history + ))) + interactant <- tag match { + case "IMolecule" => contents.as[Molecule].map(IMolecule.apply) + case "ICatalyst" => contents.as[Catalyst].map(ICatalyst.apply) + case "IAccelerate" => contents.as[ACCELERATE].map(IAccelerate.apply) + case "IProductFrom" => contents.as[PRODUCT_FROM].map(IProductFrom.apply) + case "IReagentIn" => contents.as[REAGENT_IN].map(IReagentIn.apply) + case "IReaction" => contents.as[Reaction].map(IReaction.apply) + case _ => Left(DecodingFailure(s"Unknown tag: $tag", cursor.history)) + } + } yield interactant + } + + implicit val stageInteractantDecoder: Decoder[List[(Stage, List[Interactant])]] = + Decoder.decodeList( + Decoder.instance { cursor => + for { + stage <- cursor.downArray.as[Stage] + interactants <- cursor.downArray.right.as[List[Interactant]] + } yield (stage, interactants) + } + ) + +} + +object IMolecule { + implicit val iMoleculeEncoder: Encoder[IMolecule] = deriveEncoder[IMolecule] + implicit val iMoleculeDecoder: Decoder[IMolecule] = deriveDecoder[IMolecule] +} + +object ICatalyst { + implicit val iCatalystEncoder: Encoder[ICatalyst] = deriveEncoder[ICatalyst] + implicit val iCatalystDecoder: Decoder[ICatalyst] = deriveDecoder[ICatalyst] +} + +object IAccelerate { + implicit val iAccelerateEncoder: Encoder[IAccelerate] = deriveEncoder + implicit val iAccelerateDecoder: Decoder[IAccelerate] = 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/preprocessor/Mechenism.scala similarity index 73% rename from src/main/scala/core/domain/Mechenism.scala rename to src/main/scala/core/domain/preprocessor/Mechenism.scala index 22061d8..2ee41a2 100644 --- a/src/main/scala/core/domain/Mechenism.scala +++ b/src/main/scala/core/domain/preprocessor/Mechenism.scala @@ -1,16 +1,16 @@ -package core.domain +package core.domain.preprocessor import io.circe.{Decoder, Encoder} import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} type MechanismId = Int -type StageID = Int +type StageId = Int case class Mechanism( - id: MechanismId, - name: String, - family: String, - activationEnergy: Float + mechanismId: MechanismId, + mechanismName: String, + mechanismType: String, + mechanismActivationEnergy: Float ) object Mechanism { @@ -28,10 +28,10 @@ object FOLLOW { } case class Stage( - order: StageID, - name: String, - description: String, - products: List[String] + stageOrder: StageId, + stageName: String, + stageDescription: String, + stageProducts: List[String] ) object Stage { diff --git a/src/main/scala/core/domain/Process.scala b/src/main/scala/core/domain/preprocessor/Process.scala similarity index 97% rename from src/main/scala/core/domain/Process.scala rename to src/main/scala/core/domain/preprocessor/Process.scala index bf82ea9..3bec751 100644 --- a/src/main/scala/core/domain/Process.scala +++ b/src/main/scala/core/domain/preprocessor/Process.scala @@ -1,4 +1,4 @@ -package core.domain +package core.domain.preprocessor import io.circe.{Decoder, Encoder} import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} diff --git a/src/main/scala/core/errors/http/flow/SystemPropsError.scala b/src/main/scala/core/errors/http/flow/SystemPropsError.scala new file mode 100644 index 0000000..28acac0 --- /dev/null +++ b/src/main/scala/core/errors/http/flow/SystemPropsError.scala @@ -0,0 +1,10 @@ +package core.errors.http.flow + +sealed trait SystemPropsError extends Throwable { + def message: String +} + +object SystemPropsError { + case class BadRequestError(message: String) extends SystemPropsError + case class ChemistEngineError(message: String) extends SystemPropsError +} diff --git a/src/main/scala/core/errors/http/MechanismError.scala b/src/main/scala/core/errors/http/preprocessor/MechanismError.scala similarity index 51% rename from src/main/scala/core/errors/http/MechanismError.scala rename to src/main/scala/core/errors/http/preprocessor/MechanismError.scala index 17db9b7..811029e 100644 --- a/src/main/scala/core/errors/http/MechanismError.scala +++ b/src/main/scala/core/errors/http/preprocessor/MechanismError.scala @@ -1,11 +1,15 @@ -package core.errors.http +package core.errors.http.preprocessor sealed trait MechanismError extends Throwable { def message: String + override def getMessage: String = message } object MechanismError { case class NotFoundError(message: String) extends MechanismError case class CreationError(message: String) extends MechanismError case class DeletionError(message: String) extends MechanismError + case class NetworkError(message: String) extends MechanismError + case class HttpError(message: String) extends MechanismError + case class DecodingError(message: String) extends MechanismError } diff --git a/src/main/scala/core/errors/http/ReactionError.scala b/src/main/scala/core/errors/http/preprocessor/ReactionError.scala similarity index 51% rename from src/main/scala/core/errors/http/ReactionError.scala rename to src/main/scala/core/errors/http/preprocessor/ReactionError.scala index 7ae87eb..4d18a5c 100644 --- a/src/main/scala/core/errors/http/ReactionError.scala +++ b/src/main/scala/core/errors/http/preprocessor/ReactionError.scala @@ -1,11 +1,15 @@ -package core.errors.http +package core.errors.http.preprocessor sealed trait ReactionError extends Throwable { def message: String + override def getMessage: String = message } object ReactionError { case class NotFoundError(message: String) extends ReactionError case class CreationError(message: String) extends ReactionError case class DeletionError(message: String) extends ReactionError + case class NetworkError(message: String) extends ReactionError + case class HttpError(message: String) extends ReactionError + case class DecodingError(message: String) extends ReactionError } diff --git a/src/main/scala/core/repositories/InMemoryMechanismRepository.scala b/src/main/scala/core/repositories/InMemoryMechanismRepository.scala index a5812ee..a95df76 100644 --- a/src/main/scala/core/repositories/InMemoryMechanismRepository.scala +++ b/src/main/scala/core/repositories/InMemoryMechanismRepository.scala @@ -1,14 +1,14 @@ 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 core.domain.preprocessor.{Mechanism, MechanismId} +import core.errors.http.preprocessor.MechanismError +import core.errors.http.preprocessor.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 + * `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. * @@ -22,6 +22,8 @@ import types.MechanismRepository * * @tparam F * The abstract effect type, which could be likened to an effectful monad in Haskell (e.g., `IO`, `StateT`). + * + * type MechanismRepository m = StateT (Map MechanismId Mechanism) m */ class InMemoryMechanismRepository[F[_]: Sync](state: Ref[F, Map[MechanismId, Mechanism]]) extends MechanismRepository[F] { @@ -47,7 +49,7 @@ class InMemoryMechanismRepository[F[_]: Sync](state: Ref[F, Map[MechanismId, Mec * * This function’s signature in Haskell might look like: * - * get :: Monad m => MechanismId -> StateT (Map MechanismId Mechanism) m (Maybe Mechanism) + * get :: MonadIO m => Mechanism -> MechanismRepository m (Either MechanismError 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 @@ -61,7 +63,7 @@ class InMemoryMechanismRepository[F[_]: Sync](state: Ref[F, Map[MechanismId, Mec * * Haskell equivalent signature: * - * create :: Monad m => Mechanism -> StateT (Map MechanismId Mechanism) m Mechanism + * create :: MonadIO m => Mechanism -> MechanismRepository (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. @@ -71,9 +73,10 @@ class InMemoryMechanismRepository[F[_]: Sync](state: Ref[F, Map[MechanismId, Mec def create(mechanism: Mechanism): F[Either[MechanismError, Mechanism]] = { state.modify { mechanisms => val id = generateId(mechanisms) - if (mechanisms.values.exists(_.name == mechanism.name)) { + + if (mechanisms.values.exists(_.mechanismName == mechanism.mechanismName)) { // Returns Left if a mechanism with the same name already exists - (mechanisms, Left(CreationError(s"Mechanism with name '${mechanism.name}' already exists"))) + (mechanisms, Left(CreationError(s"Mechanism with name '${mechanism.mechanismName}' already exists"))) } else { val newMechanism = mechanism.copy(id) (mechanisms + (id -> newMechanism), Right(newMechanism)) @@ -86,10 +89,8 @@ class InMemoryMechanismRepository[F[_]: Sync](state: Ref[F, Map[MechanismId, Mec * * Equivalent Haskell signature: * - * delete :: Monad m => MechanismId -> StateT (Map MechanismId Mechanism) m Bool + * delete :: MonadIO m => MechanismId -> MechanismRepository (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. */ diff --git a/src/main/scala/core/repositories/InMemoryReactionRepository.scala b/src/main/scala/core/repositories/InMemoryReactionRepository.scala index 4d161bb..36e1a87 100644 --- a/src/main/scala/core/repositories/InMemoryReactionRepository.scala +++ b/src/main/scala/core/repositories/InMemoryReactionRepository.scala @@ -2,12 +2,13 @@ package core.repositories import cats.effect.{Ref, Sync} import cats.implicits.toFunctorOps -import core.domain.{Reaction, ReactionId} +import core.domain.preprocessor.{Reaction, ReactionId} import types.ReactionRepository -import core.errors.http.ReactionError -import core.errors.http.ReactionError.CreationError +import core.errors.http.preprocessor.ReactionError +import core.errors.http.preprocessor.ReactionError.CreationError -class InMemoryReactionRepository[F[_]: Sync](state: Ref[F, Map[ReactionId, Reaction]]) extends ReactionRepository[F] { +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 @@ -18,8 +19,8 @@ class InMemoryReactionRepository[F[_]: Sync](state: Ref[F, Map[ReactionId, React 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"))) + if (reactions.values.exists(_.reactionName == reaction.reactionName)) { + (reactions, Left(CreationError(s"Reaction with name '${reaction.reactionName}' already exists"))) } else { val newReaction = reaction.copy(id) (reactions + (id -> newReaction), Right(newReaction)) diff --git a/src/main/scala/core/repositories/Neo4jReactionRepository.scala b/src/main/scala/core/repositories/Neo4jReactionRepository.scala index 2790126..f726d6a 100644 --- a/src/main/scala/core/repositories/Neo4jReactionRepository.scala +++ b/src/main/scala/core/repositories/Neo4jReactionRepository.scala @@ -2,9 +2,9 @@ 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 core.domain.preprocessor.{Reaction, ReactionId} +import core.errors.http.preprocessor.ReactionError +import core.errors.http.preprocessor.ReactionError.CreationError import io.circe.parser.decode import io.circe.syntax.EncoderOps import infrastructure.http.HttpClient @@ -14,7 +14,7 @@ import types.ReactionRepository /** * ADDITIONAL MODULE * - * Neo4jReactionRepository provides a direct interface to the Chemist service for managing reactions. This + * Neo4jReactionRepository provides a direct interface to the Chemist Pre-processor for managing reactions. This * implementation bypasses any caching or additional service logic, directly interacting with the Neo4j-backed Chemist * service through HTTP requests. * @@ -24,7 +24,7 @@ import types.ReactionRepository class Neo4jReactionRepository[F[_]: Sync](client: HttpClient[F]) extends ReactionRepository[F] { /** - * Fetches a reaction by ID from the Chemist service. + * Fetches a reaction by ID from the Chemist Pre-processor. * * @param id * The ReactionId of the reaction to fetch. @@ -38,7 +38,7 @@ class Neo4jReactionRepository[F[_]: Sync](client: HttpClient[F]) extends Reactio .flatMap(Sync[F].fromEither) /** - * Creates a new reaction in the Chemist service. + * Creates a new reaction in the Chemist Pre-processor. * * @param reaction * The Reaction object to be created. @@ -52,12 +52,12 @@ class Neo4jReactionRepository[F[_]: Sync](client: HttpClient[F]) extends Reactio .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}"))) + case Left(_) => Sync[F].pure(Left(CreationError(s"Failed to create reaction: ${reaction.reactionName}"))) } } /** - * Updates an existing reaction by ID in the Chemist service. + * Updates an existing reaction by ID in the Chemist Pre-processor. * * @param id * The ID of the reaction to update. @@ -74,7 +74,7 @@ class Neo4jReactionRepository[F[_]: Sync](client: HttpClient[F]) extends Reactio .flatMap(Sync[F].fromEither) /** - * Deletes a reaction by ID from the Chemist service. + * Deletes a reaction by ID from the Chemist Pre-processor. * * @param id * The ID of the reaction to delete. diff --git a/src/main/scala/core/repositories/types/MechanismRepository.scala b/src/main/scala/core/repositories/types/MechanismRepository.scala index a790724..b8ca46e 100644 --- a/src/main/scala/core/repositories/types/MechanismRepository.scala +++ b/src/main/scala/core/repositories/types/MechanismRepository.scala @@ -1,7 +1,7 @@ package core.repositories.types -import core.domain.{Mechanism, MechanismId} -import core.errors.http.MechanismError +import core.domain.preprocessor.{Mechanism, MechanismId} +import core.errors.http.preprocessor.MechanismError trait MechanismRepository[F[_]] { def get(id: MechanismId): F[Option[Mechanism]] diff --git a/src/main/scala/core/repositories/types/ReactionRepository.scala b/src/main/scala/core/repositories/types/ReactionRepository.scala index 1c73467..cab3211 100644 --- a/src/main/scala/core/repositories/types/ReactionRepository.scala +++ b/src/main/scala/core/repositories/types/ReactionRepository.scala @@ -1,7 +1,7 @@ package core.repositories.types -import core.domain.{Reaction, ReactionId} -import core.errors.http.ReactionError +import core.domain.preprocessor.{Reaction, ReactionId} +import core.errors.http.preprocessor.ReactionError trait ReactionRepository[F[_]] { def get(id: ReactionId): F[Option[Reaction]] diff --git a/src/main/scala/core/services/MechanismService.scala b/src/main/scala/core/services/MechanismService.scala deleted file mode 100644 index 2c73ccc..0000000 --- a/src/main/scala/core/services/MechanismService.scala +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index 0f85665..0000000 --- a/src/main/scala/core/services/ReactionService.scala +++ /dev/null @@ -1,70 +0,0 @@ -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/core/services/CacheService.scala b/src/main/scala/core/services/cache/CacheService.scala similarity index 64% rename from src/main/scala/core/services/CacheService.scala rename to src/main/scala/core/services/cache/CacheService.scala index a312d70..86453bf 100644 --- a/src/main/scala/core/services/CacheService.scala +++ b/src/main/scala/core/services/cache/CacheService.scala @@ -1,22 +1,24 @@ -package core.services +package core.services.cache import scala.concurrent.duration._ -import core.domain.{Mechanism, MechanismId, Reaction, ReactionId} +import core.domain.preprocessor.{Mechanism, MechanismDetails, MechanismId, Reaction, ReactionDetails, 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 val mechanismCache: TrieMap[MechanismId, (Mechanism, Long)] = TrieMap.empty + private val mechanismDetailsCache: TrieMap[MechanismId, (MechanismDetails, Long)] = TrieMap.empty + private val reactionCache: TrieMap[ReactionId, (Reaction, Long)] = TrieMap.empty + private val reactionDetailsCache: TrieMap[ReactionId, (ReactionDetails, 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 { + def getMechanism(id: MechanismId): F[Option[MechanismDetails]] = Sync[F].delay { + mechanismDetailsCache.get(id).collect { case (mechanism, timestamp) if !isExpired(timestamp) => mechanism } } @@ -25,6 +27,10 @@ class CacheService[F[_]: Sync] { mechanismCache.update(id, (mechanism, currentTime)) } + def putMechanismDetails(id: MechanismId, mechanism: MechanismDetails): F[Unit] = Sync[F].delay { + mechanismDetailsCache.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 { @@ -33,8 +39,8 @@ class CacheService[F[_]: Sync] { } } - def getReaction(id: ReactionId): F[Option[Reaction]] = Sync[F].delay { - reactionCache.get(id).collect { + def getReaction(id: ReactionId): F[Option[ReactionDetails]] = Sync[F].delay { + reactionDetailsCache.get(id).collect { case (reaction, timestamp) if !isExpired(timestamp) => reaction } } @@ -51,6 +57,10 @@ class CacheService[F[_]: Sync] { reactionCache.update(id, (reaction, currentTime)) } + def putReactionDetails(id: ReactionId, reaction: ReactionDetails): F[Unit] = Sync[F].delay { + reactionDetailsCache.update(id, (reaction, currentTime)) + } + def cleanExpiredEntries: F[Unit] = Sync[F].delay { val now = currentTime mechanismCache.filterInPlace { case (_, (_, timestamp)) => now - timestamp <= ttl.toMillis } diff --git a/src/main/scala/core/services/DistributedCacheService.scala b/src/main/scala/core/services/cache/DistributedCacheService.scala similarity index 100% rename from src/main/scala/core/services/DistributedCacheService.scala rename to src/main/scala/core/services/cache/DistributedCacheService.scala diff --git a/src/main/scala/core/services/flow/ReaktoroService.scala b/src/main/scala/core/services/flow/ReaktoroService.scala new file mode 100644 index 0000000..d6bcbf5 --- /dev/null +++ b/src/main/scala/core/services/flow/ReaktoroService.scala @@ -0,0 +1,101 @@ +package core.services.flow + +import cats.effect.Concurrent +import cats.implicits._ +import cats.effect.kernel.implicits.parallelForGenSpawn + +import core.services.preprocessor.ReactionService +import core.domain.preprocessor.{ACCELERATE, Molecule, ReactionDetails, ReactionId} +import core.domain.flow.{DataBase, MoleculeAmountList, SystemProps, SystemState} +import core.errors.http.flow.SystemPropsError +import core.errors.http.flow.SystemPropsError.{BadRequestError, ChemistEngineError} + +import org.http4s.circe.CirceEntityEncoder.circeEntityEncoder +import org.http4s.circe.CirceSensitiveDataEntityDecoder.circeEntityDecoder +import org.http4s.circe.toMessageSyntax +import org.http4s.client.Client +import org.http4s.{Method, Request, Status, Uri} + +class ReaktoroService[F[_]: Concurrent]( + reactionService: ReactionService[F], + chemistEngineClient: Client[F], + chemistEngineUri: Uri +) { + + def computeSystemPropsForReaction( + reactionId: ReactionId, + database: DataBase, + amounts: MoleculeAmountList + ): F[List[Either[SystemPropsError, SystemProps]]] = + reactionService + .getReaction(reactionId) + .map(reactionDetails => createSystemStateList(reactionDetails, database, amounts)) + .attempt + .flatMap { + case Right(systemStates) => sendBatchToChemistEngine(systemStates) + case Left(error: ChemistEngineError) => List(Left(error)).pure[F] + case Left(error) => Concurrent[F].raiseError(error) + } + + private def createSystemStateList( + reactionDetails: ReactionDetails, + database: DataBase, + amounts: MoleculeAmountList + ): List[SystemState] = { + val accelerates: List[ACCELERATE] = reactionDetails.conditions.map(_._1) + + val moleculeAmounts: Map[Molecule, Double] = computeMoleculeAmounts(reactionDetails, amounts) + + accelerates.flatMap { accelerate => + accelerate + .temperature + .zip(accelerate.pressure) + .map { + case (temp, pres) => + SystemState( + temperature = temp.toDouble, + pressure = pres.toDouble, + database, + moleculeAmounts + ) + } + } + } + + private def computeMoleculeAmounts( + reactionDetails: ReactionDetails, + amounts: MoleculeAmountList + ): Map[Molecule, Double] = { + val inboundAmounts = reactionDetails.inboundReagents.zip(amounts.inboundReagentAmounts).map { + case ((_, molecule), amount) => molecule -> amount + } + + val outboundAmounts = reactionDetails.outboundProducts.zip(amounts.outboundProductAmounts).map { + case ((_, molecule), amount) => molecule -> amount + } + + (inboundAmounts ++ outboundAmounts).toMap + } + + private def sendBatchToChemistEngine( + systemStates: List[SystemState] + ): F[List[Either[SystemPropsError, SystemProps]]] = + systemStates.parTraverse(sendToChemistEngine) + + private def sendToChemistEngine( + systemState: SystemState + ): F[Either[SystemPropsError, SystemProps]] = + chemistEngineClient + .run(Request[F](Method.POST, chemistEngineUri).withEntity(systemState)) + .use { response => + response.status match { + case status if status.isSuccess => + response.decodeJson[SystemProps].map(Right(_)) + case Status.BadRequest => + response.as[String].map(msg => Left(BadRequestError(msg))) + case _ => + Concurrent[F].pure(Left(ChemistEngineError("Failed to compute SystemProps"))) + } + } + +} diff --git a/src/main/scala/core/services/preprocessor/MechanismService.scala b/src/main/scala/core/services/preprocessor/MechanismService.scala new file mode 100644 index 0000000..8f0897c --- /dev/null +++ b/src/main/scala/core/services/preprocessor/MechanismService.scala @@ -0,0 +1,82 @@ +package core.services.preprocessor + +import cats.effect.Concurrent +import cats.implicits._ +import core.domain.preprocessor.{Mechanism, MechanismDetails, MechanismId} +import core.errors.http.preprocessor.MechanismError +import core.errors.http.preprocessor.MechanismError._ +import core.services.cache.CacheService +import org.http4s.client.Client +import org.http4s.{Method, Request, Status, Uri} +import io.circe.syntax._ +import io.circe.parser.decode +import org.http4s.circe._ + +class MechanismService[F[_]: Concurrent]( + cacheService: CacheService[F], + client: Client[F], + baseUri: Uri +) { + + def getMechanism(id: MechanismId): F[MechanismDetails] = + cacheService.getMechanism(id).flatMap { + case Some(cachedMechanism) => cachedMechanism.pure[F] + case None => fetchMechanismFromRemote(id) + } + + def createMechanism(mechanism: Mechanism): F[Mechanism] = + makeRequest[Mechanism]( + Request[F](Method.POST, baseUri).withEntity(mechanism.asJson), + responseBody => + decode[Mechanism](responseBody).leftMap { error => + DecodingError(s"Failed to parse created Mechanism: ${error.getMessage}") + } + ).flatTap(createdMechanism => + cacheService.putMechanism(createdMechanism.mechanismId, createdMechanism) + ) + + def deleteMechanism(id: MechanismId): F[Either[MechanismError, Boolean]] = + client + .run(Request[F](Method.DELETE, baseUri / id.toString)) + .use { response => + response.status match { + case Status.NoContent => cacheService.cleanExpiredEntries.as(Right(true)) + case status => Left(DeletionError(s"HTTP error ${status.code}: ${status.reason}")).pure[F] + } + } + .recoverWith { case error => + Left(NetworkError(s"Network error: ${error.getMessage}")).pure[F] + } + + private def fetchMechanismFromRemote(id: MechanismId): F[MechanismDetails] = + makeRequest[MechanismDetails]( + Request[F](Method.GET, baseUri / id.toString), + responseBody => + decode[MechanismDetails](responseBody).leftMap { error => + DecodingError(s"Failed to parse MechanismDetails: ${error.getMessage}") + } + ).flatTap(mechanism => cacheService.putMechanismDetails(id, mechanism)) + + private def makeRequest[A]( + request: Request[F], + decodeFn: String => Either[MechanismError, A] + ): F[A] = + client + .run(request) + .use { response => + response.as[String].flatMap { responseBody => + response.status match { + case status if status.isSuccess => + decodeFn(responseBody).fold(Concurrent[F].raiseError, Concurrent[F].pure) + case Status.NotFound => + Concurrent[F].raiseError(NotFoundError(s"Resource not found: ${request.uri}")) + case status => + Concurrent[F].raiseError(HttpError(s"HTTP error ${status.code}: ${status.reason}")) + } + } + } + .recoverWith { case error => + Concurrent[F].raiseError(NetworkError(s"Network error: ${error.getMessage}")) + } + +} diff --git a/src/main/scala/core/services/preprocessor/ReactionService.scala b/src/main/scala/core/services/preprocessor/ReactionService.scala new file mode 100644 index 0000000..9e35957 --- /dev/null +++ b/src/main/scala/core/services/preprocessor/ReactionService.scala @@ -0,0 +1,82 @@ +package core.services.preprocessor + +import cats.effect.Concurrent +import cats.implicits._ +import core.domain.preprocessor.{Reaction, ReactionDetails, ReactionId} +import core.errors.http.preprocessor.ReactionError +import core.errors.http.preprocessor.ReactionError._ +import core.services.cache.CacheService +import org.http4s.client.Client +import org.http4s.{Method, Request, Status, Uri} +import io.circe.syntax._ +import io.circe.parser.decode +import org.http4s.circe._ + +class ReactionService[F[_]: Concurrent]( + cacheService: CacheService[F], + client: Client[F], + baseUri: Uri +) { + + def getReaction(id: ReactionId): F[ReactionDetails] = + cacheService.getReaction(id).flatMap { + case Some(cachedReaction) => cachedReaction.pure[F] + case None => fetchReactionFromRemote(id) + } + + def createReaction(reaction: Reaction): F[Reaction] = + makeRequest[Reaction]( + Request[F](Method.POST, baseUri).withEntity(reaction.asJson), + responseBody => + decode[Reaction](responseBody).leftMap { error => + CreationError(s"Failed to create Reaction: ${error.getMessage}") + } + ).flatTap(createdReaction => + cacheService.putReaction(createdReaction.reactionId, createdReaction) + ) + + def deleteReaction(id: ReactionId): F[Either[ReactionError, Boolean]] = + client + .run(Request[F](Method.DELETE, baseUri / id.toString)) + .use { response => + response.status match { + case Status.NoContent => cacheService.cleanExpiredEntries.as(Right(true)) + case status => Left(DeletionError(s"HTTP error ${status.code}: ${status.reason}")).pure[F] + } + } + .recoverWith { case error => + Left(NetworkError(s"Network error: ${error.getMessage}")).pure[F] + } + + private def fetchReactionFromRemote(id: ReactionId): F[ReactionDetails] = + makeRequest[ReactionDetails]( + Request[F](Method.GET, baseUri / id.toString), + responseBody => + decode[ReactionDetails](responseBody).leftMap { error => + DecodingError(s"Failed to parse ReactionDetails: ${error.getMessage}") + } + ).flatTap(reaction => cacheService.putReactionDetails(id, reaction)) + + private def makeRequest[A]( + request: Request[F], + decodeFn: String => Either[ReactionError, A] + ): F[A] = + client + .run(request) + .use { response => + response.as[String].flatMap { responseBody => + response.status match { + case status if status.isSuccess => + decodeFn(responseBody).fold(Concurrent[F].raiseError, Concurrent[F].pure) + case Status.NotFound => + Concurrent[F].raiseError(NotFoundError(s"Resource not found: ${request.uri}")) + case status => + Concurrent[F].raiseError(HttpError(s"HTTP error ${status.code}: ${status.reason}")) + } + } + } + .recoverWith { case error => + Concurrent[F].raiseError(NetworkError(s"Network error: ${error.getMessage}")) + } + +} diff --git a/src/main/scala/resource/application.conf b/src/main/scala/resource/application.conf index b2438cc..966779c 100644 --- a/src/main/scala/resource/application.conf +++ b/src/main/scala/resource/application.conf @@ -1,4 +1,8 @@ akka { + license { + service-key = ${?AKKA_LICENSE_KEY} + } + actor { provider = "cluster" # Use Akka cluster default-mailbox.mailbox-type = "akka.dispatch.UnboundedMailbox" @@ -34,30 +38,49 @@ kafka { } } +http { + host = "0.0.0.0" # Host for HTTP server + port = 8081 # Port for HTTP server +} + database { url = "jdbc:postgresql://localhost:5432/chemist_db" + url = ${?POSTGRE_URL} user = "chemist_user" + user = ${?POSTGRE_USER} password = "chemist_password" - driver = "org.postgresql.Driver" + password = ${?POSTGRE_PASSWORD} + driver = org.postgresql.Driver + driver = ${?POSTGRE_DRIVER} connection-pool { max-pool-size = 10 } } -http { - host = "0.0.0.0" # Host for HTTP server - port = 8081 # Port for HTTP server +chemistPreprocessorHttpClient { + baseUri = "http://localhost:8080" + baseUri = ${?CHEMIST_PREPROCESSOR_BASE_URI} + timeout { + connect = 5s + request = 10s + } + retries = 3 + pool { + max-connections = 50 + max-idle-time = 30s + } } -httpClient { - baseUri = "http://localhost:8080" # Default base URI for external requests +chemistEngineHttpClient { + baseUri = "http://localhost:8082" + baseUri = ${?CHEMIST_ENGINE_BASE_URI} timeout { - connect = 5s # Connection timeout - request = 10s # Request timeout + connect = 5s + request = 10s } - retries = 3 # Number of retries on failure + retries = 3 pool { - max-connections = 50 # Max number of connections - max-idle-time = 30s # Max idle time for connections + max-connections = 50 + max-idle-time = 30s } } diff --git a/src/test/scala/app/MainSpec.scala b/src/test/scala/app/MainSpec.scala index e209182..f30bf9d 100644 --- a/src/test/scala/app/MainSpec.scala +++ b/src/test/scala/app/MainSpec.scala @@ -1,30 +1,69 @@ package app -import api.Endpoints +import api.endpoints.preprocessor.PreprocessorEndpoints import api.ServerBuilder -import cats.effect.IO + +import cats.effect.{IO, Resource} import cats.effect.unsafe.implicits.global -import com.comcast.ip4s.Host -import com.comcast.ip4s.Port +import cats.implicits.toSemigroupKOps + +import com.comcast.ip4s.{Host, Port} + +import core.services.cache.CacheService +import core.services.flow.ReaktoroService +import core.services.preprocessor.{MechanismService, ReactionService} + +import org.http4s.Uri +import org.http4s.ember.client.EmberClientBuilder import org.scalatest.BeforeAndAfterAll import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AsyncWordSpec +import org.typelevel.log4cats.slf4j.Slf4jLogger +import org.typelevel.log4cats.Logger class MainSpec extends AsyncWordSpec with Matchers with BeforeAndAfterAll { + implicit val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + "Main" should { "start the http4s server as a Resource" in { - 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 + val maybeHost = Host.fromString("0.0.0.0").get + val maybePort = Port.fromInt(8081).get + val preprocessorUri = Uri.unsafeFromString("http://localhost:8080") + val engineUri = Uri.unsafeFromString("http://localhost:8082/api") + + val serverResource = for { + client <- EmberClientBuilder.default[IO].build + cacheService <- Resource.make( + IO(new CacheService[IO]) + )(_ => IO.unit) + mechanismService <- Resource.make( + IO(new MechanismService[IO](cacheService, client, preprocessorUri / "mechanism")) + )(_ => IO.unit) + reactionService <- Resource.make( + IO(new ReactionService[IO](cacheService, client, preprocessorUri / "reaction")) + )(_ => IO.unit) + reaktoroService <- Resource.make( + IO(new ReaktoroService[IO](reactionService, client, engineUri / "reaction")) + )(_ => IO.unit) + preprocessorEndpoints <- Resource.make( + IO(new PreprocessorEndpoints(reactionService, mechanismService)) + )(_ => IO.unit) + reaktoroEndpoints <- Resource.make( + IO(new PreprocessorEndpoints(reactionService, mechanismService)) + )(_ => IO.unit) + serverBuilder <- Resource.make( + IO(new ServerBuilder(preprocessorEndpoints.routes <+> reaktoroEndpoints.routes)) + )(_ => IO.unit) + server <- serverBuilder.startServer(maybeHost, maybePort) + } yield server + + serverResource .use { server => IO { server.address.getPort shouldEqual 8081 - // More tests here to verify specific endpoint behaviour + // Additional tests can verify the availability and behaviour of endpoints. } } .unsafeToFuture() diff --git a/src/test/scala/config/ConfigLoaderSpec.scala b/src/test/scala/config/ConfigLoaderSpec.scala index 579b661..7182059 100644 --- a/src/test/scala/config/ConfigLoaderSpec.scala +++ b/src/test/scala/config/ConfigLoaderSpec.scala @@ -1,17 +1,20 @@ package config import com.comcast.ip4s.{Host, Port} +import org.http4s.Uri import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec import pureconfig.error.ConfigReaderException import pureconfig.ConfigSource +import config.ConfigLoader.DefaultConfigLoader +import scala.concurrent.duration._ class ConfigLoaderSpec extends AnyWordSpec with Matchers { "ConfigLoader" should { "load the Kafka configuration correctly" in { - val kafkaConfig = ConfigLoader.kafkaConfig + val kafkaConfig = DefaultConfigLoader.kafkaConfig kafkaConfig.bootstrapServers shouldBe "localhost:9092" kafkaConfig.topic.reactions shouldBe "reactions-topic" @@ -19,21 +22,44 @@ class ConfigLoaderSpec extends AnyWordSpec with Matchers { } "load the HTTP configuration correctly" in { - val httpConfig = ConfigLoader.httpConfig + val httpConfig = DefaultConfigLoader.httpConfig httpConfig.host shouldBe Host.fromString("0.0.0.0").get httpConfig.port shouldBe Port.fromInt(8081).get } "load the Database configuration correctly" in { - val databaseConfig = ConfigLoader.databaseConfig + val databaseConfig = DefaultConfigLoader.databaseConfig + databaseConfig.url shouldBe "jdbc:postgresql://localhost:5432/chemist_db" databaseConfig.user shouldBe "chemist_user" databaseConfig.password shouldBe "chemist_password" } + "load the ChemistPreprocessorHttpClient configuration correctly" in { + val preprocessorHttpClientConfig = DefaultConfigLoader.preprocessorHttpClientConfig + + preprocessorHttpClientConfig.baseUri shouldBe Uri.unsafeFromString("http://localhost:8080") + preprocessorHttpClientConfig.timeout.connect shouldBe 5.seconds + preprocessorHttpClientConfig.timeout.request shouldBe 10.seconds + preprocessorHttpClientConfig.retries shouldBe 3 + preprocessorHttpClientConfig.pool.maxConnections shouldBe 50 + preprocessorHttpClientConfig.pool.maxIdleTime shouldBe 30.seconds + } + + "load the ChemistEngineHttpClient configuration correctly" in { + val engineHttpClientConfig = DefaultConfigLoader.engineHttpClientConfig + + engineHttpClientConfig.baseUri shouldBe Uri.unsafeFromString("http://localhost:8082") + engineHttpClientConfig.timeout.connect shouldBe 5.seconds + engineHttpClientConfig.timeout.request shouldBe 10.seconds + engineHttpClientConfig.retries shouldBe 3 + engineHttpClientConfig.pool.maxConnections shouldBe 50 + engineHttpClientConfig.pool.maxIdleTime shouldBe 30.seconds + } + "load the entire AppConfig correctly" in { - val appConfig = ConfigLoader.appConfig + val appConfig = DefaultConfigLoader.appConfig appConfig.kafka.bootstrapServers shouldBe "localhost:9092" appConfig.http.host shouldBe Host.fromString("0.0.0.0").get @@ -41,6 +67,8 @@ class ConfigLoaderSpec extends AnyWordSpec with Matchers { appConfig.database.url shouldBe "jdbc:postgresql://localhost:5432/chemist_db" appConfig.database.user shouldBe "chemist_user" appConfig.database.password shouldBe "chemist_password" + appConfig.preprocessorHttpClient.baseUri shouldBe Uri.unsafeFromString("http://localhost:8080") + appConfig.engineHttpClient.baseUri shouldBe Uri.unsafeFromString("http://localhost:8082") } "fail gracefully if required configuration is missing" in {