diff --git a/.githooks/pre-push b/.githooks/pre-push index e8fca06..0d4df0e 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,5 +1,21 @@ #!/bin/sh +echo "Checking for unstaged changes..." +git diff --exit-code +EXIT_CODE=$? + +if [ $EXIT_CODE -ne 0 ]; then + echo "" + echo "======================================" + echo "Unstaged changes detected. Please stage or discard changes before running this script." + echo "======================================" + echo "" + exit 1 +fi + +echo "No unstaged changes found." +echo "" + echo "Running scalafmt check..." echo "" sbt scalafmtCheckAll diff --git a/src/main/scala/app/Main.scala b/src/main/scala/app/Main.scala index d90316a..36a2c28 100644 --- a/src/main/scala/app/Main.scala +++ b/src/main/scala/app/Main.scala @@ -7,9 +7,13 @@ import akka.util.Timeout import cats.effect.{ExitCode, IO, IOApp, Resource} import cats.implicits.toSemigroupKOps +import com.comcast.ip4s.Host +import com.comcast.ip4s.Port + import config.ConfigLoader import config.ConfigLoader.DefaultConfigLoader +import org.http4s.Uri import org.typelevel.log4cats.slf4j.Slf4jLogger import org.typelevel.log4cats.Logger @@ -81,10 +85,10 @@ object Main extends IOApp { ttl: Timeout, 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 + val preprocessorBaseUri: Uri = config.preprocessorHttpClientConfig.baseUri + val engineBaseUri: Uri = config.engineHttpClientConfig.baseUri + val host: Host = config.httpConfig.host + val port: Port = config.httpConfig.port for { system <- actorSystemResource @@ -125,7 +129,7 @@ object Main extends IOApp { implicit val ec: ExecutionContext = system.dispatcher implicit val distributedData = DistributedData(system) implicit val selfUniqueAddress: SelfUniqueAddress = distributedData.selfUniqueAddress - implicit val cacheExpiration: Timeout = Timeout(5.minutes) + implicit val ttl: Timeout = Timeout(5.minutes) runApp(config) .use(_ => IO.unit) diff --git a/src/main/scala/core/repositories/InMemoryMechanismRepository.scala b/src/main/scala/core/repositories/InMemoryMechanismRepository.scala index a95df76..3f775de 100644 --- a/src/main/scala/core/repositories/InMemoryMechanismRepository.scala +++ b/src/main/scala/core/repositories/InMemoryMechanismRepository.scala @@ -1,10 +1,12 @@ package core.repositories import cats.effect.{Ref, Sync} +import cats.implicits.{catsSyntaxApplicativeId, catsSyntaxEitherId, catsSyntaxEq, toFlatMapOps, toFunctorOps} + 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 /** @@ -70,19 +72,20 @@ class InMemoryMechanismRepository[F[_]: Sync](state: Ref[F, Map[MechanismId, Mec * - 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(_.mechanismName == mechanism.mechanismName)) { - // Returns Left if a mechanism with the same 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)) - } + def create(mechanism: Mechanism): F[Either[MechanismError, Mechanism]] = + state.get.flatMap { mechanisms => + mechanisms.values + .find(_.mechanismName === mechanism.mechanismName) + .fold { + val newId = generateId(mechanisms) + val newMechanism = mechanism.copy(newId) + state.update(_ + (newId -> newMechanism)).as(newMechanism.asRight[MechanismError]) + } { _ => + CreationError(s"Mechanism with name '${mechanism.mechanismName}' already exists") + .asLeft[Mechanism] + .pure[F] + } } - } /** * Deletes a Mechanism from the state by its identifier. @@ -96,8 +99,8 @@ class InMemoryMechanismRepository[F[_]: Sync](state: Ref[F, Map[MechanismId, Mec */ def delete(id: MechanismId): F[Boolean] = state.modify { mechanisms => - if (mechanisms.contains(id)) (mechanisms - id, true) - else (mechanisms, false) + mechanisms.get(id) + .fold((mechanisms, false))(_ => (mechanisms - id, true)) } } diff --git a/src/main/scala/core/repositories/InMemoryReactionRepository.scala b/src/main/scala/core/repositories/InMemoryReactionRepository.scala index 6fb542f..db00362 100644 --- a/src/main/scala/core/repositories/InMemoryReactionRepository.scala +++ b/src/main/scala/core/repositories/InMemoryReactionRepository.scala @@ -1,33 +1,35 @@ package core.repositories import cats.effect.{Ref, Sync} -import cats.implicits.toFunctorOps +import cats.syntax.all._ + import core.domain.preprocessor.{Reaction, ReactionId} -import types.ReactionRepository import core.errors.http.preprocessor.ReactionError import core.errors.http.preprocessor.ReactionError.CreationError +import types.ReactionRepository + /** - * An in-memory implementation of the `ReactionRepository` for testing and local use. + * An in-memory implementation of `ReactionRepository` for testing and local use. * * @param state - * A reference to a mutable map representing the current state of stored reactions. + * A `Ref` encapsulating the mutable map of reactions. * @tparam F - * The effect type (e.g., `IO`, `SyncIO`, etc.). + * Effect type, such as `IO` or `SyncIO`. */ -class InMemoryReactionRepository[F[_]: Sync](state: Ref[F, Map[ReactionId, Reaction]]) +class FunctionalInMemoryReactionRepository[F[_]: Sync](state: Ref[F, Map[ReactionId, Reaction]]) extends ReactionRepository[F] { /** * Generates a new unique ID for a reaction. * - * @param currentState + * @param reactions * The current state of the stored reactions. * @return * The next available integer ID. */ - private def generateId(currentState: Map[ReactionId, Reaction]): Int = - currentState.keys.maxOption.getOrElse(0) + 1 + private def generateId(reactions: Map[ReactionId, Reaction]): ReactionId = + reactions.keys.maxOption.fold(1)(_ + 1) /** * Retrieves a reaction by its ID. @@ -35,31 +37,33 @@ class InMemoryReactionRepository[F[_]: Sync](state: Ref[F, Map[ReactionId, React * @param id * The ID of the reaction to retrieve. * @return - * An effect wrapping an optional `Reaction`. Returns `None` if the reaction is not found. + * Effectful optional `Reaction`. Returns `None` if not found. */ def get(id: ReactionId): F[Option[Reaction]] = state.get.map(_.get(id)) /** - * Creates a new reaction and stores it in the repository. + * Creates a new reaction. * * @param reaction - * The `Reaction` instance to create. + * The `Reaction` instance to add. * @return - * An effect wrapping either a `ReactionError` if the creation fails or the created `Reaction` on success. - * - If a reaction with the same name already exists, returns a `CreationError`. + * Effectful result of `Either` with an error or the new reaction. */ - def create(reaction: Reaction): F[Either[ReactionError, Reaction]] = { - state.modify { reactions => - val id = generateId(reactions) - 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)) - } + def create(reaction: Reaction): F[Either[ReactionError, Reaction]] = + state.get.flatMap { reactions => + reactions.values + .find(_.reactionName === reaction.reactionName) + .fold { + val newId = generateId(reactions) + val newReaction = reaction.copy(newId) + state.update(_ + (newId -> newReaction)).as(newReaction.asRight[ReactionError]) + } { _ => + CreationError(s"Reaction with name '${reaction.reactionName}' already exists") + .asLeft[Reaction] + .pure[F] + } } - } /** * Deletes a reaction by its ID. @@ -67,14 +71,13 @@ class InMemoryReactionRepository[F[_]: Sync](state: Ref[F, Map[ReactionId, React * @param id * The ID of the reaction to delete. * @return - * An effect wrapping a `Boolean` indicating whether the deletion was successful. - * - Returns `true` if the reaction was successfully deleted. - * - Returns `false` if the reaction ID was not found. + * Effectful `Boolean` indicating if the deletion was successful. */ def delete(id: ReactionId): F[Boolean] = - state.modify { reactions => - if (reactions.contains(id)) (reactions - id, true) - else (reactions, false) - } + state.modify(reactions => + reactions + .get(id) + .fold((reactions, false))(_ => (reactions - id, true)) + ) } diff --git a/src/main/scala/core/repositories/Neo4jReactionRepository.scala b/src/main/scala/core/repositories/Neo4jReactionRepository.scala index f726d6a..2c09612 100644 --- a/src/main/scala/core/repositories/Neo4jReactionRepository.scala +++ b/src/main/scala/core/repositories/Neo4jReactionRepository.scala @@ -2,13 +2,18 @@ package core.repositories import cats.effect.Sync import cats.implicits.{toFlatMapOps, toFunctorOps} + 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 + import org.http4s.Uri + import types.ReactionRepository /** diff --git a/src/main/scala/core/services/cache/DistributedCacheService.scala b/src/main/scala/core/services/cache/DistributedCacheService.scala index 283268c..22b2956 100644 --- a/src/main/scala/core/services/cache/DistributedCacheService.scala +++ b/src/main/scala/core/services/cache/DistributedCacheService.scala @@ -13,7 +13,6 @@ import core.domain.preprocessor.{Mechanism, MechanismDetails, MechanismId, React import core.services.cache.types.CacheServiceTrait import scala.concurrent.ExecutionContext -import scala.concurrent.duration._ /** * A distributed cache service for managing mechanisms and reactions using Akka Distributed Data. @@ -89,7 +88,7 @@ class DistributedCacheService[F[_]: Async]( private def getFromCache[K, V](key: LWWMapKey[K, V], id: K): F[Option[V]] = Async[F].fromFuture { Async[F].delay { - (replicator ? Get(key, ReadAll(5.seconds))).map { + (replicator ? Get(key, ReadAll(ttl.duration))).map { case response: Replicator.GetSuccess[LWWMap[K, V]] @unchecked => response.dataValue.get(id) case _: Replicator.NotFound[V] @unchecked => @@ -104,9 +103,9 @@ class DistributedCacheService[F[_]: Async]( private def putInCache[K, V](key: LWWMapKey[K, V], id: K, value: V): F[Unit] = Async[F].fromFuture { Async[F].delay { - (replicator ? Update(key, LWWMap.empty[K, V], WriteAll(5.seconds)) { + (replicator ? Update(key, LWWMap.empty[K, V], WriteAll(ttl.duration)) { _.put(selfUniqueAddress, id, value) - }).map(_ => ()) + }).void } } diff --git a/src/main/scala/core/services/cache/LocalCacheService.scala b/src/main/scala/core/services/cache/LocalCacheService.scala index f079765..25bee2a 100644 --- a/src/main/scala/core/services/cache/LocalCacheService.scala +++ b/src/main/scala/core/services/cache/LocalCacheService.scala @@ -1,11 +1,14 @@ package core.services.cache -import scala.concurrent.duration._ -import core.domain.preprocessor.{Mechanism, MechanismDetails, MechanismId, Reaction, ReactionDetails, ReactionId} -import scala.collection.concurrent.TrieMap import cats.effect.Sync +import cats.implicits.{toFlatMapOps, toFunctorOps} + +import core.domain.preprocessor.{Mechanism, MechanismDetails, MechanismId, Reaction, ReactionDetails, ReactionId} import core.services.cache.types.CacheServiceTrait +import scala.collection.concurrent.TrieMap +import scala.concurrent.duration._ + /** * A local, in-memory service for caching mechanisms and reactions with a time-to-live (TTL) mechanism. * @@ -32,8 +35,8 @@ class LocalCacheService[F[_]: Sync]( private def isExpired(entryTime: Long): Boolean = currentTime - entryTime > ttl.toMillis - private def cleanCache[K, V](cache: TrieMap[K, (V, Long)]): Unit = - cache.filterInPlace { case (_, (_, timestamp)) => !isExpired(timestamp) } + private def cleanCache[K, V](cache: TrieMap[K, (V, Long)]): F[Unit] = + Sync[F].delay { cache.filterInPlace { case (_, (_, timestamp)) => !isExpired(timestamp) } } private def getFromCache[K, V](cache: TrieMap[K, (V, Long)], id: K): F[Option[V]] = Sync[F].delay { @@ -94,11 +97,13 @@ class LocalCacheService[F[_]: Sync]( * @return * An effectful computation that completes when all expired entries have been removed. */ - def cleanExpiredEntries: F[Unit] = Sync[F].delay { - cleanCache(mechanismCache) - cleanCache(mechanismDetailsCache) - cleanCache(reactionCache) - cleanCache(reactionDetailsCache) + def cleanExpiredEntries: F[Unit] = { + for { + _ <- cleanCache(mechanismCache) + _ <- cleanCache(mechanismDetailsCache) + _ <- cleanCache(reactionCache) + _ <- cleanCache(reactionDetailsCache) + } yield () } } diff --git a/src/main/scala/core/services/preprocessor/MechanismService.scala b/src/main/scala/core/services/preprocessor/MechanismService.scala index 97ff108..09d456e 100644 --- a/src/main/scala/core/services/preprocessor/MechanismService.scala +++ b/src/main/scala/core/services/preprocessor/MechanismService.scala @@ -8,14 +8,13 @@ import core.errors.http.preprocessor.MechanismError import core.errors.http.preprocessor.MechanismError._ import core.services.cache.DistributedCacheService +import org.http4s.circe._ 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._ - /** * Service for managing mechanisms using both a distributed cache and remote HTTP service. * diff --git a/src/main/scala/core/services/preprocessor/ReactionService.scala b/src/main/scala/core/services/preprocessor/ReactionService.scala index 68d4ca4..351458c 100644 --- a/src/main/scala/core/services/preprocessor/ReactionService.scala +++ b/src/main/scala/core/services/preprocessor/ReactionService.scala @@ -1,16 +1,19 @@ package core.services.preprocessor import cats.effect.Concurrent -import cats.implicits._ +import cats.implicits.{catsSyntaxApplicativeError, catsSyntaxApplicativeId, toBifunctorOps, toFlatMapOps, toFunctorOps} + import core.domain.preprocessor.{Reaction, ReactionDetails, ReactionId} import core.errors.http.preprocessor.ReactionError import core.errors.http.preprocessor.ReactionError._ import core.services.cache.DistributedCacheService + +import org.http4s.circe._ 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._ /** * Service for managing reactions using both a distributed cache and remote HTTP service. diff --git a/src/test/scala/core/repositories/InMemoryMechanismRepositorySpec.scala b/src/test/scala/core/repositories/InMemoryMechanismRepositorySpec.scala new file mode 100644 index 0000000..3ab308e --- /dev/null +++ b/src/test/scala/core/repositories/InMemoryMechanismRepositorySpec.scala @@ -0,0 +1,93 @@ +package core.repositories + +import cats.effect.IO +import cats.effect.Ref +import cats.effect.unsafe.implicits.global + +import core.domain.preprocessor.{Mechanism, MechanismId} +import core.errors.http.preprocessor.MechanismError +import core.errors.http.preprocessor.MechanismError.CreationError + +import org.scalatest.wordspec.AnyWordSpec +import org.scalatest.matchers.should.Matchers + +class InMemoryMechanismRepositorySpec extends AnyWordSpec with Matchers { + + "InMemoryMechanismRepository" should { + + "store and retrieve a mechanism by ID using create and get" in { + val initialState = Map.empty[MechanismId, Mechanism] + val ref = Ref.of[IO, Map[MechanismId, Mechanism]](initialState).unsafeRunSync() + val repository = new InMemoryMechanismRepository[IO](ref) + + val mechanism = Mechanism(0, "mechanism-1", "type", 1.0) + + val result = for { + created <- repository.create(mechanism) + retrieved <- repository.get(created.toOption.map(_.mechanismId).get) + } yield (created, retrieved) + + val (created, retrieved) = result.unsafeRunSync() + + created shouldBe Right(mechanism.copy(1)) // ID auto-generated + retrieved shouldBe Some(mechanism.copy(1)) + } + + "return an error when creating a mechanism with a duplicate name" in { + val initialState = Map.empty[MechanismId, Mechanism] + val ref = Ref.of[IO, Map[MechanismId, Mechanism]](initialState).unsafeRunSync() + val repository = new InMemoryMechanismRepository[IO](ref) + + val mechanism1 = Mechanism(0, "mechanism-1", "type", 1.0) + val mechanism2 = Mechanism(0, "mechanism-1", "type", 2.0) + + val result = for { + _ <- repository.create(mechanism1) + attempt <- repository.create(mechanism2) + } yield attempt + + val attempt = result.unsafeRunSync() + + attempt shouldBe Left(CreationError("Mechanism with name 'mechanism-1' already exists")) + } + + "delete a mechanism by ID using delete" in { + val initialState = Map.empty[MechanismId, Mechanism] + val ref = Ref.of[IO, Map[MechanismId, Mechanism]](initialState).unsafeRunSync() + val repository = new InMemoryMechanismRepository[IO](ref) + + val mechanism = Mechanism(0, "mechanism-to-delete", "type", 1.0) + + val result = for { + created <- repository.create(mechanism) + deleted <- repository.delete(created.toOption.map(_.mechanismId).get) + retrieved <- repository.get(created.toOption.map(_.mechanismId).get) + } yield (deleted, retrieved) + + val (deleted, retrieved) = result.unsafeRunSync() + + deleted shouldBe true + retrieved shouldBe None + } + + "return false when deleting a non-existent mechanism ID" in { + val initialState = Map.empty[MechanismId, Mechanism] + val ref = Ref.of[IO, Map[MechanismId, Mechanism]](initialState).unsafeRunSync() + val repository = new InMemoryMechanismRepository[IO](ref) + + val result = repository.delete(999).unsafeRunSync() + + result shouldBe false + } + + "return None when retrieving a non-existent mechanism ID" in { + val initialState = Map.empty[MechanismId, Mechanism] + val ref = Ref.of[IO, Map[MechanismId, Mechanism]](initialState).unsafeRunSync() + val repository = new InMemoryMechanismRepository[IO](ref) + + val result = repository.get(999).unsafeRunSync() + + result shouldBe None + } + } +} diff --git a/src/test/scala/core/repositories/InMemoryReactionRepositorySpec.scala b/src/test/scala/core/repositories/InMemoryReactionRepositorySpec.scala new file mode 100644 index 0000000..63977a7 --- /dev/null +++ b/src/test/scala/core/repositories/InMemoryReactionRepositorySpec.scala @@ -0,0 +1,92 @@ +package core.repositories + +import cats.effect.IO +import cats.effect.Ref +import cats.effect.unsafe.implicits.global + +import core.domain.preprocessor.{Reaction, ReactionId} +import core.errors.http.preprocessor.ReactionError.CreationError + +import org.scalatest.wordspec.AnyWordSpec +import org.scalatest.matchers.should.Matchers + +class InMemoryReactionRepositorySpec extends AnyWordSpec with Matchers { + + "InMemoryReactionRepository" should { + + "store and retrieve a reaction by ID using create and get" in { + val initialState = Map.empty[ReactionId, Reaction] + val ref = Ref.of[IO, Map[ReactionId, Reaction]](initialState).unsafeRunSync() + val repository = new FunctionalInMemoryReactionRepository[IO](ref) + + val reaction = Reaction(0, "reaction-1") + + val result = for { + created <- repository.create(reaction) + retrieved <- repository.get(created.toOption.map(_.reactionId).get) + } yield (created, retrieved) + + val (created, retrieved) = result.unsafeRunSync() + + created shouldBe Right(reaction.copy(1)) // ID auto-generated + retrieved shouldBe Some(reaction.copy(1)) + } + + "return an error when creating a reaction with a duplicate name" in { + val initialState = Map.empty[ReactionId, Reaction] + val ref = Ref.of[IO, Map[ReactionId, Reaction]](initialState).unsafeRunSync() + val repository = new FunctionalInMemoryReactionRepository[IO](ref) + + val reaction1 = Reaction(0, "reaction-1") + val reaction2 = Reaction(0, "reaction-1") + + val result = for { + _ <- repository.create(reaction1) + attempt <- repository.create(reaction2) + } yield attempt + + val attempt = result.unsafeRunSync() + + attempt shouldBe Left(CreationError("Reaction with name 'reaction-1' already exists")) + } + + "delete a reaction by ID using delete" in { + val initialState = Map.empty[ReactionId, Reaction] + val ref = Ref.of[IO, Map[ReactionId, Reaction]](initialState).unsafeRunSync() + val repository = new FunctionalInMemoryReactionRepository[IO](ref) + + val reaction = Reaction(0, "reaction-to-delete") + + val result = for { + created <- repository.create(reaction) + deleted <- repository.delete(created.toOption.map(_.reactionId).get) + retrieved <- repository.get(created.toOption.map(_.reactionId).get) + } yield (deleted, retrieved) + + val (deleted, retrieved) = result.unsafeRunSync() + + deleted shouldBe true + retrieved shouldBe None + } + + "return false when deleting a non-existent reaction ID" in { + val initialState = Map.empty[ReactionId, Reaction] + val ref = Ref.of[IO, Map[ReactionId, Reaction]](initialState).unsafeRunSync() + val repository = new FunctionalInMemoryReactionRepository[IO](ref) + + val result = repository.delete(999).unsafeRunSync() + + result shouldBe false + } + + "return None when retrieving a non-existent reaction ID" in { + val initialState = Map.empty[ReactionId, Reaction] + val ref = Ref.of[IO, Map[ReactionId, Reaction]](initialState).unsafeRunSync() + val repository = new FunctionalInMemoryReactionRepository[IO](ref) + + val result = repository.get(999).unsafeRunSync() + + result shouldBe None + } + } +}