Skip to content

Commit

Permalink
Basic Redis Cache (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
RawToast authored Mar 16, 2024
1 parent 68f20c8 commit c0704c8
Show file tree
Hide file tree
Showing 10 changed files with 143 additions and 123 deletions.
2 changes: 1 addition & 1 deletion server/.scalafmt.conf
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version = 3.7.7
version = 3.8.0
runner.dialect = scala3
lineEndings = unix
importSelectors = singleLine
Expand Down
12 changes: 7 additions & 5 deletions server/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ val EdoMataVersion = "0.11.1"
val CirceVersion = "0.14.5"
val SkunkVersion = "0.6.0"
val MonocleVersion = "3.2.0"
val CatsEffect = "3.5.1"
val CatsVersion = "2.9.0"
val Redis4CatsVersion = "1.4.3"
val CatsEffect = "3.5.4"
val CatsVersion = "2.10.0"
val Redis4CatsVersion = "1.6.0"

val MunitVersion = "0.7.29"
val MunitCatsEffectVersion = "1.0.7"
Expand All @@ -33,13 +33,15 @@ lazy val root = project
"io.circe" %% "circe-parser" % CirceVersion,
"io.circe" %% "circe-optics" % "0.15.0",

// Redis
"dev.profunktor" %% "redis4cats-effects" % Redis4CatsVersion,

// Monocle lenses
"dev.optics" %% "monocle-core" % MonocleVersion,
"dev.optics" %% "monocle-macro" % MonocleVersion,

// Logging
"ch.qos.logback" % "logback-classic" % LogbackVersion,
"dev.profunktor" %% "redis4cats-log4cats" % Redis4CatsVersion,
"ch.qos.logback" % "logback-classic" % LogbackVersion,
// "com.typesafe.scala-logging" %% "scala-logging" % "3.5.0",

// Test
Expand Down
2 changes: 1 addition & 1 deletion server/project/plugins.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// addSbtPlugin("com.typesafe.sbt" % "sbt-twirl" % "1.5.0")

// Formatting
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2")

// Code coverage
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8")
2 changes: 0 additions & 2 deletions server/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,3 @@ This api is built on the following Scala / Java open source libraries:
* [ScalaTest](http://www.scalatest.org)
* [Mockito](http://site.mockito.org)
* [WireMock](http://wiremock.org)

# http://api.ratings.food.gov.uk/Help/Index
39 changes: 26 additions & 13 deletions server/src/main/scala/manjuu/Server.scala
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
package manjuu

import manjuu.client.Cache
import manjuu.client.FSAClient
import manjuu.domain.EstablishmentRatings
import manjuu.domain.{Authority, EstablishmentRatings}
import manjuu.routes.AuthorityController
import manjuu.services.{AuthorityService, EstablishmentService}
import manjuu.services.util.AuthorityParser
import manjuu.services.util.EstablishmentParser
import manjuu.services.util.RatingsFormatter
import manjuu.services.util.{AuthorityParser, EstablishmentParser, RatingsFormatter}

import cats.effect._
import com.comcast.ip4s._
import dev.profunktor.redis4cats.Redis
import dev.profunktor.redis4cats.codecs.Codecs
import dev.profunktor.redis4cats.codecs.splits.SplitEpi
import dev.profunktor.redis4cats.data.RedisCodec
import org.http4s.Method
import org.http4s.ember.client.EmberClientBuilder
import org.http4s.ember.server._
import org.http4s.implicits.uri
import org.http4s.server.middleware.CORS
import org.http4s.server.middleware.CORSConfig
import org.http4s.server.middleware.ErrorAction
import org.http4s.server.middleware.ErrorHandling
import org.http4s.server.middleware.{CORS, CORSConfig, ErrorAction, ErrorHandling}
import scala.concurrent.duration.DurationInt

object Server extends IOApp:
override protected def blockedThreadDetectionEnabled = true
Expand All @@ -26,16 +27,28 @@ object Server extends IOApp:
.default[IO]
.build

val authParser = AuthorityParser.impl()
val establishmentParser = EstablishmentParser.impl()
val authParser = AuthorityParser.impl()
val establishmentParser = EstablishmentParser.impl()
val redisUri = "redis://localhost:6379"

val redisResource = Cache.redis[IO](redisUri, 1.hour)

val fsaClient = FSAClient.impl(clientResource, uri"https://api.ratings.food.gov.uk")
val authorityService = AuthorityService.impl(fsaClient, authParser)
val ratingsFormatter = RatingsFormatter.impl()
val establishmentService: EstablishmentService[IO] =
EstablishmentService.impl(fsaClient, establishmentParser, ratingsFormatter)
val authorityController = AuthorityController.impl[IO](authorityService, establishmentService)
val authorityHttpApp = authorityController.routes.orNotFound
val corsEnabledApp =

val cachedEstablishmentService = EstablishmentService.withCache(
establishmentService,
redisResource
)
val cachedAuthorityService = AuthorityService.withCache(authorityService, redisResource)

val authorityController =
AuthorityController.impl[IO](cachedAuthorityService, cachedEstablishmentService)
val authorityHttpApp = authorityController.routes.orNotFound
val corsEnabledApp =
CORS.policy.withAllowOriginAll.withAllowMethodsIn(Set(Method.GET)).apply(authorityHttpApp)

val corsEnabledAppWithErrorLogging = ErrorHandling.Recover.total(
Expand Down
41 changes: 41 additions & 0 deletions server/src/main/scala/manjuu/client/RedisClient.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package manjuu.client

import cats.effect.kernel.{Async, Resource}
import dev.profunktor.redis4cats.Redis
import dev.profunktor.redis4cats.codecs.Codecs
import dev.profunktor.redis4cats.codecs.splits.SplitEpi
import dev.profunktor.redis4cats.data.RedisCodec
import dev.profunktor.redis4cats.effect.Log.Stdout.given
import io.circe.{Json, JsonObject}
import io.circe.parser.{decode => jsonDecode}
import io.circe.syntax._
import org.http4s.headers.`Access-Control-Max-Age`.Cache
import scala.concurrent.duration.FiniteDuration

trait Cache[F[_], K, V]:
def get(key: K): F[Option[V]]
def set(key: K, value: V): F[Unit]

object Cache:
val eventSplitEpi: SplitEpi[String, Json] =
SplitEpi[String, Json](
str => jsonDecode(str).getOrElse(JsonObject.empty.asJson),
_.asJson.noSpaces
)
val jsonCodec = Codecs.derive(RedisCodec.Utf8, eventSplitEpi)

def redis[F[_]: Async](uri: String, ttl: FiniteDuration): Resource[F, Cache[F, String, Json]] =
Redis[F].simple(uri, jsonCodec).map {
redis =>
new Cache[F, String, Json]:
def get(key: String): F[Option[Json]] = redis.get(key)
def set(key: String, value: Json): F[Unit] = redis.setEx(key, value, ttl)
}

def memory[F[_]: Async]: Resource[F, Cache[F, String, Json]] =
val cache = new Cache[F, String, Json]:
val store = scala.collection.mutable.Map.empty[String, Json]
def get(key: String): F[Option[Json]] = Async[F].pure(store.get(key))
def set(key: String, value: Json): F[Unit] = Async[F].pure(store.update(key, value))

Resource.pure[F, Cache[F, String, Json]](cache)
30 changes: 29 additions & 1 deletion server/src/main/scala/manjuu/services/AuthorityService.scala
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package manjuu.services

import manjuu.client.FSAClient
import manjuu.client.{Cache, FSAClient}
import manjuu.domain.Authority
import manjuu.services.util.AuthorityParser

import cats.Monad
import cats.effect.IO
import cats.effect.kernel.{Async, Resource}
import cats.implicits._
import dev.profunktor.redis4cats.RedisCommands
import io.circe.Json
import io.circe.syntax._
import scala.collection.immutable.Seq
import scala.concurrent.duration.FiniteDuration

trait AuthorityService[F[_]] {
def authorities: F[Seq[Authority]]
Expand All @@ -22,3 +28,25 @@ object AuthorityService:
.fetch("authorities/basic")
.map(authorityParser.summariseAuthorites)
}

def withCache[F[_]: Async](
fsaClient: AuthorityService[F],
redisResource: Resource[F, Cache[F, String, Json]]
): AuthorityService[F] =
new AuthorityService[F] {
val KEY = "authorities"
def authorities =
return redisResource.use(
redis =>
for {
cachedData <- redis.get(KEY)
data = cachedData.flatMap(_.as[Seq[Authority]].toOption)
authorities <- data match
case Some(authorities) => Monad[F].pure(authorities)
case None =>
fsaClient.authorities.flatTap {
authorities => redis.set(KEY, authorities.asJson)
}
} yield authorities
)
}
30 changes: 28 additions & 2 deletions server/src/main/scala/manjuu/services/EstablishmentService.scala
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
package manjuu.services

import manjuu.client.FSAClient
import manjuu.client.{Cache, FSAClient}
import manjuu.domain.AuthoritySummary
import manjuu.services.util.{EstablishmentParser, FormatterError, RatingsFormatter}

import cats._
import cats.data._
import cats.effect.IO
import cats.effect.kernel.Resource
import cats.effect.kernel.{Async, Resource}
import cats.implicits._
import io.circe.Json
import io.circe.syntax._
import org.http4s.EntityDecoder
import org.http4s.implicits._
import scala.concurrent.duration.FiniteDuration

enum EstablishmentServiceError:
case AuthorityNotFound
Expand Down Expand Up @@ -86,3 +88,27 @@ object EstablishmentService:
)

resultT.value

def withCache[F[_]: Async](
service: EstablishmentService[F],
redisResource: Resource[F, Cache[F, String, Json]]
): EstablishmentService[F] =
new EstablishmentService[F]:
def makeKey(id: Int) = s"establishments:$id"
def hygieneRatings(id: Int): F[Either[EstablishmentServiceError, AuthoritySummary]] =
val key = makeKey(id)
redisResource.use {
redis =>
for
cachedData <- redis.get(key)
data = cachedData.flatMap(_.as[AuthoritySummary].toOption)

summary <- data match
case Some(summary) => Monad[F].pure(summary.asRight)
case None =>
service.hygieneRatings(id).flatTap {
case Right(summary) => redis.set(key, summary.asJson)
case _ => Applicative[F].unit
}
yield summary
}
95 changes: 0 additions & 95 deletions server/src/test/scala/manjuu/services/AuthorityServiceSpec.scala

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package manjuu.services

import manjuu.Responses._
import manjuu.client.Cache
import manjuu.client.FSAClient
import manjuu.services.util._

Expand All @@ -17,7 +18,7 @@ import org.http4s.implicits._
import org.http4s.implicits.uri

class EstablishmentServiceSpec extends munit.FunSuite:
test("EstablishmentService"):
test("EstablishmentService can fetch hygiene ratings for an establishment"):
val parser = EstablishmentParser.impl()
val formatter = RatingsFormatter.impl()
implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global
Expand All @@ -26,10 +27,16 @@ class EstablishmentServiceSpec extends munit.FunSuite:
val clientResource: Resource[IO, Client[IO]] = Resource.pure[IO, Client[IO]](client)
val fsaClient = FSAClient.impl(clientResource, uri"")

val service = EstablishmentService.impl(fsaClient, parser, formatter)
val result = service.hygieneRatings(111).unsafeRunSync()
val service = EstablishmentService.impl(fsaClient, parser, formatter)
val withCache = EstablishmentService.withCache(service, Cache.memory[IO])
val result = withCache.hygieneRatings(111).unsafeRunSync()
assert(result.isRight)

val result2 = withCache.hygieneRatings(111).unsafeRunSync()
assert(result2.isRight)

assert(result == result2)

object EstablishmentServiceSpec:
val dsl = new Http4sDsl[IO] {}
import dsl._
Expand Down

0 comments on commit c0704c8

Please sign in to comment.