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

implement global identifier spec #2510

Draft
wants to merge 1 commit into
base: series/2.x
Choose a base branch
from
Draft
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
3 changes: 2 additions & 1 deletion core/src/main/scala/caliban/GraphQL.scala
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ trait GraphQL[-R] { self =>
schemaBuilder.mutation.flatMap(_.opType.name),
schemaBuilder.subscription.flatMap(_.opType.name),
schemaBuilder.schemaDescription
) :: schemaBuilder.types.flatMap(_.toTypeDefinition) ++ additionalDirectives.map(_.toDirectiveDefinition),
) :: schemaBuilder.types.map(transformer.typeVisitor.visit).flatMap(_.toTypeDefinition) ++ additionalDirectives
.map(_.toDirectiveDefinition),
SourceMapper.empty
)

Expand Down
15 changes: 15 additions & 0 deletions core/src/main/scala/caliban/introspection/adt/__Type.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ case class __Type(

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

private[caliban] lazy val implements: Set[String] =
self.interfaces().getOrElse(Nil).flatMap(_.name).toSet

def |+|(that: __Type): __Type = __Type(
kind,
(name ++ that.name).reduceOption((_, b) => b),
Expand Down Expand Up @@ -234,6 +237,18 @@ object TypeVisitor {
val set: __Type => (List[Directive] => List[Directive]) => __Type =
t => f => t.copy(directives = t.directives.map(f))
}
object interfaces extends ListVisitorConstructors[__Type] {
val set: __Type => (List[__Type] => List[__Type]) => __Type =
t =>
f =>
t.copy(interfaces =
() =>
t.interfaces() match {
case Some(interfaces) => Some(f(interfaces))
case None => Some(f(Nil)).filter(_.nonEmpty)
}
)
}
}

private[caliban] sealed abstract class ListVisitor[A](implicit val set: __Type => (List[A] => List[A]) => __Type) {
Expand Down
5 changes: 5 additions & 0 deletions core/src/main/scala/caliban/relay/Node.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package caliban.relay

trait Node[ID] {
def id: ID
}
32 changes: 32 additions & 0 deletions core/src/main/scala/caliban/relay/NodeResolver.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package caliban.relay

import caliban.CalibanError
import caliban.introspection.adt.__Type
import caliban.schema.{ Schema, Step }
import zio.query.ZQuery

trait NodeResolver[-R, ID] {
def resolve(id: ID): ZQuery[R, CalibanError, Step[R]]
def toType: __Type
}

object NodeResolver {

def from[ID]: FromPartiallyApplied[ID] = new FromPartiallyApplied[ID]

final class FromPartiallyApplied[ID] {
def apply[R, T <: Node[ID]](
resolver: ID => ZQuery[R, CalibanError, Option[T]]
)(implicit schema: Schema[R, T]): NodeResolver[R, ID] = new NodeResolver[R, ID] {
override def resolve(id: ID): ZQuery[R, CalibanError, Step[R]] =
resolver(id).map(_.fold[Step[R]](Step.NullStep)(schema.resolve))

override def toType: __Type = schema.toType_()
}
}

def apply[R, ID, T <: Node[ID]](
resolver: ID => ZQuery[R, CalibanError, Option[T]]
)(implicit schema: Schema[R, T]): NodeResolver[R, ID] =
from[ID].apply(resolver)
}
102 changes: 102 additions & 0 deletions core/src/main/scala/caliban/relay/RelaySupport.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package caliban.relay

import caliban.CalibanError.ExecutionError
import caliban.execution.Field
import caliban.introspection.adt.{ __Field, __Type, __TypeKind, TypeVisitor }
import caliban.schema.Step.QueryStep
import caliban.schema.{ ArgBuilder, GenericSchema, Schema, Step }
import caliban.transformers.Transformer
import caliban.{ graphQL, GraphQL, GraphQLAspect, RootResolver }
import zio.NonEmptyChunk
import zio.query.ZQuery

object RelaySupport {

def globalIdentifiers[R, ID: ArgBuilder](
original: GraphQL[R],
resolvers: NonEmptyChunk[NodeResolver[R, ID]]
)(implicit idSchema: Schema[Any, ID], typeResolver: TypeResolver[ID]): GraphQL[R] = {
val _resolvers = resolvers.toList
lazy val _typeMap = _resolvers.flatMap(r => r.toType.name.map(_ -> r)).toMap

val genericSchema = new GenericSchema[R] {}
implicit val nodeArgBuilder: ArgBuilder[NodeArgs[ID]] = ArgBuilder.gen[NodeArgs[ID]]
implicit val nodeArgsSchema: Schema[Any, NodeArgs[ID]] = Schema.gen[Any, NodeArgs[ID]]

val nodeType = __Type(
__TypeKind.INTERFACE,
name = Some("Node"),
possibleTypes = Some(_resolvers.map(_.toType)),
fields = _ =>
Some(
List(
__Field(
"id",
None,
args = _ => Nil,
`type` = () => idSchema.toType_(isInput = true).nonNull
)
)
)
)

implicit val nodeSchema: Schema[R, Identifier[ID]] = new Schema[R, Identifier[ID]] {
override val nullable: Boolean = true
override def toType(isInput: Boolean, isSubscription: Boolean): __Type = nodeType

override def resolve(value: Identifier[ID]): Step[R] =
_typeMap
.get(value.typename)
.fold[Step[R]](Step.NullStep)(resolver => QueryStep(resolver.resolve(value.id)))
}

val transformer = new Transformer[Any] {
private def shouldAdd(__type: __Type): Boolean =
if (__type.innerType.name.isEmpty) false
else typeNames.contains(__type.name.get) && !__type.implements.contains("Node")

override val typeVisitor: TypeVisitor =
TypeVisitor.interfaces.addWith(inner =>
if (shouldAdd(inner)) List(nodeType)
else Nil
)

override protected lazy val typeNames: collection.Set[String] =
_typeMap.keySet

override protected def transformStep[R1 <: Any](step: Step.ObjectStep[R1], field: Field): Step.ObjectStep[R1] =
step
}

case class Query(
node: NodeArgs[ID] => ZQuery[Any, ExecutionError, Identifier[ID]]
)

implicit val querySchema: Schema[R, Query] = genericSchema.gen[R, Query]

(original |+| graphQL[R, Query, Unit, Unit](
RootResolver(
Query(node = args => ZQuery.fromEither(typeResolver.resolve(args.id)).map(Identifier(_, args.id)))
)
)).transform(transformer)
}

def withGlobalIdentifiers[ID]: WithGlobalIdentifiers[ID] =
WithGlobalIdentifiers()

private case class Identifier[A](typename: String, id: A)

private case class NodeArgs[ID](id: ID)

case class WithGlobalIdentifiers[ID](dummy: Boolean = false) extends AnyVal {
def apply[R](resolver: NodeResolver[R, ID], rest: NodeResolver[R, ID]*)(implicit
argBuilder: ArgBuilder[ID],
schema: Schema[Any, ID],
identifiable: TypeResolver[ID]
): GraphQLAspect[Nothing, R] =
new GraphQLAspect[Nothing, R] {
def apply[R1 <: R](original: GraphQL[R1]): GraphQL[R1] =
globalIdentifiers[R1, ID](original, NonEmptyChunk(resolver, rest: _*))
}
}
}
11 changes: 11 additions & 0 deletions core/src/main/scala/caliban/relay/TypeResolver.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package caliban.relay

import caliban.CalibanError.ExecutionError

/**
* Used in GlobalIds to resolve the type of the returned object based on the ID.
* This is needed in order to correctly derive the subtype of the object.
*/
trait TypeResolver[ID] {
def resolve(a: ID): Either[ExecutionError, String]
}
8 changes: 8 additions & 0 deletions core/src/main/scala/caliban/schema/Schema.scala
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@ trait Schema[-R, T] { self =>
if (renameTypename) loop(step) else step
}
}

def mapType(f: __Type => __Type): Schema[R, T] = new Schema[R, T] {
override def nullable: Boolean = self.nullable
override def canFail: Boolean = self.canFail
override def arguments: List[__InputValue] = self.arguments
override def toType(isInput: Boolean, isSubscription: Boolean): __Type = f(self.toType_(isInput, isSubscription))
override def resolve(value: T): Step[R] = self.resolve(value)
}
}

