Skip to content

Commit

Permalink
implement global identifier spec
Browse files Browse the repository at this point in the history
  • Loading branch information
paulpdaniels committed Jan 18, 2025
1 parent f072518 commit 2248625
Show file tree
Hide file tree
Showing 8 changed files with 302 additions and 1 deletion.
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
)
)
)
}
)

}

0 comments on commit 2248625

Please sign in to comment.