diff --git a/.circleci/config.yml b/.circleci/config.yml index 83af7702b5..e982d91e46 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -42,6 +42,20 @@ jobs: - "~/.ivy2/cache" - "~/.sbt" - "~/.m2" + test3_jdk11: + docker: + - image: circleci/openjdk:11-jdk-node + steps: + - checkout + - restore_cache: + key: sbtcache + - run: sbt ++3.0.0-RC3! core/test clientJVM/test clientJS/compile zioHttp/compile + - save_cache: + key: sbtcache + paths: + - "~/.ivy2/cache" + - "~/.sbt" + - "~/.m2" test212_js: docker: - image: circleci/openjdk:8-jdk-node @@ -120,12 +134,19 @@ workflows: filters: tags: only: /^v[0-9]+(\.[0-9]+)*$/ + - test3_jdk11: + requires: + - lint + filters: + tags: + only: /^v[0-9]+(\.[0-9]+)*$/ - release: context: Sonatype requires: - test212_jdk8 - test213_jdk11 - test212_js + - test3_jdk11 filters: branches: only: diff --git a/adapters/zio-http/src/main/scala/caliban/ZHTTPAdapter.scala b/adapters/zio-http/src/main/scala/caliban/ZHttpAdapter.scala similarity index 96% rename from adapters/zio-http/src/main/scala/caliban/ZHTTPAdapter.scala rename to adapters/zio-http/src/main/scala/caliban/ZHttpAdapter.scala index 901597c207..aea0179a6a 100644 --- a/adapters/zio-http/src/main/scala/caliban/ZHTTPAdapter.scala +++ b/adapters/zio-http/src/main/scala/caliban/ZHttpAdapter.scala @@ -28,7 +28,8 @@ object ZHttpAdapter { type Subscriptions = Ref[Map[String, Promise[Any, Unit]]] - val contentTypeApplicationGraphQL = Header.custom(HttpHeaderNames.CONTENT_TYPE.toString(), "application/graphql") + private val contentTypeApplicationGraphQL: Header = + Header.custom(HttpHeaderNames.CONTENT_TYPE.toString(), "application/graphql") def makeHttpService[R, E]( interpreter: GraphQLInterpreter[R, E], @@ -62,7 +63,7 @@ object ZHttpAdapter { enableIntrospection: Boolean = true, keepAliveTime: Option[Duration] = None, queryExecution: QueryExecution = QueryExecution.Parallel - ) = + ): HttpApp[R with Clock, E] = HttpApp.responseM( for { ref <- Ref.make(Map.empty[String, Promise[Any, Unit]]) @@ -107,8 +108,7 @@ object ZHttpAdapter { case None => connectionError } case GraphQLWSRequest("stop", id, _) => - removeSubscription(id, subscriptions) - .flatMap(_ => ZStream.empty) + removeSubscription(id, subscriptions) *> ZStream.empty }) .flatten @@ -186,7 +186,7 @@ object ZHttpAdapter { HttpError.BadRequest.apply(s"Invalid json: $error") case ParsingFailure(message, _) => HttpError.BadRequest.apply(message) - case t: Throwable => HttpError.InternalServerError.apply("Internal Server Error", Some(t.getCause())) + case t: Throwable => HttpError.InternalServerError.apply("Internal Server Error", Some(t.getCause)) }) } @@ -210,7 +210,7 @@ object ZHttpAdapter { .obj( "id" -> Json.fromString(id.getOrElse("")), "type" -> Json.fromString("error"), - "message" -> Json.fromString(e.toString()) + "message" -> Json.fromString(e.toString) ) .toString() ) diff --git a/build.sbt b/build.sbt index fc789de007..ca89d52931 100644 --- a/build.sbt +++ b/build.sbt @@ -1,11 +1,13 @@ import sbtcrossproject.CrossPlugin.autoImport.{ crossProject, CrossType } -val mainScala = "2.12.13" -val allScala = Seq("2.13.5", mainScala) +val scala212 = "2.12.13" +val scala213 = "2.13.5" +val scala3 = "3.0.0-RC3" +val allScala = Seq(scala212, scala213, scala3) val akkaVersion = "2.6.14" val catsEffectVersion = "2.5.0" -val circeVersion = "0.13.0" +val circeVersion = "0.14.0-M6" val http4sVersion = "0.21.22" val magnoliaVersion = "0.17.0" val mercatorVersion = "0.2.1" @@ -23,7 +25,7 @@ val zioHttpVersion = "1.0.0.0-RC16" inThisBuild( List( - scalaVersion := mainScala, + scalaVersion := scala212, crossScalaVersions := allScala, organization := "com.github.ghostdogpr", homepage := Some(url("https://github.com/ghostdogpr/caliban")), @@ -84,10 +86,16 @@ lazy val macros = project .settings(name := "caliban-macros") .settings(commonSettings) .settings( - libraryDependencies ++= Seq( - "com.propensive" %% "magnolia" % magnoliaVersion, - "com.propensive" %% "mercator" % mercatorVersion - ) + libraryDependencies ++= { + if (scalaVersion.value == scala3) { + Seq.empty + } else { + Seq( + "com.propensive" %% "magnolia" % magnoliaVersion, + "com.propensive" %% "mercator" % mercatorVersion + ) + } + } ) lazy val core = project @@ -96,20 +104,29 @@ lazy val core = project .settings(commonSettings) .settings( testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")), - libraryDependencies ++= Seq( - "com.lihaoyi" %% "fastparse" % "2.3.2", - "com.propensive" %% "magnolia" % magnoliaVersion, - "com.propensive" %% "mercator" % mercatorVersion, - "dev.zio" %% "zio" % zioVersion, - "dev.zio" %% "zio-streams" % zioVersion, - "dev.zio" %% "zio-query" % zqueryVersion, - "dev.zio" %% "zio-test" % zioVersion % "test", - "dev.zio" %% "zio-test-sbt" % zioVersion % "test", - "io.circe" %% "circe-core" % circeVersion % Optional, - "com.typesafe.play" %% "play-json" % playJsonVersion % Optional, - "dev.zio" %% "zio-json" % zioJsonVersion % Optional, - compilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1") - ) + libraryDependencies ++= { + if (scalaVersion.value == scala3) { + Seq( + "org.typelevel" %% "cats-parse" % "0.3.3" + ) + } else { + Seq( + "com.propensive" %% "magnolia" % magnoliaVersion, + "com.propensive" %% "mercator" % mercatorVersion, + "com.lihaoyi" %% "fastparse" % "2.3.2", + "com.typesafe.play" %% "play-json" % playJsonVersion % Optional, + "dev.zio" %% "zio-json" % zioJsonVersion % Optional + ) + } + } ++ + Seq( + "dev.zio" %% "zio" % zioVersion, + "dev.zio" %% "zio-streams" % zioVersion, + "dev.zio" %% "zio-query" % zqueryVersion, + "dev.zio" %% "zio-test" % zioVersion % "test", + "dev.zio" %% "zio-test-sbt" % zioVersion % "test", + "io.circe" %% "circe-core" % circeVersion % Optional + ) ) .dependsOn(macros) .settings( @@ -122,6 +139,7 @@ lazy val tools = project .settings(name := "caliban-tools") .settings(commonSettings) .settings( + crossScalaVersions -= scala3, testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")), libraryDependencies ++= Seq( "org.scalameta" %% "scalafmt-dynamic" % "2.7.5", @@ -141,7 +159,7 @@ lazy val codegenSbt = project .settings(commonSettings) .settings( sbtPlugin := true, - crossScalaVersions := Seq("2.12.13"), + crossScalaVersions := Seq(scala212), testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")), libraryDependencies ++= Seq( "dev.zio" %% "zio-test-sbt" % zioVersion % "test" @@ -169,6 +187,7 @@ lazy val catsInterop = project .settings(name := "caliban-cats") .settings(commonSettings) .settings( + crossScalaVersions -= scala3, libraryDependencies ++= Seq( "dev.zio" %% "zio-interop-cats" % zioInteropCatsVersion, "org.typelevel" %% "cats-effect" % catsEffectVersion @@ -181,6 +200,7 @@ lazy val monixInterop = project .settings(name := "caliban-monix") .settings(commonSettings) .settings( + crossScalaVersions -= scala3, libraryDependencies ++= Seq( "dev.zio" %% "zio-interop-reactivestreams" % "1.0.3.5-RC12", "dev.zio" %% "zio-interop-cats" % zioInteropCatsVersion, @@ -194,6 +214,7 @@ lazy val tapirInterop = project .settings(name := "caliban-tapir") .settings(commonSettings) .settings( + crossScalaVersions -= scala3, testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")), libraryDependencies ++= Seq( "com.softwaremill.sttp.tapir" %% "tapir-core" % tapirVersion, @@ -209,19 +230,17 @@ lazy val http4s = project .settings(name := "caliban-http4s") .settings(commonSettings) .settings( + crossScalaVersions -= scala3, libraryDependencies ++= Seq( - "dev.zio" %% "zio-interop-cats" % zioInteropCatsVersion, - "org.typelevel" %% "cats-effect" % catsEffectVersion, - "org.http4s" %% "http4s-dsl" % http4sVersion, - "org.http4s" %% "http4s-circe" % http4sVersion, - "org.http4s" %% "http4s-blaze-server" % http4sVersion, - "io.circe" %% "circe-parser" % circeVersion, - compilerPlugin( - ("org.typelevel" %% "kind-projector" % "0.11.3") - .cross(CrossVersion.full) - ), + "dev.zio" %% "zio-interop-cats" % zioInteropCatsVersion, + "org.typelevel" %% "cats-effect" % catsEffectVersion, + "org.http4s" %% "http4s-dsl" % http4sVersion, + "org.http4s" %% "http4s-circe" % http4sVersion, + "org.http4s" %% "http4s-blaze-server" % http4sVersion, + "io.circe" %% "circe-parser" % circeVersion, + compilerPlugin(("org.typelevel" %% "kind-projector" % "0.11.3").cross(CrossVersion.full)), compilerPlugin("com.github.ghik" % "silencer-plugin" % silencerVersion cross CrossVersion.full), - "com.github.ghik" % "silencer-lib" % silencerVersion % Provided cross CrossVersion.full + "com.github.ghik" % "silencer-lib" % silencerVersion % Provided cross CrossVersion.full ) ) .dependsOn(core) @@ -232,13 +251,9 @@ lazy val zioHttp = project .settings(commonSettings) .settings( libraryDependencies ++= Seq( - "io.d11" %% "zhttp" % zioHttpVersion, - "io.circe" %% "circe-parser" % circeVersion, - "io.circe" %% "circe-generic" % circeVersion, - compilerPlugin( - ("org.typelevel" %% "kind-projector" % "0.11.3") - .cross(CrossVersion.full) - ) + "io.d11" %% "zhttp" % zioHttpVersion, + "io.circe" %% "circe-parser" % circeVersion, + "io.circe" %% "circe-generic" % circeVersion ) ) .dependsOn(core) @@ -248,16 +263,14 @@ lazy val akkaHttp = project .settings(name := "caliban-akka-http") .settings(commonSettings) .settings( + crossScalaVersions -= scala3, libraryDependencies ++= Seq( - "com.typesafe.akka" %% "akka-http" % "10.2.4", - "com.typesafe.akka" %% "akka-serialization-jackson" % akkaVersion, - "com.typesafe.akka" %% "akka-stream" % akkaVersion, - "de.heikoseeberger" %% "akka-http-circe" % "1.36.0" % Optional, - "de.heikoseeberger" %% "akka-http-play-json" % "1.36.0" % Optional, - compilerPlugin( - ("org.typelevel" %% "kind-projector" % "0.11.3") - .cross(CrossVersion.full) - ) + "com.typesafe.akka" %% "akka-http" % "10.2.4", + "com.typesafe.akka" %% "akka-serialization-jackson" % akkaVersion, + "com.typesafe.akka" %% "akka-stream" % akkaVersion, + "de.heikoseeberger" %% "akka-http-circe" % "1.36.0" % Optional, + "de.heikoseeberger" %% "akka-http-play-json" % "1.36.0" % Optional, + compilerPlugin(("org.typelevel" %% "kind-projector" % "0.11.3").cross(CrossVersion.full)) ) ) .dependsOn(core) @@ -267,6 +280,7 @@ lazy val finch = project .settings(name := "caliban-finch") .settings(commonSettings) .settings( + crossScalaVersions -= scala3, libraryDependencies ++= Seq( "com.github.finagle" %% "finchx-core" % "0.32.1", "com.github.finagle" %% "finchx-circe" % "0.32.1", @@ -282,6 +296,7 @@ lazy val play = project .settings(name := "caliban-play") .settings(commonSettings) .settings( + crossScalaVersions -= scala3, testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")), libraryDependencies ++= Seq( "com.typesafe.play" %% "play" % playVersion, @@ -303,16 +318,30 @@ lazy val client = crossProject(JSPlatform, JVMPlatform) .settings( testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")), libraryDependencies ++= Seq( - "io.circe" %%% "circe-core" % circeVersion, - "com.softwaremill.sttp.client3" %%% "core" % sttpVersion, - "com.softwaremill.sttp.client3" %%% "circe" % sttpVersion, - "dev.zio" %%% "zio-test" % zioVersion % "test", - "dev.zio" %%% "zio-test-sbt" % zioVersion % "test" + "io.circe" %%% "circe-core" % circeVersion, + "com.softwaremill.sttp.client3" %%% "core" % sttpVersion, + "com.softwaremill.sttp.client3" %%% "circe" % sttpVersion ) ) -lazy val clientJVM = client.jvm +lazy val clientJVM = client.jvm.settings( + libraryDependencies ++= Seq( + "dev.zio" %%% "zio-test" % zioVersion % "test", + "dev.zio" %%% "zio-test-sbt" % zioVersion % "test" + ) +) lazy val clientJS = client.js.settings( - libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % "2.2.2" % Test + libraryDependencies ++= { + // ZIO test is not published for Scala 3 on Scala.js yet + if (scalaVersion.value == scala3) { + Seq.empty + } else { + Seq( + "dev.zio" %%% "zio-test" % zioVersion % "test", + "dev.zio" %%% "zio-test-sbt" % zioVersion % "test", + "io.github.cquiroz" %%% "scala-java-time" % "2.2.2" % Test + ) + } + } ) lazy val examples = project @@ -320,6 +349,7 @@ lazy val examples = project .settings(commonSettings) .settings(publish / skip := true) .settings( + crossScalaVersions -= scala3, libraryDependencies ++= Seq( "de.heikoseeberger" %% "akka-http-circe" % "1.36.0", "com.softwaremill.sttp.client3" %% "async-http-client-backend-zio" % sttpVersion, @@ -339,6 +369,7 @@ lazy val benchmarks = project .dependsOn(core) .enablePlugins(JmhPlugin) .settings( + crossScalaVersions -= scala3, libraryDependencies ++= Seq( "org.sangria-graphql" %% "sangria" % "2.0.0", "org.sangria-graphql" %% "sangria-circe" % "1.3.0" @@ -351,6 +382,7 @@ lazy val federation = project .settings(commonSettings) .dependsOn(core % "compile->compile;test->test") .settings( + crossScalaVersions -= scala3, testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")), libraryDependencies ++= Seq( "dev.zio" %% "zio" % zioVersion, @@ -372,17 +404,11 @@ val commonSettings = Def.settings( "-deprecation", "-encoding", "UTF-8", - "-explaintypes", - "-Yrangepos", "-feature", "-language:higherKinds", "-language:existentials", "-unchecked", - "-Xlint:_,-type-parameter-shadow", - "-Xfatal-warnings", - "-Ywarn-numeric-widen", - "-Ywarn-unused:patvars,-implicits", - "-Ywarn-value-discard" + "-Xfatal-warnings" ) ++ (CrossVersion.partialVersion(scalaVersion.value) match { case Some((2, 12)) => Seq( @@ -397,12 +423,20 @@ val commonSettings = Def.settings( "-Ywarn-nullary-unit", "-opt-inline-from:", "-opt-warnings", - "-opt:l:inline" + "-opt:l:inline", + "-explaintypes" ) case Some((2, 13)) => Seq( - "-Xlint:-byname-implicit" + "-Xlint:-byname-implicit", + "-explaintypes" + ) + + case Some((3, _)) => + Seq( + "-explain-types", + "-Ykind-projector" ) - case _ => Nil + case _ => Nil }) ) diff --git a/client/src/main/scala/caliban/client/__Value.scala b/client/src/main/scala/caliban/client/__Value.scala index 5fb9e7e29f..14382116fa 100644 --- a/client/src/main/scala/caliban/client/__Value.scala +++ b/client/src/main/scala/caliban/client/__Value.scala @@ -34,9 +34,9 @@ object __Value { private def jsonToValue(json: Json): __Value = json.fold( __NullValue, - __BooleanValue, + __BooleanValue.apply, number => __NumberValue(number.toBigDecimal getOrElse BigDecimal(number.toDouble)), - __StringValue, + __StringValue.apply, array => __Value.__ListValue(array.toList.map(jsonToValue)), obj => __Value.__ObjectValue(obj.toList.map { case (k, v) => k -> jsonToValue(v) }) ) diff --git a/client/src/test/scala/caliban/client/SelectionBuilderSpec.scala b/client/src/test/scala/caliban/client/SelectionBuilderSpec.scala index e29702bbd6..a18eb44c39 100644 --- a/client/src/test/scala/caliban/client/SelectionBuilderSpec.scala +++ b/client/src/test/scala/caliban/client/SelectionBuilderSpec.scala @@ -155,7 +155,7 @@ object SelectionBuilderSpec extends DefaultRunnableSpec { Character.nicknames ~ Character .role(Role.Captain.shipName, Role.Pilot.shipName, Role.Mechanic.shipName, Role.Engineer.shipName)) - .mapN(CharacterView) + .mapN(CharacterView(_, _, _)) } val response = diff --git a/core/src/main/scala-2/caliban/CalibanErrorJsonCompat.scala b/core/src/main/scala-2/caliban/CalibanErrorJsonCompat.scala new file mode 100644 index 0000000000..1758183dfb --- /dev/null +++ b/core/src/main/scala-2/caliban/CalibanErrorJsonCompat.scala @@ -0,0 +1,11 @@ +package caliban + +import caliban.interop.play.IsPlayJsonWrites +import caliban.interop.zio.IsZIOJsonEncoder + +private[caliban] trait CalibanErrorJsonCompat { + implicit def playJsonWrites[F[_]](implicit ev: IsPlayJsonWrites[F]): F[CalibanError] = + caliban.interop.play.json.ErrorPlayJson.errorValueWrites.asInstanceOf[F[CalibanError]] + implicit def zioJsonEncoder[F[_]](implicit ev: IsZIOJsonEncoder[F]): F[CalibanError] = + caliban.interop.zio.ErrorZioJson.errorValueEncoder.asInstanceOf[F[CalibanError]] +} diff --git a/core/src/main/scala-2/caliban/GraphQLRequestJsonCompat.scala b/core/src/main/scala-2/caliban/GraphQLRequestJsonCompat.scala new file mode 100644 index 0000000000..64b409adab --- /dev/null +++ b/core/src/main/scala-2/caliban/GraphQLRequestJsonCompat.scala @@ -0,0 +1,11 @@ +package caliban + +import caliban.interop.play.IsPlayJsonReads +import caliban.interop.zio.IsZIOJsonDecoder + +private[caliban] trait GraphQLRequestJsonCompat { + implicit def playJsonReads[F[_]: IsPlayJsonReads]: F[GraphQLRequest] = + caliban.interop.play.json.GraphQLRequestPlayJson.graphQLRequestReads.asInstanceOf[F[GraphQLRequest]] + implicit def zioJsonDecoder[F[_]: IsZIOJsonDecoder]: F[GraphQLRequest] = + caliban.interop.zio.GraphQLRequestZioJson.graphQLRequestDecoder.asInstanceOf[F[GraphQLRequest]] +} diff --git a/core/src/main/scala-2/caliban/GraphQLResponseJsonCompat.scala b/core/src/main/scala-2/caliban/GraphQLResponseJsonCompat.scala new file mode 100644 index 0000000000..c30179140e --- /dev/null +++ b/core/src/main/scala-2/caliban/GraphQLResponseJsonCompat.scala @@ -0,0 +1,11 @@ +package caliban + +import caliban.interop.play._ +import caliban.interop.zio.IsZIOJsonEncoder + +private[caliban] trait GraphQLResponseJsonCompat { + implicit def playJsonWrites[F[_]: IsPlayJsonWrites, E]: F[GraphQLResponse[E]] = + caliban.interop.play.json.GraphQLResponsePlayJson.graphQLResponseWrites.asInstanceOf[F[GraphQLResponse[E]]] + implicit def zioJsonEncoder[F[_]: IsZIOJsonEncoder, E]: F[GraphQLResponse[E]] = + caliban.interop.zio.GraphQLResponseZioJson.graphQLResponseEncoder.asInstanceOf[F[GraphQLResponse[E]]] +} diff --git a/core/src/main/scala/caliban/Macros.scala b/core/src/main/scala-2/caliban/Macros.scala similarity index 100% rename from core/src/main/scala/caliban/Macros.scala rename to core/src/main/scala-2/caliban/Macros.scala diff --git a/core/src/main/scala-2/caliban/ValueJsonCompat.scala b/core/src/main/scala-2/caliban/ValueJsonCompat.scala new file mode 100644 index 0000000000..16617e431b --- /dev/null +++ b/core/src/main/scala-2/caliban/ValueJsonCompat.scala @@ -0,0 +1,26 @@ +package caliban + +import caliban.interop.play.{ IsPlayJsonReads, IsPlayJsonWrites } +import caliban.interop.zio.{ IsZIOJsonDecoder, IsZIOJsonEncoder } + +private[caliban] trait ValueJsonCompat { + implicit def inputValuePlayJsonWrites[F[_]: IsPlayJsonWrites]: F[InputValue] = + caliban.interop.play.json.ValuePlayJson.inputValueWrites.asInstanceOf[F[InputValue]] + implicit def inputValuePlayJsonReads[F[_]: IsPlayJsonReads]: F[InputValue] = + caliban.interop.play.json.ValuePlayJson.inputValueReads.asInstanceOf[F[InputValue]] + + implicit def inputValueZioJsonEncoder[F[_]: IsZIOJsonEncoder]: F[InputValue] = + caliban.interop.zio.ValueZIOJson.inputValueEncoder.asInstanceOf[F[InputValue]] + implicit def inputValueZioJsonDecoder[F[_]: IsZIOJsonDecoder]: F[InputValue] = + caliban.interop.zio.ValueZIOJson.inputValueDecoder.asInstanceOf[F[InputValue]] + + implicit def responseValuePlayJsonWrites[F[_]: IsPlayJsonWrites]: F[ResponseValue] = + caliban.interop.play.json.ValuePlayJson.responseValueWrites.asInstanceOf[F[ResponseValue]] + implicit def responseValuePlayJsonReads[F[_]: IsPlayJsonReads]: F[ResponseValue] = + caliban.interop.play.json.ValuePlayJson.responseValueReads.asInstanceOf[F[ResponseValue]] + + implicit def responseValueZioJsonEncoder[F[_]: IsZIOJsonEncoder]: F[ResponseValue] = + caliban.interop.zio.ValueZIOJson.responseValueEncoder.asInstanceOf[F[ResponseValue]] + implicit def responseValueZioJsonDecoder[F[_]: IsZIOJsonDecoder]: F[ResponseValue] = + caliban.interop.zio.ValueZIOJson.responseValueDecoder.asInstanceOf[F[ResponseValue]] +} diff --git a/core/src/main/scala/caliban/interop/play/play.scala b/core/src/main/scala-2/caliban/interop/play/play.scala similarity index 100% rename from core/src/main/scala/caliban/interop/play/play.scala rename to core/src/main/scala-2/caliban/interop/play/play.scala diff --git a/core/src/main/scala/caliban/interop/zio/zio.scala b/core/src/main/scala-2/caliban/interop/zio/zio.scala similarity index 100% rename from core/src/main/scala/caliban/interop/zio/zio.scala rename to core/src/main/scala-2/caliban/interop/zio/zio.scala diff --git a/core/src/main/scala-2/caliban/introspection/IntrospectionDerivation.scala b/core/src/main/scala-2/caliban/introspection/IntrospectionDerivation.scala new file mode 100644 index 0000000000..a7b2a610e7 --- /dev/null +++ b/core/src/main/scala-2/caliban/introspection/IntrospectionDerivation.scala @@ -0,0 +1,10 @@ +package caliban.introspection + +import caliban.introspection.adt.{ __Introspection, __Type } +import caliban.schema.Schema + +trait IntrospectionDerivation { + implicit lazy val typeSchema: Schema[Any, __Type] = Schema.gen[__Type] + + val introspectionSchema: Schema[Any, __Introspection] = Schema.gen[__Introspection] +} diff --git a/core/src/main/scala/caliban/parsing/Parser.scala b/core/src/main/scala-2/caliban/parsing/Parser.scala similarity index 100% rename from core/src/main/scala/caliban/parsing/Parser.scala rename to core/src/main/scala-2/caliban/parsing/Parser.scala diff --git a/core/src/main/scala/caliban/parsing/SourceMapper.scala b/core/src/main/scala-2/caliban/parsing/SourceMapper.scala similarity index 100% rename from core/src/main/scala/caliban/parsing/SourceMapper.scala rename to core/src/main/scala-2/caliban/parsing/SourceMapper.scala diff --git a/core/src/main/scala-2/caliban/schema/ArgBuilderDerivation.scala b/core/src/main/scala-2/caliban/schema/ArgBuilderDerivation.scala new file mode 100644 index 0000000000..d1cc588613 --- /dev/null +++ b/core/src/main/scala-2/caliban/schema/ArgBuilderDerivation.scala @@ -0,0 +1,59 @@ +package caliban.schema + +import caliban.CalibanError.ExecutionError +import caliban.InputValue +import caliban.Value._ +import caliban.schema.Annotations.GQLName +import magnolia._ +import mercator.Monadic + +import scala.language.experimental.macros + +trait ArgBuilderDerivation { + + type Typeclass[T] = ArgBuilder[T] + + type EitherExecutionError[A] = Either[ExecutionError, A] + + implicit val eitherMonadic: Monadic[EitherExecutionError] = new Monadic[EitherExecutionError] { + override def flatMap[A, B](from: EitherExecutionError[A])( + fn: A => EitherExecutionError[B] + ): EitherExecutionError[B] = from.flatMap(fn) + + override def point[A](value: A): EitherExecutionError[A] = Right(value) + + override def map[A, B](from: EitherExecutionError[A])(fn: A => B): EitherExecutionError[B] = from.map(fn) + } + + def combine[T](ctx: CaseClass[ArgBuilder, T]): ArgBuilder[T] = + (input: InputValue) => { + ctx.constructMonadic { p => + input match { + case InputValue.ObjectValue(fields) => + val label = p.annotations.collectFirst { case GQLName(name) => name }.getOrElse(p.label) + p.typeclass.build(fields.getOrElse(label, NullValue)) + case value => p.typeclass.build(value) + } + } + } + + def dispatch[T](ctx: SealedTrait[ArgBuilder, T]): ArgBuilder[T] = input => { + (input match { + case EnumValue(value) => Some(value) + case StringValue(value) => Some(value) + case _ => None + }) match { + case Some(value) => + ctx.subtypes + .find(t => + t.annotations.collectFirst { case GQLName(name) => name }.contains(value) || t.typeName.short == value + ) match { + case Some(subtype) => subtype.typeclass.build(InputValue.ObjectValue(Map())) + case None => Left(ExecutionError(s"Invalid value $value for trait ${ctx.typeName.short}")) + } + case None => Left(ExecutionError(s"Can't build a trait from input $input")) + } + } + + implicit def gen[T]: Typeclass[T] = macro Magnolia.gen[T] +} diff --git a/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala b/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala new file mode 100644 index 0000000000..38caddda9d --- /dev/null +++ b/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala @@ -0,0 +1,222 @@ +package caliban.schema + +import caliban.Value._ +import caliban.introspection.adt._ +import caliban.parsing.adt.Directive +import caliban.schema.Annotations._ +import caliban.schema.Step._ +import caliban.schema.Types._ +import magnolia._ + +import scala.language.experimental.macros + +trait SchemaDerivation[R] extends LowPriorityDerivedSchema { + + /** + * Default naming logic for input types. + * This is needed to avoid a name clash between a type used as an input and the same type used as an output. + * GraphQL needs 2 different types, and they can't have the same name. + * By default, we add the "Input" suffix after the type name. + */ + def customizeInputTypeName(name: String): String = s"${name}Input" + + type Typeclass[T] = Schema[R, T] + + def combine[T](ctx: ReadOnlyCaseClass[Typeclass, T]): Typeclass[T] = new Typeclass[T] { + override def toType(isInput: Boolean, isSubscription: Boolean): __Type = + if (ctx.isValueClass && ctx.parameters.nonEmpty) ctx.parameters.head.typeclass.toType_(isInput, isSubscription) + else if (isInput) + makeInputObject( + Some(ctx.annotations.collectFirst { case GQLInputName(suffix) => suffix } + .getOrElse(customizeInputTypeName(getName(ctx)))), + getDescription(ctx), + ctx.parameters + .map(p => + __InputValue( + getName(p), + getDescription(p), + () => + if (p.typeclass.optional) p.typeclass.toType_(isInput, isSubscription) + else makeNonNull(p.typeclass.toType_(isInput, isSubscription)), + None, + Some(p.annotations.collect { case GQLDirective(dir) => dir }.toList).filter(_.nonEmpty) + ) + ) + .toList, + Some(ctx.typeName.full) + ) + else + makeObject( + Some(getName(ctx)), + getDescription(ctx), + ctx.parameters + .map(p => + __Field( + getName(p), + getDescription(p), + p.typeclass.arguments, + () => + if (p.typeclass.optional) p.typeclass.toType_(isInput, isSubscription) + else makeNonNull(p.typeclass.toType_(isInput, isSubscription)), + p.annotations.collectFirst { case GQLDeprecated(_) => () }.isDefined, + p.annotations.collectFirst { case GQLDeprecated(reason) => reason }, + Option(p.annotations.collect { case GQLDirective(dir) => dir }.toList).filter(_.nonEmpty) + ) + ) + .toList, + getDirectives(ctx), + Some(ctx.typeName.full) + ) + + override def resolve(value: T): Step[R] = + if (ctx.isObject) PureStep(EnumValue(getName(ctx))) + else if (ctx.isValueClass && ctx.parameters.nonEmpty) { + val head = ctx.parameters.head + head.typeclass.resolve(head.dereference(value)) + } else { + val fields = Map.newBuilder[String, Step[R]] + ctx.parameters.foreach(p => fields += getName(p) -> p.typeclass.resolve(p.dereference(value))) + ObjectStep(getName(ctx), fields.result()) + } + } + + def dispatch[T](ctx: SealedTrait[Typeclass, T]): Typeclass[T] = new Typeclass[T] { + override def toType(isInput: Boolean, isSubscription: Boolean): __Type = { + val subtypes = + ctx.subtypes + .map(s => s.typeclass.toType_() -> s.annotations) + .toList + .sortBy { case (tpe, _) => + tpe.name.getOrElse("") + } + val isEnum = subtypes.forall { + case (t, _) + if t.fields(__DeprecatedArgs(Some(true))).forall(_.isEmpty) + && t.inputFields.forall(_.isEmpty) => + true + case _ => false + } + if (isEnum && subtypes.nonEmpty) + makeEnum( + Some(getName(ctx)), + getDescription(ctx), + subtypes.collect { case (__Type(_, Some(name), description, _, _, _, _, _, _, _, _), annotations) => + __EnumValue( + name, + description, + annotations.collectFirst { case GQLDeprecated(_) => () }.isDefined, + annotations.collectFirst { case GQLDeprecated(reason) => reason } + ) + }, + Some(ctx.typeName.full) + ) + else { + ctx.annotations.collectFirst { case GQLInterface() => + () + }.fold( + makeUnion( + Some(getName(ctx)), + getDescription(ctx), + subtypes.map { case (t, _) => fixEmptyUnionObject(t) }, + Some(ctx.typeName.full) + ) + ) { _ => + val impl = subtypes.map(_._1.copy(interfaces = () => Some(List(toType(isInput, isSubscription))))) + val commonFields = () => + impl + .flatMap(_.fields(__DeprecatedArgs(Some(true)))) + .flatten + .groupBy(_.name) + .collect { + case (name, list) + if impl.forall(_.fields(__DeprecatedArgs(Some(true))).getOrElse(Nil).exists(_.name == name)) && + list.map(t => Types.name(t.`type`())).distinct.length == 1 => + list.headOption + } + .flatten + .toList + + makeInterface(Some(getName(ctx)), getDescription(ctx), commonFields, impl, Some(ctx.typeName.full)) + } + } + } + + // see https://github.com/graphql/graphql-spec/issues/568 + private def fixEmptyUnionObject(t: __Type): __Type = + t.fields(__DeprecatedArgs(Some(true))) match { + case Some(Nil) => + t.copy( + fields = (_: __DeprecatedArgs) => + Some( + List( + __Field( + "_", + Some( + "Fake field because GraphQL does not support empty objects. Do not query, use __typename instead." + ), + Nil, + () => makeScalar("Boolean") + ) + ) + ) + ) + case _ => t + } + + override def resolve(value: T): Step[R] = + ctx.dispatch(value)(subType => subType.typeclass.resolve(subType.cast(value))) + } + + private def getDirectives(annotations: Seq[Any]): List[Directive] = + annotations.collect { case GQLDirective(dir) => dir }.toList + + private def getDirectives[Typeclass[_], Type](ctx: ReadOnlyCaseClass[Typeclass, Type]): List[Directive] = + getDirectives(ctx.annotations) + + private def getName(annotations: Seq[Any], typeName: TypeName): String = + annotations.collectFirst { case GQLName(name) => name }.getOrElse { + typeName.typeArguments match { + case Nil => typeName.short + case args => typeName.short + args.map(getName(Nil, _)).mkString + } + } + + private def getName[Typeclass[_], Type](ctx: ReadOnlyCaseClass[Typeclass, Type]): String = + getName(ctx.annotations, ctx.typeName) + + private def getName[Typeclass[_], Type](ctx: SealedTrait[Typeclass, Type]): String = + getName(ctx.annotations, ctx.typeName) + + private def getName[Typeclass[_], Type](ctx: ReadOnlyParam[Typeclass, Type]): String = + ctx.annotations.collectFirst { case GQLName(name) => name }.getOrElse(ctx.label) + + private def getDescription(annotations: Seq[Any]): Option[String] = + annotations.collectFirst { case GQLDescription(desc) => desc } + + private def getDescription[Typeclass[_], Type](ctx: ReadOnlyCaseClass[Typeclass, Type]): Option[String] = + getDescription(ctx.annotations) + + private def getDescription[Typeclass[_], Type](ctx: SealedTrait[Typeclass, Type]): Option[String] = + getDescription(ctx.annotations) + + private def getDescription[Typeclass[_], Type](ctx: ReadOnlyParam[Typeclass, Type]): Option[String] = + getDescription(ctx.annotations) + + /** + * Generates an instance of `Schema` for the given type T. + * This should be used only if T is a case class or a sealed trait. + */ + implicit def genMacro[T]: Derived[Typeclass[T]] = macro DerivedMagnolia.derivedMagnolia[Typeclass, T] + + /** + * Returns an instance of `Schema` for the given type T. + * For a case class or sealed trait, you can call `genMacro[T].schema` instead to get more details if the + * schema can't be derived. + */ + def gen[T](implicit derived: Derived[Schema[R, T]]): Schema[R, T] = derived.schema + +} + +private[schema] trait LowPriorityDerivedSchema { + implicit def derivedSchema[R, T](implicit derived: Derived[Schema[R, T]]): Schema[R, T] = derived.schema +} diff --git a/core/src/main/scala-2/caliban/schema/SubscriptionSchemaDerivation.scala b/core/src/main/scala-2/caliban/schema/SubscriptionSchemaDerivation.scala new file mode 100644 index 0000000000..c1befacefa --- /dev/null +++ b/core/src/main/scala-2/caliban/schema/SubscriptionSchemaDerivation.scala @@ -0,0 +1,13 @@ +package caliban.schema + +import magnolia._ + +import scala.language.experimental.macros + +trait SubscriptionSchemaDerivation { + type Typeclass[T] = SubscriptionSchema[T] + + def combine[T](ctx: CaseClass[SubscriptionSchema, T]): Typeclass[T] = new Typeclass[T] {} + + implicit def gen[T]: Typeclass[T] = macro Magnolia.gen[T] +} diff --git a/core/src/main/scala-3/caliban/CalibanErrorJsonCompat.scala b/core/src/main/scala-3/caliban/CalibanErrorJsonCompat.scala new file mode 100644 index 0000000000..5714b4d424 --- /dev/null +++ b/core/src/main/scala-3/caliban/CalibanErrorJsonCompat.scala @@ -0,0 +1,3 @@ +package caliban + +private[caliban] trait CalibanErrorJsonCompat diff --git a/core/src/main/scala-3/caliban/GraphQLRequestJsonCompat.scala b/core/src/main/scala-3/caliban/GraphQLRequestJsonCompat.scala new file mode 100644 index 0000000000..e2c4f5225c --- /dev/null +++ b/core/src/main/scala-3/caliban/GraphQLRequestJsonCompat.scala @@ -0,0 +1,3 @@ +package caliban + +private[caliban] trait GraphQLRequestJsonCompat diff --git a/core/src/main/scala-3/caliban/GraphQLResponseJsonCompat.scala b/core/src/main/scala-3/caliban/GraphQLResponseJsonCompat.scala new file mode 100644 index 0000000000..b610c05730 --- /dev/null +++ b/core/src/main/scala-3/caliban/GraphQLResponseJsonCompat.scala @@ -0,0 +1,3 @@ +package caliban + +private[caliban] trait GraphQLResponseJsonCompat diff --git a/core/src/main/scala-3/caliban/Macros.scala b/core/src/main/scala-3/caliban/Macros.scala new file mode 100644 index 0000000000..621ecf0ac8 --- /dev/null +++ b/core/src/main/scala-3/caliban/Macros.scala @@ -0,0 +1,21 @@ +package caliban + +import scala.quoted._ + +import caliban.parsing.Parser + +object Macros { + + /** + * Verifies at compile-time that the given string is a valid GraphQL document. + * @param document a string representing a GraphQL document. + */ + inline def gqldoc(inline document: String): String = ${ gqldocImpl('document) } + + private def gqldocImpl(document: Expr[String])(using Quotes): Expr[String] = { + import quotes.reflect.report + document.value.fold(report.throwError("This macro can only be used with string literals."))( + Parser.check(_).fold(document)(e => report.throwError(s"GraphQL document is invalid: $e")) + ) + } +} diff --git a/core/src/main/scala-3/caliban/ValueJsonCompat.scala b/core/src/main/scala-3/caliban/ValueJsonCompat.scala new file mode 100644 index 0000000000..b15f50971d --- /dev/null +++ b/core/src/main/scala-3/caliban/ValueJsonCompat.scala @@ -0,0 +1,3 @@ +package caliban + +private[caliban] trait ValueJsonCompat diff --git a/core/src/main/scala-3/caliban/introspection/IntrospectionDerivation.scala b/core/src/main/scala-3/caliban/introspection/IntrospectionDerivation.scala new file mode 100644 index 0000000000..e6cc181ed6 --- /dev/null +++ b/core/src/main/scala-3/caliban/introspection/IntrospectionDerivation.scala @@ -0,0 +1,14 @@ +package caliban.introspection + +import caliban.Value.StringValue +import caliban.introspection.adt._ +import caliban.parsing.adt.Directive +import caliban.schema.Schema + +trait IntrospectionDerivation { + implicit lazy val directiveSchema: Schema[Any, Directive] = + Schema.scalarSchema("Directive", None, d => StringValue(d.name)) + implicit lazy val typeSchema: Schema[Any, __Type] = Schema.gen + implicit lazy val __directiveSchema: Schema[Any, __Directive] = Schema.gen + val introspectionSchema: Schema[Any, __Introspection] = Schema.gen +} diff --git a/core/src/main/scala-3/caliban/parsing/Parser.scala b/core/src/main/scala-3/caliban/parsing/Parser.scala new file mode 100644 index 0000000000..332aff8112 --- /dev/null +++ b/core/src/main/scala-3/caliban/parsing/Parser.scala @@ -0,0 +1,596 @@ +package caliban.parsing + +import caliban.CalibanError.ParsingError +import caliban.InputValue +import caliban.InputValue._ +import caliban.Value._ +import caliban.parsing.adt.Definition._ +import caliban.parsing.adt.Definition.ExecutableDefinition._ +import caliban.parsing.adt.Definition.TypeSystemDefinition.DirectiveLocation._ +import caliban.parsing.adt.Definition.TypeSystemDefinition._ +import caliban.parsing.adt.Definition.TypeSystemDefinition.TypeDefinition._ +import caliban.parsing.adt.Definition.TypeSystemExtension._ +import caliban.parsing.adt.Definition.TypeSystemExtension.TypeExtension._ +import caliban.parsing.adt.Selection._ +import caliban.parsing.adt.Type._ +import caliban.parsing.adt._ +import cats.parse.{ Numbers, Parser => P } +import cats.parse._ +import zio.{ IO, Task } + +object Parser { + private final val UnicodeBOM = '\uFEFF' + private final val Tab = '\u0009' + private final val Space = '\u0020' + private final val LF = '\u000A' + private final val CR = '\u000D' + private final val Comma = ',' + private val whitespace: Parser[_] = P.charIn(UnicodeBOM, Tab, Space, LF, CR, Comma) + private val comment: Parser[_] = P.charIn('#') ~ P.until(P.char(LF) | P.string(s"$CR$LF")) + private val whitespaceWithComment = (whitespace | comment).rep0.void + private val whitespaceWithComment1 = (whitespace | comment).rep.void + private def wrapBrackets[T](t: Parser0[T]): P[T] = + P.char('{') *> whitespaceWithComment *> t <* whitespaceWithComment <* P.char('}') + private def wrapParentheses[T](t: Parser0[T]): P[T] = + P.char('(') *> whitespaceWithComment *> t <* whitespaceWithComment <* P.char(')') + private def wrapSquareBrackets[T](t: Parser0[T]): P[T] = + P.char('[').surroundedBy(whitespaceWithComment) *> t <* (P.char(']').surroundedBy(whitespaceWithComment)) + private def wrapWhitespaces[T](t: Parser[T]): P[T] = t.surroundedBy(whitespaceWithComment) + + private object StringUtil { + private val decodeTable: Map[Char, Char] = Map( + ('\\', '\\'), + ('\'', '\''), + ('\"', '\"'), + ('b', 8.toChar), // backspace + ('f', 12.toChar), // form-feed + ('n', '\n'), + ('r', '\r'), + ('t', '\t') + ) + + val escapedToken: P[Unit] = { + val escapes = P.charIn(decodeTable.keys.toSeq) + + val oct = P.charIn('0' to '7') + val octP = P.char('o') ~ oct ~ oct + + val hex = P.charIn(('0' to '9') ++ ('a' to 'f') ++ ('A' to 'F')) + val hex2 = hex ~ hex + val hexP = P.char('x') ~ hex2 + + val hex4 = hex2 ~ hex2 + val u4 = P.char('u') ~ hex4 + val hex8 = hex4 ~ hex4 + val u8 = P.char('U') ~ hex8 + + val after = P.oneOf[Any](escapes :: octP :: hexP :: u4 :: u8 :: Nil) + (P.char('\\') ~ after).void + } + + /** + * String content without the delimiter + */ + def undelimitedString(endP: P[Unit]): P[String] = + escapedToken.backtrack + .orElse((!endP).with1 ~ P.anyChar) + .rep + .string + .flatMap { str => + unescape(str) match { + case Right(str1) => P.pure(str1) + case Left(_) => P.fail + } + } + + private val simpleString: Parser0[String] = + P.charsWhile0(c => c >= ' ' && c != '"' && c != '\\') + + def escapedString(q: Char): P[String] = { + val end: P[Unit] = P.char(q) + end *> ((simpleString <* end).backtrack + .orElse(undelimitedString(end) <* end)) + } + + def unescape(str: String): Either[Int, String] = { + val sb = new java.lang.StringBuilder + def decodeNum(idx: Int, size: Int, base: Int): Int = { + val end = idx + size + if (end <= str.length) { + val intStr = str.substring(idx, end) + val asInt = + try Integer.parseInt(intStr, base) + catch { case _: NumberFormatException => ~idx } + sb.append(asInt.toChar) + end + } else ~(str.length) + } + @annotation.tailrec + def loop(idx: Int): Int = + if (idx >= str.length) { + // done + idx + } else if (idx < 0) { + // error from decodeNum + idx + } else { + val c0 = str.charAt(idx) + if (c0 != '\\') { + sb.append(c0) + loop(idx + 1) + } else { + // str(idx) == \ + val nextIdx = idx + 1 + if (nextIdx >= str.length) { + // error we expect there to be a character after \ + ~idx + } else { + val c = str.charAt(nextIdx) + decodeTable.get(c) match { + case Some(d) => + sb.append(d) + loop(idx + 2) + case None => + c match { + case 'o' => loop(decodeNum(idx + 2, 2, 8)) + case 'x' => loop(decodeNum(idx + 2, 2, 16)) + case 'u' => loop(decodeNum(idx + 2, 4, 16)) + case 'U' => loop(decodeNum(idx + 2, 8, 16)) + case other => + // \c is interpretted as just \c, if the character isn't escaped + sb.append('\\') + sb.append(other) + loop(idx + 2) + } + } + } + } + } + + val res = loop(0) + if (res < 0) Left(~res) + else Right(sb.toString) + } + } + + private val name: P[String] = (P.charIn(('a' to 'z') ++ ('A' to 'Z') ++ Seq('_')) ~ P + .charIn(('a' to 'z') ++ ('A' to 'Z') ++ Seq('_') ++ ('0' to '9')) + .rep0).string + + private val booleanValue: P[BooleanValue] = + P.oneOf(P.string("true").as(BooleanValue(true)) :: P.string("false").as(BooleanValue(false)) :: Nil) + + private val intValue: P[IntValue] = (Numbers.signedIntString <* (!P.char('.')).void).backtrack.map(IntValue(_)) + + private val floatValue: P[FloatValue] = Numbers.jsonNumber.map(FloatValue(_)) + + private val stringValue: P[StringValue] = + P.oneOf( + (P.string("\"\"\"") *> StringUtil.undelimitedString(P.string("\"\"\"")).map(blockStringValue) <* + P.string("\"\"\"")) :: StringUtil.escapedString('\"') :: Nil + ).map(v => StringValue(v)) + + private def blockStringValue(rawValue: String): String = { + val l1 = rawValue.split("\r?\n").toList + val commonIndent = l1 match { + case Nil => None + case _ :: tail => + tail.foldLeft(Option.empty[Int]) { case (commonIndent, line) => + val indent = "[ \t]*".r.findPrefixOf(line).fold(0)(_.length) + if (indent < line.length && commonIndent.fold(true)(_ > indent)) Some(indent) else commonIndent + } + } + // remove indentation + val l2 = (commonIndent, l1) match { + case (Some(value), head :: tail) => head :: tail.map(_.drop(value)) + case _ => l1 + } + // remove start lines that are only whitespaces + val l3 = l2.dropWhile("[ \t]*".r.replaceAllIn(_, "").isEmpty) + // remove end lines that are only whitespaces + val l4 = l3.reverse.dropWhile("[ \t]*".r.replaceAllIn(_, "").isEmpty).reverse + l4.mkString("\n") + } + + private val nullValue: P[InputValue] = P.string("null").as(NullValue) + private val enumValue: P[InputValue] = name.map(EnumValue.apply) + + private val listValue: P[ListValue] = + wrapSquareBrackets(value.repSep0(whitespaceWithComment)).map(values => ListValue(values)) + + private val objectField: P[(String, InputValue)] = (name <* wrapWhitespaces(P.char(':'))) ~ value + + private val objectValue: P[ObjectValue] = + wrapBrackets(objectField.repSep0(whitespaceWithComment)).map(values => ObjectValue(values.toMap)) + + private val variable: P[VariableValue] = (P.char('$') *> name).map(VariableValue.apply) + + private lazy val value: P[InputValue] = + P.defer( + P.oneOf( + List(intValue, floatValue, booleanValue, stringValue, nullValue, enumValue, listValue, objectValue, variable) + ) + ) + + private val defaultValue: P[InputValue] = wrapWhitespaces(P.char('=')) *> value + + private val alias: P[String] = name <* whitespaceWithComment <* P.char(':') + + private val argument: P[(String, InputValue)] = (name <* wrapWhitespaces(P.char(':'))) ~ value + private val arguments: P[Map[String, InputValue]] = + wrapParentheses(argument.repSep0(whitespaceWithComment)).map(v => v.toMap) + + private val directive: P[Directive] = + (P.index.with1 ~ ((P.char('@') *> name).soft <* whitespaceWithComment) ~ arguments.?).map { + case ((index, name), arguments) => + Directive(name, arguments.getOrElse(Map()), index) + } + private val directives: P[List[Directive]] = directive.repSep(whitespaceWithComment).map(_.toList) + + private val selection: P[Selection] = P.defer(P.oneOf(field :: fragmentSpread :: inlineFragment :: Nil)) + + private lazy val selectionSet: P[List[Selection]] = + wrapBrackets(selection.repSep0(whitespaceWithComment)).map(_.toList) + + private val namedType: P[NamedType] = (name.filter(_ != "null") ~ P.char('!').?).map { case (name, nonNull) => + NamedType(name, nonNull = nonNull.nonEmpty) + } + + private val listType: P[ListType] = + (wrapSquareBrackets(type_) ~ P.char('!').?).map { case (typ, nonNull) => ListType(typ, nonNull = nonNull.nonEmpty) } + + private lazy val type_ : P[Type] = P.defer(P.oneOf(namedType :: listType :: Nil)) + + private val argumentDefinition: P[InputValueDefinition] = + (((stringValue <* whitespaceWithComment1).?.with1 ~ name <* wrapWhitespaces(P.char(':'))) ~ + (type_ <* whitespaceWithComment) ~ ((defaultValue <* whitespaceWithComment).? ~ directives.?)).map { + case (((description, name), type_), (defaultValue, directives)) => + InputValueDefinition(description.map(_.value), name, type_, defaultValue, directives.getOrElse(Nil)) + } + private val argumentDefinitions: P[List[InputValueDefinition]] = + wrapParentheses(argumentDefinition.rep).map(_.toList) + + private val fieldDefinition: P[FieldDefinition] = + (((stringValue <* whitespaceWithComment).?.with1 ~ (name <* whitespaceWithComment)) ~ + (argumentDefinitions <* whitespaceWithComment).? ~ + ((P.char(':').void <* whitespaceWithComment) *> type_ <* whitespaceWithComment) ~ directives.?).map { + case ((((description, name), args), type_), directives) => + FieldDefinition(description.map(_.value), name, args.getOrElse(Nil), type_, directives.getOrElse(Nil)) + } + + private val variableDefinition: P[VariableDefinition] = + ((variable <* wrapWhitespaces(P.char(':'))) ~ + (type_ <* whitespaceWithComment) ~ + ((defaultValue <* whitespaceWithComment).? ~ directives.?)).map { case ((v, t), (default, dirs)) => + VariableDefinition(v.name, t, default, dirs.getOrElse(Nil)) + } + + private val variableDefinitions: P[List[VariableDefinition]] = + wrapParentheses(variableDefinition.repSep0(whitespaceWithComment)) + + private val field: P[Field] = (((P.index ~ (alias <* whitespaceWithComment).backtrack.?).soft.with1 ~ + name <* whitespaceWithComment) ~ (arguments <* whitespaceWithComment).? ~ + (directives <* whitespaceWithComment).? ~ selectionSet.?).map { + case (((((index, alias), name), args), dirs), sels) => + Field( + alias, + name, + args.getOrElse(Map()), + dirs.getOrElse(Nil), + sels.getOrElse(Nil), + index + ) + } + + private val fragmentName: P[String] = name.filter(_ != "on") + + private val fragmentSpread: P[FragmentSpread] = + ((P.string("...").soft *> fragmentName <* whitespaceWithComment) ~ directives.?).map { case (name, dirs) => + FragmentSpread(name, dirs.getOrElse(Nil)) + } + + private val typeCondition: P[NamedType] = P.string("on") *> whitespaceWithComment1 *> namedType + + private val inlineFragment: P[InlineFragment] = (P.string("...") *> whitespaceWithComment *> + (typeCondition <* whitespaceWithComment).? ~ (directives <* whitespaceWithComment).? ~ selectionSet).map { + case ((typeCondition, dirs), sel) => + InlineFragment(typeCondition, dirs.getOrElse(Nil), sel) + } + + private val operationType: P[OperationType] = + P.string("query").as(OperationType.Query) | P.string("mutation").as(OperationType.Mutation) | P + .string("subscription") + .as( + OperationType.Subscription + ) + + private val operationDefinition: P[OperationDefinition] = + P.oneOf( + ((operationType <* whitespaceWithComment) ~ ((name <* whitespaceWithComment).? ~ + (variableDefinitions <* whitespaceWithComment).?) ~ + (directives <* whitespaceWithComment).? ~ selectionSet).map { + case (((operationType, (name, variableDefinitions)), directives), selection) => + OperationDefinition( + operationType, + name, + variableDefinitions.getOrElse(Nil), + directives.getOrElse(Nil), + selection + ) + } :: selectionSet + .map(selection => OperationDefinition(OperationType.Query, None, Nil, Nil, selection)) :: Nil + ) + + private val fragmentDefinition: P[FragmentDefinition] = + ((P.string("fragment").void *> whitespaceWithComment1 *> fragmentName <* whitespaceWithComment1) ~ + (typeCondition <* whitespaceWithComment) ~ (directives <* whitespaceWithComment).? ~ selectionSet).map { + case (((name, typeCondition), dirs), sel) => + FragmentDefinition(name, typeCondition, dirs.getOrElse(Nil), sel) + } + + private def objectTypeDefinition(description: Option[String]): P[ObjectTypeDefinition] = + ((P.string("type").void *> whitespaceWithComment1 *> name <* whitespaceWithComment1) ~ + ((implements <* whitespaceWithComment).? ~ (directives <* whitespaceWithComment).?) ~ + wrapBrackets(fieldDefinition.repSep0(whitespaceWithComment))).map { + case ((name, (implements, directives)), fields) => + ObjectTypeDefinition( + description, + name, + implements.getOrElse(Nil), + directives.getOrElse(Nil), + fields + ) + } + + private val implements: P[List[NamedType]] = (((P.string("implements") <* whitespaceWithComment <* + (P.char('&') <* whitespaceWithComment).?) *> namedType <* whitespaceWithComment) ~ + (P.char('&') *> whitespaceWithComment *> namedType).repSep0(whitespaceWithComment)).map { case (head, tail) => + head :: tail + } + + private def interfaceTypeDefinition(description: Option[String]): P[InterfaceTypeDefinition] = + ((P.string("interface") *> whitespaceWithComment1 *> name <* whitespaceWithComment) ~ + (directives <* whitespaceWithComment).? ~ wrapBrackets( + fieldDefinition.repSep0(whitespaceWithComment) + )).map { case ((name, directives), fields) => + InterfaceTypeDefinition(description, name, directives.getOrElse(Nil), fields) + } + + private def inputObjectTypeDefinition(description: Option[String]): P[InputObjectTypeDefinition] = + ((P.string( + "input" + ) *> whitespaceWithComment1 *> name <* whitespaceWithComment) ~ (directives <* whitespaceWithComment).? ~ + wrapBrackets(argumentDefinition.repSep0(whitespaceWithComment))).map { case ((name, directives), fields) => + InputObjectTypeDefinition(description, name, directives.getOrElse(Nil), fields) + } + + private val enumValueDefinition: P[EnumValueDefinition] = + ((stringValue <* whitespaceWithComment).?.with1 ~ (name <* whitespaceWithComment) ~ directives.?).map { + case ((description, enumValue), directives) => + EnumValueDefinition(description.map(_.value), enumValue, directives.getOrElse(Nil)) + } + + private val enumName: P[String] = name.filter(s => s != "true" && s != "false" && s != "null") + + private def enumTypeDefinition(description: Option[String]): P[EnumTypeDefinition] = + ((P.string("enum") *> whitespaceWithComment1 *> enumName <* whitespaceWithComment) ~ + (directives <* whitespaceWithComment).? ~ wrapBrackets( + enumValueDefinition.repSep0(whitespaceWithComment) + )).map { case ((name, directives), enumValuesDefinition) => + EnumTypeDefinition(description, name, directives.getOrElse(Nil), enumValuesDefinition) + } + + private def unionTypeDefinition(description: Option[String]): P[UnionTypeDefinition] = + ((P.string("union") *> whitespaceWithComment1 *> name <* whitespaceWithComment) ~ + ((directives <* whitespaceWithComment).? <* P.char('=') <* whitespaceWithComment) ~ + ((P.char('|') <* whitespaceWithComment).? *> namedType <* whitespaceWithComment) ~ + ((P.char('|') <* whitespaceWithComment) *> namedType).repSep(whitespaceWithComment)).map { + case (((name, directives), m), ms) => + UnionTypeDefinition(description, name, directives.getOrElse(Nil), (m :: ms.toList).map(_.name)) + } + + private def scalarTypeDefinition(description: Option[String]): P[ScalarTypeDefinition] = + ((P.string("scalar") *> whitespaceWithComment1 *> name <* whitespaceWithComment) ~ directives.?).map { + case (name, directives) => + ScalarTypeDefinition(description, name, directives.getOrElse(Nil)) + } + + private val rootOperationTypeDefinition: P[(OperationType, NamedType)] = + (operationType <* wrapWhitespaces(P.char(':'))) ~ namedType + + private val schemaDefinition: P[SchemaDefinition] = + ((P.string("schema") *> whitespaceWithComment *> (directives <* whitespaceWithComment).?).with1 ~ + wrapBrackets(rootOperationTypeDefinition.repSep0(whitespaceWithComment))).map { case (directives, ops) => + val opsMap = ops.toMap + SchemaDefinition( + directives.getOrElse(Nil), + opsMap.get(OperationType.Query).map(_.name), + opsMap.get(OperationType.Mutation).map(_.name), + opsMap.get(OperationType.Subscription).map(_.name) + ) + } + + private val schemaExtensionWithOptionalDirectivesAndOperations: Parser0[SchemaExtension] = + ((directives <* whitespaceWithComment).? ~ + wrapBrackets(rootOperationTypeDefinition.repSep0(whitespaceWithComment)).?).map { case (directives, ops) => + val opsMap = ops.getOrElse(Nil).toMap + SchemaExtension( + directives.getOrElse(Nil), + opsMap.get(OperationType.Query).map(_.name), + opsMap.get(OperationType.Mutation).map(_.name), + opsMap.get(OperationType.Subscription).map(_.name) + ) + } + + private val schemaExtension: P[SchemaExtension] = + P.string("schema") *> whitespaceWithComment *> schemaExtensionWithOptionalDirectivesAndOperations + + private val scalarTypeExtension: P[ScalarTypeExtension] = + ((P.string("scalar") *> whitespaceWithComment *> name <* whitespaceWithComment) ~ directives).map { + case (name, directives) => + ScalarTypeExtension(name, directives) + } + + private val objectTypeExtensionWithOptionalInterfacesOptionalDirectivesAndFields: P[ObjectTypeExtension] = + ((name <* whitespaceWithComment) ~ ((implements <* whitespaceWithComment).? ~ + (directives <* whitespaceWithComment).?) ~ + wrapBrackets(fieldDefinition.repSep0(whitespaceWithComment)).backtrack.?).map { + case ((name, (implements, directives)), fields) => + ObjectTypeExtension( + name, + implements.getOrElse(Nil), + directives.getOrElse(Nil), + fields.getOrElse(Nil) + ) + } + + private val objectTypeExtension: P[ObjectTypeExtension] = + P.string("type") *> whitespaceWithComment1 *> + objectTypeExtensionWithOptionalInterfacesOptionalDirectivesAndFields + + private val interfaceTypeExtensionWithOptionalDirectivesAndFields: P[InterfaceTypeExtension] = + ((name <* whitespaceWithComment) ~ ((directives <* whitespaceWithComment).? ~ + wrapBrackets(fieldDefinition.repSep0(whitespaceWithComment)).?)).map { case (name, (directives, fields)) => + InterfaceTypeExtension(name, directives.getOrElse(Nil), fields.getOrElse(Nil)) + } + + private val interfaceTypeExtension: P[InterfaceTypeExtension] = + P.string("interface") *> whitespaceWithComment1 *> + interfaceTypeExtensionWithOptionalDirectivesAndFields + + private val unionTypeExtensionWithOptionalDirectivesAndUnionMembers: P[UnionTypeExtension] = + ((name <* whitespaceWithComment) ~ + ((directives <* whitespaceWithComment).? <* (P.char('=') <* whitespaceWithComment).?) ~ + ((P.char('|') <* whitespaceWithComment).? *> (namedType <* whitespaceWithComment).?) ~ + ((P.char('|') <* whitespaceWithComment) *> namedType).repSep0(whitespaceWithComment)).map { + case (((name, directives), m), ms) => + UnionTypeExtension(name, directives.getOrElse(Nil), m.map(_ :: ms).getOrElse(ms).map(_.name)) + } + + private val unionTypeExtension: P[UnionTypeExtension] = + P.string("union") *> whitespaceWithComment1 *> + unionTypeExtensionWithOptionalDirectivesAndUnionMembers + + private val enumTypeExtensionWithOptionalDirectivesAndValues: P[EnumTypeExtension] = + ((enumName <* whitespaceWithComment) ~ (directives <* whitespaceWithComment).? ~ + wrapBrackets(enumValueDefinition.repSep0(whitespaceWithComment)).backtrack.?).map { + case ((name, directives), enumValuesDefinition) => + EnumTypeExtension(name, directives.getOrElse(Nil), enumValuesDefinition.getOrElse(Nil)) + } + + private val enumTypeExtension: P[EnumTypeExtension] = + P.string("enum") *> whitespaceWithComment1 *> enumTypeExtensionWithOptionalDirectivesAndValues + + private val inputObjectTypeExtensionWithOptionalDirectivesAndFields: P[InputObjectTypeExtension] = + ((name <* whitespaceWithComment) ~ (directives <* whitespaceWithComment).? ~ + wrapBrackets(argumentDefinition.repSep0(whitespaceWithComment)).?).map { case ((name, directives), fields) => + InputObjectTypeExtension(name, directives.getOrElse(Nil), fields.getOrElse(Nil)) + } + + private val inputObjectTypeExtension: P[InputObjectTypeExtension] = + P.string("input") *> whitespaceWithComment1 *> + inputObjectTypeExtensionWithOptionalDirectivesAndFields + + private val directiveLocation: P[DirectiveLocation] = + P.oneOf( + List( + P.string("QUERY").as(ExecutableDirectiveLocation.QUERY), + P.string("MUTATION").as(ExecutableDirectiveLocation.MUTATION), + P.string("SUBSCRIPTION").as(ExecutableDirectiveLocation.SUBSCRIPTION), + P.string("FIELD").as(ExecutableDirectiveLocation.FIELD), + P.string("FRAGMENT_DEFINITION").as(ExecutableDirectiveLocation.FRAGMENT_DEFINITION), + P.string("FRAGMENT_SPREAD").as(ExecutableDirectiveLocation.FRAGMENT_SPREAD), + P.string("INLINE_FRAGMENT").as(ExecutableDirectiveLocation.INLINE_FRAGMENT), + P.string("SCHEMA").as(TypeSystemDirectiveLocation.SCHEMA), + P.string("SCALAR").as(TypeSystemDirectiveLocation.SCALAR), + P.string("OBJECT").as(TypeSystemDirectiveLocation.OBJECT), + P.string("FIELD_DEFINITION").as(TypeSystemDirectiveLocation.FIELD_DEFINITION), + P.string("ARGUMENT_DEFINITION").as(TypeSystemDirectiveLocation.ARGUMENT_DEFINITION), + P.string("INTERFACE").as(TypeSystemDirectiveLocation.INTERFACE), + P.string("UNION").as(TypeSystemDirectiveLocation.UNION), + P.string("ENUM").as(TypeSystemDirectiveLocation.ENUM), + P.string("ENUM_VALUE").as(TypeSystemDirectiveLocation.ENUM_VALUE), + P.string("INPUT_OBJECT").as(TypeSystemDirectiveLocation.INPUT_OBJECT), + P.string("INPUT_FIELD_DEFINITION").as(TypeSystemDirectiveLocation.INPUT_FIELD_DEFINITION) + ) + ) + + private val directiveDefinition: P[DirectiveDefinition] = + ((stringValue <* whitespaceWithComment).?.with1 ~ + (P.string("directive @") *> name <* whitespaceWithComment) ~ + ((argumentDefinitions <* whitespaceWithComment).? <* P.string("on") <* whitespaceWithComment1) ~ + ((P.char('|') <* whitespaceWithComment).? *> directiveLocation <* whitespaceWithComment) ~ + (P.char('|') *> whitespaceWithComment *> directiveLocation).repSep(whitespaceWithComment)).map { + case ((((description, name), args), firstLoc), otherLoc) => + DirectiveDefinition(description.map(_.value), name, args.getOrElse(Nil), otherLoc.toList.toSet + firstLoc) + } + + private val typeDefinition: P[TypeDefinition] = + (stringValue <* whitespaceWithComment).?.with1.flatMap { stringValOpt => + val description = stringValOpt.map(_.value) + P.oneOf( + objectTypeDefinition(description) :: + interfaceTypeDefinition(description) :: + inputObjectTypeDefinition(description) :: + enumTypeDefinition(description) :: + unionTypeDefinition(description) :: + scalarTypeDefinition(description) :: Nil + ) + } + + private val typeSystemDefinition: P[TypeSystemDefinition] = + P.oneOf(typeDefinition :: schemaDefinition :: directiveDefinition :: Nil) + + private val executableDefinition: P[ExecutableDefinition] = + P.oneOf(operationDefinition :: fragmentDefinition :: Nil) + + private val typeExtension: P[TypeExtension] = + P.oneOf( + objectTypeExtension :: + interfaceTypeExtension :: + inputObjectTypeExtension :: + enumTypeExtension :: + unionTypeExtension :: + scalarTypeExtension :: Nil + ) + + private val typeSystemExtension: P[TypeSystemExtension] = + P.string("extend ").void *> P.oneOf(schemaExtension :: typeExtension :: Nil) + + private def definition: P[Definition] = + P.oneOf(executableDefinition :: typeSystemDefinition :: typeSystemExtension :: Nil) + + private val document: Parser0[ParsedDocument] = + (P.start *> whitespaceWithComment *> definition.repSep0(whitespaceWithComment) <* whitespaceWithComment <* P.end) + .map(seq => ParsedDocument(seq)) + + /** + * Parses the given string into a [[caliban.parsing.adt.Document]] object or fails with a [[caliban.CalibanError.ParsingError]]. + */ + def parseQuery(query: String): IO[ParsingError, Document] = { + val sm = SourceMapper(query) + Task(document.parse(query)) + .mapError(ex => ParsingError(s"Internal parsing error", innerThrowable = Some(ex))) + .flatMap { + case Left(error) => + IO.fail( + ParsingError( + s"Parsing error at offset ${error.failedAtOffset}, expected: ${error.expected.toList.mkString(";")}", + Some(sm.getLocation(error.failedAtOffset)) + ) + ) + case Right(result) => + IO.succeed(Document(result._2.definitions, sm)) + } + } + + /** + * Checks if the query is valid, if not returns an error string. + */ + def check(query: String): Option[String] = document.parse(query) match { + case Left(error) => Some(error.toString) + case Right(_) => None + } +} + +case class ParsedDocument(definitions: List[Definition], index: Int = 0) diff --git a/core/src/main/scala-3/caliban/parsing/SourceMapper.scala b/core/src/main/scala-3/caliban/parsing/SourceMapper.scala new file mode 100644 index 0000000000..f981635eab --- /dev/null +++ b/core/src/main/scala-3/caliban/parsing/SourceMapper.scala @@ -0,0 +1,76 @@ +package caliban.parsing + +import caliban.parsing.adt.LocationInfo +import scala.collection.mutable.ArrayBuffer + +/** + * Maps an index to the "friendly" version of an index based on the underlying source. + */ +trait SourceMapper { + + def getLocation(index: Int): LocationInfo + +} + +object SourceMapper { + + /** + * Implementation taken from https://github.com/lihaoyi/fastparse/blob/dd74612224846d3743e19419b3f1191554b973f5/fastparse/src/fastparse/internal/Util.scala#L48 + */ + private def lineNumberLookup(data: String): Array[Int] = { + val lineStarts = new ArrayBuffer[Int]() + var i = 0 + var col = 1 + var cr = false + var prev: Character = null + while (i < data.length) { + val char = data(i) + if (char == '\r') { + if (prev != '\n' && col == 1) lineStarts.append(i) + col = 1 + cr = true + } else if (char == '\n') { + if (prev != '\r' && col == 1) lineStarts.append(i) + col = 1 + cr = false + } else { + if (col == 1) lineStarts.append(i) + col += 1 + cr = false + } + prev = char + i += 1 + } + if (col == 1) lineStarts.append(i) + + lineStarts.toArray + } + + /** + * Implementation taken from https://github.com/lihaoyi/fastparse/blob/e334ca88b747fb3b6637ef6d76715ad66e048a6c/fastparse/src/fastparse/ParserInput.scala#L123-L131 + * + * It is used to look up a line/column number pair given a raw index into a source string. The numbers are determined by + * computing the number of newlines occurring between 0 and the current index. + */ + private[parsing] case class DefaultSourceMapper(source: String) extends SourceMapper { + private[this] lazy val lineNumberLookup = SourceMapper.lineNumberLookup(source) + + def getLocation(index: Int): LocationInfo = { + val line = lineNumberLookup.indexWhere(_ > index) match { + case -1 => lineNumberLookup.length - 1 + case n => 0 max (n - 1) + } + + val col = index - lineNumberLookup(line) + LocationInfo(column = col + 1, line = line + 1) + } + } + + def apply(source: String): SourceMapper = DefaultSourceMapper(source) + + private case object EmptySourceMapper extends SourceMapper { + def getLocation(index: Int): LocationInfo = LocationInfo.origin + } + + val empty: SourceMapper = EmptySourceMapper +} diff --git a/core/src/main/scala-3/caliban/schema/ArgBuilderDerivation.scala b/core/src/main/scala-3/caliban/schema/ArgBuilderDerivation.scala new file mode 100644 index 0000000000..396caa4317 --- /dev/null +++ b/core/src/main/scala-3/caliban/schema/ArgBuilderDerivation.scala @@ -0,0 +1,73 @@ +package caliban.schema + +import caliban.CalibanError.ExecutionError +import caliban.InputValue +import caliban.Value._ +import caliban.schema.macros.Macros +import caliban.schema.Annotations.GQLName + +import scala.deriving.Mirror +import scala.compiletime._ + +trait ArgBuilderDerivation { + inline def recurse[Label, A <: Tuple]: List[(String, List[Any], ArgBuilder[Any])] = + inline erasedValue[(Label, A)] match { + case (_: (name *: names), _: (t *: ts)) => + val label = constValue[name].toString + val annotations = Macros.annotations[t] + val builder = summonInline[ArgBuilder[t]].asInstanceOf[ArgBuilder[Any]] + (label, annotations, builder) :: recurse[names, ts] + case (_: EmptyTuple, _) => Nil + } + + inline def derived[A]: ArgBuilder[A] = + inline summonInline[Mirror.Of[A]] match { + case m: Mirror.SumOf[A] => + lazy val subTypes = recurse[m.MirroredElemLabels, m.MirroredElemTypes] + lazy val traitLabel = constValue[m.MirroredLabel] + new ArgBuilder[A] { + def build(input: InputValue): Either[ExecutionError, A] = { + (input match { + case EnumValue(value) => Some(value) + case StringValue(value) => Some(value) + case _ => None + }) match { + case Some(value) => + subTypes + .find { (label, annotations, _) => + annotations.collectFirst { case GQLName(name) => name }.contains(value) || label == value + } match { + case Some((_, _, builder)) => builder.asInstanceOf[ArgBuilder[A]].build(InputValue.ObjectValue(Map())) + case None => Left(ExecutionError(s"Invalid value $value for trait $traitLabel")) + } + case None => Left(ExecutionError(s"Can't build a trait from input $input")) + } + } + } + + case m: Mirror.ProductOf[A] => + lazy val fields = recurse[m.MirroredElemLabels, m.MirroredElemTypes] + lazy val annotations = Macros.paramAnnotations[A].to(Map) + new ArgBuilder[A] { + def build(input: InputValue): Either[ExecutionError, A] = { + fields.map { (label, _, builder) => + val newInput = + input match { + case InputValue.ObjectValue(fields) => + val finalLabel = annotations.getOrElse(label, Nil).collectFirst { case GQLName(name) => name }.getOrElse(label) + fields.getOrElse(finalLabel, NullValue) + case value => value + } + builder.build(newInput) + }.foldRight[Either[ExecutionError, Tuple]](Right(EmptyTuple)) { case (item, acc) => + item match { + case error: Left[ExecutionError, Any] => error.asInstanceOf[Left[ExecutionError, Tuple]] + case Right(value) => acc.map(value *: _) + } + }.map(m.fromProduct) + } + } + } + + inline given gen[A]: ArgBuilder[A] = derived +} diff --git a/core/src/main/scala-3/caliban/schema/SchemaDerivation.scala b/core/src/main/scala-3/caliban/schema/SchemaDerivation.scala new file mode 100644 index 0000000000..e30c3e04b3 --- /dev/null +++ b/core/src/main/scala-3/caliban/schema/SchemaDerivation.scala @@ -0,0 +1,205 @@ +package caliban.schema + +import caliban.Value.EnumValue +import caliban.introspection.adt._ +import caliban.parsing.adt.Directive +import caliban.schema.Annotations._ +import caliban.schema.Step.ObjectStep +import caliban.schema.Types._ +import caliban.schema.macros.{Macros, TypeInfo} + +import scala.deriving.Mirror +import scala.compiletime._ + +trait SchemaDerivation[R] { + + /** + * Default naming logic for input types. + * This is needed to avoid a name clash between a type used as an input and the same type used as an output. + * GraphQL needs 2 different types, and they can't have the same name. + * By default, we add the "Input" suffix after the type name. + */ + def customizeInputTypeName(name: String): String = s"${name}Input" + + inline def recurse[Label, A <: Tuple](index: Int = 0): List[(String, List[Any], Schema[R, Any], Int)] = + inline erasedValue[(Label, A)] match { + case (_: (name *: names), _: (t *: ts)) => + val label = constValue[name].toString + val annotations = Macros.annotations[t] + val builder = summonInline[Schema[R, t]].asInstanceOf[Schema[R, Any]] + (label, annotations, builder, index) :: recurse[names, ts](index + 1) + case (_: EmptyTuple, _) => Nil + } + + inline def derived[A]: Schema[R, A] = + inline summonInline[Mirror.Of[A]] match { + case m: Mirror.SumOf[A] => + lazy val members = recurse[m.MirroredElemLabels, m.MirroredElemTypes]() + lazy val info = Macros.typeInfo[A] + lazy val annotations = Macros.annotations[A] + lazy val subTypes = + members + .map { case (label, subTypeAnnotations, schema, _) => (label, schema.toType_(), subTypeAnnotations) } + .sortBy { case (label, _, _) => label } + lazy val isEnum = subTypes.forall { + case (_, t, _) + if t.fields(__DeprecatedArgs(Some(true))).forall(_.isEmpty) + && t.inputFields.forall(_.isEmpty) => + true + case _ => false + } + new Schema[R, A] { + def toType(isInput: Boolean, isSubscription: Boolean): __Type = { + if (isEnum && subTypes.nonEmpty) { + makeEnum( + Some(getName(annotations, info)), + getDescription(annotations), + subTypes.collect { case (name, __Type(_, _, description, _, _, _, _, _, _, _, _), annotations) => + __EnumValue( + name, + description, + annotations.collectFirst { case GQLDeprecated(_) => () }.isDefined, + annotations.collectFirst { case GQLDeprecated(reason) => reason } + ) + }, + Some(info.full) + ) + } else { + annotations.collectFirst { case GQLInterface() => + () + }.fold( + makeUnion( + Some(getName(annotations, info)), + getDescription(annotations), + subTypes.map { case (_, t, _) => fixEmptyUnionObject(t) }, + Some(info.full) + ) + ) { _ => + val impl = subTypes.map(_._2.copy(interfaces = () => Some(List(toType(isInput, isSubscription))))) + val commonFields = () => impl + .flatMap(_.fields(__DeprecatedArgs(Some(true)))) + .flatten + .groupBy(_.name) + .collect { + case (name, list) + if impl.forall(_.fields(__DeprecatedArgs(Some(true))).getOrElse(Nil).exists(_.name == name)) && + list.map(t => Types.name(t.`type`())).distinct.length == 1 => + list.headOption + } + .flatten + .toList + + makeInterface(Some(getName(annotations, info)), getDescription(annotations), commonFields, impl, Some(info.full)) + } + } + } + + def resolve(value: A): Step[R] = { + val (label, _, schema, _) = members(m.ordinal(value)) + if (isEnum) PureStep(EnumValue(label)) else schema.resolve(value) + } + } + case m: Mirror.ProductOf[A] => + lazy val fields = recurse[m.MirroredElemLabels, m.MirroredElemTypes]() + lazy val info = Macros.typeInfo[A] + lazy val annotations = Macros.annotations[A] + lazy val paramAnnotations = Macros.paramAnnotations[A].toMap + new Schema[R, A] { + def toType(isInput: Boolean, isSubscription: Boolean): __Type = + if (isInput) + makeInputObject( + Some(annotations.collectFirst { case GQLInputName(suffix) => suffix } + .getOrElse(customizeInputTypeName(getName(annotations, info)))), + getDescription(annotations), + fields + .map { case (label, _, schema, _) => + val fieldAnnotations = paramAnnotations.getOrElse(label, Nil) + __InputValue( + getName(paramAnnotations.getOrElse(label, Nil), label), + getDescription(fieldAnnotations), + () => + if (schema.optional) schema.toType_(isInput, isSubscription) + else makeNonNull(schema.toType_(isInput, isSubscription)), + None, + Some(fieldAnnotations.collect { case GQLDirective(dir) => dir }).filter(_.nonEmpty) + ) + }, + Some(info.full) + ) + else + makeObject( + Some(getName(annotations, info)), + getDescription(annotations), + fields + .map { case (label, _, schema, _) => + val fieldAnnotations = paramAnnotations.getOrElse(label, Nil) + __Field( + getName(fieldAnnotations, label), + getDescription(fieldAnnotations), + schema.arguments, + () => + if (schema.optional) schema.toType_(isInput, isSubscription) + else makeNonNull(schema.toType_(isInput, isSubscription)), + fieldAnnotations.collectFirst { case GQLDeprecated(_) => () }.isDefined, + fieldAnnotations.collectFirst { case GQLDeprecated(reason) => reason }, + Option(fieldAnnotations.collect { case GQLDirective(dir) => dir }).filter(_.nonEmpty) + ) + }, + getDirectives(annotations), + Some(info.full) + ) + + def resolve(value: A): Step[R] = + if (fields.isEmpty) PureStep(EnumValue(getName(annotations, info))) + else { + val fieldsBuilder = Map.newBuilder[String, Step[R]] + fields.foreach { case (label, _, schema, index) => + val fieldAnnotations = paramAnnotations.getOrElse(label, Nil) + fieldsBuilder += getName(fieldAnnotations, label) -> schema.resolve(value.asInstanceOf[Product].productElement(index)) + } + ObjectStep(getName(annotations, info), fieldsBuilder.result()) + } + } + } + + // see https://github.com/graphql/graphql-spec/issues/568 + private def fixEmptyUnionObject(t: __Type): __Type = + t.fields(__DeprecatedArgs(Some(true))) match { + case Some(Nil) => + t.copy( + fields = (_: __DeprecatedArgs) => + Some( + List( + __Field( + "_", + Some( + "Fake field because GraphQL does not support empty objects. Do not query, use __typename instead." + ), + Nil, + () => makeScalar("Boolean") + ) + ) + ) + ) + case _ => t + } + + private def getName(annotations: Seq[Any], info: TypeInfo): String = + annotations.collectFirst { case GQLName(name) => name }.getOrElse { + info.typeParams match { + case Nil => info.short + case args => info.short + args.map(getName(Nil, _)).mkString + } + } + + private def getName(annotations: Seq[Any], label: String): String = + annotations.collectFirst { case GQLName(name) => name }.getOrElse(label) + + private def getDescription(annotations: Seq[Any]): Option[String] = + annotations.collectFirst { case GQLDescription(desc) => desc } + + private def getDirectives(annotations: Seq[Any]): List[Directive] = + annotations.collect { case GQLDirective(dir) => dir }.toList + + inline given gen[A]: Schema[R, A] = derived +} diff --git a/core/src/main/scala-3/caliban/schema/SubscriptionSchemaDerivation.scala b/core/src/main/scala-3/caliban/schema/SubscriptionSchemaDerivation.scala new file mode 100644 index 0000000000..e6e069943c --- /dev/null +++ b/core/src/main/scala-3/caliban/schema/SubscriptionSchemaDerivation.scala @@ -0,0 +1,23 @@ +package caliban.schema + +import scala.deriving.Mirror +import scala.compiletime._ + +trait SubscriptionSchemaDerivation { + inline def checkParams[T <: Tuple]: Unit = + inline erasedValue[T] match { + case _: EmptyTuple => () + case _: (t *: ts) => + summonInline[SubscriptionSchema[t]] + checkParams[ts] + } + + inline def derived[A]: SubscriptionSchema[A] = + inline summonInline[Mirror.ProductOf[A]] match { + case m: Mirror.ProductOf[A] => + checkParams[m.MirroredElemTypes] + new SubscriptionSchema[A] {} + } + + inline given gen[A]: SubscriptionSchema[A] = derived +} diff --git a/core/src/main/scala-3/caliban/schema/macros/Macros.scala b/core/src/main/scala-3/caliban/schema/macros/Macros.scala new file mode 100644 index 0000000000..73b2de0869 --- /dev/null +++ b/core/src/main/scala-3/caliban/schema/macros/Macros.scala @@ -0,0 +1,63 @@ +package caliban.schema.macros + +import scala.quoted.* + +private[caliban] object Macros { + // this code was inspired from WIP in magnolia + // https://github.com/propensive/magnolia/blob/b937cf2c7dabebb8236e7e948f37a354777fa9b7/src/core/macro.scala + + inline def annotations[T]: List[Any] = ${annotationsImpl[T]} + inline def paramAnnotations[T]: List[(String, List[Any])] = ${paramAnnotationsImpl[T]} + inline def typeInfo[T]: TypeInfo = ${typeInfoImpl[T]} + + def annotationsImpl[T: Type](using qctx: Quotes): Expr[List[Any]] = { + import qctx.reflect.* + val tpe = TypeRepr.of[T] + Expr.ofList { + tpe.typeSymbol.annotations.filter { a => + a.tpe.typeSymbol.maybeOwner.isNoSymbol || a.tpe.typeSymbol.owner.fullName != "scala.annotation.internal" + }.map(_.asExpr.asInstanceOf[Expr[Any]]) + } + } + + def paramAnnotationsImpl[T: Type](using qctx: Quotes): Expr[List[(String, List[Any])]] = { + import qctx.reflect.* + val tpe = TypeRepr.of[T] + Expr.ofList { + tpe.typeSymbol.primaryConstructor.paramSymss.flatten.map { field => + Expr(field.name) -> field.annotations.filter { a => + a.tpe.typeSymbol.maybeOwner.isNoSymbol || + a.tpe.typeSymbol.owner.fullName != "scala.annotation.internal" + }.map(_.asExpr.asInstanceOf[Expr[Any]]) + }.filter(_._2.nonEmpty).map { (name, anns) => Expr.ofTuple(name, Expr.ofList(anns)) } + } + } + + def typeInfoImpl[T: Type](using qctx: Quotes): Expr[TypeInfo] = { + import qctx.reflect._ + + def normalizedName(s: Symbol): String = if s.flags.is(Flags.Module) then s.name.stripSuffix("$") else s.name + def name(tpe: TypeRepr) : Expr[String] = Expr(normalizedName(tpe.typeSymbol)) + + def owner(tpe: TypeRepr): Expr[String] = { + def loop(s: Symbol): String = + if s.maybeOwner.isNoSymbol then "" + else if (s.owner == defn.EmptyPackageClass) "" + else if (s.owner == defn.RootClass) "" + else { + val parent = loop(s.owner) + val self = normalizedName(s.owner) + if(parent.isEmpty) self else s"$parent.$self" + } + Expr(loop(tpe.typeSymbol)) + } + + def typeInfo(tpe: TypeRepr): Expr[TypeInfo] = tpe match + case AppliedType(tpe, args) => + '{TypeInfo(${owner(tpe)}, ${name(tpe)}, ${Expr.ofList(args.map(typeInfo))})} + case _ => + '{TypeInfo(${owner(tpe)}, ${name(tpe)}, Nil)} + + typeInfo(TypeRepr.of[T]) + } +} diff --git a/core/src/main/scala-3/caliban/schema/macros/TypeInfo.scala b/core/src/main/scala-3/caliban/schema/macros/TypeInfo.scala new file mode 100644 index 0000000000..592842117d --- /dev/null +++ b/core/src/main/scala-3/caliban/schema/macros/TypeInfo.scala @@ -0,0 +1,5 @@ +package caliban.schema.macros + +case class TypeInfo(owner: String, short: String, typeParams: Iterable[TypeInfo]) { + def full: String = s"$owner.$short" +} diff --git a/core/src/main/scala/caliban/CalibanError.scala b/core/src/main/scala/caliban/CalibanError.scala index 591634cbda..336a572bc5 100644 --- a/core/src/main/scala/caliban/CalibanError.scala +++ b/core/src/main/scala/caliban/CalibanError.scala @@ -2,8 +2,6 @@ package caliban import caliban.ResponseValue.ObjectValue import caliban.interop.circe.IsCirceEncoder -import caliban.interop.play.IsPlayJsonWrites -import caliban.interop.zio.IsZIOJsonEncoder import caliban.parsing.adt.LocationInfo /** @@ -14,7 +12,7 @@ sealed trait CalibanError extends Throwable with Product with Serializable { override def getMessage: String = msg } -object CalibanError { +object CalibanError extends CalibanErrorJsonCompat { /** * Describes an error that happened while parsing a query. @@ -57,10 +55,4 @@ object CalibanError { implicit def circeEncoder[F[_]](implicit ev: IsCirceEncoder[F]): F[CalibanError] = caliban.interop.circe.json.ErrorCirce.errorValueEncoder.asInstanceOf[F[CalibanError]] - - implicit def playJsonWrites[F[_]](implicit ev: IsPlayJsonWrites[F]): F[CalibanError] = - caliban.interop.play.json.ErrorPlayJson.errorValueWrites.asInstanceOf[F[CalibanError]] - - implicit def zioJsonEncoder[F[_]](implicit ev: IsZIOJsonEncoder[F]): F[CalibanError] = - caliban.interop.zio.ErrorZioJson.errorValueEncoder.asInstanceOf[F[CalibanError]] } diff --git a/core/src/main/scala/caliban/GraphQLRequest.scala b/core/src/main/scala/caliban/GraphQLRequest.scala index 6fef082f5b..220bf954f2 100644 --- a/core/src/main/scala/caliban/GraphQLRequest.scala +++ b/core/src/main/scala/caliban/GraphQLRequest.scala @@ -3,8 +3,6 @@ package caliban import caliban.GraphQLRequest.{ `apollo-federation-include-trace`, ftv1 } import caliban.Value.StringValue import caliban.interop.circe.IsCirceDecoder -import caliban.interop.play.IsPlayJsonReads -import caliban.interop.zio.IsZIOJsonDecoder /** * Represents a GraphQL request, containing a query, an operation name and a map of variables. @@ -19,18 +17,14 @@ case class GraphQLRequest( def withExtension(key: String, value: InputValue): GraphQLRequest = copy(extensions = Some(extensions.foldLeft(Map(key -> value))(_ ++ _))) - def withFederatedTracing = + def withFederatedTracing: GraphQLRequest = withExtension(`apollo-federation-include-trace`, StringValue(ftv1)) } -object GraphQLRequest { - implicit def circeDecoder[F[_]: IsCirceDecoder]: F[GraphQLRequest] = +object GraphQLRequest extends GraphQLRequestJsonCompat { + implicit def circeDecoder[F[_]: IsCirceDecoder]: F[GraphQLRequest] = caliban.interop.circe.json.GraphQLRequestCirce.graphQLRequestDecoder.asInstanceOf[F[GraphQLRequest]] - implicit def playJsonReads[F[_]: IsPlayJsonReads]: F[GraphQLRequest] = - caliban.interop.play.json.GraphQLRequestPlayJson.graphQLRequestReads.asInstanceOf[F[GraphQLRequest]] - implicit def zioJsonDecoder[F[_]: IsZIOJsonDecoder]: F[GraphQLRequest] = - caliban.interop.zio.GraphQLRequestZioJson.graphQLRequestDecoder.asInstanceOf[F[GraphQLRequest]] private[caliban] val ftv1 = "ftv1" private[caliban] val `apollo-federation-include-trace` = "apollo-federation-include-trace" diff --git a/core/src/main/scala/caliban/GraphQLResponse.scala b/core/src/main/scala/caliban/GraphQLResponse.scala index 498ccfcc40..3bfc512877 100644 --- a/core/src/main/scala/caliban/GraphQLResponse.scala +++ b/core/src/main/scala/caliban/GraphQLResponse.scala @@ -2,19 +2,13 @@ package caliban import caliban.ResponseValue.ObjectValue import caliban.interop.circe._ -import caliban.interop.play._ -import caliban.interop.zio.IsZIOJsonEncoder /** * 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], extensions: Option[ObjectValue] = None) -object GraphQLResponse { - implicit def circeEncoder[F[_]: IsCirceEncoder, E]: F[GraphQLResponse[E]] = +object GraphQLResponse extends GraphQLResponseJsonCompat { + implicit def circeEncoder[F[_]: IsCirceEncoder, E]: F[GraphQLResponse[E]] = caliban.interop.circe.json.GraphQLResponseCirce.graphQLResponseEncoder.asInstanceOf[F[GraphQLResponse[E]]] - implicit def playJsonWrites[F[_]: IsPlayJsonWrites, E]: F[GraphQLResponse[E]] = - caliban.interop.play.json.GraphQLResponsePlayJson.graphQLResponseWrites.asInstanceOf[F[GraphQLResponse[E]]] - implicit def zioJsonEncoder[F[_]: IsZIOJsonEncoder, E]: F[GraphQLResponse[E]] = - caliban.interop.zio.GraphQLResponseZioJson.graphQLResponseEncoder.asInstanceOf[F[GraphQLResponse[E]]] } diff --git a/core/src/main/scala/caliban/Value.scala b/core/src/main/scala/caliban/Value.scala index 0adf7611ce..bf2a5c1f5d 100644 --- a/core/src/main/scala/caliban/Value.scala +++ b/core/src/main/scala/caliban/Value.scala @@ -2,12 +2,10 @@ package caliban import scala.util.Try import caliban.interop.circe._ -import caliban.interop.play.{ IsPlayJsonReads, IsPlayJsonWrites } -import caliban.interop.zio.{ IsZIOJsonDecoder, IsZIOJsonEncoder } import zio.stream.Stream sealed trait InputValue -object InputValue { +object InputValue extends ValueJsonCompat { case class ListValue(values: List[InputValue]) extends InputValue { override def toString: String = values.mkString("[", ",", "]") } @@ -23,20 +21,10 @@ object InputValue { caliban.interop.circe.json.ValueCirce.inputValueEncoder.asInstanceOf[F[InputValue]] implicit def circeDecoder[F[_]: IsCirceDecoder]: F[InputValue] = caliban.interop.circe.json.ValueCirce.inputValueDecoder.asInstanceOf[F[InputValue]] - - implicit def playJsonWrites[F[_]: IsPlayJsonWrites]: F[InputValue] = - caliban.interop.play.json.ValuePlayJson.inputValueWrites.asInstanceOf[F[InputValue]] - implicit def playJsonReads[F[_]: IsPlayJsonReads]: F[InputValue] = - caliban.interop.play.json.ValuePlayJson.inputValueReads.asInstanceOf[F[InputValue]] - - implicit def zioJsonEncoder[F[_]: IsZIOJsonEncoder]: F[InputValue] = - caliban.interop.zio.ValueZIOJson.inputValueEncoder.asInstanceOf[F[InputValue]] - implicit def zioJsonDecoder[F[_]: IsZIOJsonDecoder]: F[InputValue] = - caliban.interop.zio.ValueZIOJson.inputValueDecoder.asInstanceOf[F[InputValue]] } sealed trait ResponseValue -object ResponseValue { +object ResponseValue extends ValueJsonCompat { case class ListValue(values: List[ResponseValue]) extends ResponseValue { override def toString: String = values.mkString("[", ",", "]") } @@ -52,17 +40,6 @@ object ResponseValue { caliban.interop.circe.json.ValueCirce.responseValueEncoder.asInstanceOf[F[ResponseValue]] implicit def circeDecoder[F[_]: IsCirceDecoder]: F[ResponseValue] = caliban.interop.circe.json.ValueCirce.responseValueDecoder.asInstanceOf[F[ResponseValue]] - - implicit def playJsonWrites[F[_]: IsPlayJsonWrites]: F[ResponseValue] = - caliban.interop.play.json.ValuePlayJson.responseValueWrites.asInstanceOf[F[ResponseValue]] - implicit def playJsonReads[F[_]: IsPlayJsonReads]: F[ResponseValue] = - caliban.interop.play.json.ValuePlayJson.responseValueReads.asInstanceOf[F[ResponseValue]] - - implicit def zioJsonEncoder[F[_]: IsZIOJsonEncoder]: F[ResponseValue] = - caliban.interop.zio.ValueZIOJson.responseValueEncoder.asInstanceOf[F[ResponseValue]] - implicit def zioJsonDecoder[F[_]: IsZIOJsonDecoder]: F[ResponseValue] = - caliban.interop.zio.ValueZIOJson.responseValueDecoder.asInstanceOf[F[ResponseValue]] - } sealed trait Value extends InputValue with ResponseValue diff --git a/core/src/main/scala/caliban/execution/Executor.scala b/core/src/main/scala/caliban/execution/Executor.scala index 6871305604..e141435590 100644 --- a/core/src/main/scala/caliban/execution/Executor.scala +++ b/core/src/main/scala/caliban/execution/Executor.scala @@ -126,7 +126,7 @@ object Executor { wrappers match { case Nil => query case wrapper :: tail => - val q = if (isPure && !wrapper.wrapPureValues) query else wrapper.f(query, fieldInfo) + val q = if (isPure && !wrapper.wrapPureValues) query else wrapper.wrap(query, fieldInfo) wrap(q, isPure)(tail, fieldInfo) } diff --git a/core/src/main/scala/caliban/interop/circe/circe.scala b/core/src/main/scala/caliban/interop/circe/circe.scala index be552936d6..9b2437b014 100644 --- a/core/src/main/scala/caliban/interop/circe/circe.scala +++ b/core/src/main/scala/caliban/interop/circe/circe.scala @@ -33,7 +33,7 @@ object json { implicit val jsonSchema: Schema[Any, Json] = new Schema[Any, Json] { override def toType(isInput: Boolean, isSubscription: Boolean): __Type = makeScalar("Json") override def resolve(value: Json): Step[Any] = - QueryStep(ZQuery.fromEffect(ZIO.fromEither(Decoder[ResponseValue].decodeJson(value))).map(PureStep)) + QueryStep(ZQuery.fromEffect(ZIO.fromEither(Decoder[ResponseValue].decodeJson(value))).map(PureStep.apply)) } implicit val jsonArgBuilder: ArgBuilder[Json] = (input: InputValue) => Right(Encoder[InputValue].apply(input)) @@ -61,12 +61,12 @@ object json { private def jsonToInputValue(json: Json): InputValue = json.fold( NullValue, - BooleanValue, + BooleanValue.apply, number => number.toBigInt.map(IntValue.apply) orElse number.toBigDecimal.map(FloatValue.apply) getOrElse FloatValue(number.toDouble), - StringValue, + StringValue.apply, array => InputValue.ListValue(array.toList.map(jsonToInputValue)), obj => InputValue.ObjectValue(obj.toMap.map { case (k, v) => k -> jsonToInputValue(v) }) ) @@ -82,12 +82,12 @@ object json { private def jsonToResponseValue(json: Json): ResponseValue = json.fold( NullValue, - BooleanValue, + BooleanValue.apply, number => number.toBigInt.map(IntValue.apply) orElse number.toBigDecimal.map(FloatValue.apply) getOrElse FloatValue(number.toDouble), - StringValue, + StringValue.apply, array => ResponseValue.ListValue(array.toList.map(jsonToResponseValue)), obj => ResponseValue.ObjectValue(obj.toList.map { case (k, v) => k -> jsonToResponseValue(v) }) ) diff --git a/core/src/main/scala/caliban/introspection/Introspector.scala b/core/src/main/scala/caliban/introspection/Introspector.scala index a84c625170..9635ec1fe2 100644 --- a/core/src/main/scala/caliban/introspection/Introspector.scala +++ b/core/src/main/scala/caliban/introspection/Introspector.scala @@ -13,10 +13,7 @@ import zio.query.ZQuery import scala.annotation.tailrec -object Introspector { - - implicit lazy val typeSchema: Schema[Any, __Type] = Schema.gen[__Type] - +object Introspector extends IntrospectionDerivation { private[caliban] val directives = List( __Directive( "skip", @@ -50,7 +47,7 @@ object Introspector { )(wrappers: List[IntrospectionWrapper[R]]): ZIO[R, ExecutionError, __Introspection] = wrappers match { case Nil => query - case wrapper :: tail => wrap(wrapper.f(query))(tail) + case wrapper :: tail => wrap(wrapper.wrap(query))(tail) } val types = rootType.types.updated("Boolean", Types.boolean).values.toList.sortBy(_.name.getOrElse("")) @@ -65,7 +62,6 @@ object Introspector { args => types.find(_.name.contains(args.name)) ) - val introspectionSchema = Schema.gen[__Introspection] RootSchema( Operation( introspectionSchema.toType_(), diff --git a/core/src/main/scala/caliban/schema/ArgBuilder.scala b/core/src/main/scala/caliban/schema/ArgBuilder.scala index ab817b8ff5..45547ad2fe 100644 --- a/core/src/main/scala/caliban/schema/ArgBuilder.scala +++ b/core/src/main/scala/caliban/schema/ArgBuilder.scala @@ -1,21 +1,17 @@ package caliban.schema +import caliban.CalibanError.ExecutionError +import caliban.InputValue +import caliban.Value._ +import zio.Chunk + import java.time.format.DateTimeFormatter import java.time.temporal.Temporal -import java.time.{ Instant, LocalDate, LocalDateTime, LocalTime, OffsetDateTime, OffsetTime, ZonedDateTime } +import java.time._ import java.util.UUID - import scala.annotation.implicitNotFound -import scala.language.experimental.macros import scala.util.Try import scala.util.control.NonFatal -import caliban.CalibanError.ExecutionError -import caliban.InputValue -import caliban.Value._ -import caliban.schema.Annotations.GQLName -import magnolia._ -import mercator.Monadic -import zio.Chunk /** * Typeclass that defines how to build an argument of type `T` from an input [[caliban.InputValue]]. @@ -68,10 +64,7 @@ trait ArgBuilder[T] { self => orElse(fallback) } -object ArgBuilder { - - type Typeclass[T] = ArgBuilder[T] - +object ArgBuilder extends ArgBuilderDerivation { implicit lazy val unit: ArgBuilder[Unit] = _ => Right(()) implicit lazy val int: ArgBuilder[Int] = { case value: IntValue => Right(value.toInt) @@ -187,49 +180,4 @@ object ArgBuilder { implicit def set[A](implicit ev: ArgBuilder[A]): ArgBuilder[Set[A]] = list[A].map(_.toSet) implicit def vector[A](implicit ev: ArgBuilder[A]): ArgBuilder[Vector[A]] = list[A].map(_.toVector) implicit def chunk[A](implicit ev: ArgBuilder[A]): ArgBuilder[Chunk[A]] = list[A].map(Chunk.fromIterable) - - type EitherExecutionError[A] = Either[ExecutionError, A] - - implicit val eitherMonadic: Monadic[EitherExecutionError] = new Monadic[EitherExecutionError] { - override def flatMap[A, B](from: EitherExecutionError[A])( - fn: A => EitherExecutionError[B] - ): EitherExecutionError[B] = from.flatMap(fn) - - override def point[A](value: A): EitherExecutionError[A] = Right(value) - - override def map[A, B](from: EitherExecutionError[A])(fn: A => B): EitherExecutionError[B] = from.map(fn) - } - - def combine[T](ctx: CaseClass[ArgBuilder, T]): ArgBuilder[T] = - (input: InputValue) => { - ctx.constructMonadic { p => - input match { - case InputValue.ObjectValue(fields) => - val label = p.annotations.collectFirst { case GQLName(name) => name }.getOrElse(p.label) - p.typeclass.build(fields.getOrElse(label, NullValue)) - case value => p.typeclass.build(value) - } - } - } - - def dispatch[T](ctx: SealedTrait[ArgBuilder, T]): ArgBuilder[T] = input => { - (input match { - case EnumValue(value) => Some(value) - case StringValue(value) => Some(value) - case _ => None - }) match { - case Some(value) => - ctx.subtypes - .find(t => - t.annotations.collectFirst { case GQLName(name) => name }.contains(value) || t.typeName.short == value - ) match { - case Some(subtype) => subtype.typeclass.build(InputValue.ObjectValue(Map())) - case None => Left(ExecutionError(s"Invalid value $value for trait ${ctx.typeName.short}")) - } - case None => Left(ExecutionError(s"Can't build a trait from input $input")) - } - } - - implicit def gen[T]: Typeclass[T] = macro Magnolia.gen[T] - } diff --git a/core/src/main/scala/caliban/schema/Schema.scala b/core/src/main/scala/caliban/schema/Schema.scala index f2f5d0d740..a5b5e4ec8f 100644 --- a/core/src/main/scala/caliban/schema/Schema.scala +++ b/core/src/main/scala/caliban/schema/Schema.scala @@ -1,27 +1,24 @@ package caliban.schema -import java.time.{ Instant, LocalDate, LocalDateTime, LocalTime, OffsetDateTime, ZoneOffset, ZonedDateTime } -import java.time.format.DateTimeFormatter -import java.time.temporal.Temporal -import java.util.UUID import caliban.CalibanError.ExecutionError import caliban.ResponseValue._ import caliban.Value._ import caliban.execution.Field import caliban.introspection.adt._ import caliban.parsing.adt.Directive -import caliban.schema.Annotations._ import caliban.schema.Step._ import caliban.schema.Types._ import caliban.{ InputValue, ResponseValue } -import magnolia._ import zio.query.ZQuery import zio.stream.ZStream import zio.{ Chunk, URIO, ZIO } +import java.time._ +import java.time.format.DateTimeFormatter +import java.time.temporal.Temporal +import java.util.UUID import scala.annotation.implicitNotFound import scala.concurrent.Future -import scala.language.experimental.macros /** * Typeclass that defines how to map the type `T` to the according GraphQL concepts: how to introspect it and how to resolve it. @@ -119,7 +116,7 @@ trait Schema[-R, T] { self => object Schema extends GenericSchema[Any] -trait GenericSchema[R] extends DerivationSchema[R] with TemporalSchema { +trait GenericSchema[R] extends SchemaDerivation[R] with TemporalSchema { /** * Creates a scalar schema for a type `A` @@ -225,8 +222,8 @@ trait GenericSchema[R] extends DerivationSchema[R] with TemporalSchema { ) implicit val unitSchema: Schema[Any, Unit] = scalarSchema("Unit", None, _ => ObjectValue(Nil)) - implicit val booleanSchema: Schema[Any, Boolean] = scalarSchema("Boolean", None, BooleanValue) - implicit val stringSchema: Schema[Any, String] = scalarSchema("String", None, StringValue) + implicit val booleanSchema: Schema[Any, Boolean] = scalarSchema("Boolean", None, BooleanValue.apply) + implicit val stringSchema: Schema[Any, String] = scalarSchema("String", None, StringValue.apply) implicit val uuidSchema: Schema[Any, UUID] = scalarSchema("ID", None, uuid => StringValue(uuid.toString)) implicit val intSchema: Schema[Any, Int] = scalarSchema("Int", None, IntValue(_)) implicit val longSchema: Schema[Any, Long] = scalarSchema("Long", None, IntValue(_)) @@ -422,217 +419,6 @@ trait GenericSchema[R] extends DerivationSchema[R] with TemporalSchema { } -trait DerivationSchema[R] extends LowPriorityDerivedSchema { - - /** - * Default naming logic for input types. - * This is needed to avoid a name clash between a type used as an input and the same type used as an output. - * GraphQL needs 2 different types, and they can't have the same name. - * By default, we add the "Input" suffix after the type name. - */ - def customizeInputTypeName(name: String): String = s"${name}Input" - - type Typeclass[T] = Schema[R, T] - - def combine[T](ctx: ReadOnlyCaseClass[Typeclass, T]): Typeclass[T] = new Typeclass[T] { - override def toType(isInput: Boolean, isSubscription: Boolean): __Type = - if (ctx.isValueClass && ctx.parameters.nonEmpty) ctx.parameters.head.typeclass.toType_(isInput, isSubscription) - else if (isInput) - makeInputObject( - Some(ctx.annotations.collectFirst { case GQLInputName(suffix) => suffix } - .getOrElse(customizeInputTypeName(getName(ctx)))), - getDescription(ctx), - ctx.parameters - .map(p => - __InputValue( - getName(p), - getDescription(p), - () => - if (p.typeclass.optional) p.typeclass.toType_(isInput, isSubscription) - else makeNonNull(p.typeclass.toType_(isInput, isSubscription)), - None, - Some(p.annotations.collect { case GQLDirective(dir) => dir }.toList).filter(_.nonEmpty) - ) - ) - .toList, - Some(ctx.typeName.full) - ) - else - makeObject( - Some(getName(ctx)), - getDescription(ctx), - ctx.parameters - .map(p => - __Field( - getName(p), - getDescription(p), - p.typeclass.arguments, - () => - if (p.typeclass.optional) p.typeclass.toType_(isInput, isSubscription) - else makeNonNull(p.typeclass.toType_(isInput, isSubscription)), - p.annotations.collectFirst { case GQLDeprecated(_) => () }.isDefined, - p.annotations.collectFirst { case GQLDeprecated(reason) => reason }, - Option(p.annotations.collect { case GQLDirective(dir) => dir }.toList).filter(_.nonEmpty) - ) - ) - .toList, - getDirectives(ctx), - Some(ctx.typeName.full) - ) - - override def resolve(value: T): Step[R] = - if (ctx.isObject) PureStep(EnumValue(getName(ctx))) - else if (ctx.isValueClass && ctx.parameters.nonEmpty) { - val head = ctx.parameters.head - head.typeclass.resolve(head.dereference(value)) - } else { - val fields = Map.newBuilder[String, Step[R]] - ctx.parameters.foreach(p => fields += getName(p) -> p.typeclass.resolve(p.dereference(value))) - ObjectStep(getName(ctx), fields.result()) - } - } - - def dispatch[T](ctx: SealedTrait[Typeclass, T]): Typeclass[T] = new Typeclass[T] { - override def toType(isInput: Boolean, isSubscription: Boolean): __Type = { - val subtypes = - ctx.subtypes - .map(s => s.typeclass.toType_(isInput = false, isSubscription = false) -> s.annotations) - .toList - .sortBy { case (tpe, _) => - tpe.name.getOrElse("") - } - val isEnum = subtypes.forall { - case (t, _) - if t.fields(__DeprecatedArgs(Some(true))).forall(_.isEmpty) - && t.inputFields.forall(_.isEmpty) => - true - case _ => false - } - if (isEnum && subtypes.nonEmpty) - makeEnum( - Some(getName(ctx)), - getDescription(ctx), - subtypes.collect { case (__Type(_, Some(name), description, _, _, _, _, _, _, _, _), annotations) => - __EnumValue( - name, - description, - annotations.collectFirst { case GQLDeprecated(_) => () }.isDefined, - annotations.collectFirst { case GQLDeprecated(reason) => reason } - ) - }, - Some(ctx.typeName.full) - ) - else { - ctx.annotations.collectFirst { case GQLInterface() => - () - }.fold( - makeUnion( - Some(getName(ctx)), - getDescription(ctx), - subtypes.map { case (t, _) => fixEmptyUnionObject(t) }, - Some(ctx.typeName.full) - ) - ) { _ => - val impl = subtypes.map(_._1.copy(interfaces = () => Some(List(toType(isInput, isSubscription))))) - val commonFields = () => - impl - .flatMap(_.fields(__DeprecatedArgs(Some(true)))) - .flatten - .groupBy(_.name) - .collect { - case (name, list) - if impl.forall(_.fields(__DeprecatedArgs(Some(true))).getOrElse(Nil).exists(_.name == name)) && - list.map(t => Types.name(t.`type`())).distinct.length == 1 => - list.headOption - } - .flatten - .toList - - makeInterface(Some(getName(ctx)), getDescription(ctx), commonFields, impl, Some(ctx.typeName.full)) - } - } - } - - // see https://github.com/graphql/graphql-spec/issues/568 - private def fixEmptyUnionObject(t: __Type): __Type = - t.fields(__DeprecatedArgs(Some(true))) match { - case Some(Nil) => - t.copy( - fields = (_: __DeprecatedArgs) => - Some( - List( - __Field( - "_", - Some( - "Fake field because GraphQL does not support empty objects. Do not query, use __typename instead." - ), - Nil, - () => makeScalar("Boolean") - ) - ) - ) - ) - case _ => t - } - - override def resolve(value: T): Step[R] = - ctx.dispatch(value)(subType => subType.typeclass.resolve(subType.cast(value))) - } - - private def getDirectives(annotations: Seq[Any]): List[Directive] = - annotations.collect { case GQLDirective(dir) => dir }.toList - - private def getDirectives[Typeclass[_], Type](ctx: ReadOnlyCaseClass[Typeclass, Type]): List[Directive] = - getDirectives(ctx.annotations) - - private def getName(annotations: Seq[Any], typeName: TypeName): String = - annotations.collectFirst { case GQLName(name) => name }.getOrElse { - typeName.typeArguments match { - case Nil => typeName.short - case args => typeName.short + args.map(getName(Nil, _)).mkString - } - } - - private def getName[Typeclass[_], Type](ctx: ReadOnlyCaseClass[Typeclass, Type]): String = - getName(ctx.annotations, ctx.typeName) - - private def getName[Typeclass[_], Type](ctx: SealedTrait[Typeclass, Type]): String = - getName(ctx.annotations, ctx.typeName) - - private def getName[Typeclass[_], Type](ctx: ReadOnlyParam[Typeclass, Type]): String = - ctx.annotations.collectFirst { case GQLName(name) => name }.getOrElse(ctx.label) - - private def getDescription(annotations: Seq[Any]): Option[String] = - annotations.collectFirst { case GQLDescription(desc) => desc } - - private def getDescription[Typeclass[_], Type](ctx: ReadOnlyCaseClass[Typeclass, Type]): Option[String] = - getDescription(ctx.annotations) - - private def getDescription[Typeclass[_], Type](ctx: SealedTrait[Typeclass, Type]): Option[String] = - getDescription(ctx.annotations) - - private def getDescription[Typeclass[_], Type](ctx: ReadOnlyParam[Typeclass, Type]): Option[String] = - getDescription(ctx.annotations) - - /** - * Generates an instance of `Schema` for the given type T. - * This should be used only if T is a case class or a sealed trait. - */ - implicit def genMacro[T]: Derived[Typeclass[T]] = macro DerivedMagnolia.derivedMagnolia[Typeclass, T] - - /** - * Returns an instance of `Schema` for the given type T. - * For a case class or sealed trait, you can call `genMacro[T].schema` instead to get more details if the - * schema can't be derived. - */ - def gen[T](implicit derived: Derived[Schema[R, T]]): Schema[R, T] = derived.schema - -} - -private[schema] trait LowPriorityDerivedSchema { - implicit def derivedSchema[R, T](implicit derived: Derived[Schema[R, T]]): Schema[R, T] = derived.schema -} - trait TemporalSchema { private[schema] abstract class TemporalSchema[T <: Temporal]( diff --git a/core/src/main/scala/caliban/schema/SubscriptionSchema.scala b/core/src/main/scala/caliban/schema/SubscriptionSchema.scala index 36a0ebeaaf..79c67afc93 100644 --- a/core/src/main/scala/caliban/schema/SubscriptionSchema.scala +++ b/core/src/main/scala/caliban/schema/SubscriptionSchema.scala @@ -1,26 +1,17 @@ package caliban.schema -import scala.language.experimental.macros - -import magnolia._ import zio.stream.ZStream /** * Typeclass used to guarantee that the Subscriptions type is either `Unit` or a case class with `zio.stream.ZStream` for fields. */ -trait SubscriptionSchema[-T] - -object SubscriptionSchema { - - type Typeclass[T] = SubscriptionSchema[T] - - implicit val unitSubscriptionSchema: Typeclass[Unit] = new Typeclass[Unit] {} - implicit def streamSubscriptionSchema[R, E, A]: Typeclass[ZStream[R, E, A]] = new Typeclass[ZStream[R, E, A]] {} - implicit def functionSubscriptionSchema[R, E, A, ARG]: Typeclass[ARG => ZStream[R, E, A]] = - new Typeclass[ARG => ZStream[R, E, A]] {} - - def combine[T](ctx: CaseClass[SubscriptionSchema, T]): Typeclass[T] = new Typeclass[T] {} +trait SubscriptionSchema[T] - implicit def gen[T]: Typeclass[T] = macro Magnolia.gen[T] +object SubscriptionSchema extends SubscriptionSchemaDerivation { + implicit val unitSubscriptionSchema: SubscriptionSchema[Unit] = new SubscriptionSchema[Unit] {} + implicit def streamSubscriptionSchema[R, E, A]: SubscriptionSchema[ZStream[R, E, A]] = + new SubscriptionSchema[ZStream[R, E, A]] {} + implicit def functionSubscriptionSchema[R, E, A, ARG]: SubscriptionSchema[ARG => ZStream[R, E, A]] = + new SubscriptionSchema[ARG => ZStream[R, E, A]] {} } diff --git a/core/src/main/scala/caliban/validation/Validator.scala b/core/src/main/scala/caliban/validation/Validator.scala index a3f28cffdc..1b30e678b8 100644 --- a/core/src/main/scala/caliban/validation/Validator.scala +++ b/core/src/main/scala/caliban/validation/Validator.scala @@ -800,18 +800,21 @@ object Validator { .collectFirst { case (_, f :: _ :: _) => f } .fold[IO[ValidationError, Unit]](IO.unit)(duplicate => failValidation(messageBuilder(duplicate), explanatoryText)) - private[caliban] def doesNotStartWithUnderscore(field: __Field, errorContext: String) = { + private[caliban] def doesNotStartWithUnderscore(field: __Field, errorContext: String): IO[ValidationError, Unit] = { val explanatory = s"""The field must not have a name which begins with the characters {"__"} (two underscores)""" doesNotStartWithUnderscore[__Field](field, _.name, errorContext, explanatory) } - private[caliban] def doesNotStartWithUnderscore(inputValue: __InputValue, errorContext: String) = { + private[caliban] def doesNotStartWithUnderscore( + inputValue: __InputValue, + errorContext: String + ): IO[ValidationError, Unit] = { val explanatory = s"""The input field must not have a name which begins with the characters "__" (two underscores)""" doesNotStartWithUnderscore[__InputValue](inputValue, _.name, errorContext, explanatory) } - private def doesNotStartWithUnderscore(directive: Directive, errorContext: String) = { + private def doesNotStartWithUnderscore(directive: Directive, errorContext: String): IO[ValidationError, Unit] = { val explanatory = s"""The directive must not have a name which begins with the characters "__" (two underscores)""" doesNotStartWithUnderscore[Directive](directive, _.name, errorContext, explanatory) diff --git a/core/src/main/scala/caliban/wrappers/ApolloCaching.scala b/core/src/main/scala/caliban/wrappers/ApolloCaching.scala index 74d0fbb79a..5a5f7d4e52 100644 --- a/core/src/main/scala/caliban/wrappers/ApolloCaching.scala +++ b/core/src/main/scala/caliban/wrappers/ApolloCaching.scala @@ -1,14 +1,17 @@ package caliban.wrappers -import java.util.concurrent.TimeUnit -import caliban.{ GraphQLRequest, ResponseValue } +import caliban.CalibanError.ExecutionError import caliban.ResponseValue.{ ListValue, ObjectValue } import caliban.Value.{ EnumValue, IntValue, StringValue } +import caliban.execution.FieldInfo import caliban.parsing.adt.Directive import caliban.wrappers.Wrapper.{ EffectfulWrapper, FieldWrapper, OverallWrapper } -import zio.Ref +import caliban.{ CalibanError, GraphQLRequest, GraphQLResponse, ResponseValue } import zio.duration.Duration import zio.query.ZQuery +import zio.{ Ref, ZIO } + +import java.util.concurrent.TimeUnit /** * Returns a wrapper which applies apollo caching response extensions @@ -59,7 +62,7 @@ object ApolloCaching { def toResponseValue: ResponseValue = ObjectValue( List( - "path" -> ListValue((Left(fieldName) :: path).reverse.map(_.fold(StringValue, IntValue(_)))), + "path" -> ListValue((Left(fieldName) :: path).reverse.map(_.fold(StringValue.apply, IntValue(_)))), "maxAge" -> IntValue(maxAge.toMillis / 1000), "scope" -> StringValue(scope match { case CacheScope.Private => "PRIVATE" @@ -97,24 +100,31 @@ object ApolloCaching { } private def apolloCachingOverall(ref: Ref[Caching]): OverallWrapper[Any] = - OverallWrapper { process => (request: GraphQLRequest) => - for { - result <- process(request) - cache <- ref.get - } yield result.copy( - extensions = Some( - ObjectValue( - ("cacheControl" -> cache.toResponseValue) :: result.extensions.fold( - List.empty[(String, ResponseValue)] - )(_.fields) + new OverallWrapper[Any] { + def wrap[R1 <: Any]( + process: GraphQLRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] + ): GraphQLRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] = + (request: GraphQLRequest) => + for { + result <- process(request) + cache <- ref.get + } yield result.copy( + extensions = Some( + ObjectValue( + ("cacheControl" -> cache.toResponseValue) :: result.extensions.fold( + List.empty[(String, ResponseValue)] + )(_.fields) + ) + ) ) - ) - ) } private def apolloCachingField(ref: Ref[Caching]): FieldWrapper[Any] = - FieldWrapper( - { case (query, fieldInfo) => + new FieldWrapper(true) { + def wrap[R1 <: Any]( + query: ZQuery[R1, ExecutionError, ResponseValue], + fieldInfo: FieldInfo + ): ZQuery[R1, ExecutionError, ResponseValue] = { val cacheDirectives = extractCacheDirective( fieldInfo.directives ++ fieldInfo.details.fieldType.ofType.flatMap(_.directives).getOrElse(Nil) ) @@ -133,8 +143,6 @@ object ApolloCaching { ) ) } - }, - wrapPureValues = true - ) - + } + } } diff --git a/core/src/main/scala/caliban/wrappers/ApolloPersistedQueries.scala b/core/src/main/scala/caliban/wrappers/ApolloPersistedQueries.scala index b25e75d991..0aede24302 100644 --- a/core/src/main/scala/caliban/wrappers/ApolloPersistedQueries.scala +++ b/core/src/main/scala/caliban/wrappers/ApolloPersistedQueries.scala @@ -3,8 +3,8 @@ package caliban.wrappers import caliban.CalibanError.ValidationError import caliban.Value.{ NullValue, StringValue } import caliban.wrappers.Wrapper.OverallWrapper -import caliban.{ GraphQLRequest, GraphQLResponse, InputValue } -import zio.{ Has, Layer, Ref, UIO, ZIO, ZLayer } +import caliban.{ CalibanError, GraphQLRequest, GraphQLResponse, InputValue } +import zio.{ Has, Layer, Ref, UIO, ZIO } object ApolloPersistedQueries { @@ -24,31 +24,35 @@ object ApolloPersistedQueries { } } - val live: Layer[Nothing, ApolloPersistence] = ZLayer.fromEffect(Service.live) + val live: Layer[Nothing, ApolloPersistence] = Service.live.toLayer /** * Returns a wrapper that persists and retrieves queries based on a hash * following Apollo Persisted Queries spec: https://github.com/apollographql/apollo-link-persisted-queries. */ val apolloPersistedQueries: OverallWrapper[ApolloPersistence] = - OverallWrapper { process => (request: GraphQLRequest) => - readHash(request) match { - case Some(hash) => - ZIO - .accessM[ApolloPersistence](_.get.get(hash)) - .flatMap { - case Some(query) => UIO(request.copy(query = Some(query))) - case None => - request.query match { - case Some(value) => ZIO.accessM[ApolloPersistence](_.get.add(hash, value)).as(request) - case None => ZIO.fail(ValidationError("PersistedQueryNotFound", "")) - } + new OverallWrapper[ApolloPersistence] { + def wrap[R1 <: ApolloPersistence]( + process: GraphQLRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] + ): GraphQLRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] = + (request: GraphQLRequest) => + readHash(request) match { + case Some(hash) => + ZIO + .accessM[ApolloPersistence](_.get.get(hash)) + .flatMap { + case Some(query) => UIO(request.copy(query = Some(query))) + case None => + request.query match { + case Some(value) => ZIO.accessM[ApolloPersistence](_.get.add(hash, value)).as(request) + case None => ZIO.fail(ValidationError("PersistedQueryNotFound", "")) + } - } - .flatMap(process) - .catchAll(ex => UIO(GraphQLResponse(NullValue, List(ex)))) - case None => process(request) - } + } + .flatMap(process) + .catchAll(ex => UIO(GraphQLResponse(NullValue, List(ex)))) + case None => process(request) + } } private def readHash(request: GraphQLRequest): Option[String] = diff --git a/core/src/main/scala/caliban/wrappers/ApolloTracing.scala b/core/src/main/scala/caliban/wrappers/ApolloTracing.scala index e3cc774367..4ebe901a9b 100644 --- a/core/src/main/scala/caliban/wrappers/ApolloTracing.scala +++ b/core/src/main/scala/caliban/wrappers/ApolloTracing.scala @@ -5,10 +5,11 @@ import java.time.{ Instant, ZoneId } import java.util.concurrent.TimeUnit import caliban.ResponseValue.{ ListValue, ObjectValue } import caliban.Value.{ IntValue, StringValue } +import caliban.execution.{ ExecutionRequest, FieldInfo } import caliban.parsing.adt.Document import caliban.wrappers.Wrapper.{ EffectfulWrapper, FieldWrapper, OverallWrapper, ParsingWrapper, ValidationWrapper } -import caliban.{ GraphQLRequest, Rendering, ResponseValue } -import zio.{ clock, Ref } +import caliban.{ CalibanError, GraphQLRequest, GraphQLResponse, Rendering, ResponseValue } +import zio.{ clock, Ref, ZIO } import zio.clock.Clock import zio.duration.Duration import zio.query.ZQuery @@ -103,57 +104,75 @@ object ApolloTracing { } private def apolloTracingOverall(ref: Ref[Tracing]): OverallWrapper[Clock] = - OverallWrapper { process => (request: GraphQLRequest) => - for { - nanoTime <- clock.nanoTime - currentTime <- clock.currentTime(TimeUnit.MILLISECONDS) - _ <- ref.update(_.copy(startTime = currentTime, startTimeMonotonic = nanoTime)) - result <- process(request).timed.flatMap { case (duration, result) => - for { - endTime <- clock.currentTime(TimeUnit.MILLISECONDS) - _ <- ref.update(_.copy(duration = duration, endTime = endTime)) - tracing <- ref.get - } yield result.copy( - extensions = Some( - ObjectValue( - ("tracing" -> tracing.toResponseValue) :: - result.extensions.fold(List.empty[(String, ResponseValue)])(_.fields) + new OverallWrapper[Clock] { + def wrap[R1 <: Clock]( + process: GraphQLRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] + ): GraphQLRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] = + (request: GraphQLRequest) => + for { + nanoTime <- clock.nanoTime + currentTime <- clock.currentTime(TimeUnit.MILLISECONDS) + _ <- ref.update(_.copy(startTime = currentTime, startTimeMonotonic = nanoTime)) + result <- process(request).timed.flatMap { case (duration, result) => + for { + endTime <- clock.currentTime(TimeUnit.MILLISECONDS) + _ <- ref.update(_.copy(duration = duration, endTime = endTime)) + tracing <- ref.get + } yield result.copy( + extensions = Some( + ObjectValue( + ("tracing" -> tracing.toResponseValue) :: + result.extensions.fold(List.empty[(String, ResponseValue)])(_.fields) + ) + ) ) - ) - ) - } - } yield result + } + } yield result } private def apolloTracingParsing(ref: Ref[Tracing]): ParsingWrapper[Clock] = - ParsingWrapper { process => (query: String) => - for { - start <- clock.nanoTime - (duration, result) <- process(query).timed - _ <- ref.update(state => - state.copy( - parsing = state.parsing.copy(startOffset = start - state.startTimeMonotonic, duration = duration) - ) - ) - } yield result + new ParsingWrapper[Clock] { + def wrap[R1 <: Clock]( + process: String => ZIO[R1, CalibanError.ParsingError, Document] + ): String => ZIO[R1, CalibanError.ParsingError, Document] = + (query: String) => + for { + start <- clock.nanoTime + resultWithDuration <- process(query).timed + (duration, result) = resultWithDuration + _ <- ref.update(state => + state.copy( + parsing = state.parsing.copy(startOffset = start - state.startTimeMonotonic, duration = duration) + ) + ) + } yield result } private def apolloTracingValidation(ref: Ref[Tracing]): ValidationWrapper[Clock] = - ValidationWrapper { process => (doc: Document) => - for { - start <- clock.nanoTime - (duration, result) <- process(doc).timed - _ <- ref.update(state => - state.copy( - validation = state.validation.copy(startOffset = start - state.startTimeMonotonic, duration = duration) - ) - ) - } yield result + new ValidationWrapper[Clock] { + def wrap[R1 <: Clock]( + process: Document => ZIO[R1, CalibanError.ValidationError, ExecutionRequest] + ): Document => ZIO[R1, CalibanError.ValidationError, ExecutionRequest] = + (doc: Document) => + for { + start <- clock.nanoTime + resultWithDuration <- process(doc).timed + (duration, result) = resultWithDuration + _ <- ref.update(state => + state.copy( + validation = + state.validation.copy(startOffset = start - state.startTimeMonotonic, duration = duration) + ) + ) + } yield result } private def apolloTracingField(ref: Ref[Tracing]): FieldWrapper[Clock] = - FieldWrapper( - { case (query, fieldInfo) => + new FieldWrapper[Clock](true) { + def wrap[R1 <: Clock]( + query: ZQuery[R1, CalibanError.ExecutionError, ResponseValue], + fieldInfo: FieldInfo + ): ZQuery[R1, CalibanError.ExecutionError, ResponseValue] = for { summarized <- query.summarized(clock.nanoTime)((_, _)) ((start, end), result) = summarized @@ -176,8 +195,6 @@ object ApolloTracing { ) ) } yield result - }, - wrapPureValues = true - ) + } } diff --git a/core/src/main/scala/caliban/wrappers/Wrapper.scala b/core/src/main/scala/caliban/wrappers/Wrapper.scala index 147104e3be..15ef81f83e 100644 --- a/core/src/main/scala/caliban/wrappers/Wrapper.scala +++ b/core/src/main/scala/caliban/wrappers/Wrapper.scala @@ -31,34 +31,37 @@ object Wrapper { * `WrappingFunction[R, E, A, Info]` is an alias for a function that transforms an initial function * from `Info` to `ZIO[R, E, A]` into a new function from `Info` to `ZIO[R, E, A]`. */ - type WrappingFunction[R, E, A, Info] = (Info => ZIO[R, E, A]) => Info => ZIO[R, E, A] + trait WrappingFunction[-R, E, A, Info] { + def wrap[R1 <: R](f: Info => ZIO[R1, E, A]): Info => ZIO[R1, E, A] + } + + sealed trait SimpleWrapper[-R, E, A, Info] extends Wrapper[R] { + def wrap[R1 <: R](f: Info => ZIO[R1, E, A]): Info => ZIO[R1, E, A] + } /** * Wrapper for the whole query processing. * Wraps a function from a request `GraphQLRequest` to a `URIO[R, GraphQLResponse[CalibanError]]`. */ - case class OverallWrapper[R](f: WrappingFunction[R, Nothing, GraphQLResponse[CalibanError], GraphQLRequest]) - extends Wrapper[R] + trait OverallWrapper[-R] extends SimpleWrapper[R, Nothing, GraphQLResponse[CalibanError], GraphQLRequest] /** * Wrapper for the query parsing stage. * Wraps a function from a query `String` to a `ZIO[R, ParsingError, Document]`. */ - case class ParsingWrapper[R](f: WrappingFunction[R, ParsingError, Document, String]) extends Wrapper[R] + trait ParsingWrapper[-R] extends SimpleWrapper[R, ParsingError, Document, String] /** * Wrapper for the query validation stage. * Wraps a function from a `Document` to a `ZIO[R, ValidationError, ExecutionRequest]`. */ - case class ValidationWrapper[R](f: WrappingFunction[R, ValidationError, ExecutionRequest, Document]) - extends Wrapper[R] + trait ValidationWrapper[-R] extends SimpleWrapper[R, ValidationError, ExecutionRequest, Document] /** * Wrapper for the query execution stage. * Wraps a function from an `ExecutionRequest` to a `URIO[R, GraphQLResponse[CalibanError]]`. */ - case class ExecutionWrapper[R](f: WrappingFunction[R, Nothing, GraphQLResponse[CalibanError], ExecutionRequest]) - extends Wrapper[R] + trait ExecutionWrapper[-R] extends SimpleWrapper[R, Nothing, GraphQLResponse[CalibanError], ExecutionRequest] /** * Wrapper for each individual field. @@ -67,19 +70,23 @@ object Wrapper { * If `wrapPureValues` is true, every single field will be wrapped, which could have an impact on performances. * If false, simple pure values will be ignored. */ - case class FieldWrapper[R]( - f: (ZQuery[R, ExecutionError, ResponseValue], FieldInfo) => ZQuery[R, ExecutionError, ResponseValue], - wrapPureValues: Boolean = false - ) extends Wrapper[R] + abstract class FieldWrapper[-R](val wrapPureValues: Boolean = false) extends Wrapper[R] { + def wrap[R1 <: R]( + query: ZQuery[R1, ExecutionError, ResponseValue], + info: FieldInfo + ): ZQuery[R1, ExecutionError, ResponseValue] + } + + trait EffectWrappingFunction[-R] /** * Wrapper for the introspection query processing. * Takes a function from a `ZIO[R, ExecutionError, __Introspection]` and that returns a * `ZIO[R, ExecutionError, __Introspection]`. */ - case class IntrospectionWrapper[R]( - f: ZIO[R, ExecutionError, __Introspection] => ZIO[R, ExecutionError, __Introspection] - ) extends Wrapper[R] + trait IntrospectionWrapper[-R] extends Wrapper[R] { + def wrap[R1 <: R](effect: ZIO[R1, ExecutionError, __Introspection]): ZIO[R1, ExecutionError, __Introspection] + } /** * Wrapper that combines multiple wrappers. @@ -101,40 +108,40 @@ object Wrapper { private[caliban] def wrap[R1 >: R, R, E, A, Info]( process: Info => ZIO[R1, E, A] - )(wrappers: List[WrappingFunction[R, E, A, Info]], info: Info): ZIO[R, E, A] = { + )(wrappers: List[SimpleWrapper[R, E, A, Info]], info: Info): ZIO[R, E, A] = { @tailrec - def loop(process: Info => ZIO[R, E, A], wrappers: List[WrappingFunction[R, E, A, Info]]): Info => ZIO[R, E, A] = + def loop(process: Info => ZIO[R, E, A], wrappers: List[SimpleWrapper[R, E, A, Info]]): Info => ZIO[R, E, A] = wrappers match { case Nil => process - case wrapper :: tail => loop(wrapper((info: Info) => process(info)), tail) + case wrapper :: tail => loop(wrapper.wrap((info: Info) => process(info)), tail) } loop(process, wrappers)(info) } private[caliban] def decompose[R](wrappers: List[Wrapper[R]]): UIO[ ( - List[WrappingFunction[R, Nothing, GraphQLResponse[CalibanError], GraphQLRequest]], - List[WrappingFunction[R, ParsingError, Document, String]], - List[WrappingFunction[R, ValidationError, ExecutionRequest, Document]], - List[WrappingFunction[R, Nothing, GraphQLResponse[CalibanError], ExecutionRequest]], + List[OverallWrapper[R]], + List[ParsingWrapper[R]], + List[ValidationWrapper[R]], + List[ExecutionWrapper[R]], List[FieldWrapper[R]], List[IntrospectionWrapper[R]] ) ] = ZIO.foldLeft(wrappers)( ( - List.empty[WrappingFunction[R, Nothing, GraphQLResponse[CalibanError], GraphQLRequest]], - List.empty[WrappingFunction[R, ParsingError, Document, String]], - List.empty[WrappingFunction[R, ValidationError, ExecutionRequest, Document]], - List.empty[WrappingFunction[R, Nothing, GraphQLResponse[CalibanError], ExecutionRequest]], + List.empty[OverallWrapper[R]], + List.empty[ParsingWrapper[R]], + List.empty[ValidationWrapper[R]], + List.empty[ExecutionWrapper[R]], List.empty[FieldWrapper[R]], List.empty[IntrospectionWrapper[R]] ) ) { - case ((o, p, v, e, f, i), wrapper: OverallWrapper[R]) => UIO.succeed((wrapper.f :: o, p, v, e, f, i)) - case ((o, p, v, e, f, i), wrapper: ParsingWrapper[R]) => UIO.succeed((o, wrapper.f :: p, v, e, f, i)) - case ((o, p, v, e, f, i), wrapper: ValidationWrapper[R]) => UIO.succeed((o, p, wrapper.f :: v, e, f, i)) - case ((o, p, v, e, f, i), wrapper: ExecutionWrapper[R]) => UIO.succeed((o, p, v, wrapper.f :: e, f, i)) + case ((o, p, v, e, f, i), wrapper: OverallWrapper[R]) => UIO.succeed((wrapper :: o, p, v, e, f, i)) + case ((o, p, v, e, f, i), wrapper: ParsingWrapper[R]) => UIO.succeed((o, wrapper :: p, v, e, f, i)) + case ((o, p, v, e, f, i), wrapper: ValidationWrapper[R]) => UIO.succeed((o, p, wrapper :: v, e, f, i)) + case ((o, p, v, e, f, i), wrapper: ExecutionWrapper[R]) => UIO.succeed((o, p, v, wrapper :: e, f, i)) case ((o, p, v, e, f, i), wrapper: FieldWrapper[R]) => UIO.succeed((o, p, v, e, wrapper :: f, i)) case ((o, p, v, e, f, i), wrapper: IntrospectionWrapper[R]) => UIO.succeed((o, p, v, e, f, wrapper :: i)) case ((o, p, v, e, f, i), CombinedWrapper(wrappers)) => diff --git a/core/src/main/scala/caliban/wrappers/Wrappers.scala b/core/src/main/scala/caliban/wrappers/Wrappers.scala index 7a1c57ee51..43b7b3e678 100644 --- a/core/src/main/scala/caliban/wrappers/Wrappers.scala +++ b/core/src/main/scala/caliban/wrappers/Wrappers.scala @@ -2,10 +2,10 @@ package caliban.wrappers import caliban.CalibanError.{ ExecutionError, ValidationError } import caliban.Value.NullValue -import caliban.execution.Field +import caliban.execution.{ ExecutionRequest, Field } import caliban.parsing.adt.Document import caliban.wrappers.Wrapper.{ OverallWrapper, ValidationWrapper } -import caliban.{ GraphQLRequest, GraphQLResponse } +import caliban.{ CalibanError, GraphQLRequest, GraphQLResponse } import zio.clock.Clock import zio.console.{ putStrLn, putStrLnErr, Console } import zio.duration._ @@ -19,12 +19,16 @@ object Wrappers { * Returns a wrapper that prints errors to the console */ lazy val printErrors: OverallWrapper[Console] = - OverallWrapper { process => request => - process(request).tap(response => - ZIO.when(response.errors.nonEmpty)( - putStrLnErr(response.errors.flatMap(prettyStackStrace).mkString("", "\n", "\n")) - ) - ) + new OverallWrapper[Console] { + def wrap[R1 <: Console]( + process: GraphQLRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] + ): GraphQLRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] = + request => + process(request).tap(response => + ZIO.when(response.errors.nonEmpty)( + putStrLnErr(response.errors.flatMap(prettyStackStrace).mkString("", "\n", "\n")) + ) + ) } private def prettyStackStrace(t: Throwable): Chunk[String] = { @@ -46,10 +50,14 @@ object Wrappers { * @param duration threshold above which queries are considered slow */ def onSlowQueries[R](duration: Duration)(f: (Duration, String) => URIO[R, Any]): OverallWrapper[R with Clock] = - OverallWrapper { process => (request: GraphQLRequest) => - process(request).timed.flatMap { case (time, res) => - ZIO.when(time > duration)(f(time, request.query.getOrElse(""))).as(res) - } + new OverallWrapper[R with Clock] { + def wrap[R1 <: R with Clock]( + process: GraphQLRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] + ): GraphQLRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] = + (request: GraphQLRequest) => + process(request).timed.flatMap { case (time, res) => + ZIO.when(time > duration)(f(time, request.query.getOrElse(""))).as(res) + } } /** @@ -57,21 +65,25 @@ object Wrappers { * @param duration threshold above which queries should be timed out */ def timeout(duration: Duration): OverallWrapper[Clock] = - OverallWrapper { process => (request: GraphQLRequest) => - process(request) - .timeout(duration) - .map( - _.getOrElse( - GraphQLResponse( - NullValue, - List( - ExecutionError( - s"Query was interrupted after timeout of ${duration.render}:\n${request.query.getOrElse("")}" + new OverallWrapper[Clock] { + def wrap[R1 <: Clock]( + process: GraphQLRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] + ): GraphQLRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] = + (request: GraphQLRequest) => + process(request) + .timeout(duration) + .map( + _.getOrElse( + GraphQLResponse( + NullValue, + List( + ExecutionError( + s"Query was interrupted after timeout of ${duration.render}:\n${request.query.getOrElse("")}" + ) + ) ) ) ) - ) - ) } /** @@ -79,14 +91,18 @@ object Wrappers { * @param maxDepth the max allowed depth */ def maxDepth(maxDepth: Int): ValidationWrapper[Any] = - ValidationWrapper { process => (doc: Document) => - for { - req <- process(doc) - depth <- calculateDepth(req.field) - _ <- IO.when(depth > maxDepth)( - IO.fail(ValidationError(s"Query is too deep: $depth. Max depth: $maxDepth.", "")) - ) - } yield req + new ValidationWrapper[Any] { + def wrap[R1 <: Any]( + process: Document => ZIO[R1, ValidationError, ExecutionRequest] + ): Document => ZIO[R1, ValidationError, ExecutionRequest] = + (doc: Document) => + for { + req <- process(doc) + depth <- calculateDepth(req.field) + _ <- IO.when(depth > maxDepth)( + IO.fail(ValidationError(s"Query is too deep: $depth. Max depth: $maxDepth.", "")) + ) + } yield req } private def calculateDepth(field: Field): UIO[Int] = { @@ -105,14 +121,18 @@ object Wrappers { * @param maxFields the max allowed number of fields */ def maxFields(maxFields: Int): ValidationWrapper[Any] = - ValidationWrapper { process => (doc: Document) => - for { - req <- process(doc) - fields <- countFields(req.field) - _ <- IO.when(fields > maxFields)( - IO.fail(ValidationError(s"Query has too many fields: $fields. Max fields: $maxFields.", "")) - ) - } yield req + new ValidationWrapper[Any] { + def wrap[R1 <: Any]( + process: Document => ZIO[R1, ValidationError, ExecutionRequest] + ): Document => ZIO[R1, ValidationError, ExecutionRequest] = + (doc: Document) => + for { + req <- process(doc) + fields <- countFields(req.field) + _ <- IO.when(fields > maxFields)( + IO.fail(ValidationError(s"Query has too many fields: $fields. Max fields: $maxFields.", "")) + ) + } yield req } private def countFields(field: Field): UIO[Int] = diff --git a/core/src/test/scala-2/caliban/Scala2SpecificSpec.scala b/core/src/test/scala-2/caliban/Scala2SpecificSpec.scala new file mode 100644 index 0000000000..18fe87fd6b --- /dev/null +++ b/core/src/test/scala-2/caliban/Scala2SpecificSpec.scala @@ -0,0 +1,44 @@ +package caliban + +import caliban.GraphQL._ +import caliban.TestUtils._ +import caliban.introspection.adt.__DeprecatedArgs +import caliban.schema.SchemaSpec.introspect +import zio.test.Assertion._ +import zio.test._ +import zio.test.environment.TestEnvironment + +object Scala2SpecificSpec extends DefaultRunnableSpec { + + override def spec: ZSpec[TestEnvironment, Any] = + suite("Scala2SpecificSpec")( + test("value classes should unwrap") { + case class Queries(organizationId: OrganizationId, painter: WrappedPainter) + val fieldTypes = introspect[Queries].fields(__DeprecatedArgs()).toList.flatten.map(_.`type`()) + assert(fieldTypes.map(_.ofType.flatMap(_.name)))(equalTo(Some("Long") :: Some("Painter") :: Nil)) + }, + testM("value classes") { + case class Queries(events: List[Event], painters: List[WrappedPainter]) + val event = Event(OrganizationId(7), "Frida Kahlo exhibition") + val painter = Painter("Claude Monet", "Impressionism") + val api = graphQL(RootResolver(Queries(event :: Nil, WrappedPainter(painter) :: Nil))) + val interpreter = api.interpreter + val query = + """query { + | events { + | organizationId + | title + | } + | painters { + | name + | movement + | } + |}""".stripMargin + assertM(interpreter.flatMap(_.execute(query)).map(_.data.toString))( + equalTo( + """{"events":[{"organizationId":7,"title":"Frida Kahlo exhibition"}],"painters":[{"name":"Claude Monet","movement":"Impressionism"}]}""" + ) + ) + } + ) +} diff --git a/core/src/test/scala-2/caliban/interop/play/ExecutionSpec.scala b/core/src/test/scala-2/caliban/interop/play/ExecutionSpec.scala new file mode 100644 index 0000000000..deaa434279 --- /dev/null +++ b/core/src/test/scala-2/caliban/interop/play/ExecutionSpec.scala @@ -0,0 +1,28 @@ +package caliban.interop.play + +import caliban.GraphQL._ +import caliban.Macros.gqldoc +import caliban.RootResolver +import zio.test.Assertion._ +import zio.test._ +import zio.test.environment.TestEnvironment + +object ExecutionSpec extends DefaultRunnableSpec { + + override def spec: ZSpec[TestEnvironment, Any] = + suite("Play ExecutionSpec")( + testM("Play Json scalar") { + import caliban.interop.play.json._ + import play.api.libs.json._ + case class Queries(test: JsValue) + + val interpreter = graphQL(RootResolver(Queries(Json.obj(("a", JsNumber(333)))))).interpreter + val query = gqldoc(""" + { + test + }""") + + assertM(interpreter.flatMap(_.execute(query)).map(_.data.toString))(equalTo("""{"test":{"a":333}}""")) + } + ) +} diff --git a/core/src/test/scala/caliban/interop/play/GraphQLRequestPlaySpec.scala b/core/src/test/scala-2/caliban/interop/play/GraphQLRequestPlaySpec.scala similarity index 100% rename from core/src/test/scala/caliban/interop/play/GraphQLRequestPlaySpec.scala rename to core/src/test/scala-2/caliban/interop/play/GraphQLRequestPlaySpec.scala diff --git a/core/src/test/scala/caliban/interop/play/GraphQLResponsePlaySpec.scala b/core/src/test/scala-2/caliban/interop/play/GraphQLResponsePlaySpec.scala similarity index 100% rename from core/src/test/scala/caliban/interop/play/GraphQLResponsePlaySpec.scala rename to core/src/test/scala-2/caliban/interop/play/GraphQLResponsePlaySpec.scala diff --git a/core/src/test/scala-2/caliban/interop/play/SchemaSpec.scala b/core/src/test/scala-2/caliban/interop/play/SchemaSpec.scala new file mode 100644 index 0000000000..df09a0b4d5 --- /dev/null +++ b/core/src/test/scala-2/caliban/interop/play/SchemaSpec.scala @@ -0,0 +1,25 @@ +package caliban.interop.play + +import caliban.introspection.adt.{ __DeprecatedArgs, __Type } +import caliban.schema.Schema +import play.api.libs.json.JsValue +import zio.test.Assertion._ +import zio.test._ +import zio.test.environment.TestEnvironment + +object SchemaSpec extends DefaultRunnableSpec { + + override def spec: ZSpec[TestEnvironment, Any] = + suite("Play SchemaSpec")( + test("field with Json object [play]") { + import caliban.interop.play.json._ + case class Queries(to: JsValue, from: JsValue => Unit) + + assert(introspect[Queries].fields(__DeprecatedArgs()).toList.flatten.headOption.map(_.`type`()))( + isSome(hasField[__Type, String]("to", _.ofType.flatMap(_.name).get, equalTo("Json"))) + ) + } + ) + + def introspect[Q](implicit schema: Schema[Any, Q]): __Type = schema.toType_() +} diff --git a/core/src/test/scala/caliban/interop/zio/GraphQLRequestZIOSpec.scala b/core/src/test/scala-2/caliban/interop/zio/GraphQLRequestZIOSpec.scala similarity index 100% rename from core/src/test/scala/caliban/interop/zio/GraphQLRequestZIOSpec.scala rename to core/src/test/scala-2/caliban/interop/zio/GraphQLRequestZIOSpec.scala diff --git a/core/src/test/scala/caliban/interop/zio/GraphQLResponseZIOSpec.scala b/core/src/test/scala-2/caliban/interop/zio/GraphQLResponseZIOSpec.scala similarity index 100% rename from core/src/test/scala/caliban/interop/zio/GraphQLResponseZIOSpec.scala rename to core/src/test/scala-2/caliban/interop/zio/GraphQLResponseZIOSpec.scala diff --git a/core/src/test/scala-3/caliban/Scala3SpecificSpec.scala b/core/src/test/scala-3/caliban/Scala3SpecificSpec.scala new file mode 100644 index 0000000000..d6c76b2dca --- /dev/null +++ b/core/src/test/scala-3/caliban/Scala3SpecificSpec.scala @@ -0,0 +1,74 @@ +package caliban + +import caliban.GraphQL._ +import caliban.schema.Annotations.GQLInterface +import zio.test.Assertion._ +import zio.test._ +import zio.test.environment.TestEnvironment + +object Scala3SpecificSpec extends DefaultRunnableSpec { + + enum MyEnum { + case A, B, C + } + + enum MyADT { + case A(a: Int) + case B(b: String) + } + + @GQLInterface + enum MyADT2 { + case A(a: Int) + case B(a: Int) + } + + override def spec: ZSpec[TestEnvironment, Any] = + suite("Scala3SpecificSpec")( + testM("Scala 3 enum") { + case class Queries(item: MyEnum) + val api = graphQL(RootResolver(Queries(MyEnum.A))) + val interpreter = api.interpreter + val query = + """query { + | item + |}""".stripMargin + assertM(interpreter.flatMap(_.execute(query)).map(_.data.toString))( + equalTo("""{"item":"A"}""") + ) + }, + testM("Scala 3 union") { + case class Queries(item: MyADT) + val api = graphQL(RootResolver(Queries(MyADT.A(1)))) + val interpreter = api.interpreter + val query = + """query { + | item { + | ... on A { + | a + | } + | ... on B { + | b + | } + | } + |}""".stripMargin + assertM(interpreter.flatMap(_.execute(query)).map(_.data.toString))( + equalTo("""{"item":{"a":1}}""") + ) + }, + testM("Scala 3 interface") { + case class Queries(item: MyADT2) + val api = graphQL(RootResolver(Queries(MyADT2.A(1)))) + val interpreter = api.interpreter + val query = + """query { + | item { + | a + | } + |}""".stripMargin + assertM(interpreter.flatMap(_.execute(query)).map(_.data.toString))( + equalTo("""{"item":{"a":1}}""") + ) + } + ) +} diff --git a/core/src/test/scala/caliban/TestUtils.scala b/core/src/test/scala/caliban/TestUtils.scala index e5580bb5ab..f473a0799b 100644 --- a/core/src/test/scala/caliban/TestUtils.scala +++ b/core/src/test/scala/caliban/TestUtils.scala @@ -110,6 +110,11 @@ object TestUtils { case class SubscriptionIO(deleteCharacters: ZStream[Any, Nothing, String]) + implicit val querySchema: Schema[Any, Query] = Schema.gen + implicit val queryIOSchema: Schema[Any, QueryIO] = Schema.gen + implicit val mutationIOSchema: Schema[Any, MutationIO] = Schema.gen + implicit val subscriptionIOSchema: Schema[Any, SubscriptionIO] = Schema.gen + val resolver = RootResolver( Query( args => characters.filter(c => args.origin.forall(c.origin == _)), diff --git a/core/src/test/scala/caliban/execution/ExecutionSpec.scala b/core/src/test/scala/caliban/execution/ExecutionSpec.scala index ffc4dfc8c3..8b62334d1a 100644 --- a/core/src/test/scala/caliban/execution/ExecutionSpec.scala +++ b/core/src/test/scala/caliban/execution/ExecutionSpec.scala @@ -425,19 +425,6 @@ object ExecutionSpec extends DefaultRunnableSpec { assertM(interpreter.flatMap(_.execute(query)).map(_.data.toString))(equalTo("""{"test":{"a":333}}""")) }, - testM("Play Json scalar") { - import caliban.interop.play.json._ - import play.api.libs.json._ - case class Queries(test: JsValue) - - val interpreter = graphQL(RootResolver(Queries(Json.obj(("a", JsNumber(333)))))).interpreter - val query = gqldoc(""" - { - test - }""") - - assertM(interpreter.flatMap(_.execute(query)).map(_.data.toString))(equalTo("""{"test":{"a":333}}""")) - }, testM("test Interface") { case class Test(i: Interface) val interpreter = graphQL(RootResolver(Test(Interface.B("ok")))).interpreter @@ -556,29 +543,6 @@ object ExecutionSpec extends DefaultRunnableSpec { |}""".stripMargin assertM(interpreter.flatMap(_.execute(query)).map(_.data.toString))(equalTo("""{"test":1}""")) }, - testM("value classes") { - case class Queries(events: List[Event], painters: List[WrappedPainter]) - val event = Event(OrganizationId(7), "Frida Kahlo exhibition") - val painter = Painter("Claude Monet", "Impressionism") - val api = graphQL(RootResolver(Queries(event :: Nil, WrappedPainter(painter) :: Nil))) - val interpreter = api.interpreter - val query = - """query { - | events { - | organizationId - | title - | } - | painters { - | name - | movement - | } - |}""".stripMargin - assertM(interpreter.flatMap(_.execute(query)).map(_.data.toString))( - equalTo( - """{"events":[{"organizationId":7,"title":"Frida Kahlo exhibition"}],"painters":[{"name":"Claude Monet","movement":"Impressionism"}]}""" - ) - ) - }, testM("field name customization") { case class Query(@GQLName("test2") test: Int) val api = graphQL(RootResolver(Query(1))) @@ -660,12 +624,15 @@ object ExecutionSpec extends DefaultRunnableSpec { case object C extends A } case class Query(test: A) - val interpreter = graphQL(RootResolver(Query(A.C))).interpreter - val query = gqldoc(""" + implicit val schemaB: Schema[Any, A.B] = Schema.gen + implicit val schemaC: Schema[Any, A.C.type] = Schema.gen + implicit val schemaCharacter: Schema[Any, Character] = Schema.gen + val interpreter = graphQL(RootResolver(Query(A.C))).interpreter + val query = gqldoc(""" { test { ... on C { - _ + _ } ... on B { b @@ -731,6 +698,8 @@ object ExecutionSpec extends DefaultRunnableSpec { case class Query(search: SearchArgs => List[SearchResult]) object CustomSchema { + implicit val schemaHuman: Schema[Any, Character.Human] = Schema.gen + implicit val schemaDroid: Schema[Any, Character.Droid] = Schema.gen implicit val schemaSearchResult: Schema[Any, SearchResult] = eitherUnionSchema("SearchResult") implicit val schemaQuery: Schema[Any, Query] = Schema.gen[Query] } diff --git a/core/src/test/scala/caliban/introspection/IntrospectionSpec.scala b/core/src/test/scala/caliban/introspection/IntrospectionSpec.scala index b9a036ce00..2cd5b4226b 100644 --- a/core/src/test/scala/caliban/introspection/IntrospectionSpec.scala +++ b/core/src/test/scala/caliban/introspection/IntrospectionSpec.scala @@ -1,15 +1,17 @@ package caliban.introspection -import caliban.CalibanError.ValidationError -import caliban.Value._ -import caliban.{ GraphQLResponse } +import caliban.CalibanError.{ ExecutionError, ValidationError } import caliban.GraphQL._ +import caliban.GraphQLResponse import caliban.Macros.gqldoc import caliban.TestUtils._ +import caliban.Value._ +import caliban.introspection.adt.__Introspection import caliban.wrappers.Wrapper.IntrospectionWrapper +import zio.ZIO import zio.test.Assertion._ -import zio.test.environment.TestEnvironment import zio.test._ +import zio.test.environment.TestEnvironment object IntrospectionSpec extends DefaultRunnableSpec { @@ -127,19 +129,22 @@ object IntrospectionSpec extends DefaultRunnableSpec { ) }, testM("introspect schema with wrapper") { - val hideWrapper = IntrospectionWrapper[Any] { - _.map { intro => - intro.copy(__schema = - intro.__schema.copy( - types = intro.__schema.types.collect { - case ttp if ttp.name.contains("QueryIO") => - // hide all methods except first - ttp.copy(fields = ttp.fields.andThen(_.map(fields => fields.headOption.toList))) - case other => other - } + val hideWrapper = new IntrospectionWrapper[Any] { + def wrap[R1 <: Any]( + effect: ZIO[R1, ExecutionError, __Introspection] + ): ZIO[R1, ExecutionError, __Introspection] = + effect.map { intro => + intro.copy(__schema = + intro.__schema.copy( + types = intro.__schema.types.collect { + case ttp if ttp.name.contains("QueryIO") => + // hide all methods except first + ttp.copy(fields = ttp.fields.andThen(_.map(fields => fields.headOption.toList))) + case other => other + } + ) ) - ) - } + } } val interpreter = (graphQL(resolverIO) @@ hideWrapper).interpreter diff --git a/core/src/test/scala/caliban/parsing/ParserSpec.scala b/core/src/test/scala/caliban/parsing/ParserSpec.scala index a25ae88c70..7aa9329a8e 100644 --- a/core/src/test/scala/caliban/parsing/ParserSpec.scala +++ b/core/src/test/scala/caliban/parsing/ParserSpec.scala @@ -429,7 +429,7 @@ object ParserSpec extends DefaultRunnableSpec { | } |}""".stripMargin assertM(Parser.parseQuery(query).run)( - fails(equalTo(ParsingError("Position 4:3, found \"}\\n}\"", locationInfo = Some(LocationInfo(3, 4))))) + fails(isSubtype[ParsingError](hasField("locationInfo", _.locationInfo, isSome(equalTo(LocationInfo(3, 4)))))) ) }, testM("type") { diff --git a/core/src/test/scala/caliban/schema/SchemaSpec.scala b/core/src/test/scala/caliban/schema/SchemaSpec.scala index b10c531074..49e8b96cc4 100644 --- a/core/src/test/scala/caliban/schema/SchemaSpec.scala +++ b/core/src/test/scala/caliban/schema/SchemaSpec.scala @@ -2,10 +2,8 @@ package caliban.schema import java.util.UUID -import caliban.TestUtils.{ OrganizationId, WrappedPainter } import caliban.introspection.adt.{ __DeprecatedArgs, __Type, __TypeKind } import caliban.schema.Annotations.GQLInterface -import play.api.libs.json.JsValue import zio.blocking.Blocking import zio.console.Console import zio.query.ZQuery @@ -35,7 +33,7 @@ object SchemaSpec extends DefaultRunnableSpec { case class Field(value: ZQuery[Console, Nothing, String]) case class Queries(field: ZQuery[Blocking, Nothing, Field]) object MySchema extends GenericSchema[Console with Blocking] { - implicit lazy val queriesSchema = gen[Queries] + implicit lazy val queriesSchema: Schema[Console with Blocking, Queries] = gen } assert(MySchema.queriesSchema.toType_().fields(__DeprecatedArgs()).toList.flatten.headOption.map(_.`type`()))( isSome(hasField[__Type, __TypeKind]("kind", _.kind, equalTo(__TypeKind.NON_NULL))) @@ -63,20 +61,20 @@ object SchemaSpec extends DefaultRunnableSpec { ) }, test("nested types with explicit schema in companion object") { - object blockingSchema extends GenericSchema[Blocking] - import blockingSchema._ + object blockingSchema extends GenericSchema[Blocking] { - case class A(s: String) - object A { - implicit val aSchema: Schema[Blocking, A] = blockingSchema.gen[A] - } - case class B(a: List[Option[A]]) + case class A(s: String) + object A { + implicit val aSchema: Schema[Blocking, A] = gen[A] + } + case class B(a: List[Option[A]]) - A.aSchema.toType_() + A.aSchema.toType_() - val schema: Schema[Blocking, B] = blockingSchema.gen[B] + val schema: Schema[Blocking, B] = gen[B] + } - assert(Types.collectTypes(schema.toType_()).map(_.name.getOrElse("")))( + assert(Types.collectTypes(blockingSchema.schema.toType_()).map(_.name.getOrElse("")))( not(contains("SomeA")) && not(contains("OptionA")) && not(contains("None")) ) }, @@ -98,19 +96,6 @@ object SchemaSpec extends DefaultRunnableSpec { isSome(hasField[__Type, String]("to", _.ofType.flatMap(_.name).get, equalTo("Json"))) ) }, - test("field with Json object [play]") { - import caliban.interop.play.json._ - case class Queries(to: JsValue, from: JsValue => Unit) - - assert(introspect[Queries].fields(__DeprecatedArgs()).toList.flatten.headOption.map(_.`type`()))( - isSome(hasField[__Type, String]("to", _.ofType.flatMap(_.name).get, equalTo("Json"))) - ) - }, - test("value classes should unwrap") { - case class Queries(organizationId: OrganizationId, painter: WrappedPainter) - val fieldTypes = introspect[Queries].fields(__DeprecatedArgs()).toList.flatten.map(_.`type`()) - assert(fieldTypes.map(_.ofType.flatMap(_.name)))(equalTo(Some("Long") :: Some("Painter") :: Nil)) - }, test("ZStream in a Query returns a list type") { case class Query(a: ZStream[Any, Throwable, Int]) diff --git a/core/src/test/scala/caliban/validation/ValidationSpec.scala b/core/src/test/scala/caliban/validation/ValidationSpec.scala index 6df999b205..e5782f8ffa 100644 --- a/core/src/test/scala/caliban/validation/ValidationSpec.scala +++ b/core/src/test/scala/caliban/validation/ValidationSpec.scala @@ -29,7 +29,7 @@ object ValidationSpec extends DefaultRunnableSpec { name } } - + query a { characters { name @@ -65,7 +65,7 @@ object ValidationSpec extends DefaultRunnableSpec { ...f } } - + fragment f on Character { unknown }""") @@ -114,7 +114,7 @@ object ValidationSpec extends DefaultRunnableSpec { ...f } } - + fragment f on Character { name } @@ -160,7 +160,7 @@ object ValidationSpec extends DefaultRunnableSpec { name } } - + fragment f on Character { name }""") @@ -182,7 +182,7 @@ object ValidationSpec extends DefaultRunnableSpec { ...f1 } } - + fragment f1 on Character { ...f2 } diff --git a/core/src/test/scala/caliban/wrappers/WrappersSpec.scala b/core/src/test/scala/caliban/wrappers/WrappersSpec.scala index 671dde8138..6e67e3a4bc 100644 --- a/core/src/test/scala/caliban/wrappers/WrappersSpec.scala +++ b/core/src/test/scala/caliban/wrappers/WrappersSpec.scala @@ -6,14 +6,15 @@ import caliban.InputValue.ObjectValue import caliban.Macros.gqldoc import caliban.TestUtils.resolver import caliban.Value.StringValue +import caliban._ +import caliban.execution.{ ExecutionRequest, FieldInfo } import caliban.introspection.adt.{ __Directive, __DirectiveLocation } import caliban.schema.Annotations.GQLDirective -import caliban.schema.GenericSchema +import caliban.schema.{ GenericSchema, Schema } import caliban.wrappers.ApolloCaching.CacheControl import caliban.wrappers.ApolloPersistedQueries.apolloPersistedQueries import caliban.wrappers.Wrapper.{ ExecutionWrapper, FieldWrapper } import caliban.wrappers.Wrappers._ -import caliban._ import io.circe.syntax._ import zio.clock.Clock import zio.duration._ @@ -32,10 +33,13 @@ object WrappersSpec extends DefaultRunnableSpec { case class Test(a: Int, b: UIO[Int]) for { ref <- Ref.make[Int](0) - wrapper = FieldWrapper[Any]( - { case (query, _) => ZQuery.fromEffect(ref.update(_ + 1)) *> query }, - wrapPureValues = false - ) + wrapper = new FieldWrapper[Any](false) { + def wrap[R1 <: Any]( + query: ZQuery[R1, ExecutionError, ResponseValue], + info: FieldInfo + ): ZQuery[R1, ExecutionError, ResponseValue] = + ZQuery.fromEffect(ref.update(_ + 1)) *> query + } interpreter <- (graphQL(RootResolver(Test(1, UIO(2)))) @@ wrapper).interpreter.orDie query = gqldoc("""{ a b }""") _ <- interpreter.execute(query) @@ -46,10 +50,13 @@ object WrappersSpec extends DefaultRunnableSpec { case class Test(a: Int, b: UIO[Int]) for { ref <- Ref.make[Int](0) - wrapper = FieldWrapper[Any]( - { case (query, _) => ZQuery.fromEffect(ref.update(_ + 1)) *> query }, - wrapPureValues = true - ) + wrapper = new FieldWrapper[Any](true) { + def wrap[R1 <: Any]( + query: ZQuery[R1, ExecutionError, ResponseValue], + info: FieldInfo + ): ZQuery[R1, ExecutionError, ResponseValue] = + ZQuery.fromEffect(ref.update(_ + 1)) *> query + } interpreter <- (graphQL(RootResolver(Test(1, UIO(2)))) @@ wrapper).interpreter.orDie query = gqldoc("""{ a b }""") _ <- interpreter.execute(query) @@ -84,10 +91,10 @@ object WrappersSpec extends DefaultRunnableSpec { ...f } } - + fragment f on A { b { - c + c } } """) @@ -115,17 +122,17 @@ object WrappersSpec extends DefaultRunnableSpec { testM("Timeout") { case class Test(a: URIO[Clock, Int]) - object schema extends GenericSchema[Clock] - import schema._ + object schema extends GenericSchema[Clock] { + val interpreter = + (graphQL(RootResolver(Test(clock.sleep(2 minutes).as(0)))) @@ timeout(1 minute)).interpreter + } - val interpreter = - (graphQL(RootResolver(Test(clock.sleep(2 minutes).as(0)))) @@ timeout(1 minute)).interpreter - val query = gqldoc(""" + val query = gqldoc(""" { a }""") assertM(for { - fiber <- interpreter.flatMap(_.execute(query)).map(_.errors).fork + fiber <- schema.interpreter.flatMap(_.execute(query)).map(_.errors).fork _ <- TestClock.adjust(1 minute) res <- fiber.join } yield res)(equalTo(List(ExecutionError("""Query was interrupted after timeout of 1 m: @@ -138,24 +145,25 @@ object WrappersSpec extends DefaultRunnableSpec { case class Query(hero: Hero) case class Hero(name: URIO[Clock, String], friends: List[Hero] = Nil) - object schema extends GenericSchema[Clock] - import schema._ + object schema extends GenericSchema[Clock] { + implicit lazy val heroSchema: Schema[Clock, Hero] = schema.gen[Hero] - def api(latch: Promise[Nothing, Unit]): GraphQL[Clock] = - graphQL( - RootResolver( - Query( - Hero( - latch.succeed(()) *> ZIO.sleep(1 second).as("R2-D2"), - List( - Hero(ZIO.sleep(2 second).as("Luke Skywalker")), - Hero(ZIO.sleep(3 second).as("Han Solo")), - Hero(ZIO.sleep(4 second).as("Leia Organa")) + def api(latch: Promise[Nothing, Unit]): GraphQL[Clock] = + graphQL( + RootResolver( + Query( + Hero( + latch.succeed(()) *> ZIO.sleep(1 second).as("R2-D2"), + List( + Hero(ZIO.sleep(2 second).as("Luke Skywalker")), + Hero(ZIO.sleep(3 second).as("Han Solo")), + Hero(ZIO.sleep(4 second).as("Leia Organa")) + ) ) ) ) - ) - ) @@ ApolloTracing.apolloTracing + ) @@ ApolloTracing.apolloTracing + } val query = gqldoc(""" { @@ -168,7 +176,7 @@ object WrappersSpec extends DefaultRunnableSpec { }""") assertM(for { latch <- Promise.make[Nothing, Unit] - interpreter <- api(latch).interpreter + interpreter <- schema.api(latch).interpreter fiber <- interpreter.execute(query).map(_.extensions.map(_.toString)).fork _ <- latch.await _ <- TestClock.adjust(4 seconds) @@ -187,24 +195,24 @@ object WrappersSpec extends DefaultRunnableSpec { @GQLDirective(CacheControl(2.seconds)) case class Hero(name: URIO[Clock, String], friends: List[Hero] = Nil) - object schema extends GenericSchema[Clock] - import schema._ - - def api: GraphQL[Clock] = - graphQL( - RootResolver( - Query( - Hero( - ZIO.succeed("R2-D2"), - List( - Hero(ZIO.succeed("Luke Skywalker")), - Hero(ZIO.succeed("Han Solo")), - Hero(ZIO.succeed("Leia Organa")) + object schema extends GenericSchema[Clock] { + implicit lazy val heroSchema: Schema[Clock, Hero] = schema.gen[Hero] + def api: GraphQL[Clock] = + graphQL( + RootResolver( + Query( + Hero( + ZIO.succeed("R2-D2"), + List( + Hero(ZIO.succeed("Luke Skywalker")), + Hero(ZIO.succeed("Han Solo")), + Hero(ZIO.succeed("Leia Organa")) + ) ) ) ) - ) - ) @@ ApolloCaching.apolloCaching + ) @@ ApolloCaching.apolloCaching + } val query = gqldoc(""" { @@ -216,7 +224,7 @@ object WrappersSpec extends DefaultRunnableSpec { } }""") assertM(for { - interpreter <- api.interpreter + interpreter <- schema.api.interpreter result <- interpreter.execute(query).map(_.extensions.map(_.toString)) } yield result)( isSome( @@ -256,15 +264,18 @@ object WrappersSpec extends DefaultRunnableSpec { } ), testM("custom query directive") { - val customWrapper = ExecutionWrapper[Any](f => - request => { - if (request.field.directives.exists(_.name == "customQueryDirective")) { - UIO { - GraphQLResponse(Value.BooleanValue(true), Nil) - } - } else f(request) - } - ) + val customWrapper = new ExecutionWrapper[Any] { + def wrap[R1 <: Any]( + f: ExecutionRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] + ): ExecutionRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] = + request => { + if (request.field.directives.exists(_.name == "customQueryDirective")) { + UIO { + GraphQLResponse(Value.BooleanValue(true), Nil) + } + } else f(request) + } + } val customQueryDirective = __Directive( "customQueryDirective", None, diff --git a/examples/src/main/scala/example/ziohttp/ExampleApp.scala b/examples/src/main/scala/example/ziohttp/ExampleApp.scala index bf9e14ec5d..5c1012ba7b 100644 --- a/examples/src/main/scala/example/ziohttp/ExampleApp.scala +++ b/examples/src/main/scala/example/ziohttp/ExampleApp.scala @@ -1,4 +1,4 @@ -package example.zhttp +package example.ziohttp import example.ExampleData._ import example.{ ExampleApi, ExampleService } @@ -10,7 +10,7 @@ import zhttp.service.Server import caliban.ZHttpAdapter object ExampleApp extends App { - val graphiql = Http.succeed(Response.http(content = HttpData.fromStream(ZStream.fromResource("graphiql.html")))) + private val graphiql = Http.succeed(Response.http(content = HttpData.fromStream(ZStream.fromResource("graphiql.html")))) override def run(args: List[String]): ZIO[ZEnv, Nothing, ExitCode] = (for { @@ -20,8 +20,7 @@ object ExampleApp extends App { 8088, Http.route { case _ -> Root / "api" / "graphql" => ZHttpAdapter.makeHttpService(interpreter) - case _ -> Root / "ws" / "graphql" => - ZHttpAdapter.makeWebSocketService(interpreter) + case _ -> Root / "ws" / "graphql" => ZHttpAdapter.makeWebSocketService(interpreter) case _ -> Root / "graphiql" => graphiql } ) diff --git a/federation/src/main/scala/caliban/federation/tracing/ApolloFederatedTracing.scala b/federation/src/main/scala/caliban/federation/tracing/ApolloFederatedTracing.scala index dfdbb7711f..dcb35c512a 100644 --- a/federation/src/main/scala/caliban/federation/tracing/ApolloFederatedTracing.scala +++ b/federation/src/main/scala/caliban/federation/tracing/ApolloFederatedTracing.scala @@ -1,10 +1,12 @@ package caliban.federation.tracing +import caliban.CalibanError import caliban.CalibanError.ExecutionError +import caliban.execution.FieldInfo import caliban.ResponseValue.ObjectValue import caliban.Value.StringValue import caliban.wrappers.Wrapper.{ EffectfulWrapper, FieldWrapper, OverallWrapper } -import caliban.{ GraphQLRequest, ResponseValue } +import caliban.{ GraphQLRequest, GraphQLResponse, ResponseValue } import com.google.protobuf.timestamp.Timestamp import mdg.engine.proto.reports.Trace import mdg.engine.proto.reports.Trace.{ Error, Location, Node } @@ -36,45 +38,52 @@ object ApolloFederatedTracing { ) private def apolloTracingOverall(ref: Ref[Tracing], enabled: Ref[Boolean]): OverallWrapper[Clock] = - OverallWrapper { process => (request: GraphQLRequest) => - ZIO.ifM( - enabled.updateAndGet(_ => - request.extensions.exists( - _.get(GraphQLRequest.`apollo-federation-include-trace`).contains(StringValue(GraphQLRequest.ftv1)) - ) - ) - )( - for { - startNano <- clock.nanoTime - _ <- ref.update(_.copy(startTime = startNano)) - ((start, end), result) <- process(request).summarized(clock.currentTime(TimeUnit.MILLISECONDS))((_, _)) - endNano <- clock.nanoTime - tracing <- ref.get - } yield { - val root = Trace( - startTime = Some(toTimestamp(start)), - endTime = Some(toTimestamp(end)), - durationNs = endNano - startNano, - root = Some(tracing.root.reduce()) - ) - - result.copy( - extensions = Some( - ObjectValue( - ( - "ftv1" -> (StringValue(new String(Base64.getEncoder.encode(root.toByteArray))): ResponseValue) - ) :: result.extensions.fold(List.empty[(String, ResponseValue)])(_.fields) + new OverallWrapper[Clock] { + def wrap[R1 <: Clock]( + process: GraphQLRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] + ): GraphQLRequest => ZIO[R1, Nothing, GraphQLResponse[CalibanError]] = + (request: GraphQLRequest) => + ZIO.ifM( + enabled.updateAndGet(_ => + request.extensions.exists( + _.get(GraphQLRequest.`apollo-federation-include-trace`).contains(StringValue(GraphQLRequest.ftv1)) ) ) + )( + for { + startNano <- clock.nanoTime + _ <- ref.update(_.copy(startTime = startNano)) + ((start, end), result) <- process(request).summarized(clock.currentTime(TimeUnit.MILLISECONDS))((_, _)) + endNano <- clock.nanoTime + tracing <- ref.get + } yield { + val root = Trace( + startTime = Some(toTimestamp(start)), + endTime = Some(toTimestamp(end)), + durationNs = endNano - startNano, + root = Some(tracing.root.reduce()) + ) + + result.copy( + extensions = Some( + ObjectValue( + ( + "ftv1" -> (StringValue(new String(Base64.getEncoder.encode(root.toByteArray))): ResponseValue) + ) :: result.extensions.fold(List.empty[(String, ResponseValue)])(_.fields) + ) + ) + ) + }, + process(request) ) - }, - process(request) - ) } private def apolloTracingField(ref: Ref[Tracing], enabled: Ref[Boolean]): FieldWrapper[Clock] = - FieldWrapper( - { case (query, fieldInfo) => + new FieldWrapper[Clock](true) { + def wrap[R1 <: Clock]( + query: ZQuery[R1, CalibanError.ExecutionError, ResponseValue], + fieldInfo: FieldInfo + ): ZQuery[R1, CalibanError.ExecutionError, ResponseValue] = ZQuery .fromEffect(enabled.get) .flatMap( @@ -109,9 +118,7 @@ object ApolloFederatedTracing { } yield result else query ) - }, - wrapPureValues = true - ) + } private type VPath = Vector[Either[String, Int]] private final case class Tracing(root: NodeTrie, startTime: Long = 0) diff --git a/macros/src/main/scala/caliban/schema/Derived.scala b/macros/src/main/scala-2/caliban/schema/Derived.scala similarity index 100% rename from macros/src/main/scala/caliban/schema/Derived.scala rename to macros/src/main/scala-2/caliban/schema/Derived.scala diff --git a/macros/src/main/scala/caliban/schema/DerivedMagnolia.scala b/macros/src/main/scala-2/caliban/schema/DerivedMagnolia.scala similarity index 100% rename from macros/src/main/scala/caliban/schema/DerivedMagnolia.scala rename to macros/src/main/scala-2/caliban/schema/DerivedMagnolia.scala diff --git a/vuepress/docs/docs/middleware.md b/vuepress/docs/docs/middleware.md index 28730229d3..d2bd6432ea 100644 --- a/vuepress/docs/docs/middleware.md +++ b/vuepress/docs/docs/middleware.md @@ -20,21 +20,25 @@ There are 6 basic types of wrappers: Each one requires a function that takes a `ZIO` or `ZQuery` computation together with some contextual information (e.g. the query string) and should return another computation. -Let's see how to implement a wrapper that times out the whole query if its processing takes longer that 1 minute. +Let's see how to implement a wrapper that times out the whole query if its processing takes longer than 1 minute. ```scala -val wrapper = OverallWrapper { process => (request: GraphQLRequest) => - process(request) - .timeout(1 minute) - .map( - _.getOrElse( - GraphQLResponse( - NullValue, - List(ExecutionError(s"Query was interrupted after timeout of ${duration.render}:\n$query")) +val wrapper = new OverallWrapper[Clock] { + def wrap[R <: Clock]( + process: GraphQLRequest => ZIO[R, Nothing, GraphQLResponse[CalibanError]] + ): GraphQLRequest => ZIO[R, Nothing, GraphQLResponse[CalibanError]] = + (request: GraphQLRequest) => + process(request) + .timeout(1 minute) + .map( + _.getOrElse( + GraphQLResponse( + NullValue, + List(ExecutionError(s"Query was interrupted after 1 minute:\n${request.query}")) + ) + ) ) - ) - ) - } +} ``` You can also combine wrappers using `|+|` and create a wrapper that requires an effect to be run at each query using `EffectfulWrapper`. diff --git a/vuepress/docs/docs/schema.md b/vuepress/docs/docs/schema.md index 7783ae149d..a3a550457e 100644 --- a/vuepress/docs/docs/schema.md +++ b/vuepress/docs/docs/schema.md @@ -44,7 +44,7 @@ See the [Custom Types](#custom-types) section to find out how to support your ow If you want Caliban to support other standard types, feel free to [file an issue](https://github.com/ghostdogpr/caliban/issues) or even a PR. ::: warning Schema derivation issues -Magnolia (the library used to derive the schema at compile-time) sometimes has some trouble generating schemas with a lot of nested types, or types reused in multiple places. +The schema derivation sometimes has some trouble generating schemas with a lot of nested types, or types reused in multiple places. To deal with this, you can declare schemas for your case classes and sealed traits explicitly: ```scala @@ -52,15 +52,17 @@ implicit val roleSchema = Schema.gen[Role] implicit val characterSchema = Schema.gen[Character] ``` -Make sure those implicits are in scope when you call `graphQL(...)`. This will make Magnolia's job easier by pre-generating schemas for those classes and re-using them when needed. +Make sure those implicits are in scope when you call `graphQL(...)`. This will make derivation's job easier by pre-generating schemas for those classes and re-using them when needed. This will also improve compilation times and generate less bytecode. -If the derivation fails and you're not sure why, you can also call Magnolia's macro directly by using `genMacro`. +In Scala 2, if the derivation fails and you're not sure why, you can also call Magnolia's macro directly by using `genMacro`. The compilation will return better error messages in case something is missing: ```scala implicit val characterSchema = Schema.genMacro[Character].schema ``` + +In Scala 3, derivation doesn't support value classes, opaque types and nested sealed traits. ::: ## Enums, unions, interfaces @@ -212,7 +214,7 @@ implicit val localDateArgBuilder: ArgBuilder[LocalDate] = { } ``` -Value classes (`case class SomeWrapper(self: SomeType) extends AnyVal`) will be unwrapped by default. +Value classes (`case class SomeWrapper(self: SomeType) extends AnyVal`) will be unwrapped by default in Scala 2 (this is not supported by Scala 3 derivation). ## Code generation