Last active
December 17, 2022 14:02
-
-
Save gvolpe/3fa32dd1b6abce2a5466efbf0eca9e94 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} |
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
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
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 😸