Forked from martinsandredev/zio-cats-tagless-final.scala
Created
May 9, 2023 08:59
-
-
Save mvillafuertem/f89303ae6afaee85e5124191c788d649 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
// 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