-
-
Notifications
You must be signed in to change notification settings - Fork 248
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add contextual interop between cats-effect and ZIO (#1246)
- Loading branch information
Showing
10 changed files
with
900 additions
and
66 deletions.
There are no files selected for viewing
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
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
139 changes: 139 additions & 0 deletions
139
examples/src/main/scala/example/http4s/AuthExampleAppF.scala
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
package example.http4s | ||
|
||
import caliban.GraphQL._ | ||
import caliban.interop.cats.{ CatsInterop, InjectEnv } | ||
import caliban.schema.GenericSchema | ||
import caliban.{ CalibanError, GraphQL, Http4sAdapter, RootResolver } | ||
import cats.data.{ Kleisli, OptionT } | ||
import cats.MonadThrow | ||
import cats.syntax.flatMap._ | ||
import cats.syntax.functor._ | ||
import cats.effect.{ Async, IO, IOApp, Resource } | ||
import cats.effect.std.Dispatcher | ||
import cats.mtl.Local | ||
import cats.mtl.syntax.local._ | ||
import org.http4s.HttpApp | ||
import org.http4s.server.Server | ||
import org.http4s.{ HttpRoutes, Request } | ||
import org.http4s.dsl.Http4sDsl | ||
import org.http4s.implicits._ | ||
import org.http4s.blaze.server.BlazeServerBuilder | ||
import org.http4s.server.{ Router, ServiceErrorHandler } | ||
import org.typelevel.ci._ | ||
import zio.Runtime | ||
|
||
/** | ||
* The examples shows how to utilize contextual interop between cats-effect and ZIO. | ||
* | ||
* Run server: | ||
* examples/runMain example.http4s.AuthExampleAppF | ||
* | ||
* Send a request: | ||
* curl -X POST -H "X-Token: my-token" -d '{"query": "query { token }"}' localhost:8088/api/graphql | ||
* | ||
* Response: | ||
* {"data":{"token":"my-token"}} | ||
*/ | ||
object AuthExampleAppF extends IOApp.Simple { | ||
|
||
type AuthLocal[F[_]] = Local[F, AuthInfo] | ||
object AuthLocal { | ||
def apply[F[_]](implicit ev: AuthLocal[F]): AuthLocal[F] = ev | ||
|
||
def token[F[_]: MonadThrow](implicit local: AuthLocal[F]): F[AuthInfo.Token] = | ||
local.ask.flatMap { | ||
case t: AuthInfo.Token => MonadThrow[F].pure(t) | ||
case AuthInfo.Empty => MonadThrow[F].raiseError(MissingToken()) | ||
} | ||
} | ||
|
||
sealed trait AuthInfo | ||
object AuthInfo { | ||
final case object Empty extends AuthInfo | ||
final case class Token(token: String) extends AuthInfo | ||
} | ||
|
||
final case class MissingToken() extends Throwable | ||
|
||
// http4s middleware that extracts a token from the request and executes the request with AuthInfo available in the scope | ||
object AuthMiddleware { | ||
private val TokenHeader = ci"X-Token" | ||
|
||
def httpRoutes[F[_]: MonadThrow: AuthLocal](routes: HttpRoutes[F]): HttpRoutes[F] = | ||
Kleisli { (req: Request[F]) => | ||
req.headers.get(TokenHeader) match { | ||
case Some(token) => routes.run(req).scope(AuthInfo.Token(token.head.value): AuthInfo) | ||
case None => OptionT.liftF(MonadThrow[F].raiseError(MissingToken())) | ||
} | ||
} | ||
} | ||
|
||
// Simple service that returns the token coming from the request | ||
final case class Query[F[_]](token: F[String]) | ||
|
||
class GQL[F[_]: MonadThrow: AuthLocal](implicit interop: CatsInterop[F, AuthInfo]) { | ||
|
||
def createGraphQL: GraphQL[AuthInfo] = { | ||
val schema: GenericSchema[AuthInfo] = new GenericSchema[AuthInfo] {} | ||
import schema._ | ||
import caliban.interop.cats.implicits._ // summons `Schema[Auth, F[String]]` instance | ||
|
||
graphQL(RootResolver(query)) | ||
} | ||
|
||
private def query: Query[F] = | ||
Query( | ||
token = AuthLocal.token[F].map(authInfo => authInfo.token) | ||
) | ||
} | ||
|
||
class Api[F[_]: Async: AuthLocal](implicit interop: CatsInterop[F, AuthInfo]) extends Http4sDsl[F] { | ||
|
||
def httpApp(graphQL: GraphQL[AuthInfo]): F[HttpApp[F]] = | ||
for { | ||
routes <- createRoutes(graphQL) | ||
} yield Router("/api/graphql" -> AuthMiddleware.httpRoutes(routes)).orNotFound | ||
|
||
def createRoutes(graphQL: GraphQL[AuthInfo]): F[HttpRoutes[F]] = | ||
for { | ||
interpreter <- interop.toEffect(graphQL.interpreter) | ||
} yield Http4sAdapter.makeHttpServiceF[F, AuthInfo, CalibanError](interpreter) | ||
|
||
// http4s error handler to customize the response for our throwable | ||
def errorHandler: ServiceErrorHandler[F] = _ => { case MissingToken() => Forbidden() } | ||
} | ||
|
||
def program[F[_]: Async: AuthLocal](implicit | ||
runtime: Runtime[AuthInfo], | ||
injector: InjectEnv[F, AuthInfo] | ||
): Resource[F, Server] = { | ||
|
||
def makeHttpServer(httpApp: HttpApp[F], errorHandler: ServiceErrorHandler[F]): Resource[F, Server] = | ||
BlazeServerBuilder[F] | ||
.withServiceErrorHandler(errorHandler) | ||
.bindHttp(8088, "localhost") | ||
.withHttpApp(httpApp) | ||
.resource | ||
|
||
Dispatcher[F].flatMap { dispatcher => | ||
implicit val interop: CatsInterop.Contextual[F, AuthInfo] = CatsInterop.contextual(dispatcher) | ||
|
||
val gql = new GQL[F] | ||
val api = new Api[F] | ||
|
||
for { | ||
httpApp <- Resource.eval(api.httpApp(gql.createGraphQL)) | ||
httpServer <- makeHttpServer(httpApp, api.errorHandler) | ||
} yield httpServer | ||
} | ||
} | ||
|
||
def run: IO[Unit] = { | ||
type Effect[A] = Kleisli[IO, AuthInfo, A] | ||
|
||
implicit val runtime: Runtime[AuthInfo] = Runtime.default.as(AuthInfo.Empty) | ||
|
||
program[Effect].useForever.run(AuthInfo.Empty).void | ||
} | ||
|
||
} |
112 changes: 112 additions & 0 deletions
112
examples/src/main/scala/example/interop/cats/ContextualCatsInterop.scala
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
package example.interop.cats | ||
|
||
import caliban.GraphQL.graphQL | ||
import caliban.interop.cats.CatsInterop | ||
import caliban.schema.GenericSchema | ||
import caliban.{ GraphQL, RootResolver } | ||
import cats.data.Kleisli | ||
import cats.effect.{ Async, ExitCode, IO, IOApp } | ||
import cats.effect.std.{ Console, Dispatcher } | ||
import cats.mtl.syntax.local._ | ||
import cats.mtl.Local | ||
import cats.syntax.functor._ | ||
import cats.syntax.flatMap._ | ||
import zio.{ Runtime, ZEnv } | ||
|
||
object ContextualCatsInterop extends IOApp { | ||
|
||
import caliban.interop.cats.implicits._ | ||
|
||
case class Number(value: Int) | ||
|
||
case class Queries[F[_]](numbers: List[Number], randomNumber: F[Number]) | ||
|
||
val query = """ | ||
{ | ||
numbers { | ||
value | ||
} | ||
randomNumber { | ||
value | ||
} | ||
}""" | ||
|
||
case class LogContext(operation: String) { | ||
def child(next: String): LogContext = LogContext(s"$operation -> $next") | ||
} | ||
|
||
/** | ||
* The example shows the propagation of the `LogContext` from cats-effect to ZIO and vice-versa. | ||
* | ||
* Console output: | ||
* Executing request - root | ||
* Get random number - root -> execute-request | ||
* Generating a random number - root -> execute-request -> random-number | ||
* Generated number: 485599760 - root -> execute-request -> random-number | ||
* Request result: {"numbers":[{"value":1},{"value":2},{"value":3},{"value":4}],"randomNumber":{"value":485599760}} - root | ||
*/ | ||
override def run(args: List[String]): IO[ExitCode] = { | ||
type Effect[A] = Kleisli[IO, LogContext, A] | ||
|
||
val root = LogContext("root") | ||
|
||
Dispatcher[Effect].use { dispatcher => | ||
implicit val logger: Logger[Effect] = | ||
new Logger[Effect] { | ||
def info(message: String): Effect[Unit] = | ||
for { | ||
ctx <- Local[Effect, LogContext].ask[LogContext] | ||
_ <- Console[Effect].println(s"$message - ${ctx.operation}") | ||
} yield () | ||
} | ||
|
||
implicit val zioRuntime: Runtime[LogContext] = Runtime.default.as(root) | ||
implicit val interop: CatsInterop[Effect, LogContext] = CatsInterop.contextual(dispatcher) | ||
|
||
program[Effect] | ||
}.run(root) | ||
} | ||
|
||
def program[F[_]: Async: Logger](implicit | ||
local: Local[F, LogContext], | ||
interop: CatsInterop[F, LogContext], // required for a derivation of the schema | ||
runtime: Runtime[LogContext] | ||
): F[ExitCode] = { | ||
val numbers = List(1, 2, 3, 4).map(Number) | ||
|
||
val randomNumber = | ||
Logger[F].info("Get random number") >> { | ||
for { | ||
_ <- Logger[F].info("Generating a random number") | ||
number <- Async[F].delay(scala.util.Random.nextInt()) | ||
_ <- Logger[F].info(s"Generated number: $number") | ||
} yield Number(number) | ||
}.local[LogContext](_.child("random-number")) | ||
|
||
val queries = Queries[F](numbers, randomNumber) | ||
|
||
val api: GraphQL[LogContext] = { | ||
object ContextSchema extends GenericSchema[LogContext] | ||
import ContextSchema._ // required for a derivation of the schema | ||
|
||
graphQL(RootResolver(queries)) | ||
} | ||
|
||
for { | ||
interpreter <- api.interpreterAsync[F] | ||
_ <- interpreter.checkAsync[F](query) | ||
_ <- Logger[F].info("Executing request") | ||
result <- interpreter.executeAsync[F](query)(interop).local[LogContext](_.child("execute-request")) | ||
_ <- Logger[F].info(s"Request result: ${result.data}") | ||
} yield ExitCode.Success | ||
} | ||
|
||
trait Logger[F[_]] { | ||
def info(message: String): F[Unit] | ||
} | ||
object Logger { | ||
def apply[F[_]](implicit ev: Logger[F]): Logger[F] = ev | ||
} | ||
|
||
} |
Oops, something went wrong.