From f66fa4a1686eafdef9e9ab3dcd545c9fe4544b6a Mon Sep 17 00:00:00 2001 From: Diederik Jan Lemkes Date: Thu, 27 Feb 2020 20:09:18 +0100 Subject: [PATCH 1/3] Make error extension section an ObjectValue for arbitrary k,v support --- .../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 +--- 5 files changed, 77 insertions(+), 37 deletions(-) diff --git a/core/src/main/scala/caliban/CalibanError.scala b/core/src/main/scala/caliban/CalibanError.scala index b7740765f..a474ddaf5 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.asInstanceOf[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.asInstanceOf[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.asInstanceOf[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] = From b9ca4b5e3ac6bf426ea3731a26dd8631b7755257 Mon Sep 17 00:00:00 2001 From: Diederik Jan Lemkes Date: Tue, 10 Mar 2020 23:18:50 +0100 Subject: [PATCH 2/3] Update docs with example on error extension usage --- vuepress/docs/docs/middleware.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/vuepress/docs/docs/middleware.md b/vuepress/docs/docs/middleware.md index 62cde6ef9..c1f03b9fd 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 errror 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 From d9b5950b0d12b39e89fb369a72db0dc7f0cfc4db Mon Sep 17 00:00:00 2001 From: Diederik Jan Lemkes Date: Wed, 11 Mar 2020 07:51:40 +0100 Subject: [PATCH 3/3] Remove .asInstanceOf and fix typo --- core/src/main/scala/caliban/CalibanError.scala | 6 +++--- vuepress/docs/docs/middleware.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/scala/caliban/CalibanError.scala b/core/src/main/scala/caliban/CalibanError.scala index a474ddaf5..d0fa8cabd 100644 --- a/core/src/main/scala/caliban/CalibanError.scala +++ b/core/src/main/scala/caliban/CalibanError.scala @@ -69,7 +69,7 @@ private object ErrorCirce { "locations" -> Some(locationInfo).collect { case Some(li) => Json.arr(locationToJson(li)) }.asJson, - "extensions" -> extensions.asInstanceOf[Option[ResponseValue]].asJson.dropNullValues + "extensions" -> (extensions: Option[ResponseValue]).asJson.dropNullValues ) .dropNullValues case CalibanError.ValidationError(msg, _, locationInfo, extensions) => @@ -79,7 +79,7 @@ private object ErrorCirce { "locations" -> Some(locationInfo).collect { case Some(li) => Json.arr(locationToJson(li)) }.asJson, - "extensions" -> extensions.asInstanceOf[Option[ResponseValue]].asJson.dropNullValues + "extensions" -> (extensions: Option[ResponseValue]).asJson.dropNullValues ) .dropNullValues case CalibanError.ExecutionError(msg, path, locationInfo, _, extensions) => @@ -96,7 +96,7 @@ private object ErrorCirce { case Right(value) => value.asJson }) }.asJson, - "extensions" -> extensions.asInstanceOf[Option[ResponseValue]].asJson.dropNullValues + "extensions" -> (extensions: Option[ResponseValue]).asJson.dropNullValues ) .dropNullValues } diff --git a/vuepress/docs/docs/middleware.md b/vuepress/docs/docs/middleware.md index c1f03b9fd..bca0439d7 100644 --- a/vuepress/docs/docs/middleware.md +++ b/vuepress/docs/docs/middleware.md @@ -96,7 +96,7 @@ val i4: GraphQLInterpreter[MyEnv with Clock, CalibanError] = ## 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 errror extends `Throwable`. +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.