diff --git a/build.sbt b/build.sbt index dde6aebae..bf4f8e696 100644 --- a/build.sbt +++ b/build.sbt @@ -57,13 +57,14 @@ lazy val core = crossProject(JSPlatform, JVMPlatform) .settings( testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")), libraryDependencies ++= Seq( - "com.lihaoyi" %%% "fastparse" % "2.1.3", - "com.propensive" %%% "magnolia" % "0.12.2", - "com.propensive" %%% "mercator" % "0.2.1", - "dev.zio" %%% "zio" % "1.0.0-RC17", - "dev.zio" %%% "zio-streams" % "1.0.0-RC17", - "dev.zio" %%% "zio-test" % "1.0.0-RC17" % "test", - "dev.zio" %%% "zio-test-sbt" % "1.0.0-RC17" % "test", + "com.lihaoyi" %%% "fastparse" % "2.1.3", + "com.propensive" %%% "magnolia" % "0.12.2", + "com.propensive" %%% "mercator" % "0.2.1", + "dev.zio" %%% "zio" % "1.0.0-RC17", + "dev.zio" %%% "zio-streams" % "1.0.0-RC17", + "dev.zio" %%% "zio-test" % "1.0.0-RC17" % "test", + "dev.zio" %%% "zio-test-sbt" % "1.0.0-RC17" % "test", + "io.circe" %%% "circe-derivation" % "0.12.0-M7" % Optional, compilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1") ) ) @@ -103,7 +104,6 @@ lazy val http4s = project "org.http4s" %% "http4s-circe" % "0.21.0-M5", "org.http4s" %% "http4s-blaze-server" % "0.21.0-M5", "io.circe" %% "circe-parser" % "0.12.3", - "io.circe" %% "circe-derivation" % "0.12.0-M7", compilerPlugin( ("org.typelevel" %% "kind-projector" % "0.11.0") .cross(CrossVersion.full) diff --git a/core/src/main/scala/caliban/GraphQLRequest.scala b/core/src/main/scala/caliban/GraphQLRequest.scala new file mode 100644 index 000000000..883f0dbb2 --- /dev/null +++ b/core/src/main/scala/caliban/GraphQLRequest.scala @@ -0,0 +1,25 @@ +package caliban + +import caliban.interop.circe.IsCirceDecoder + +import scala.language.higherKinds + +/** + * Represents a GraphQL request, containing a query, an operation name and a map of variables. + */ +case class GraphQLRequest( + query: String, + operationName: Option[String], + variables: Option[Map[String, InputValue]] +) + +object GraphQLRequest { + implicit def circeDecoder[F[_]: IsCirceDecoder]: F[GraphQLRequest] = + GraphQLRequestCirce.graphQLRequestDecoder.asInstanceOf[F[GraphQLRequest]] +} + +private object GraphQLRequestCirce { + import io.circe._ + import io.circe.derivation._ + val graphQLRequestDecoder: Decoder[GraphQLRequest] = deriveDecoder[GraphQLRequest] +} diff --git a/core/src/main/scala/caliban/GraphQLResponse.scala b/core/src/main/scala/caliban/GraphQLResponse.scala index 8e0e17f04..08cd9437f 100644 --- a/core/src/main/scala/caliban/GraphQLResponse.scala +++ b/core/src/main/scala/caliban/GraphQLResponse.scala @@ -1,6 +1,28 @@ package caliban +import caliban.interop.circe._ + +import scala.language.higherKinds + /** * Represents the result of a GraphQL query, containing a data object and a list of errors. */ case class GraphQLResponse[+E](data: ResponseValue, errors: List[E]) + +object GraphQLResponse { + implicit def circeEncoder[F[_]: IsCirceEncoder, E]: F[GraphQLResponse[E]] = + GraphQLResponceCirce.graphQLResponseEncoder.asInstanceOf[F[GraphQLResponse[E]]] +} + +private object GraphQLResponceCirce { + import io.circe._ + import io.circe.syntax._ + val graphQLResponseEncoder: Encoder[GraphQLResponse[CalibanError]] = Encoder + .instance[GraphQLResponse[CalibanError]]( + response => + Json.obj( + "data" -> response.data.asJson, + "errors" -> Json.fromValues(response.errors.map(err => Json.fromString(err.toString))) + ) + ) +} diff --git a/core/src/main/scala/caliban/Value.scala b/core/src/main/scala/caliban/Value.scala index f5f0991ad..b68b9fdb8 100644 --- a/core/src/main/scala/caliban/Value.scala +++ b/core/src/main/scala/caliban/Value.scala @@ -1,8 +1,12 @@ package caliban -import scala.util.Try +import caliban.Value._ +import caliban.interop.circe.{ IsCirceDecoder, IsCirceEncoder } import zio.stream.Stream +import scala.language.higherKinds +import scala.util.Try + sealed trait InputValue sealed trait ResponseValue sealed trait Value extends InputValue with ResponseValue @@ -89,6 +93,9 @@ object InputValue { case class ListValue(values: List[InputValue]) extends InputValue case class ObjectValue(fields: Map[String, InputValue]) extends InputValue case class VariableValue(name: String) extends InputValue + + implicit def circeDecoder[F[_]: IsCirceDecoder]: F[InputValue] = + ValueCirce.inputValueDecoder.asInstanceOf[F[InputValue]] } object ResponseValue { @@ -102,4 +109,47 @@ object ResponseValue { case class StreamValue(stream: Stream[Throwable, ResponseValue]) extends ResponseValue { override def toString: String = "" } + + implicit def circeEncoder[F[_]](implicit ev: IsCirceEncoder[F]): F[ResponseValue] = + ValueCirce.responseValueEncoder.asInstanceOf[F[ResponseValue]] +} + +private object ValueCirce { + import io.circe._ + private def jsonToValue(json: Json): InputValue = + json.fold( + NullValue, + BooleanValue, + number => + number.toBigInt.map(IntValue.apply) orElse + number.toBigDecimal.map(FloatValue.apply) getOrElse + FloatValue(number.toDouble), + StringValue, + array => InputValue.ListValue(array.toList.map(jsonToValue)), + obj => InputValue.ObjectValue(obj.toMap.map { case (k, v) => k -> jsonToValue(v) }) + ) + val inputValueDecoder: Decoder[InputValue] = Decoder.instance(hcursor => Right(jsonToValue(hcursor.value))) + val responseValueEncoder: Encoder[ResponseValue] = Encoder + .instance[ResponseValue]({ + case NullValue => Json.Null + case v: IntValue => + v match { + case IntValue.IntNumber(value) => Json.fromInt(value) + case IntValue.LongNumber(value) => Json.fromLong(value) + case IntValue.BigIntNumber(value) => Json.fromBigInt(value) + } + case v: FloatValue => + v match { + case FloatValue.FloatNumber(value) => Json.fromFloatOrNull(value) + case FloatValue.DoubleNumber(value) => Json.fromDoubleOrNull(value) + case FloatValue.BigDecimalNumber(value) => Json.fromBigDecimal(value) + } + case StringValue(value) => Json.fromString(value) + case BooleanValue(value) => Json.fromBoolean(value) + case EnumValue(value) => Json.fromString(value) + case ResponseValue.ListValue(values) => Json.arr(values.map(responseValueEncoder.apply): _*) + case ResponseValue.ObjectValue(fields) => + Json.obj(fields.map { case (k, v) => k -> responseValueEncoder.apply(v) }: _*) + case s: ResponseValue.StreamValue => Json.fromString(s.toString) + }) } diff --git a/core/src/main/scala/caliban/interop/circe/circe.scala b/core/src/main/scala/caliban/interop/circe/circe.scala new file mode 100644 index 000000000..e01328b26 --- /dev/null +++ b/core/src/main/scala/caliban/interop/circe/circe.scala @@ -0,0 +1,23 @@ +package caliban.interop.circe + +import io.circe._ + +import scala.language.higherKinds + +/** + * This class is an implementation of the pattern described in https://blog.7mind.io/no-more-orphans.html + * It makes it possible to mark circe dependency as optional and keep Encoders defined in the companion object. + */ +private[caliban] trait IsCirceEncoder[F[_]] +private[caliban] object IsCirceEncoder { + implicit val isCirceEncoder: IsCirceEncoder[Encoder] = null +} + +/** + * This class is an implementation of the pattern described in https://blog.7mind.io/no-more-orphans.html + * It makes it possible to mark circe dependency as optional and keep Decoders defined in the companion object. + */ +private[caliban] trait IsCirceDecoder[F[_]] +private[caliban] object IsCirceDecoder { + implicit val isCirceDecoder: IsCirceDecoder[Decoder] = null +} diff --git a/core/src/test/scala/caliban/GraphQLRequestSpec.scala b/core/src/test/scala/caliban/GraphQLRequestSpec.scala new file mode 100644 index 000000000..6f892f08c --- /dev/null +++ b/core/src/test/scala/caliban/GraphQLRequestSpec.scala @@ -0,0 +1,21 @@ +package caliban + +import io.circe._ +import zio.test.Assertion._ +import zio.test._ + +object GraphQLRequestSpec + extends DefaultRunnableSpec( + suite("GraphQLRequestSpec")( + test("can be parsed from JSON") { + val request = Json + .obj("query" -> Json.fromString("{}"), "operationName" -> Json.fromString("op"), "variables" -> Json.obj()) + assert( + request.as[GraphQLRequest], + isRight( + equalTo(GraphQLRequest(query = "{}", operationName = Some("op"), variables = Some(Map.empty))) + ) + ) + } + ) + ) diff --git a/core/src/test/scala/caliban/GraphQLResponseSpec.scala b/core/src/test/scala/caliban/GraphQLResponseSpec.scala new file mode 100644 index 000000000..935780f85 --- /dev/null +++ b/core/src/test/scala/caliban/GraphQLResponseSpec.scala @@ -0,0 +1,20 @@ +package caliban + +import caliban.Value._ +import io.circe._ +import io.circe.syntax._ +import zio.test.Assertion._ +import zio.test._ + +object GraphQLResponseSpec + extends DefaultRunnableSpec( + suite("GraphQLResponseSpec")( + test("can be converted to JSON") { + val response = GraphQLResponse(StringValue("data"), Nil) + assert( + response.asJson, + equalTo(Json.obj("data" -> Json.fromString("data"), "errors" -> Json.arr())) + ) + } + ) + ) diff --git a/http4s/src/main/scala/caliban/Http4sAdapter.scala b/http4s/src/main/scala/caliban/Http4sAdapter.scala index 23ed8b28c..1a067c868 100644 --- a/http4s/src/main/scala/caliban/Http4sAdapter.scala +++ b/http4s/src/main/scala/caliban/Http4sAdapter.scala @@ -7,7 +7,6 @@ import cats.effect.Effect import cats.effect.syntax.all._ import cats.~> import fs2.{ Pipe, Stream } -import io.circe.derivation.deriveDecoder import io.circe._ import io.circe.parser._ import io.circe.syntax._ @@ -17,68 +16,16 @@ import org.http4s.dsl.Http4sDsl import org.http4s.server.websocket.WebSocketBuilder import org.http4s.websocket.WebSocketFrame import org.http4s.websocket.WebSocketFrame.Text -import zio.interop.catz._ import zio._ +import zio.interop.catz._ object Http4sAdapter { - case class GraphQLRequest(query: String, operationName: Option[String], variables: Option[Json] = None) - - implicit val queryDecoder: Decoder[GraphQLRequest] = deriveDecoder[GraphQLRequest] - - private def responseToJson(responseValue: ResponseValue): Json = - responseValue match { - case NullValue => Json.Null - case v: IntValue => - v match { - case IntValue.IntNumber(value) => Json.fromInt(value) - case IntValue.LongNumber(value) => Json.fromLong(value) - case IntValue.BigIntNumber(value) => Json.fromBigInt(value) - } - case v: FloatValue => - v match { - case FloatValue.FloatNumber(value) => Json.fromFloatOrNull(value) - case FloatValue.DoubleNumber(value) => Json.fromDoubleOrNull(value) - case FloatValue.BigDecimalNumber(value) => Json.fromBigDecimal(value) - } - case StringValue(value) => Json.fromString(value) - case BooleanValue(value) => Json.fromBoolean(value) - case EnumValue(value) => Json.fromString(value) - case ListValue(values) => Json.arr(values.map(responseToJson): _*) - case ObjectValue(fields) => Json.obj(fields.map { case (k, v) => k -> responseToJson(v) }: _*) - case s: StreamValue => Json.fromString(s.toString) - } - - implicit def responseEncoder[E]: Encoder[GraphQLResponse[E]] = - (response: GraphQLResponse[E]) => - Json.obj( - "data" -> responseToJson(response.data), - "errors" -> Json.fromValues(response.errors.map(err => Json.fromString(err.toString))) - ) - - private def jsonToValue(json: Json): InputValue = - json.fold( - NullValue, - BooleanValue, - number => - number.toBigInt.map(IntValue.apply) orElse - number.toBigDecimal.map(FloatValue.apply) getOrElse - FloatValue(number.toDouble), - StringValue, - array => InputValue.ListValue(array.toList.map(jsonToValue)), - obj => InputValue.ObjectValue(obj.toMap.map { case (k, v) => k -> jsonToValue(v) }) - ) - - private def jsonToVariables(json: Json): Map[String, InputValue] = jsonToValue(json) match { - case InputValue.ObjectValue(fields) => fields - case _ => Map() - } - private def execute[R, Q, M, S, E]( interpreter: GraphQL[R, Q, M, S, E], query: GraphQLRequest ): URIO[R, GraphQLResponse[E]] = - interpreter.execute(query.query, query.operationName, query.variables.map(jsonToVariables).getOrElse(Map())) + interpreter.execute(query.query, query.operationName, query.variables.getOrElse(Map())) def makeRestService[R, Q, M, S, E](interpreter: GraphQL[R, Q, M, S, E]): HttpRoutes[RIO[R, *]] = { object dsl extends Http4sDsl[RIO[R, *]] @@ -137,7 +84,7 @@ object Http4sAdapter { case Some(query) => val operationName = payload.downField("operationName").success.flatMap(_.value.asString) (for { - result <- execute(interpreter, GraphQLRequest(query, operationName)) + result <- execute(interpreter, GraphQLRequest(query, operationName, None)) _ <- result.data match { case ObjectValue((fieldName, StreamValue(stream)) :: Nil) => stream.foreach { item =>