Skip to content

Commit 2248625

Browse files
committed
implement global identifier spec
1 parent f072518 commit 2248625

File tree

8 files changed

+302
-1
lines changed

8 files changed

+302
-1
lines changed

core/src/main/scala/caliban/GraphQL.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ trait GraphQL[-R] { self =>
5252
schemaBuilder.mutation.flatMap(_.opType.name),
5353
schemaBuilder.subscription.flatMap(_.opType.name),
5454
schemaBuilder.schemaDescription
55-
) :: schemaBuilder.types.flatMap(_.toTypeDefinition) ++ additionalDirectives.map(_.toDirectiveDefinition),
55+
) :: schemaBuilder.types.map(transformer.typeVisitor.visit).flatMap(_.toTypeDefinition) ++ additionalDirectives
56+
.map(_.toDirectiveDefinition),
5657
SourceMapper.empty
5758
)
5859

core/src/main/scala/caliban/introspection/adt/__Type.scala

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ case class __Type(
3030

3131
private[caliban] lazy val typeNameRepr: String = DocumentRenderer.renderTypeName(this)
3232

33+
private[caliban] lazy val implements: Set[String] =
34+
self.interfaces().getOrElse(Nil).flatMap(_.name).toSet
35+
3336
def |+|(that: __Type): __Type = __Type(
3437
kind,
3538
(name ++ that.name).reduceOption((_, b) => b),
@@ -234,6 +237,18 @@ object TypeVisitor {
234237
val set: __Type => (List[Directive] => List[Directive]) => __Type =
235238
t => f => t.copy(directives = t.directives.map(f))
236239
}
240+
object interfaces extends ListVisitorConstructors[__Type] {
241+
val set: __Type => (List[__Type] => List[__Type]) => __Type =
242+
t =>
243+
f =>
244+
t.copy(interfaces =
245+
() =>
246+
t.interfaces() match {
247+
case Some(interfaces) => Some(f(interfaces))
248+
case None => Some(f(Nil)).filter(_.nonEmpty)
249+
}
250+
)
251+
}
237252
}
238253

239254
private[caliban] sealed abstract class ListVisitor[A](implicit val set: __Type => (List[A] => List[A]) => __Type) {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package caliban.relay
2+
3+
trait Node[ID] {
4+
def id: ID
5+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package caliban.relay
2+
3+
import caliban.CalibanError
4+
import caliban.introspection.adt.__Type
5+
import caliban.schema.{ Schema, Step }
6+
import zio.query.ZQuery
7+
8+
trait NodeResolver[-R, ID] {
9+
def resolve(id: ID): ZQuery[R, CalibanError, Step[R]]
10+
def toType: __Type
11+
}
12+
13+
object NodeResolver {
14+
15+
def from[ID]: FromPartiallyApplied[ID] = new FromPartiallyApplied[ID]
16+
17+
final class FromPartiallyApplied[ID] {
18+
def apply[R, T <: Node[ID]](
19+
resolver: ID => ZQuery[R, CalibanError, Option[T]]
20+
)(implicit schema: Schema[R, T]): NodeResolver[R, ID] = new NodeResolver[R, ID] {
21+
override def resolve(id: ID): ZQuery[R, CalibanError, Step[R]] =
22+
resolver(id).map(_.fold[Step[R]](Step.NullStep)(schema.resolve))
23+
24+
override def toType: __Type = schema.toType_()
25+
}
26+
}
27+
28+
def apply[R, ID, T <: Node[ID]](
29+
resolver: ID => ZQuery[R, CalibanError, Option[T]]
30+
)(implicit schema: Schema[R, T]): NodeResolver[R, ID] =
31+
from[ID].apply(resolver)
32+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package caliban.relay
2+
3+
import caliban.CalibanError.ExecutionError
4+
import caliban.execution.Field
5+
import caliban.introspection.adt.{ __Field, __Type, __TypeKind, TypeVisitor }
6+
import caliban.schema.Step.QueryStep
7+
import caliban.schema.{ ArgBuilder, GenericSchema, Schema, Step }
8+
import caliban.transformers.Transformer
9+
import caliban.{ graphQL, GraphQL, GraphQLAspect, RootResolver }
10+
import zio.NonEmptyChunk
11+
import zio.query.ZQuery
12+
13+
object RelaySupport {
14+
15+
def globalIdentifiers[R, ID: ArgBuilder](
16+
original: GraphQL[R],
17+
resolvers: NonEmptyChunk[NodeResolver[R, ID]]
18+
)(implicit idSchema: Schema[Any, ID], typeResolver: TypeResolver[ID]): GraphQL[R] = {
19+
val _resolvers = resolvers.toList
20+
lazy val _typeMap = _resolvers.flatMap(r => r.toType.name.map(_ -> r)).toMap
21+
22+
val genericSchema = new GenericSchema[R] {}
23+
implicit val nodeArgBuilder: ArgBuilder[NodeArgs[ID]] = ArgBuilder.gen[NodeArgs[ID]]
24+
implicit val nodeArgsSchema: Schema[Any, NodeArgs[ID]] = Schema.gen[Any, NodeArgs[ID]]
25+
26+
val nodeType = __Type(
27+
__TypeKind.INTERFACE,
28+
name = Some("Node"),
29+
possibleTypes = Some(_resolvers.map(_.toType)),
30+
fields = _ =>
31+
Some(
32+
List(
33+
__Field(
34+
"id",
35+
None,
36+
args = _ => Nil,
37+
`type` = () => idSchema.toType_(isInput = true).nonNull
38+
)
39+
)
40+
)
41+
)
42+
43+
implicit val nodeSchema: Schema[R, Identifier[ID]] = new Schema[R, Identifier[ID]] {
44+
override val nullable: Boolean = true
45+
override def toType(isInput: Boolean, isSubscription: Boolean): __Type = nodeType
46+
47+
override def resolve(value: Identifier[ID]): Step[R] =
48+
_typeMap
49+
.get(value.typename)
50+
.fold[Step[R]](Step.NullStep)(resolver => QueryStep(resolver.resolve(value.id)))
51+
}
52+
53+
val transformer = new Transformer[Any] {
54+
private def shouldAdd(__type: __Type): Boolean =
55+
if (__type.innerType.name.isEmpty) false
56+
else typeNames.contains(__type.name.get) && !__type.implements.contains("Node")
57+
58+
override val typeVisitor: TypeVisitor =
59+
TypeVisitor.interfaces.addWith(inner =>
60+
if (shouldAdd(inner)) List(nodeType)
61+
else Nil
62+
)
63+
64+
override protected lazy val typeNames: collection.Set[String] =
65+
_typeMap.keySet
66+
67+
override protected def transformStep[R1 <: Any](step: Step.ObjectStep[R1], field: Field): Step.ObjectStep[R1] =
68+
step
69+
}
70+
71+
case class Query(
72+
node: NodeArgs[ID] => ZQuery[Any, ExecutionError, Identifier[ID]]
73+
)
74+
75+
implicit val querySchema: Schema[R, Query] = genericSchema.gen[R, Query]
76+
77+
(original |+| graphQL[R, Query, Unit, Unit](
78+
RootResolver(
79+
Query(node = args => ZQuery.fromEither(typeResolver.resolve(args.id)).map(Identifier(_, args.id)))
80+
)
81+
)).transform(transformer)
82+
}
83+
84+
def withGlobalIdentifiers[ID]: WithGlobalIdentifiers[ID] =
85+
WithGlobalIdentifiers()
86+
87+
private case class Identifier[A](typename: String, id: A)
88+
89+
private case class NodeArgs[ID](id: ID)
90+
91+
case class WithGlobalIdentifiers[ID](dummy: Boolean = false) extends AnyVal {
92+
def apply[R](resolver: NodeResolver[R, ID], rest: NodeResolver[R, ID]*)(implicit
93+
argBuilder: ArgBuilder[ID],
94+
schema: Schema[Any, ID],
95+
identifiable: TypeResolver[ID]
96+
): GraphQLAspect[Nothing, R] =
97+
new GraphQLAspect[Nothing, R] {
98+
def apply[R1 <: R](original: GraphQL[R1]): GraphQL[R1] =
99+
globalIdentifiers[R1, ID](original, NonEmptyChunk(resolver, rest: _*))
100+
}
101+
}
102+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package caliban.relay
2+
3+
import caliban.CalibanError.ExecutionError
4+
5+
/**
6+
* Used in GlobalIds to resolve the type of the returned object based on the ID.
7+
* This is needed in order to correctly derive the subtype of the object.
8+
*/
9+
trait TypeResolver[ID] {
10+
def resolve(a: ID): Either[ExecutionError, String]
11+
}

core/src/main/scala/caliban/schema/Schema.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ trait Schema[-R, T] { self =>
154154
if (renameTypename) loop(step) else step
155155
}
156156
}
157+
158+
def mapType(f: __Type => __Type): Schema[R, T] = new Schema[R, T] {
159+
override def nullable: Boolean = self.nullable
160+
override def canFail: Boolean = self.canFail
161+
override def arguments: List[__InputValue] = self.arguments
162+
override def toType(isInput: Boolean, isSubscription: Boolean): __Type = f(self.toType_(isInput, isSubscription))
163+
override def resolve(value: T): Step[R] = self.resolve(value)
164+
}
157165
}
158166

159167
object Schema extends GenericSchema[Any] with SchemaVersionSpecific
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package caliban.relay
2+
3+
import caliban.CalibanError.ExecutionError
4+
import caliban.Value.StringValue
5+
import caliban.rendering.DocumentRenderer
6+
import caliban.{ graphQL, CalibanError, GraphQLResponse, InputValue, ResponseValue, RootResolver, Value }
7+
import zio.test.{ assertTrue, assertZIO, ZIOSpecDefault }
8+
import caliban.schema.Schema.auto._
9+
import caliban.schema.ArgBuilder.auto._
10+
import caliban.schema.{ ArgBuilder, Schema }
11+
import zio.query.ZQuery
12+
import zio.test.Assertion.equalTo
13+
14+
object GlobalIdentifierSpec extends ZIOSpecDefault {
15+
case class ID(value: String)
16+
17+
implicit val schema: caliban.schema.Schema[Any, ID] = Schema.scalarSchema(
18+
"ID",
19+
None,
20+
None,
21+
None,
22+
id => StringValue(id.value)
23+
)
24+
25+
implicit val argBuilder: caliban.schema.ArgBuilder[ID] =
26+
new ArgBuilder[ID] {
27+
override def build(input: InputValue): Either[ExecutionError, ID] =
28+
input match {
29+
case StringValue(value) => Right(ID(value))
30+
case _ => Left(ExecutionError("Expected a string"))
31+
}
32+
}
33+
34+
implicit val typeResolver: TypeResolver[ID] = new TypeResolver[ID] {
35+
override def resolve(a: ID): Either[ExecutionError, String] =
36+
a.value.split(":") match {
37+
case Array(typename, _) => Right(typename)
38+
case _ => Left(ExecutionError("Invalid id"))
39+
}
40+
}
41+
42+
case class Ship(id: ID, name: String, purpose: String) extends Node[ID]
43+
case class Character(id: ID, name: String) extends Node[ID]
44+
case class Query(
45+
characters: List[Character],
46+
ships: List[Ship]
47+
)
48+
49+
val characters = List(
50+
Character(ID("1"), "James Holden"),
51+
Character(ID("2"), "Naomi Nagata"),
52+
Character(ID("3"), "Amos Burton"),
53+
Character(ID("4"), "Alex Kamal")
54+
)
55+
56+
val ships = List(
57+
Ship(ID("1"), "Rocinante", "Stealth Frigate"),
58+
Ship(ID("2"), "Canterbury", "Destroyer"),
59+
Ship(ID("3"), "Nauvoo", "Generation Ship"),
60+
Ship(ID("4"), "Behemoth", "Belter Ship")
61+
)
62+
63+
val shipResolver: NodeResolver[Any, ID] = NodeResolver.from[ID] { id =>
64+
ZQuery.succeed(ships.find(s => id.value.endsWith(s.id.value)).map(_.copy(id = id)))
65+
}
66+
67+
val characterResolver: NodeResolver[Any, ID] = NodeResolver.from[ID] { id =>
68+
ZQuery.succeed(characters.find(c => id.value.endsWith(c.id.value)).map(_.copy(id = id)))
69+
}
70+
71+
val api = graphQL(
72+
RootResolver(
73+
Query(
74+
characters = characters,
75+
ships = ships
76+
)
77+
)
78+
)
79+
80+
val spec = suite("GlobalIdentifierSpec")(
81+
test("augment schema with node field") {
82+
val augmented = api @@
83+
RelaySupport.withGlobalIdentifiers[ID](shipResolver, characterResolver)
84+
85+
assertTrue(
86+
DocumentRenderer.renderCompact(
87+
augmented.toDocument
88+
) == "schema{query:Query}interface Node{id:ID!}type Character implements Node{id:ID! name:String!}type Query{characters:[Character!]! ships:[Ship!]! node(id:ID!):Node}type Ship implements Node{id:ID! name:String! purpose:String!}"
89+
)
90+
},
91+
test("resolve node field") {
92+
val augmented = api @@
93+
RelaySupport.withGlobalIdentifiers[ID](shipResolver, characterResolver)
94+
95+
val query =
96+
"""{
97+
| character: node(id:"Character:2"){
98+
| id,...on Character{name}
99+
| }
100+
| ship: node(id:"Ship:3"){
101+
| id,...on Ship{purpose}
102+
| }
103+
|}""".stripMargin
104+
105+
val result = augmented.interpreterUnsafe.execute(query)
106+
107+
assertZIO(result)(
108+
equalTo(
109+
GraphQLResponse[CalibanError](
110+
data = ResponseValue.ObjectValue(
111+
List(
112+
"character" -> ResponseValue.ObjectValue(
113+
List("id" -> Value.StringValue("Character:2"), "name" -> Value.StringValue("Naomi Nagata"))
114+
),
115+
"ship" -> ResponseValue.ObjectValue(
116+
List("id" -> Value.StringValue("Ship:3"), "purpose" -> Value.StringValue("Generation Ship"))
117+
)
118+
)
119+
),
120+
errors = Nil
121+
)
122+
)
123+
)
124+
}
125+
)
126+
127+
}

0 commit comments

Comments
 (0)