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 12, 2025
1 parent f072518 commit f2644c3
Show file tree
Hide file tree
Showing 5 changed files with 260 additions and 0 deletions.
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)
}
93 changes: 93 additions & 0 deletions core/src/main/scala/caliban/relay/RelaySupport.scala
Original file line number Diff line number Diff line change
@@ -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: _*))
}
}
}
3 changes: 3 additions & 0 deletions core/src/main/scala/caliban/relay/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package caliban

package object relay extends RelaySupport
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, 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
)
)
)
}
)

}

0 comments on commit f2644c3

Please sign in to comment.