Skip to content

Commit

Permalink
cats-effect integration (#646)
Browse files Browse the repository at this point in the history
I wanted to avoid this integration because of past issues with
Typelevel, but I think it's best if we get ahead of a possible
integration and ensure it's under our control in the repository.
  • Loading branch information
fwbrasil authored Sep 9, 2024
1 parent f7b95d0 commit fdc699e
Show file tree
Hide file tree
Showing 5 changed files with 265 additions and 1 deletion.
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2978,6 +2978,57 @@ val d: Task[Int] =

> Note: Support for ZIO environments (`R` in `ZIO[R, E, A]`) is currently in development. Once implemented, it will be possible to use ZIO effects with environments directly within Kyo computations.
### Cats: Integration with Cats Effect

The `Cats` effect provides seamless integration between Kyo and the Cats Effect library. This integration is designed to enable gradual adoption of Kyo within a Cats Effect codebase. The integration properly suspends side effects and propagates fiber cancellations/interrupts between both libraries.

```scala
import kyo.*
import cats.effect.IO as CatsIO

// Use the 'get' method to extract a 'IO' effect from Cats Effect:
val a: Int < Cats =
Cats.get(CatsIO.pure(42))

// Handle the 'Cats' effect to obtain a 'CatsIO' effect:
val b: CatsIO[Int] =
Cats.run(a)
```

Kyo and Cats effects can be seamlessly mixed and matched within computations, allowing developers to leverage the power of both libraries. Here are a few examples showcasing this integration:

```scala
import kyo.*
import cats.effect.IO as CatsIO
import cats.effect.kernel.Outcome.Succeeded

// Note how Cats includes the IO, Async, and Abort[Throwable] effects:
val a: Int < Cats =
for
v1 <- Cats.get(CatsIO.pure(21))
v2 <- IO(21)
_ <- Abort.when(v1 > 10)(new Exception)
v3 <- Async.run(-42).map(_.get)
yield v1 + v2 + v3

// Using fibers from both libraries:
val b: Int < Cats =
for
f1 <- Cats.get(CatsIO.pure(21).start)
f2 <- Async.run(IO(21))
v1 <- Cats.get(f1.joinWith(CatsIO(99)))
v2 <- f2.get
yield v1 + v2

// Transforming Cats Effect IO within Kyo computations:
val c: Int < Cats =
Cats.get(CatsIO.pure(21)).map(_ * 2)

// Transforming Kyo effects within Cats Effect IO:
val d: CatsIO[Int] =
Cats.run(IO(21).map(_ * 2))
```

### Resolvers: GraphQL Server via Caliban

`Resolvers` integrates with the [Caliban](https://github.com/ghostdogpr/caliban) library to help setup GraphQL servers.
Expand Down
19 changes: 18 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ val scala212Version = "2.12.20"
val scala213Version = "2.13.14"

val zioVersion = "2.1.9"
val catsVersion = "3.5.4"
val scalaTestVersion = "3.2.19"

val compilerOptions = Set(
Expand Down Expand Up @@ -92,6 +93,7 @@ lazy val kyoJVM = project
`kyo-bench`.jvm,
`kyo-test`.jvm,
`kyo-zio`.jvm,
`kyo-cats`.jvm,
`kyo-combinators`.jvm,
`kyo-examples`.jvm
)
Expand All @@ -113,6 +115,7 @@ lazy val kyoJS = project
`kyo-sttp`.js,
`kyo-test`.js,
`kyo-zio`.js,
`kyo-cats`.js,
`kyo-combinators`.js
)

Expand Down Expand Up @@ -344,6 +347,19 @@ lazy val `kyo-zio` =
`js-settings`
)

lazy val `kyo-cats` =
crossProject(JSPlatform, JVMPlatform)
.withoutSuffixFor(JVMPlatform)
.crossType(CrossType.Full)
.in(file("kyo-cats"))
.dependsOn(`kyo-core`)
.settings(
`kyo-settings`,
libraryDependencies += "org.typelevel" %%% "cats-effect" % catsVersion
).jsSettings(
`js-settings`
)

