diff --git a/core/src/main/scala/caliban/relay/Node.scala b/core/src/main/scala/caliban/relay/Node.scala new file mode 100644 index 0000000000..4f527cc38a --- /dev/null +++ b/core/src/main/scala/caliban/relay/Node.scala @@ -0,0 +1,5 @@ +package caliban.relay + +trait Node[ID] { + def id: ID +} diff --git a/core/src/main/scala/caliban/relay/NodeResolver.scala b/core/src/main/scala/caliban/relay/NodeResolver.scala new file mode 100644 index 0000000000..3295ff02ee --- /dev/null +++ b/core/src/main/scala/caliban/relay/NodeResolver.scala @@ -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) +} diff --git a/core/src/main/scala/caliban/relay/RelaySupport.scala b/core/src/main/scala/caliban/relay/RelaySupport.scala new file mode 100644 index 0000000000..95f506f5fe --- /dev/null +++ b/core/src/main/scala/caliban/relay/RelaySupport.scala @@ -0,0 +1,93 @@ +package caliban.relay + +import caliban.CalibanError.ExecutionError +import caliban.introspection.adt.{ __Field, __Type, __TypeKind } +import caliban.relay.RelaySupport.WithGlobalIdentifiers +import caliban.schema.Step.QueryStep +import caliban.schema.{ ArgBuilder, GenericSchema, Schema, Step } +import caliban.{ graphQL, GraphQL, GraphQLAspect, RootResolver } +import zio.NonEmptyChunk +import zio.query.ZQuery + +trait TypeResolver[A] { + def resolve(a: A): Either[ExecutionError, Identifier[A]] +} + +case class Identifier[A](typename: String, id: A) + +abstract class RelaySupport { + + def globalIdentifiers[R, ID: ArgBuilder]( + original: GraphQL[R], + resolvers: NonEmptyChunk[NodeResolver[R, ID]] + )(implicit idSchema: Schema[Any, ID], identifiable: TypeResolver[ID]): GraphQL[R] = { + val _resolvers = resolvers.toList + val genericSchema = new GenericSchema[R] {} + + case class NodeArgs(id: ID) + + implicit val nodeArgBuilder: ArgBuilder[NodeArgs] = + ArgBuilder.gen[NodeArgs] + implicit val nodeArgsSchema: Schema[Any, NodeArgs] = Schema.gen + + 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 = + __Type( + __TypeKind.INTERFACE, + name = Some("Node"), + possibleTypes = Some(_resolvers.map(_.toType)), + fields = _ => + Some( + List( + __Field( + "id", + None, + args = _ => Nil, + `type` = () => idSchema.toType_(isInput, isSubscription).nonNull + ) + ) + ) + ) + + private lazy val _nodeMap = _resolvers.flatMap(r => r.toType.name.map(_ -> r)).toMap + + override def resolve(value: Identifier[ID]): Step[R] = + _nodeMap + .get(value.typename) + .fold[Step[R]](Step.NullStep)(resolver => QueryStep(resolver.resolve(value.id))) + } + + case class Query( + node: NodeArgs => ZQuery[Any, ExecutionError, Identifier[ID]] + ) + + implicit val querySchema: Schema[R, Query] = genericSchema.gen[R, Query] + + graphQL[R, Query, Unit, Unit]( + RootResolver( + Query(node = args => ZQuery.fromEither(identifiable.resolve(args.id))) + ) + ) |+| original + } + + def withGlobalIdentifiers[ID]: WithGlobalIdentifiers[ID] = + WithGlobalIdentifiers() + +} + +object RelaySupport { + case class ID(typename: String, id: String) + + 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: _*)) + } + } +} diff --git a/core/src/main/scala/caliban/relay/package.scala b/core/src/main/scala/caliban/relay/package.scala new file mode 100644 index 0000000000..fcb04197f5 --- /dev/null +++ b/core/src/main/scala/caliban/relay/package.scala @@ -0,0 +1,3 @@ +package caliban + +package object relay extends RelaySupport diff --git a/core/src/test/scala/caliban/relay/GlobalIdentifierSpec.scala b/core/src/test/scala/caliban/relay/GlobalIdentifierSpec.scala new file mode 100644 index 0000000000..2bd7c7fc63 --- /dev/null +++ b/core/src/test/scala/caliban/relay/GlobalIdentifierSpec.scala @@ -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, isRight } + +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, Identifier[ID]] = + a.value.split(":") match { + case Array(typename, _) => Right(Identifier(typename, a)) + 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.from[ID] { id => + ZQuery.succeed(ships.find(s => id.value.endsWith(s.id.value)).map(_.copy(id = id))) + } + + val characterResolver = 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 @@ + withGlobalIdentifiers[ID](shipResolver, characterResolver) + + assertTrue( + DocumentRenderer.renderCompact( + augmented.toDocument + ) == "schema{query:Query}interface Node{id:ID!}type Character{id:ID! name:String!}type Query{node(id:ID!):Node characters:[Character!]! ships:[Ship!]!}type Ship{id:ID! name:String! purpose:String!}" + ) + }, + test("resolve node field") { + val augmented = api @@ + 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 + ) + ) + ) + } + ) + +}