diff --git a/core/src/main/scala/caliban/GraphQL.scala b/core/src/main/scala/caliban/GraphQL.scala index af12eb32f3..7f81037b38 100644 --- a/core/src/main/scala/caliban/GraphQL.scala +++ b/core/src/main/scala/caliban/GraphQL.scala @@ -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 ) diff --git a/core/src/main/scala/caliban/introspection/adt/__Type.scala b/core/src/main/scala/caliban/introspection/adt/__Type.scala index 0fc9824205..58e5494243 100644 --- a/core/src/main/scala/caliban/introspection/adt/__Type.scala +++ b/core/src/main/scala/caliban/introspection/adt/__Type.scala @@ -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), @@ -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) { 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..b0cb884e0f --- /dev/null +++ b/core/src/main/scala/caliban/relay/RelaySupport.scala @@ -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: _*)) + } + } +} diff --git a/core/src/main/scala/caliban/relay/TypeResolver.scala b/core/src/main/scala/caliban/relay/TypeResolver.scala new file mode 100644 index 0000000000..645e667691 --- /dev/null +++ b/core/src/main/scala/caliban/relay/TypeResolver.scala @@ -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] +} diff --git a/core/src/main/scala/caliban/schema/Schema.scala b/core/src/main/scala/caliban/schema/Schema.scala index d4eccb0767..929724d35a 100644 --- a/core/src/main/scala/caliban/schema/Schema.scala +++ b/core/src/main/scala/caliban/schema/Schema.scala @@ -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 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..a637d719eb --- /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 + +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 + ) + ) + ) + } + ) + +}