From 7e829507d3828d0b9a1d5c5f2cc808c25e7fd776 Mon Sep 17 00:00:00 2001 From: Diederik Jan Lemkes <3764257+DJLemkes@users.noreply.github.com> Date: Wed, 11 Mar 2020 10:35:14 +0100 Subject: [PATCH] Allow errors to send an error code in the response extension (#246) * Make error extension section an ObjectValue for arbitrary k,v support * Update docs with example on error extension usage * Remove .asInstanceOf and fix typo --- .../src/main/scala/caliban/CalibanError.scala | 30 ++++++++---- .../scala/caliban/GraphQLResponseSpec.scala | 46 +++++++++++++++++-- .../main/scala/caliban/ExampleService.scala | 29 ++++++------ .../main/scala/caliban/client/Client.scala | 1 - .../caliban/optimizations/OptimizedTest.scala | 8 +--- vuepress/docs/docs/middleware.md | 28 +++++++++++ 6 files changed, 105 insertions(+), 37 deletions(-) diff --git a/core/src/main/scala/caliban/CalibanError.scala b/core/src/main/scala/caliban/CalibanError.scala index b7740765f..d0fa8cabd 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 55899898f..f6a02fb38 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 3c9578549..9ae638de7 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 fe99ec830..318e9fa27 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 e6b46dcdd..fbe9f8089 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 62cde6ef9..bca0439d7 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