diff --git a/core/src/main/scala/caliban/CalibanError.scala b/core/src/main/scala/caliban/CalibanError.scala index b7740765f7..d0fa8cabd0 100644 --- a/core/src/main/scala/caliban/CalibanError.scala +++ b/core/src/main/scala/caliban/CalibanError.scala @@ -1,5 +1,6 @@ package caliban +import caliban.ResponseValue.ObjectValue import caliban.interop.circe.IsCirceEncoder import caliban.parsing.adt.LocationInfo @@ -18,7 +19,8 @@ object CalibanError { case class ParsingError( msg: String, locationInfo: Option[LocationInfo] = None, - innerThrowable: Option[Throwable] = None + innerThrowable: Option[Throwable] = None, + extensions: Option[ObjectValue] = None ) extends CalibanError { override def toString: String = s"Parsing Error: $msg ${innerThrowable.fold("")(_.toString)}" } @@ -26,8 +28,12 @@ object CalibanError { /** * Describes an error that happened while validating a query. */ - case class ValidationError(msg: String, explanatoryText: String, locationInfo: Option[LocationInfo] = None) - extends CalibanError { + case class ValidationError( + msg: String, + explanatoryText: String, + locationInfo: Option[LocationInfo] = None, + extensions: Option[ObjectValue] = None + ) extends CalibanError { override def toString: String = s"ValidationError Error: $msg" } @@ -38,7 +44,8 @@ object CalibanError { msg: String, path: List[Either[String, Int]] = Nil, locationInfo: Option[LocationInfo] = None, - innerThrowable: Option[Throwable] = None + innerThrowable: Option[Throwable] = None, + extensions: Option[ObjectValue] = None ) extends CalibanError { override def toString: String = s"Execution Error: $msg ${innerThrowable.fold("")(_.toString)}" } @@ -55,25 +62,27 @@ private object ErrorCirce { Json.obj("line" -> li.line.asJson, "column" -> li.column.asJson) val errorValueEncoder: Encoder[CalibanError] = Encoder.instance[CalibanError] { - case CalibanError.ParsingError(msg, locationInfo, _) => + case CalibanError.ParsingError(msg, locationInfo, _, extensions) => Json .obj( "message" -> s"Parsing Error: $msg".asJson, "locations" -> Some(locationInfo).collect { case Some(li) => Json.arr(locationToJson(li)) - }.asJson + }.asJson, + "extensions" -> (extensions: Option[ResponseValue]).asJson.dropNullValues ) .dropNullValues - case CalibanError.ValidationError(msg, _, locationInfo) => + case CalibanError.ValidationError(msg, _, locationInfo, extensions) => Json .obj( "message" -> msg.asJson, "locations" -> Some(locationInfo).collect { case Some(li) => Json.arr(locationToJson(li)) - }.asJson + }.asJson, + "extensions" -> (extensions: Option[ResponseValue]).asJson.dropNullValues ) .dropNullValues - case CalibanError.ExecutionError(msg, path, locationInfo, _) => + case CalibanError.ExecutionError(msg, path, locationInfo, _, extensions) => Json .obj( "message" -> msg.asJson, @@ -86,7 +95,8 @@ private object ErrorCirce { case Left(value) => value.asJson case Right(value) => value.asJson }) - }.asJson + }.asJson, + "extensions" -> (extensions: Option[ResponseValue]).asJson.dropNullValues ) .dropNullValues } diff --git a/core/src/test/scala/caliban/GraphQLResponseSpec.scala b/core/src/test/scala/caliban/GraphQLResponseSpec.scala index 55899898f6..f6a02fb387 100644 --- a/core/src/test/scala/caliban/GraphQLResponseSpec.scala +++ b/core/src/test/scala/caliban/GraphQLResponseSpec.scala @@ -1,6 +1,7 @@ package caliban import caliban.CalibanError.ExecutionError +import caliban.ResponseValue.ObjectValue import caliban.Value._ import io.circe._ import io.circe.syntax._ @@ -14,16 +15,51 @@ object GraphQLResponseSpec extends DefaultRunnableSpec { suite("GraphQLResponseSpec")( test("can be converted to JSON") { val response = GraphQLResponse(StringValue("data"), Nil) - assert(response.asJson)(equalTo(Json.obj("data" -> Json.fromString("data")))) + assert(response.asJson)( + equalTo(Json.obj("data" -> Json.fromString("data"))) + ) }, - test("should include error only if non-empty") { - val response = GraphQLResponse(StringValue("data"), List(ExecutionError("Resolution failed"))) + test("should include error objects for every error, including extensions") { + + val errorExtensions = List( + ("errorCode", StringValue("TEST_ERROR")), + ("myCustomKey", StringValue("my-value")) + ) + + val response = GraphQLResponse( + StringValue("data"), + List( + ExecutionError( + "Resolution failed", + extensions = Some(ObjectValue(errorExtensions)) + ) + ) + ) + + assert(response.asJson)( + equalTo( + Json.obj( + "data" -> Json.fromString("data"), + "errors" -> Json.arr( + Json.obj( + "message" -> Json.fromString("Resolution failed"), + "extensions" -> Json.obj("errorCode" -> "TEST_ERROR".asJson, "myCustomKey" -> "my-value".asJson) + ) + ) + ) + ) + ) + }, + test("should not include errors element when there are none") { + val response = GraphQLResponse( + StringValue("data"), + List.empty + ) assert(response.asJson)( equalTo( Json.obj( - "data" -> Json.fromString("data"), - "errors" -> Json.arr(Json.obj("message" -> Json.fromString("Resolution failed"))) + "data" -> Json.fromString("data") ) ) ) diff --git a/examples/src/main/scala/caliban/ExampleService.scala b/examples/src/main/scala/caliban/ExampleService.scala index 3c95785498..9ae638de73 100644 --- a/examples/src/main/scala/caliban/ExampleService.scala +++ b/examples/src/main/scala/caliban/ExampleService.scala @@ -13,24 +13,23 @@ class ExampleService(characters: Ref[List[Character]], subscribers: Ref[List[Que def deleteCharacter(name: String): UIO[Boolean] = characters - .modify( - list => - if (list.exists(_.name == name)) (true, list.filterNot(_.name == name)) - else (false, list) + .modify(list => + if (list.exists(_.name == name)) (true, list.filterNot(_.name == name)) + else (false, list) ) - .tap( - deleted => - UIO.when(deleted)( - subscribers.get.flatMap( - // add item to all subscribers - UIO.foreach(_)( - queue => - queue - .offer(name) - .onInterrupt(subscribers.update(_.filterNot(_ == queue))) // if queue was shutdown, remove from subscribers - ) + .tap(deleted => + UIO.when(deleted)( + subscribers.get.flatMap( + // add item to all subscribers + UIO.foreach(_)(queue => + queue + .offer(name) + .onInterrupt( + subscribers.update(_.filterNot(_ == queue)) + ) // if queue was shutdown, remove from subscribers ) ) + ) ) def deletedEvents: ZStream[Any, Nothing, String] = ZStream.unwrap { diff --git a/examples/src/main/scala/caliban/client/Client.scala b/examples/src/main/scala/caliban/client/Client.scala index fe99ec830f..318e9fa273 100644 --- a/examples/src/main/scala/caliban/client/Client.scala +++ b/examples/src/main/scala/caliban/client/Client.scala @@ -95,4 +95,3 @@ object Client { } } - diff --git a/examples/src/main/scala/caliban/optimizations/OptimizedTest.scala b/examples/src/main/scala/caliban/optimizations/OptimizedTest.scala index e6b46dcdd5..fbe9f80895 100644 --- a/examples/src/main/scala/caliban/optimizations/OptimizedTest.scala +++ b/examples/src/main/scala/caliban/optimizations/OptimizedTest.scala @@ -69,9 +69,7 @@ object OptimizedTest extends App with GenericSchema[Console] { case class GetEvent(id: Int) extends Request[Nothing, Event] val EventDataSource: DataSource[Console, GetEvent] = - fromFunctionBatchedM("EventDataSource") { requests => - putStrLn("getEvents").as(requests.map(r => fakeEvent(r.id))) - } + fromFunctionBatchedM("EventDataSource")(requests => putStrLn("getEvents").as(requests.map(r => fakeEvent(r.id)))) case class GetViewerMetadataForEvents(id: Int) extends Request[Nothing, ViewerMetadata] val ViewerMetadataDataSource: DataSource[Console, GetViewerMetadataForEvents] = @@ -81,9 +79,7 @@ object OptimizedTest extends App with GenericSchema[Console] { case class GetVenue(id: Int) extends Request[Nothing, Venue] val VenueDataSource: DataSource[Console, GetVenue] = - fromFunctionBatchedM("VenueDataSource") { requests => - putStrLn("getVenues").as(requests.map(_ => Venue("venue"))) - } + fromFunctionBatchedM("VenueDataSource")(requests => putStrLn("getVenues").as(requests.map(_ => Venue("venue")))) case class GetTags(ids: List[Int]) extends Request[Nothing, List[Tag]] val TagsDataSource: DataSource[Console, GetTags] = diff --git a/vuepress/docs/docs/middleware.md b/vuepress/docs/docs/middleware.md index 62cde6ef9a..bca0439d7c 100644 --- a/vuepress/docs/docs/middleware.md +++ b/vuepress/docs/docs/middleware.md @@ -92,4 +92,32 @@ val i4: GraphQLInterpreter[MyEnv with Clock, CalibanError] = _.getOrElse(GraphQLResponse(NullValue, List(ExecutionError("Timeout!")))) ) ) +``` + +## Customizing error responses + +During various phases of executing a query, an error may occur. Caliban renders the different instances of `CalibanError` to a GraphQL spec compliant response. As a user, you will most likely encounter `ExecutionError` at some point because this will encapsulate the errors in the error channel of your effects. For Caliban to be able to render some basic message about the error that occured during query execution, it is important that your error extends `Throwable`. + +For more meaningful error handling, GraphQL spec allows for an [`extension`](http://spec.graphql.org/June2018/#example-fce18) object in the error response. This object may include, for instance, `code` information to model enum-like error codes that can be handled by a front-end. In order to generate this information, one can use the `mapError` function on a `GraphQLInterpreter`. An example is provided below in which we map a custom domain error within an `ExecutionError` to a meaningful error code. + +```scala +sealed trait ExampleAppEncodableError extends Throwable { + def errorCode: String +} +case object UnauthorizedError extends ExampleAppEncodableError { + override def errorCode: String = "UNAUTHORIZED" +} + +def withErrorCodeExtensions[R]( + interpreter: GraphQLInterpreter[R, CalibanError] +): GraphQLInterpreter[R, CalibanError] = interpreter.mapError { + case err @ ExecutionError(_, _, _, Some(exampleError: ExampleAppEncodableError), _) => + err.copy(extensions = Some(ObjectValue(List(("errorCode", StringValue(exampleError.errorCode)))))) + case err: ExecutionError => + err.copy(extensions = Some(ObjectValue(List(("errorCode", StringValue("EXECUTION_ERROR")))))) + case err: ValidationError => + err.copy(extensions = Some(ObjectValue(List(("errorCode", StringValue("VALIDATION_ERROR")))))) + case err: ParsingError => + err.copy(extensions = Some(ObjectValue(List(("errorCode", StringValue("PARSING_ERROR")))))) +} ``` \ No newline at end of file