Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save mvillafuertem/f89303ae6afaee85e5124191c788d649 to your computer and use it in GitHub Desktop.
Save mvillafuertem/f89303ae6afaee85e5124191c788d649 to your computer and use it in GitHub Desktop.
// scalaVersion := "3.2.0"
// libraryDependencies ++= Seq(
// libraryDependencies ++= Seq(
// "org.typelevel" %% "cats-core" % "2.8.0",
// "dev.zio" %% "zio" % "2.0.0",
// "org.typelevel" %% "cats-effect" % "3.3.14",
// "org.typelevel" %% "kittens" % "3.0.0",
// "dev.zio" %% "zio-json" % "0.3.0-RC10",
// "io.d11" %% "zhttp" % "2.0.0-RC10"
// )
import cats.*
import cats.data.*
import cats.data.Validated.*
import cats.implicits.*
import cats.derived.*
package domain:
enum ValidationError derives Eq:
case InvalidCpf(cpf: String)
case InvalidName(name: String)
case EmptyName
case InvalidAge(age: Int)
case UnderEighteen
given Show[ValidationError] with
def show(e: ValidationError) = e match
case ValidationError.InvalidCpf(cpf) => show"Invalid CPF '$cpf'"
case ValidationError.InvalidName(name) => show"Invalid name '$name'"
case ValidationError.EmptyName => "Name cannot be empty"
case ValidationError.InvalidAge(age) => show"Invalid age '$age'"
case ValidationError.UnderEighteen => "Age must be greater than or equal to eighteen"
enum DomainError derives Eq:
case PersonNotFound
case ValidationFailure(errors: NonEmptyList[ValidationError])
given Show[DomainError] with
def show(e: DomainError) = e match
case DomainError.PersonNotFound => "Person not found"
case DomainError.ValidationFailure(errors) => errors `mkString_` ", "
object name:
opaque type Name = String
given Show[Name] with
def show(name: Name) = name
given Eq[Name] with
def eqv(x: Name, y: Name) = x == y
def mkName(s: String): ValidatedNec[ValidationError, Name] =
val l = s.length
if l >= 2 && l <= 50 then s.validNec
else if l === 0 then ValidationError.EmptyName.invalidNec
else ValidationError.InvalidName(s).invalidNec
def unsafeName(s: String): Name = s
object age:
opaque type Age = Int
given Show[Age] with
def show(age: Age) = age.toString
given Eq[Age] with
def eqv(x: Age, y: Age) = x == y
given Order[Age] with
def compare(x: Age, y: Age) =
if x > y then 1
else if x < y then -1
else 0
def mkAge(i: Int): ValidatedNec[ValidationError, Age] =
if i > 18 then i.validNec
else if i < 0 then ValidationError.InvalidAge(i).invalidNec
else ValidationError.UnderEighteen.invalidNec
extension (age: Age) def toInt: Int = age
def unsafeAge(i: Int): Age = i
object cpf:
opaque type Cpf = String
given Show[Cpf] with
def show(cpf: Cpf) = cpf
given Eq[Cpf] with
def eqv(x: Cpf, y: Cpf) = x == y
private val cpfRegex =
"([0-9]{2}[\\.]?[0-9]{3}[\\.]?[0-9]{3}[\\/]?[0-9]{4}[-]?[0-9]{2})|([0-9]{3}[\\.]?[0-9]{3}[\\.]?[0-9]{3}[-]?[0-9]{2})"
def mkCpf(s: String): ValidatedNec[ValidationError, Cpf] =
if s.matches(cpfRegex) then s.validNec
else ValidationError.InvalidCpf(s).invalidNec
def unsafeCpf(s: String): Cpf = s
import name.Name
import cpf.Cpf
import age.Age
final case class Person(name: Name, age: Age, cpf: Cpf) derives Eq, Show
package application:
import domain.*
import domain.name.{given, *}
import domain.cpf.{given, *}
import domain.age.{given, *}
trait PersonRepository[F[_]]:
def findPersonByCpf(cpf: Cpf): F[Option[Person]]
def savePerson(person: Person): F[Unit]
trait Console[F[_]]:
def error[A: Show](a: A): F[Unit]
def errorln[A: Show](a: A): F[Unit]
def print[A: Show](a: A): F[Unit]
def println[A: Show](a: A): F[Unit]
inline def findPersonByCpf[F[_]: PersonRepository](cpf: Cpf) =
summon[PersonRepository[F]].findPersonByCpf(cpf)
inline def savePerson[F[_]: PersonRepository](person: Person) =
summon[PersonRepository[F]].savePerson(person)
inline def println[F[_]: Console, A: Show](a: A) =
summon[Console[F]].println(a)
inline def errorln[F[_]: Console, A: Show](a: A) =
summon[Console[F]].errorln(a)
extension [E, A](either: Either[E, A])
def fromEither[F[_]: [F[_]] =>> ApplicativeError[F, E]] = either match
case Right(a) => a.pure
case Left(e) => e.raiseError
extension [A](validated: ValidatedNec[ValidationError, A])
def fromValidated[F[_]: [F[_]] =>> ApplicativeError[F, DomainError]] = validated match
case Valid(a) => a.pure
case Invalid(e) => DomainError.ValidationFailure(e.toNonEmptyList).raiseError
final case class UpdateNameRequest(name: String, age: Int, cpf: String)
final case class UpdateNameResponse(name: String, age: Int, cpf: String)
def updatePerson[F[_]: [F[_]] =>> MonadError[F, DomainError]: PersonRepository: Console](
request: UpdateNameRequest
) =
for
_ <- println("Updating...")
person <- ((mkCpf(request.cpf), mkName(request.name), mkAge(request.age)) `mapN` ((
_,
_,
_
))).fromValidated >>= { (cpf, name, age) =>
findPersonByCpf(cpf) >>= {
case Some(person) =>
val updatedPerson = person.copy(name = name, age = age)
savePerson(updatedPerson) >>
println(person === person) >>
println(person === updatedPerson) >>
println(updatedPerson) `map`
(_ => updatedPerson)
case _ => DomainError.PersonNotFound.raiseError
}
}
_ <- println("Updated!")
yield UpdateNameResponse(person.name.show, person.age.toInt, person.cpf.show)
package infrastructure:
import domain.{given, *}
import domain.name.*
import domain.cpf.*
import domain.age.*
import application.*
import zio.{Console as _, *}
import zio.Console as ZIOConsole
import zio.json.*
import zhttp.http.*
import zhttp.service.Server
enum InfraError derives Eq, Show:
case InvalidJson(msg: String)
case ConnectionTimeout
final case class DB():
def commit: UIO[Unit] = ZIOConsole.printLine("Commit").orDie
def rollback: UIO[Unit] = ZIOConsole.printLine("Rollback").orDie
final case class HttpResponse(status: Int, body: String)
trait ToHttpResponse[A]:
def toHttpResponse(a: A): HttpResponse
extension [A: ToHttpResponse](a: A)
def toHttpResponse = summon[ToHttpResponse[A]].toHttpResponse(a)
type App[A] = ZIO[DB, DomainError | InfraError, A]
given PersonRepository[App] with
def findPersonByCpf(cpf: Cpf) = for
db <- ZIO.service[DB]
// _ <- ZIO.fail(InfraError.ConnectionTimeout)
_ <- println("Fake reading DB...")
yield Some(Person(unsafeName("Test"), unsafeAge(18), unsafeCpf("12345678912")))
// yield None
def savePerson(person: Person) = for
db <- ZIO.service[DB]
_ <- println("Fake writing DB...")
yield ()
given Console[App] with
def error[A: Show](a: A) = ZIOConsole.printError(a.show).orDie
def errorln[A: Show](a: A) = ZIOConsole.printLineError(a.show).orDie
def print[A: Show](a: A) = ZIOConsole.print(a.show).orDie
def println[A: Show](a: A) = ZIOConsole.printLine(a.show).orDie
given MonadError[App, DomainError] with
def pure[A](x: A) = ZIO.succeed(x)
def tailRecM[A, B](a: A)(f: A => App[Either[A, B]]) =
def loop(a: A): App[B] = f(a).flatMap {
case Left(a) => loop(a)
case Right(b) => ZIO.succeed(b)
}
ZIO.suspendSucceed(loop(a))
def flatMap[A, B](fa: App[A])(f: A => App[B]) = fa.flatMap(f)
def handleErrorWith[A](fa: App[A])(f: DomainError => App[A]) = fa.catchAll({
case e: InfraError => fa
case e: DomainError => f(e)
})
def raiseError[A](e: DomainError) = ZIO.fail(e)
final case class ErrorResponse(code: String, message: String)
given JsonEncoder[UpdateNameResponse] = DeriveJsonEncoder.gen[UpdateNameResponse]
given JsonEncoder[ErrorResponse] = DeriveJsonEncoder.gen[ErrorResponse]
given ToHttpResponse[UpdateNameResponse] with
def toHttpResponse(a: UpdateNameResponse) = HttpResponse(200, a.toJson)
def mapValidationErrorToCode(e: ValidationError) = e match
case ValidationError.InvalidCpf(_) => "invalid-cpf"
case ValidationError.InvalidName(_) => "invalid-name"
case ValidationError.EmptyName => "empty-name"
case ValidationError.InvalidAge(_) => "invalid-age"
case ValidationError.UnderEighteen => "under-eighteen"
def mapDomainErrorToResponse(e: DomainError) = e match
case DomainError.PersonNotFound => List(ErrorResponse("person-not-found", e.show))
case DomainError.ValidationFailure(errors) =>
errors.toList `map` (e => ErrorResponse(mapValidationErrorToCode(e), e.show))
given ToHttpResponse[DomainError] with
def toHttpResponse(e: DomainError) = HttpResponse(
e match
case DomainError.ValidationFailure(_) => 400
case DomainError.PersonNotFound => 404
,
mapDomainErrorToResponse(e).toJson
)
given ToHttpResponse[InfraError] with
def toHttpResponse(e: InfraError) = e match
case InfraError.InvalidJson(msg) =>
HttpResponse(400, List(ErrorResponse("invalid-json", msg)).toJson)
case _ => HttpResponse(500, "")
final case class UpdatePersonBody(name: String, age: Int)
given JsonDecoder[UpdatePersonBody] = DeriveJsonDecoder.gen[UpdatePersonBody]
extension (req: Request)
def as[A: JsonDecoder]: IO[InfraError, A] = for
body <- req.bodyAsString.orDie
a <- ZIO.fromEither(
body.fromJson[A]
) `mapError` InfraError.InvalidJson.apply
yield a
def program: Http[DB, DomainError | InfraError, Request, HttpResponse] =
Http.collectZIO[Request] {
case req @ Method.PUT -> !! / "person" / cpf =>
for
body <- req.as[UpdatePersonBody]
res <- updatePerson(
UpdateNameRequest(name = body.name, age = body.age, cpf = cpf)
) `map` toHttpResponse
_ <- ZIO.serviceWithZIO[DB](_.commit)
yield res
case _ => ZIO.succeed(HttpResponse(404, ""))
}
object Main extends zio.ZIOAppDefault:
import domain.{given, *}
import application.*
import infrastructure.{given, *}
import zio.*
import zhttp.service.Server
import zhttp.http.*
def run =
Server
.start(
8090,
program
.tapError {
case e: DomainError => Http.fromZIO(errorln(e))
case e: InfraError => Http.fromZIO(errorln(e))
}
.tapError { _ =>
Http.fromZIO(ZIO.serviceWith[DB](_.rollback))
}
.mapError {
case e: DomainError => e.toHttpResponse
case e: InfraError => e.toHttpResponse
}
.merge
.map { e =>
Response
.json(
e.body
)
.setStatus(Status.Custom(e.status))
}
.catchAllDefect { _ =>
Http.succeed(Response.status(Status.InternalServerError))
}
)
.provideEnvironment(ZEnvironment(DB()))
.exitCode
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment