Skip to content

Commit

Permalink
Adapter for uzhttp (#313)
Browse files Browse the repository at this point in the history
* uzhttp adapter

* Update to latest master

* Update docs
  • Loading branch information
ghostdogpr authored Apr 11, 2020
1 parent 61f0147 commit 6b8c0ec
Show file tree
Hide file tree
Showing 7 changed files with 202 additions and 10 deletions.
4 changes: 2 additions & 2 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- checkout
- restore_cache:
key: sbtcache
- run: sbt ++2.12.10! coreJVM/test http4s/compile akkaHttp/compile finch/compile examples/compile catsInteropJVM/compile benchmarks/compile codegen/test clientJVM/test monixInterop/compile
- run: sbt ++2.12.10! coreJVM/test http4s/compile akkaHttp/compile finch/compile uzhttp/compile examples/compile catsInteropJVM/compile benchmarks/compile codegen/test clientJVM/test monixInterop/compile
- save_cache:
key: sbtcache
paths:
Expand All @@ -35,7 +35,7 @@ jobs:
- checkout
- restore_cache:
key: sbtcache
- run: sbt ++2.13.1! coreJVM/test http4s/compile akkaHttp/compile finch/compile examples/compile catsInteropJVM/compile monixInterop/compile clientJVM/test
- run: sbt ++2.13.1! coreJVM/test http4s/compile akkaHttp/compile finch/compile uzhttp/compile examples/compile catsInteropJVM/compile monixInterop/compile clientJVM/test
- save_cache:
key: sbtcache
paths:
Expand Down
159 changes: 159 additions & 0 deletions adapters/uzhttp/src/main/scala/caliban/UzHttpAdapter.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package caliban

import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import caliban.ResponseValue.{ ObjectValue, StreamValue }
import caliban.Value.NullValue
import io.circe.Json
import io.circe.parser._
import io.circe.syntax._
import uzhttp.HTTPError.BadRequest
import uzhttp.Request.Method
import uzhttp.Status.Ok
import uzhttp.websocket.{ Close, Frame, Text }
import uzhttp.{ HTTPError, Request, Response }
import zio.stream.{ Take, ZSink, ZStream }
import zio.{ Fiber, IO, Queue, Ref, Task, UIO, URIO, ZIO }

object UzHttpAdapter {

def makeHttpService[R, E](
path: String,
interpreter: GraphQLInterpreter[R, E],
skipValidation: Boolean = false
): PartialFunction[Request, ZIO[R, HTTPError, Response]] = {

// POST case
case req if req.method == Method.POST && req.uri.getPath == path =>
for {
body <- req.body match {
case Some(value) => value.run(ZSink.utf8DecodeChunk)
case None => ZIO.fail(BadRequest("Missing body"))
}
req <- ZIO.fromEither(decode[GraphQLRequest](body)).mapError(e => BadRequest(e.getMessage))
res <- executeHttpResponse(interpreter, req, skipValidation)
} yield res

// GET case
case req if req.method == Method.GET && req.uri.getPath == path =>
val params = Option(req.uri.getQuery)
.getOrElse("")
.split("&")
.toList
.flatMap(_.split("=").toList match {
case key :: value :: Nil => Some(key -> URLDecoder.decode(value, "UTF-8"))
case _ => None
})
.toMap

for {
variables <- ZIO
.foreach(params.get("variables"))(s => ZIO.fromEither(decode[Map[String, InputValue]](s)))
.mapError(e => BadRequest(e.getMessage))
extensions <- ZIO
.foreach(params.get("extensions"))(s => ZIO.fromEither(decode[Map[String, InputValue]](s)))
.mapError(e => BadRequest(e.getMessage))
req = GraphQLRequest(params.get("query"), params.get("operationName"), variables, extensions)
res <- executeHttpResponse(interpreter, req, skipValidation)
} yield res
}

def makeWebSocketService[R, E](
path: String,
interpreter: GraphQLInterpreter[R, E],
skipValidation: Boolean = false
): PartialFunction[Request, ZIO[R, HTTPError, Response]] = {
case req @ Request.WebsocketRequest(_, uri, _, _, inputFrames) if uri.getPath == path =>
for {
subscriptions <- Ref.make(Map.empty[String, Fiber[Throwable, Unit]])
sendQueue <- Queue.unbounded[Take[Nothing, Frame]]
_ <- inputFrames.collect { case Text(text, _) => text }.mapM { text =>
for {
msg <- Task.fromEither(decode[Json](text))
msgType = msg.hcursor.downField("type").success.flatMap(_.value.asString).getOrElse("")
_ <- IO.whenCase(msgType) {
case "connection_init" => sendQueue.offer(Take.Value(Text("""{"type":"connection_ack"}""")))
case "connection_terminate" => sendQueue.offerAll(List(Take.Value(Close), Take.End))
case "start" =>
val payload = msg.hcursor.downField("payload")
val id = msg.hcursor.downField("id").success.flatMap(_.value.asString).getOrElse("")
Task.whenCase(payload.downField("query").success.flatMap(_.value.asString)) {
case Some(query) =>
val operationName = payload.downField("operationName").success.flatMap(_.value.asString)
for {
result <- interpreter.executeRequest(
GraphQLRequest(Some(query), operationName),
skipValidation
)
_ <- result.data match {
case ObjectValue((fieldName, StreamValue(stream)) :: Nil) =>
stream.foreach { item =>
sendMessage(
sendQueue,
id,
ObjectValue(List(fieldName -> item)),
result.errors
)
}.forkDaemon.flatMap(fiber => subscriptions.update(_.updated(id, fiber)))
case other =>
sendMessage(sendQueue, id, other, result.errors) *> sendQueue.offer(
Take.Value(Text(s"""{"type":"complete","id":"$id"}"""))
)
}
} yield ()
}
case "stop" =>
val id = msg.hcursor.downField("id").success.flatMap(_.value.asString).getOrElse("")
subscriptions
.modify(map => (map.get(id), map - id))
.flatMap(fiber => IO.whenCase(fiber) { case Some(fiber) => fiber.interrupt })
}
} yield ()
}.runDrain
.mapError(e => BadRequest(e.getMessage))
.forkDaemon
ws <- Response
.websocket(req, ZStream.fromQueue(sendQueue).unTake)
.map(_.addHeaders("Sec-WebSocket-Protocol" -> "graphql-ws"))
} yield ws
}

private def sendMessage[E](
sendQueue: Queue[Take[Nothing, Frame]],
id: String,
data: ResponseValue,
errors: List[E]
): UIO[Unit] =
sendQueue
.offer(
Take.Value(
Text(
Json
.obj(
"id" -> Json.fromString(id),
"type" -> Json.fromString("data"),
"payload" -> GraphQLResponse(data, errors).asJson
)
.noSpaces
)
)
)
.unit

private def executeHttpResponse[R, E](
interpreter: GraphQLInterpreter[R, E],
request: GraphQLRequest,
skipValidation: Boolean
): URIO[R, Response] =
interpreter
.executeRequest(request, skipValidation)
.foldCause(cause => GraphQLResponse(NullValue, cause.defects).asJson, _.asJson)
.map(gqlResult =>
Response.const(
gqlResult.noSpaces.getBytes(StandardCharsets.UTF_8),
Ok,
contentType = s"application/json; charset=${StandardCharsets.UTF_8.name()}"
)
)

}
15 changes: 14 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ lazy val root = project
finch,
http4s,
akkaHttp,
uzhttp,
catsInteropJVM,
catsInteropJS,
monixInterop,
Expand Down Expand Up @@ -195,6 +196,18 @@ lazy val finch = project
)
.dependsOn(coreJVM)

lazy val uzhttp = project
.in(file("adapters/uzhttp"))
.settings(name := "caliban-uzhttp")
.settings(commonSettings)
.settings(
libraryDependencies ++= Seq(
"org.polynote" %% "uzhttp" % "0.1.3",
"io.circe" %% "circe-parser" % "0.13.0"
)
)
.dependsOn(coreJVM)

lazy val client = crossProject(JSPlatform, JVMPlatform)
.crossType(CrossType.Pure)
.in(file("client"))
Expand Down Expand Up @@ -225,7 +238,7 @@ lazy val examples = project
"com.softwaremill.sttp.client" %% "async-http-client-backend-zio" % sttpVersion
)
)
.dependsOn(akkaHttp, http4s, catsInteropJVM, finch, monixInterop, clientJVM)
.dependsOn(akkaHttp, http4s, catsInteropJVM, finch, uzhttp, monixInterop, clientJVM)

lazy val benchmarks = project
.in(file("benchmarks"))
Expand Down
4 changes: 3 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ libraryDependencies ++= Seq(
"com.github.ghostdogpr" %% "caliban-akka-http" % "0.7.3",
"de.heikoseeberger" %% "akka-http-circe" % "1.31.0",
"com.github.ghostdogpr" %% "caliban-cats" % "0.7.3",
"com.github.ghostdogpr" %% "caliban-finch" % "0.7.3"
"com.github.ghostdogpr" %% "caliban-monix" % "0.7.3",
"com.github.ghostdogpr" %% "caliban-finch" % "0.7.3",
"com.github.ghostdogpr" %% "caliban-uzhttp" % "0.7.3"
)

