Skip to content

Commit

Permalink
Allow errors to send an error code in the response extension (#246)
Browse files Browse the repository at this point in the history
* Make error extension section an ObjectValue for arbitrary k,v support

* Update docs with example on error extension usage

* Remove .asInstanceOf and fix typo
  • Loading branch information
DJLemkes authored Mar 11, 2020
1 parent f46b9fd commit 7e82950
Show file tree
Hide file tree
Showing 6 changed files with 105 additions and 37 deletions.
30 changes: 20 additions & 10 deletions core/src/main/scala/caliban/CalibanError.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package caliban

import caliban.ResponseValue.ObjectValue
import caliban.interop.circe.IsCirceEncoder
import caliban.parsing.adt.LocationInfo

Expand All @@ -18,16 +19,21 @@ 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)}"
}

/**
* 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"
}

Expand All @@ -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)}"
}
Expand All @@ -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,
Expand All @@ -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
}
Expand Down
46 changes: 41 additions & 5 deletions core/src/test/scala/caliban/GraphQLResponseSpec.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package caliban

import caliban.CalibanError.ExecutionError
import caliban.ResponseValue.ObjectValue
import caliban.Value._
import io.circe._
import io.circe.syntax._
Expand All @@ -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")
)
)
)
Expand Down
29 changes: 14 additions & 15 deletions examples/src/main/scala/caliban/ExampleService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 0 additions & 1 deletion examples/src/main/scala/caliban/client/Client.scala
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,3 @@ object Client {
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -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] =
Expand All @@ -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] =
Expand Down
28 changes: 28 additions & 0 deletions vuepress/docs/docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"))))))
}
```

0 comments on commit 7e82950

Please sign in to comment.