lazy val `kyo-combinators` =
crossProject(JSPlatform, JVMPlatform)
.withoutSuffixFor(JVMPlatform)
Expand Down Expand Up @@ -411,7 +427,7 @@ lazy val `kyo-bench` =
}
},
libraryDependencies += "dev.zio" %% "izumi-reflect" % "2.3.10",
libraryDependencies += "org.typelevel" %% "cats-effect" % "3.5.4",
libraryDependencies += "org.typelevel" %% "cats-effect" % catsVersion,
libraryDependencies += "org.typelevel" %% "log4cats-core" % "2.7.0",
libraryDependencies += "org.typelevel" %% "log4cats-slf4j" % "2.7.0",
libraryDependencies += "org.typelevel" %% "cats-mtl" % "1.5.0",
Expand Down Expand Up @@ -460,6 +476,7 @@ lazy val readme =
`kyo-tapir`,
`kyo-bench`,
`kyo-zio`,
`kyo-cats`,
`kyo-caliban`,
`kyo-combinators`
)
Expand Down
46 changes: 46 additions & 0 deletions kyo-cats/shared/src/main/scala/kyo/Cats.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package kyo

import Cats.GetCatsIO
import cats.effect.IO as CatsIO
import kyo.kernel.*
import scala.util.control.NonFatal

opaque type Cats <: (Async & Abort[Throwable]) = GetCatsIO & Async & Abort[Throwable]

object Cats:

/** Lifts a cats.effect.IO into a Kyo effect.
*
* @param io
* The cats.effect.IO to lift
* @return
* A Kyo effect that, when run, will execute the cats.effect.IO
*/
def get[A](io: CatsIO[A])(using Frame): A < Cats =
ArrowEffect.suspendMap(Tag[GetCatsIO], io)(Abort.get(_))

/** Runs a Kyo effect that uses Cats and converts it to a cats.effect.IO.
*
* @param v
* The Kyo effect to run
* @return
* A cats.effect.IO that, when run, will execute the Kyo effect
*/
def run[A](v: => A < Cats)(using frame: Frame): CatsIO[A] =
CatsIO.defer {
ArrowEffect.handle(Tag[GetCatsIO], v.map(CatsIO.pure))(
[C] => (input, cont) => input.attempt.flatMap(r => run(cont(r)).flatten)
).pipe(Async.run)
.map { fiber =>
CatsIO.async[CatsIO[A]] { cb =>
CatsIO {
fiber.unsafe.onComplete(r => cb(r.toEither))
Some(CatsIO(fiber.unsafe.interrupt(Result.Panic(Fiber.Interrupted(frame)))).void)
}
}
}.pipe(IO.run).eval.flatten
}
end run

sealed private[kyo] trait GetCatsIO extends ArrowEffect[CatsIO, Either[Throwable, *]]
end Cats
132 changes: 132 additions & 0 deletions kyo-cats/shared/src/test/scala/kyo/CatsTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package kyo

import cats.effect.IO as CatsIO
import cats.effect.kernel.Fiber as CatsFiber
import cats.effect.kernel.Outcome
import cats.effect.unsafe.implicits.global
import kyo.*
import kyo.kernel.Platform
import org.scalatest.compatible.Assertion
import org.scalatest.concurrent.Eventually.*
import scala.concurrent.Future
import scala.concurrent.duration.*

class CatsTest extends Test:

def runCatsIO[T](v: CatsIO[T]): Future[T] =
v.unsafeToFuture()

def runKyo(v: => Assertion < (Abort[Throwable] & Cats)): Future[Assertion] =
Cats.run(v).unsafeToFuture()