object Schema extends GenericSchema[Any] with SchemaVersionSpecific
Expand Down
127 changes: 127 additions & 0 deletions core/src/test/scala/caliban/relay/GlobalIdentifierSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package caliban.relay

import caliban.CalibanError.ExecutionError
import caliban.Value.StringValue
import caliban.rendering.DocumentRenderer
import caliban.{ graphQL, CalibanError, GraphQLResponse, InputValue, ResponseValue, RootResolver, Value }
import zio.test.{ assertTrue, assertZIO, ZIOSpecDefault }
import caliban.schema.Schema.auto._
import caliban.schema.ArgBuilder.auto._
import caliban.schema.{ ArgBuilder, Schema }
import zio.query.ZQuery
import zio.test.Assertion.equalTo

object GlobalIdentifierSpec extends ZIOSpecDefault {
case class ID(value: String)

implicit val schema: caliban.schema.Schema[Any, ID] = Schema.scalarSchema(
"ID",
None,
None,
None,
id => StringValue(id.value)
)

implicit val argBuilder: caliban.schema.ArgBuilder[ID] =
new ArgBuilder[ID] {
override def build(input: InputValue): Either[ExecutionError, ID] =
input match {
case StringValue(value) => Right(ID(value))
case _ => Left(ExecutionError("Expected a string"))
}
}

implicit val typeResolver: TypeResolver[ID] = new TypeResolver[ID] {
override def resolve(a: ID): Either[ExecutionError, String] =
a.value.split(":") match {
case Array(typename, _) => Right(typename)
case _ => Left(ExecutionError("Invalid id"))
}
}

case class Ship(id: ID, name: String, purpose: String) extends Node[ID]
case class Character(id: ID, name: String) extends Node[ID]
case class Query(
characters: List[Character],
ships: List[Ship]
)

val characters = List(
Character(ID("1"), "James Holden"),
Character(ID("2"), "Naomi Nagata"),
Character(ID("3"), "Amos Burton"),
Character(ID("4"), "Alex Kamal")
)

val ships = List(
Ship(ID("1"), "Rocinante", "Stealth Frigate"),
Ship(ID("2"), "Canterbury", "Destroyer"),
Ship(ID("3"), "Nauvoo", "Generation Ship"),
Ship(ID("4"), "Behemoth", "Belter Ship")
)

val shipResolver: NodeResolver[Any, ID] = NodeResolver.from[ID] { id =>
ZQuery.succeed(ships.find(s => id.value.endsWith(s.id.value)).map(_.copy(id = id)))
}

val characterResolver: NodeResolver[Any, ID] = NodeResolver.from[ID] { id =>
ZQuery.succeed(characters.find(c => id.value.endsWith(c.id.value)).map(_.copy(id = id)))
}

val api = graphQL(
RootResolver(
Query(
characters = characters,
ships = ships
)
)
)

val spec = suite("GlobalIdentifierSpec")(
test("augment schema with node field") {
val augmented = api @@
RelaySupport.withGlobalIdentifiers[ID](shipResolver, characterResolver)

assertTrue(
DocumentRenderer.renderCompact(
augmented.toDocument
) == "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!}"
)
},
test("resolve node field") {
val augmented = api @@
RelaySupport.withGlobalIdentifiers[ID](shipResolver, characterResolver)

val query =
"""{
| character: node(id:"Character:2"){
| id,...on Character{name}
| }
| ship: node(id:"Ship:3"){
| id,...on Ship{purpose}
| }
|}""".stripMargin

val result = augmented.interpreterUnsafe.execute(query)

assertZIO(result)(
equalTo(
GraphQLResponse[CalibanError](
data = ResponseValue.ObjectValue(
List(
"character" -> ResponseValue.ObjectValue(
List("id" -> Value.StringValue("Character:2"), "name" -> Value.StringValue("Naomi Nagata"))
),
"ship" -> ResponseValue.ObjectValue(
List("id" -> Value.StringValue("Ship:3"), "purpose" -> Value.StringValue("Generation Ship"))
)
)
),
errors = Nil
)
)
)
}
)

}