scalacOptions += "-Ypartial-unification"
Expand Down
22 changes: 22 additions & 0 deletions examples/src/main/scala/caliban/uzhttp/ExampleApp.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package caliban.uzhttp

import java.net.InetSocketAddress
import _root_.uzhttp.server._
import caliban.ExampleData._
import caliban._
import zio.console.putStrLn
import zio.{ App, ZEnv, ZIO }

object ExampleApp extends App {

override def run(args: List[String]): ZIO[ZEnv, Nothing, Int] =
(for {
interpreter <- ExampleApi.api.interpreter
address = new InetSocketAddress(8088)
route = UzHttpAdapter.makeHttpService("/api/graphql", interpreter)
wsRoute = UzHttpAdapter.makeWebSocketService("/ws/graphql", interpreter)
server = Server.builder(address).handleSome(route orElse wsRoute)
_ <- server.serve.useForever.provideCustomLayer(ExampleService.make(sampleCharacters))
} yield 0).catchAll(err => putStrLn(err.toString).as(1))

}
1 change: 1 addition & 0 deletions vuepress/docs/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ The following modules are optional:
libraryDependencies += "com.github.ghostdogpr" %% "caliban-http4s" % "0.7.3" // routes for http4s
libraryDependencies += "com.github.ghostdogpr" %% "caliban-akka-http" % "0.7.3" // routes for akka-http
libraryDependencies += "com.github.ghostdogpr" %% "caliban-finch" % "0.7.3" // routes for finch
libraryDependencies += "com.github.ghostdogpr" %% "caliban-uzhttp" % "0.7.3" // routes for uzhttp
libraryDependencies += "com.github.ghostdogpr" %% "caliban-cats" % "0.7.3" // interop with cats effect
libraryDependencies += "com.github.ghostdogpr" %% "caliban-monix" % "0.7.3" // interop with monix
```
Expand Down
7 changes: 1 addition & 6 deletions vuepress/docs/docs/examples.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,2 @@
# Examples
A sample project showing how to serve a simple GraphQL schema over HTTP and WebSocket using
[http4s](https://github.com/http4s/http4s) or
[Akka HTTP](https://doc.akka.io/docs/akka-http/current/index.html) or
[Finch](https://finagle.github.io/finch/) is available in
the
[examples](https://github.com/ghostdogpr/caliban/tree/master/examples/) folder.
A sample project showing how to serve a simple GraphQL schema over HTTP and WebSocket using [http4s](https://github.com/http4s/http4s), [Akka HTTP](https://doc.akka.io/docs/akka-http/current/index.html), [Finch](https://finagle.github.io/finch/) or [uzhttp](https://github.com/polynote/uzhttp) is available in the [examples](https://github.com/ghostdogpr/caliban/tree/master/examples/) folder.

0 comments on commit 6b8c0ec

Please sign in to comment.