Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow errors to send an error code in the response extension #246

Merged
merged 3 commits into from
Mar 11, 2020
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.asInstanceOf[Option[ResponseValue]].asJson.dropNullValues
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(extensions: Option[ResponseValue]) would be better than asInstanceOf (here and below)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes a lot of sense but I can't seem to get the encoder for an Option[ResponseValue] in implicit scope such that .asJson works. Thought that the implicit circeEncoder of ResponseValue would be able to pick this one up? I got the .asInstanceOf[ResponseValue] trick from GraphQLResponse actually.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(extensions: Option[ResponseValue]).asJson.dropNullValues doesn't work? We were about to remove the asInstanceOf in this PR by using that same trick: https://github.com/ghostdogpr/caliban/pull/234/files#diff-16ab822f0162e843af921d1e9d0c34dfR23

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha, I was trying to put it in the match clause. Apparently didn't fully understand what you meant. Changed it now and it works!

)
.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,
Expand All @@ -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
}
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 errror extends `Throwable`.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: errror

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed.


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.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice doc!


```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"))))))
}
```