import cats.MonadError import cats.effect.Sync import cats.effect.concurrent.Ref import cats.syntax.all._ import io.circe._ import io.circe.generic.auto._ import org.http4s._ import org.http4s.circe._ import org.http4s.dsl.Http4sDsl // format: off case class User(username: String, age: Int) case class UserUpdateAge(age: Int) sealed trait UserError extends Throwable 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)) } } } } object JsonCodecs { implicit def jsonDecoder[A <: Product: Decoder, F[_]: Sync]: EntityDecoder[F, A] = jsonOf[F, A] implicit def jsonEncoder[A <: Product: Encoder, F[_]: Sync]: EntityEncoder[F, A] = jsonEncoderOf[F, A] } class UserRoutes[F[_]: Sync](userAlgebra: UserAlgebra[F]) extends Http4sDsl[F] { import JsonCodecs._ val routes: HttpRoutes[F] = HttpRoutes.of[F] { case GET -> Root / "users" / username => userAlgebra.find(username).flatMap { case Some(user) => Ok(user) case None => NotFound(username) } case req @ POST -> Root / "users" => req.as[User].flatMap { user => userAlgebra.save(user) *> Created(user.username) } case req @ PUT -> Root / "users" / username => req.as[UserUpdateAge].flatMap { userUpdate => userAlgebra.updateAge(username, userUpdate.age) *> Ok(username) } } } class UserRoutesAlt[F[_]: Sync](userAlgebra: UserAlgebra[F]) extends Http4sDsl[F] { import JsonCodecs._ val routes: HttpRoutes[F] = HttpRoutes.of[F] { case GET -> Root / "users" / username => userAlgebra.find(username).flatMap { case Some(user) => Ok(user) case None => NotFound(username) } case req @ POST -> Root / "users" => req.as[User].flatMap { user => userAlgebra.save(user) *> Created(user.username) }.handleErrorWith { // compiles without giving you "match non-exhaustive" error case UserAlreadyExists(username) => Conflict(username) } case req @ PUT -> Root / "users" / username => req.as[UserUpdateAge].flatMap { userUpdate => userAlgebra.updateAge(username, userUpdate.age) *> Ok(username) }.handleErrorWith { // compiles without giving you "match non-exhaustive" error case InvalidUserAge(age) => BadRequest(s"Invalid age $age") } } } trait HttpErrorHandler[F[_], E <: Throwable] { def handle(fa: F[Response[F]]): F[Response[F]] } object HttpErrorHandler { def apply[F[_], E <: Throwable](implicit ev: HttpErrorHandler[F, E]) = ev } object syntax { implicit class HttpErrorHandlerOps[F[_], E <: Throwable](fa: F[Response[F]]) { def handleHttpErrorResponse(implicit H: HttpErrorHandler[F, E]): F[Response[F]] = H.handle(fa) } } class UserRoutesMTL[F[_]: Sync: HttpErrorHandler[?[_], UserError]](userAlgebra: UserAlgebra[F]) extends Http4sDsl[F] { import JsonCodecs._ import syntax._ val routes: HttpRoutes[F] = HttpRoutes.of[F] { case GET -> Root / "users" / username => userAlgebra.find(username).flatMap { case Some(user) => Ok(user) case None => NotFound(username) }.handleHttpErrorResponse case req @ POST -> Root / "users" => req.as[User].flatMap { user => userAlgebra.save(user) *> Created(user.username) }.handleHttpErrorResponse case req @ PUT -> Root / "users" / username => req.as[UserUpdateAge].flatMap { userUpdate => userAlgebra.updateAge(username, userUpdate.age) *> Created(username) }.handleHttpErrorResponse } } class UserHttpErrorHandler[F[_]: MonadError[?[_], UserError]] extends HttpErrorHandler[F, UserError] with Http4sDsl[F] { // match may not be exhaustive override def handle(fa: F[Response[F]]): F[Response[F]] = fa.handleErrorWith { // It would fail on the following inputs: UserAlreadyExists(_), UserNotFound(_) case InvalidUserAge(age) => BadRequest(s"Invalid age $age") } }