Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
125 changes: 125 additions & 0 deletions modules/circe/src/test/scala/CirceEffectHandlerErrorData.scala
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ import grackle.QueryInterpreter.EffectErrorPolicy
import grackle.circe.CirceMapping
import grackle.syntax._

object FailingEffectHandler {

/**
* An effect handler whose batch always fails.
*/
def apply[F[_]: Sync]: EffectHandler[F] =
new EffectHandler[F] {
def runEffects(queries: List[(Query, Cursor)]): F[Result[List[Cursor]]] =
Result.failure[List[Cursor]]("boom").pure[F]
}
}

class TestCirceEffectHandlerErrorMapping[F[_]: Sync](
ref: SignallingRef[F, Int],
policy: EffectErrorPolicy)
Expand Down Expand Up @@ -69,6 +81,119 @@ class TestCirceEffectHandlerErrorMapping[F[_]: Sync](

}

/**
* A failing effect handler beside a pure sibling field, both nullable. A failed batch is a
* field error, not a request error: the response keeps its `data` entry, with the effect field
* null and the sibling value intact.
*/
class TestCirceEffectHandlerSiblingMapping[F[_]: Sync] extends CirceMapping[F] {
val schema =
schema"""
type Query {
ping: String
viaEffect: String
}
"""

val QueryType = schema.ref("Query")

val typeMappings = List(
ObjectMapping(
tpe = QueryType,
fieldMappings = List(
CursorFieldJson("ping", _ => Result.success(Json.fromString("pong")), Nil),
EffectField("viaEffect", FailingEffectHandler[F], Nil)
)
)
)
}

/**
* A succeeding effect handler whose continuation fails at a non-null field.
*
* The batch succeeds, so the failure belongs to one position. `viaEffect` is non-null, so the
* null bubbles up to the `data` entry.
*/
class TestCirceFailingContinuationMapping[F[_]: Sync] extends CirceMapping[F] {
val schema =
schema"""
type Query {
ping: String
viaEffect: Child!
}
type Child {
name: String!
}
"""

val QueryType = schema.ref("Query")
val ChildType = schema.ref("Child")

val handler: EffectHandler[F] =
new EffectHandler[F] {
def runEffects(queries: List[(Query, Cursor)]): F[Result[List[Cursor]]] =
queries
.traverse {
case (query, parentCursor) =>
Query
.childContext(parentCursor.context, query)
.map(ctx => CirceCursor(ctx, Json.obj(), Some(parentCursor), Env.empty): Cursor)
}
.pure[F]
}

val typeMappings = List(
ObjectMapping(
tpe = QueryType,
fieldMappings = List(
CursorFieldJson("ping", _ => Result.success(Json.fromString("pong")), Nil),
EffectField("viaEffect", handler, Nil)
)
),
ObjectMapping(
tpe = ChildType,
fieldMappings = List(
CursorFieldJson("name", _ => Result.failure("boom"), Nil)
)
)
)
}

/**
* A failing effect handler at a non-null field of a nullable object.
*
* The failed field is non-null, so the null bubbles up to the `child` position.
*/
class TestCirceNestedNonNullEffectMapping[F[_]: Sync] extends CirceMapping[F] {
val schema =
schema"""
type Query {
ping: String
child: Child
}
type Child {
viaEffect: String!
}
"""

val QueryType = schema.ref("Query")
val ChildType = schema.ref("Child")

val typeMappings = List(
ObjectMapping(
tpe = QueryType,
fieldMappings = List(
CursorFieldJson("ping", _ => Result.success(Json.fromString("pong")), Nil),
CursorFieldJson("child", _ => Result.success(Json.obj()), Nil)
)
),
ObjectMapping(
tpe = ChildType,
fieldMappings = List(EffectField("viaEffect", FailingEffectHandler[F], Nil))
)
)
}