"Abort ordering" - {
"kyo then cats" in runKyo {
object catsFailure extends RuntimeException
object kyoFailure extends RuntimeException
val a = Abort.fail(kyoFailure)
val b = Cats.get(CatsIO.raiseError(catsFailure))
Abort.run[Throwable](a.map(_ => b)).map {
case Result.Fail(ex) =>
assert(ex == kyoFailure)
case _ =>
fail()
}
}
"cats then kyo" in runKyo {
object catsFailure extends RuntimeException
object kyoFailure extends RuntimeException
val a = Cats.get(CatsIO.raiseError(catsFailure))
val b = Abort.fail(kyoFailure)
Abort.run[Throwable](a.map(_ => b)).map {
case Result.Fail(ex) =>
assert(ex == catsFailure)
case ex =>
fail()
}
}
}

"A < Cats" in runKyo {
val a = Cats.get(CatsIO.pure(10))
val b = a.map(_ * 2)
b.map(i => assert(i == 20))
}

"nested" in runKyo {
val a = Cats.get(CatsIO.pure(Cats.get(CatsIO.pure("Nested")))).flatten
a.map(s => assert(s == "Nested"))
}

"fibers" in runKyo {
for
v1 <- Cats.get(CatsIO.pure(1))
v2 <- Async.run(2).map(_.get)
v3 <- Cats.get(CatsIO.pure(3))
yield assert(v1 == 1 && v2 == 2 && v3 == 3)
}

"interrupts" - {

import java.util.concurrent.atomic.LongAdder

def kyoLoop(a: LongAdder = new LongAdder): Unit < IO =
IO(a.increment()).map(_ => kyoLoop(a))

def catsLoop(a: LongAdder = new LongAdder): CatsIO[Unit] =
CatsIO.delay(a.increment()).flatMap(_ => catsLoop(a))

if Platform.isJVM then

"cats to kyo" in runCatsIO {
for
f <- Cats.run(kyoLoop()).start
_ <- f.cancel
r <- f.join
yield assert(r.isCanceled)
end for
}
"kyo to cats" in runKyo {
for
f <- Async.run(Cats.run(catsLoop()))
_ <- f.interrupt(Result.Panic(new Exception))
r <- f.getResult
yield assert(r.isPanic)
end for
}
"both" in runCatsIO {
val v =
for
_ <- Cats.get(catsLoop())
_ <- Async.run(kyoLoop())
yield ()
for
f <- Cats.run(v).start
_ <- f.cancel
r <- f.join
yield assert(r.isCanceled)
end for
}
end if
}

"Error handling" - {
"Kyo Abort to Cats IO error" in runKyo {
val kyoAbort = Abort.fail(new Exception("Kyo error"))
val converted = Cats.get(CatsIO.fromEither(Abort.run(kyoAbort).eval.toEither))
Abort.run[Throwable](converted).map {
case Result.Fail(ex) => assert(ex.getMessage() == "Kyo error")
case _ => fail("Expected a String error")
}
}

"Cats IO error to Kyo Abort" in runKyo {
val catsError = CatsIO.raiseError[Int](new Exception("Cats error"))
val converted = Cats.get(catsError)
Abort.run[Throwable](converted).map {
case Result.Fail(error: Exception) => assert(error.getMessage == "Cats error")
case _ => fail("Expected an Exception")
}
}
}

end CatsTest
18 changes: 18 additions & 0 deletions kyo-cats/shared/src/test/scala/kyo/Test.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package kyo

import kyo.internal.BaseKyoTest
import kyo.kernel.Platform
import org.scalatest.NonImplicitAssertions
import org.scalatest.freespec.AsyncFreeSpec
import scala.concurrent.ExecutionContext
import scala.concurrent.Future

abstract class Test extends AsyncFreeSpec with BaseKyoTest[Any] with NonImplicitAssertions:

def run(v: Future[Assertion] < Any): Future[Assertion] = v.eval

type Assertion = org.scalatest.compatible.Assertion
def success = succeed

override given executionContext: ExecutionContext = Platform.executionContext
end Test

0 comments on commit fdc699e

Please sign in to comment.