Skip to content

Commit 6a74066

Browse files
committed
implement global identifier spec
1 parent 2d96815 commit 6a74066

File tree

8 files changed

+360
-1
lines changed

8 files changed

+360
-1
lines changed

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ trait GraphQL[-R] { self =>
6969
schemaBuilder.mutation.flatMap(_.opType.name),
7070
schemaBuilder.subscription.flatMap(_.opType.name),
7171
schemaBuilder.schemaDescription
72-
) :: schemaBuilder.types.flatMap(_.toTypeDefinition) ++ additionalDirectives.map(_.toDirectiveDefinition),
72+
) :: schemaBuilder.types.map(transformer.typeVisitor.visit).flatMap(_.toTypeDefinition) ++ additionalDirectives
73+
.map(_.toDirectiveDefinition),
7374
SourceMapper.empty
7475
)
7576

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

165173
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)