/**
* As `TestCirceEffectHandlerErrorMapping`, but both fields are backed by a *single, shared*
* handler. Because effects are batched by `(mapping, handler)`, this means both fields end up
Expand Down
91 changes: 84 additions & 7 deletions modules/circe/src/test/scala/CirceEffectHandlerErrorSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ final class CirceEffectHandlerErrorSuite extends CatsEffectSuite {
val expected = json"""
{
"errors" : [
{ "message": "value: hi" },
{ "message": "value: 42" }
]
{ "message": "value: hi", "path": ["s"] },
{ "message": "value: 42", "path": ["n"] }
],
"data" : null
}
"""

Expand All @@ -61,8 +62,9 @@ final class CirceEffectHandlerErrorSuite extends CatsEffectSuite {
val expected = json"""
{
"errors" : [
{ "message": "value: hi" }
]
{ "message": "value: hi", "path": ["s"] }
],
"data" : null
}
"""

Expand All @@ -82,7 +84,8 @@ final class CirceEffectHandlerErrorSuite extends CatsEffectSuite {
"errors" : [
{ "message": "value: s" },
{ "message": "value: n" }
]
],
"data" : null
}
"""

Expand All @@ -97,6 +100,30 @@ final class CirceEffectHandlerErrorSuite extends CatsEffectSuite {
assertIO(prg, (expected, 2))
}

test("circe effect handler failure is a field error, sibling data is retained") {
val query = """
query {
ping,
viaEffect
}
"""

val expected = json"""
{
"errors" : [
{ "message": "boom", "path": ["viaEffect"] }
],
"data" : {
"ping" : "pong",
"viaEffect" : null
}
}
"""

val map = new TestCirceEffectHandlerSiblingMapping[IO]
assertIO(map.compileAndRun(query), expected)
}

test("circe nested effect handler errors are accumulated in document order") {
val query = """
query {
Expand All @@ -118,12 +145,62 @@ final class CirceEffectHandlerErrorSuite extends CatsEffectSuite {
{ "message": "nested: a/y" },
{ "message": "nested: b/x" },
{ "message": "nested: b/y" }
]
],
"data" : null
}
"""

val map = new TestCirceNestedEffectHandlerErrorMapping[IO]
assertIO(map.compileAndRun(query), expected)
}

test("a failed continuation of a succeeding effect handler nulls its own position") {
val query = """
query {
ping
viaEffect {
name
}
}
"""

val expected = json"""
{
"errors" : [
{ "message": "boom", "path": ["viaEffect", "name"] }
],
"data" : null
}
"""

val map = new TestCirceFailingContinuationMapping[IO]
assertIO(map.compileAndRun(query), expected)
}

test("a null from a nested effect handler stops at the nearest nullable position") {
val query = """
query {
ping
child {
viaEffect
}
}
"""

val expected = json"""
{
"errors" : [
{ "message": "boom", "path": ["child", "viaEffect"] }
],
"data" : {
"ping" : "pong",
"child" : null
}
}
"""

val map = new TestCirceNestedNonNullEffectMapping[IO]
assertIO(map.compileAndRun(query), expected)
}

}
37 changes: 36 additions & 1 deletion modules/core/src/main/scala/problem.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,17 @@ import io.circe.syntax._
final case class Problem(
message: String,
locations: List[(Int, Int)] = Nil,
path: List[String] = Nil,
path: List[Problem.PathSegment] = Nil,
extensions: Option[JsonObject] = None
) {

/**
* Yields this problem with `path` as its response path, if it has none. A path set deeper in
* the response is more precise, so it wins.
*/
def atPath(path: List[Problem.PathSegment]): Problem =
if (this.path.isEmpty) copy(path = path) else this

override def toString = {

lazy val pathText: String =
Expand Down Expand Up @@ -56,6 +64,33 @@ final case class Problem(

object Problem {

/**
* A segment of a response path: a field name, or an index into a list.
*
* @see
* https://spec.graphql.org/September2025/#sec-Response-Position
*/
sealed trait PathSegment

object PathSegment {

final case class Name(name: String) extends PathSegment {
override def toString: String = name
}

final case class Index(index: Int) extends PathSegment {
assert(index >= 0, s"Index must be non-negative: $index")
override def toString: String = index.toString
}

implicit val PathSegmentEncoder: Encoder[PathSegment] = {
case Name(name) => name.asJson
case Index(index) => index.asJson
}

implicit val eqPathSegment: Eq[PathSegment] = Eq.fromUniversalEquals
}

implicit val ProblemEncoder: Encoder[Problem] = { p =>
val locationsField: List[(String, Json)] =
if (p.locations.isEmpty) Nil
Expand Down
Loading
Loading