Skip to content

Instantly share code, notes, and snippets.

@gvolpe
Last active December 17, 2022 14:02
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save gvolpe/3fa32dd1b6abce2a5466efbf0eca9e94 to your computer and use it in GitHub Desktop.
Save gvolpe/3fa32dd1b6abce2a5466efbf0eca9e94 to your computer and use it in GitHub Desktop.
import cats.{ ApplicativeError, MonadError }
import cats.data.{ Kleisli, OptionT }
import cats.effect.Sync
import cats.effect.concurrent.Ref
import cats.syntax.all._
import io.circe.generic.auto._
import io.circe.syntax._
import org.http4s._
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe._
import org.http4s.dsl.Http4sDsl
case class User(username: String, age: Int)
case class UserUpdateAge(age: Int)
sealed trait UserError extends Exception
case class UserAlreadyExists(username: String) extends UserError
case class UserNotFound(username: String) extends UserError
case class InvalidUserAge(age: Int) extends UserError
trait UserAlgebra[F[_]] {
def find(username: String): F[Option[User]]
def save(user: User): F[Unit]
def updateAge(username: String, age: Int): F[Unit]
}
object UserInterpreter {
def create[F[_]](implicit F: Sync[F]): F[UserAlgebra[F]] =
Ref.of[F, Map[String, User]](Map.empty).map { state =>
new UserAlgebra[F] {
private def validateAge(age: Int): F[Unit] =
if (age <= 0) F.raiseError(InvalidUserAge(age)) else F.unit
override def find(username: String): F[Option[User]] =
state.get.map(_.get(username))
override def save(user: User): F[Unit] =
validateAge(user.age) *>
find(user.username).flatMap {
case Some(_) =>
F.raiseError(UserAlreadyExists(user.username))
case None =>
state.update(_.updated(user.username, user))
}
override def updateAge(username: String, age: Int): F[Unit] =
validateAge(age) *>
find(username).flatMap {
case Some(user) =>
state.update(_.updated(username, user.copy(age = age)))
case None =>
F.raiseError(UserNotFound(username))
}
}
}
}
class UserRoutes[F[_]: Sync](userAlgebra: UserAlgebra[F]) extends Http4sDsl[F] {
val routes: HttpRoutes[F] = HttpRoutes.of[F] {
case GET -> Root / "users" / username =>
userAlgebra.find(username).flatMap {
case Some(user) => Ok(user.asJson)
case None => NotFound(username.asJson)
}
case req @ POST -> Root / "users" =>
req.as[User].flatMap { user =>
userAlgebra.save(user) *> Created(user.username.asJson)
}
case req @ PUT -> Root / "users" / username =>
req.as[UserUpdateAge].flatMap { userUpdate =>
userAlgebra.updateAge(username, userUpdate.age) *> Ok(username.asJson)
}
}
}
class UserRoutesAlt[F[_]: Sync](userAlgebra: UserAlgebra[F]) extends Http4sDsl[F] {
val routes: HttpRoutes[F] = HttpRoutes.of[F] {
case GET -> Root / "users" / username =>
userAlgebra.find(username).flatMap {
case Some(user) => Ok(user.asJson)
case None => NotFound(username.asJson)
}
case req @ POST -> Root / "users" =>
req.as[User].flatMap { user =>
userAlgebra.save(user) *> Created(user.username.asJson)
}.handleErrorWith { // compiles without giving you "match non-exhaustive" error
case UserAlreadyExists(username) => Conflict(username.asJson)
}
case req @ PUT -> Root / "users" / username =>
req.as[UserUpdateAge].flatMap { userUpdate =>
userAlgebra.updateAge(username, userUpdate.age) *> Ok(username.asJson)
}.handleErrorWith { // compiles without giving you "match non-exhaustive" error
case InvalidUserAge(age) => BadRequest(s"Invalid age $age".asJson)
}
}
}
trait HttpErrorHandler[F[_], E <: Throwable] {
def handle(routes: HttpRoutes[F]): HttpRoutes[F]
}
object RoutesHttpErrorHandler {
def apply[F[_]: ApplicativeError[?[_], E], E <: Throwable](routes: HttpRoutes[F])(handler: E => F[Response[F]]): HttpRoutes[F] =
Kleisli { req =>
OptionT {
routes.run(req).value.handleErrorWith(e => handler(e).map(Option(_)))
}
}
}
object HttpErrorHandler {
def apply[F[_], E <: Throwable](implicit ev: HttpErrorHandler[F, E]) = ev
}
class UserRoutesMTL[F[_]: Sync](userAlgebra: UserAlgebra[F])
(implicit H: HttpErrorHandler[F, UserError]) extends Http4sDsl[F] {
private val httpRoutes: HttpRoutes[F] = HttpRoutes.of[F] {
case GET -> Root / "users" / username =>
userAlgebra.find(username).flatMap {
case Some(user) => Ok(user.asJson)
case None => NotFound(username.asJson)
}
case req @ POST -> Root / "users" =>
req.as[User].flatMap { user =>
userAlgebra.save(user) *> Created(user.username.asJson)
}
case req @ PUT -> Root / "users" / username =>
req.as[UserUpdateAge].flatMap { userUpdate =>
userAlgebra.updateAge(username, userUpdate.age) *> Created(username.asJson)
}
}
val routes: HttpRoutes[F] = H.handle(httpRoutes)
}
class UserHttpErrorHandler[F[_]: MonadError[?[_], UserError]] extends HttpErrorHandler[F, UserError] with Http4sDsl[F] {
private val handler: UserError => F[Response[F]] = {
case InvalidUserAge(age) => BadRequest(s"Invalid age $age".asJson)
case UserAlreadyExists(username) => Conflict(username.asJson)
case UserNotFound(username) => NotFound(username.asJson)
}
override def handle(routes: HttpRoutes[F]): HttpRoutes[F] =
RoutesHttpErrorHandler(routes)(handler)
}
@PeterPerhac
Copy link

Hello Gabriel, I found your article very good and thought provoking and I would like to fully digest (and play around with) the code, potentially then implement this idea of error handling in our project. While it's great all the relevant code is here in one place, I am somewhat disappointed that I now have to go hunt for libraries which I need to import into my sbt build in order to run it. Could you list your sbt library dependencies here so it could be easier to get up and running with your code sample? That would help others, I am sure. For me, I guess, I'll just hunt around and figure it out 😸

@PeterPerhac
Copy link

Here's a git-clone-able, runnable, adjusted (cut down) version of the final (mtl-based) solution from your gist: https://github.com/PeterPerhac/errorhandling-with-optics-http4s

@gvolpe
Copy link
Author

gvolpe commented Nov 16, 2018

Hey @PeterPerhac I'm just seeing this because I'm giving a talk on the topic today 😄 , unfortunately GitHub does not plan to add notifications to gist...

Sorry about that, you can always ping me on Gitter / Twitter if you have any other questions next time. I'll check out your code soon, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment