diff --git a/build.sbt b/build.sbt index dab0a8a0..61860ec6 100644 --- a/build.sbt +++ b/build.sbt @@ -5,7 +5,7 @@ lazy val scala3Version = "3.3.1" lazy val rulesCrossVersions = Seq(V.scala213) lazy val allVersions = rulesCrossVersions :+ scala3Version -ThisBuild / tlBaseVersion := "0.34" +ThisBuild / tlBaseVersion := "0.35" ThisBuild / tlCiReleaseBranches := Seq("master") ThisBuild / tlJdkRelease := Some(8) ThisBuild / githubWorkflowJavaVersions := Seq("11", "17").map(JavaSpec.temurin(_)) @@ -56,6 +56,7 @@ lazy val core = Settings.Libraries.Fs2.value ++ Settings.Libraries.Log4Cats.value ++ Settings.Libraries.DisciplineMUnit.value ++ + Settings.Libraries.MUnitCatsEffect.value ++ Settings.Libraries.MUnit.value ) .dependsOn(model) diff --git a/core/src/main/scala/clue/ErrorPolicy.scala b/core/src/main/scala/clue/ErrorPolicy.scala index 3796787b..696ad11d 100644 --- a/core/src/main/scala/clue/ErrorPolicy.scala +++ b/core/src/main/scala/clue/ErrorPolicy.scala @@ -10,6 +10,7 @@ import cats.syntax.all._ import clue.model.GraphQLDataResponse import clue.model.GraphQLErrors import clue.model.GraphQLResponse +import org.typelevel.log4cats.Logger sealed trait ErrorPolicy { type ReturnType[D] @@ -18,7 +19,7 @@ sealed trait ErrorPolicy { } sealed trait ErrorPolicyProcessor[D, R] { - def process[F[_]: ApplicativeThrow](response: GraphQLResponse[D]): F[R] + def process[F[_]: ApplicativeThrow](response: GraphQLResponse[D])(implicit log: Logger[F]): F[R] } object ErrorPolicy { @@ -31,15 +32,24 @@ object ErrorPolicy { ApplicativeThrow[F].raiseError(ResponseException(errors, data)) } + /** + * If the response contains data, return it. If the response contains errors, raise an exception. + * If the response contains both data and errors, log the errors and return the data. + */ object IgnoreOnData extends ErrorPolicy { type ReturnType[D] = D def processor[D]: ErrorPolicyProcessor[D, D] = new Distinct[D] { - def process[F[_]: ApplicativeThrow](response: GraphQLResponse[D]): F[D] = + def process[F[_]: ApplicativeThrow]( + response: GraphQLResponse[D] + )(implicit log: Logger[F]): F[D] = response.result match { - case Ior.Left(errors) => processErrors(errors) - case Ior.Right(data) => processData(data) - case Ior.Both(_, data) => processData(data) + case Ior.Left(errors) => processErrors(errors) + case Ior.Right(data) => processData(data) + case Ior.Both(errors, data) => + log.warn(ResponseException(errors, data.some))( + "Received both data and errors" + ) *> processData(data) } } } @@ -48,7 +58,9 @@ object ErrorPolicy { type ReturnType[D] = D def processor[D]: ErrorPolicyProcessor[D, D] = new Distinct[D] { - def process[F[_]: ApplicativeThrow](response: GraphQLResponse[D]): F[ReturnType[D]] = + def process[F[_]: ApplicativeThrow]( + response: GraphQLResponse[D] + )(implicit log: Logger[F]): F[ReturnType[D]] = response.result match { case Ior.Left(errors) => processErrors(errors) case Ior.Right(data) => processData(data) @@ -65,7 +77,7 @@ object ErrorPolicy { new ErrorPolicyProcessor[D, GraphQLResponse[D]] { def process[F[_]: ApplicativeThrow]( response: GraphQLResponse[D] - ): F[ReturnType[D]] = + )(implicit log: Logger[F]): F[ReturnType[D]] = Applicative[F].pure(response) } } @@ -78,7 +90,7 @@ object ErrorPolicy { def process[F[_]: ApplicativeThrow]( response: GraphQLResponse[D] - ): F[ReturnType[D]] = + )(implicit log: Logger[F]): F[ReturnType[D]] = response.result match { case Ior.Left(errors) => ApplicativeThrow[F].raiseError(ResponseException(errors, none)) case Ior.Right(data) => Applicative[F].pure(GraphQLDataResponse(data, none, none)) diff --git a/core/src/test/scala/clue/ErrorPolicySpec.scala b/core/src/test/scala/clue/ErrorPolicySpec.scala new file mode 100644 index 00000000..713dde48 --- /dev/null +++ b/core/src/test/scala/clue/ErrorPolicySpec.scala @@ -0,0 +1,123 @@ +// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package clue + +import cats.data.Ior +import cats.data.NonEmptyList +import clue.model.GraphQLError +import clue.model.GraphQLResponse +import org.scalacheck.Arbitrary +import org.typelevel.log4cats.testing.StructuredTestingLogger +import cats.effect.IO +import cats.syntax.all.* +import clue.model.GraphQLDataResponse + +class ErrorPolicySpec extends munit.CatsEffectSuite { + + private val loggerFixture = + FunFixture[StructuredTestingLogger[IO]](_ => StructuredTestingLogger.impl[IO](), _ => ()) + + loggerFixture.test("IgnoreOnData only errors") { implicit logger => + val policy = ErrorPolicy.IgnoreOnData.processor[Int] + + val result = policy.process[IO](leftResponse) + + result.intercept[ResponseException[Int]] *> + logger.logged.assert(_.isEmpty) + } + + loggerFixture.test("IgnoreOnData only data") { implicit logger => + val policy = ErrorPolicy.IgnoreOnData.processor[Int] + + val result = policy.process[IO](rightResponse) + + result.assertEquals(1) *> + logger.logged.assert(_.isEmpty) + } + + loggerFixture.test("IgnoreOnData errors and data") { implicit logger => + val policy = ErrorPolicy.IgnoreOnData.processor[Int] + + val result = policy.process[IO](bothResponse) + + result.assertEquals(1) *> + logger.logged.assertEquals( + Vector( + StructuredTestingLogger.WARN( + "Received both data and errors", + throwOpt = ResponseException(NonEmptyList.one(graphQlError), 1.some).some + ) + ) + ) + } + + loggerFixture.test("RaiseAlways only errors") { implicit logger => + val policy = ErrorPolicy.RaiseAlways.processor[Int] + + val result = policy.process[IO](leftResponse) + + result.intercept[ResponseException[Int]] *> + logger.logged.assert(_.isEmpty) + } + + loggerFixture.test("RaiseAlways only data") { implicit logger => + val policy = ErrorPolicy.RaiseAlways.processor[Int] + + val result = policy.process[IO](rightResponse) + + result.assertEquals(1) *> + logger.logged.assert(_.isEmpty) + } + + loggerFixture.test("RaiseAlways errors and data") { implicit logger => + val policy = ErrorPolicy.RaiseAlways.processor[Int] + + val result = policy.process[IO](bothResponse) + + result.intercept[ResponseException[Int]] *> logger.logged.assert(_.isEmpty) + } + + loggerFixture.test("ReturnAlways always returns whole response") { implicit logger => + val policy = ErrorPolicy.ReturnAlways.processor[Int] + + val result = policy.process[IO](bothResponse) + + result.assertEquals(bothResponse) *> + logger.logged.assert(_.isEmpty) + } + + loggerFixture.test("RaiseOnNoData only errors") { implicit logger => + val policy = ErrorPolicy.RaiseOnNoData.processor[Int] + + val result = policy.process[IO](leftResponse) + + result.intercept[ResponseException[Int]] *> + logger.logged.assert(_.isEmpty) + } + + loggerFixture.test("RaiseOnNoData only data") { implicit logger => + val policy = ErrorPolicy.RaiseOnNoData.processor[Int] + + val result = policy.process[IO](rightResponse) + + result.assertEquals(GraphQLDataResponse(1, None, None)) *> + logger.logged.assert(_.isEmpty) + } + + loggerFixture.test("RaiseOnNoData errors and data") { implicit logger => + val policy = ErrorPolicy.RaiseOnNoData.processor[Int] + + val result = policy.process[IO](bothResponse) + + result.assertEquals(GraphQLDataResponse(1, NonEmptyList.one(graphQlError).some, None)) *> + logger.logged.assert(_.isEmpty) + } + + val graphQlError = Arbitrary.arbString.arbitrary.map(GraphQLError(_)).sample.get + + def leftResponse = GraphQLResponse.errors[Int](NonEmptyList.one(graphQlError)) + def rightResponse = GraphQLResponse[Int](result = Ior.right(1)) + def bothResponse = GraphQLResponse[Int](result = Ior.both(NonEmptyList.one(graphQlError), 1)) + +} diff --git a/project/Settings.scala b/project/Settings.scala index e67fbb72..606e3dda 100644 --- a/project/Settings.scala +++ b/project/Settings.scala @@ -18,6 +18,7 @@ object Settings { val log4Cats = "2.6.0" val monocle = "3.2.0" val munit = "0.7.29" + val munitCatsEffect = "2.0.0-M4" val scalaFix = scalafix.sbt.BuildInfo.scalafixVersion val scalaJSDom = "2.8.0" val scalaJSMacrotaskExecutor = "1.1.1" @@ -109,7 +110,8 @@ object Settings { val Log4Cats = Def.setting( Seq( - "org.typelevel" %%% "log4cats-core" % log4Cats + "org.typelevel" %%% "log4cats-core" % log4Cats, + "org.typelevel" %%% "log4cats-testing" % log4Cats % "test" ) ) @@ -126,6 +128,12 @@ object Settings { ) ) + val MUnitCatsEffect = Def.setting( + Seq[ModuleID]( + "org.typelevel" %%% "munit-cats-effect" % munitCatsEffect % "test" + ) + ) + val ScalaFix = Def.setting( Seq( "ch.epfl.scala" %%% "scalafix-core" % scalaFix