diff --git a/core/src/main/scala/caliban/relay/Base64Cursor.scala b/core/src/main/scala/caliban/relay/Base64Cursor.scala new file mode 100644 index 0000000000..e6c0808460 --- /dev/null +++ b/core/src/main/scala/caliban/relay/Base64Cursor.scala @@ -0,0 +1,41 @@ +package caliban.relay + +import caliban.schema.Schema +import caliban.Value +import scala.util.Try + +/** + * A cursor implementation that models an index/offset as an + * opaque base64 cursor. + */ +case class Base64Cursor(value: Int) + +object Base64Cursor { + import java.util.Base64 + lazy val encoder = Base64.getEncoder() + lazy val decoder = Base64.getDecoder() + + private val prefix = "cursor:" + + implicit val cursor: Cursor[Base64Cursor] = new Cursor[Base64Cursor] { + type T = Int + def encode(a: Base64Cursor): String = + encoder.encodeToString(s"$prefix${a.value}".getBytes("UTF-8")) + + def decode(raw: String): Either[String, Base64Cursor] = + Try({ + val bytes = decoder.decode(raw) + val s = new String(bytes, "UTF-8") + if (s.startsWith(prefix)) { + Base64Cursor(s.replaceFirst(prefix, "").toInt) + } else { + throw new Throwable("invalid cursor") + } + }).toEither.left.map(_.getMessage()) + + def value(cursor: Base64Cursor): Int = cursor.value + } + + implicit val schema: Schema[Any, Base64Cursor] = + Schema.stringSchema.contramap(Cursor[Base64Cursor].encode) +} diff --git a/core/src/main/scala/caliban/relay/Connection.scala b/core/src/main/scala/caliban/relay/Connection.scala new file mode 100644 index 0000000000..3e72ed6517 --- /dev/null +++ b/core/src/main/scala/caliban/relay/Connection.scala @@ -0,0 +1,80 @@ +package caliban.relay + +/** + * The Relay PageInfo type which models pagination info + * for a connection. + */ +case class PageInfo( + hasNextPage: Boolean, + hasPreviousPage: Boolean, + startCursor: Option[String], + endCursor: Option[String] +) + +/** + * An abstract class representing a Relay connection edge + * for some type `T`. + */ +abstract class Edge[+C: Cursor, +T] { + def cursor: C + def node: T + + def encodeCursor = Cursor[C].encode(cursor) +} + +/** + * An abstract class representing a Relay connection for + * some edge `T`. + */ +abstract class Connection[T <: Edge[_, _]] { + val pageInfo: PageInfo + val edges: List[T] +} + +object Connection { + + /** + * A function that returns a sliced result set based + * on some mapping functions, a full input list of entities + * and pagination information. + * + * @param makeConnection translates a [[caliban.relay.PageInfo]] object and a list of edges to a `Connection` + * @param makeEdge translates an entity and an offset to an `Edge` + * @param items the list of items to paginate + * @param args a set of [[caliban.relay.Pagination]] arguments + * @return a paginated connection + */ + def fromList[A, E <: Edge[Base64Cursor, _], C <: Connection[E]]( + makeConnection: (PageInfo, List[E]) => C + )(makeEdge: (A, Int) => E)( + items: List[A], + args: Pagination[Base64Cursor] + ): C = { + val itemsWithIndex = items.zipWithIndex + + val sliced = (args.cursor match { + case PaginationCursor.NoCursor => itemsWithIndex + case PaginationCursor.After(cursor) => itemsWithIndex.drop(cursor.value + 1) + case PaginationCursor.Before(cursor) => itemsWithIndex.dropRight(cursor.value + 1) + }) + + val dropped = args.count match { + case PaginationCount.First(count) => sliced.take(count) + case PaginationCount.Last(count) => sliced.takeRight(count) + } + + val edges = dropped.map(makeEdge.tupled) + + val pageInfo = PageInfo( + hasNextPage = edges.headOption + .map(e => e.cursor.value + args.count.count < items.size) + .getOrElse(false), + hasPreviousPage = edges.lastOption + .map(e => e.cursor.value > args.count.count) + .getOrElse(false), + startCursor = edges.headOption.map(start => start.encodeCursor), + endCursor = edges.lastOption.map(end => end.encodeCursor) + ) + makeConnection(pageInfo, edges) + } +} diff --git a/core/src/main/scala/caliban/relay/Cursor.scala b/core/src/main/scala/caliban/relay/Cursor.scala new file mode 100644 index 0000000000..4f7a232531 --- /dev/null +++ b/core/src/main/scala/caliban/relay/Cursor.scala @@ -0,0 +1,15 @@ +package caliban.relay + +/** + * A trait representing an abstract Relay Connection cursor. + */ +trait Cursor[A] { + type T + def encode(a: A): String + def decode(s: String): Either[String, A] + def value(cursor: A): T +} + +object Cursor { + def apply[A](implicit c: Cursor[A]): Cursor[A] = c +} diff --git a/core/src/main/scala/caliban/relay/PaginationArgs.scala b/core/src/main/scala/caliban/relay/PaginationArgs.scala new file mode 100644 index 0000000000..cbf5122702 --- /dev/null +++ b/core/src/main/scala/caliban/relay/PaginationArgs.scala @@ -0,0 +1,90 @@ +package caliban.relay + +import caliban.CalibanError +import zio._ + +object Pagination { + import PaginationCount._ + import PaginationCursor._ + + def apply[C: Cursor]( + args: PaginationArgs[C] + ): ZIO[Any, CalibanError, Pagination[C]] = + apply(args.first, args.last, args.before, args.after) + + def apply[C: Cursor]( + first: Option[Int], + last: Option[Int], + before: Option[String], + after: Option[String] + ): ZIO[Any, CalibanError, Pagination[C]] = + ZIO + .mapParN( + validateFirstLast(first, last), + validateCursors(before, after) + )((count, cursor) => new Pagination[C](count, cursor)) + .parallelErrors + .mapError((errors: ::[String]) => CalibanError.ValidationError(msg = errors.mkString(", "), explanatoryText = "")) + + private def validateCursors[C: Cursor]( + before: Option[String], + after: Option[String] + ) = + (before, after) match { + case (Some(_), Some(_)) => + ZIO.fail("before and after cannot both be set") + case (Some(x), _) => + ZIO.fromEither(Cursor[C].decode(x)).map(Before(_)) + case (_, Some(x)) => + ZIO.fromEither(Cursor[C].decode(x)).map(After(_)) + case (None, None) => ZIO.succeed(NoCursor) + } + + private def validateFirstLast(first: Option[Int], last: Option[Int]) = + (first, last) match { + case (None, None) => + ZIO.fail("first and last cannot both be empty") + case (Some(_), Some(_)) => + ZIO.fail("first and last cannot both be set") + case (Some(a), _) => + validatePositive("first", a).map(First(_)) + case (_, Some(b)) => + validatePositive("last", b).map(Last(_)) + } + + private def validatePositive(which: String, i: Int) = + ZIO.cond(i > -1, i, s"$which cannot be negative") +} + +sealed trait PaginationCount extends Product with Serializable { + def count: Int +} +object PaginationCount { + case class First(count: Int) extends PaginationCount + case class Last(count: Int) extends PaginationCount +} + +sealed trait PaginationCursor[+C] +object PaginationCursor { + case class After[C](cursor: C) extends PaginationCursor[C] + case class Before[C](cursor: C) extends PaginationCursor[C] + case object NoCursor extends PaginationCursor[Nothing] +} + +case class Pagination[+C]( + count: PaginationCount, + cursor: PaginationCursor[C] +) + +abstract class PaginationArgs[C: Cursor] { self => + val first: Option[Int] + val last: Option[Int] + val before: Option[String] + val after: Option[String] + + def toPagination: ZIO[Any, CalibanError, Pagination[C]] = Pagination( + self + ) +} + +case class PaginationError(reason: String) diff --git a/core/src/test/scala/caliban/relay/ConnectionSpec.scala b/core/src/test/scala/caliban/relay/ConnectionSpec.scala new file mode 100644 index 0000000000..9c537ae009 --- /dev/null +++ b/core/src/test/scala/caliban/relay/ConnectionSpec.scala @@ -0,0 +1,204 @@ +package caliban.relay + +import caliban._ +import zio.test.Assertion._ +import zio.test._ +import zio.ZIO + +object ConnectionSpec extends DefaultRunnableSpec { + case class ItemEdge(cursor: Base64Cursor, node: Item) extends Edge[Base64Cursor, Item] + + object ItemEdge { + def apply(x: Item, i: Int): ItemEdge = ItemEdge(Base64Cursor(i), x) + } + + case class ItemConnection( + pageInfo: PageInfo, + edges: List[ItemEdge] + ) extends Connection[ItemEdge] + + object ItemConnection { + val fromList = + Connection.fromList(ItemConnection.apply)(ItemEdge.apply)(_, _) + } + + case class Item(name: String) + + case class Args( + first: Option[Int], + last: Option[Int], + before: Option[String], + after: Option[String] + ) extends PaginationArgs[Base64Cursor] + + val conn = ItemConnection.fromList( + List(Item("a"), Item("b"), Item("c")), + Pagination( + cursor = PaginationCursor.NoCursor, + count = PaginationCount.First(2) + ) + ) + + case class Query(connection: Args => ZIO[Any, CalibanError, ItemConnection]) + val api = GraphQL.graphQL( + RootResolver( + Query(args => + for { + pageInfo <- Pagination(args) + items = ItemConnection.fromList(List(Item("1"), Item("2"), Item("3")), pageInfo) + } yield items + ) + ) + ) + + def spec = suite("ConnectionSpec")( + test("it correctly creates the connection") { + assertTrue( + conn == + ItemConnection( + PageInfo( + hasNextPage = true, + hasPreviousPage = false, + startCursor = Some("Y3Vyc29yOjA="), + endCursor = Some("Y3Vyc29yOjE=") + ), + List( + ItemEdge(Base64Cursor(0), Item("a")), + ItemEdge(Base64Cursor(1), Item("b")) + ) + ) + ) + }, + testM("it paginates the response forwards") { + for { + int <- api.interpreter + res <- int.execute("""{connection(first:2) { edges { node { name } } } }""") + } yield assert(res.data.toString)( + equalTo("""{"connection":{"edges":[{"node":{"name":"1"}},{"node":{"name":"2"}}]}}""") + ) + }, + testM("it paginates the response forwards with a cursor") { + for { + int <- api.interpreter + res <- int.execute("""{connection(first:2, after:"Y3Vyc29yOjE=") { edges { node { name } } } }""") + } yield assert(res.data.toString)( + equalTo("""{"connection":{"edges":[{"node":{"name":"3"}}]}}""") + ) + }, + testM("it paginates the response backwards") { + for { + int <- api.interpreter + res <- int.execute("""{connection(last:2) { edges { node { name } } } }""") + } yield assert(res.data.toString)( + equalTo("""{"connection":{"edges":[{"node":{"name":"2"}},{"node":{"name":"3"}}]}}""") + ) + }, + testM("it paginates the response backwards with a cursor") { + for { + int <- api.interpreter + res <- int.execute("""{connection(last:2, before: "Y3Vyc29yOjE=") { edges { node { name } } } }""") + } yield assert(res.data.toString)( + equalTo("""{"connection":{"edges":[{"node":{"name":"1"}}]}}""") + ) + }, + test("it correctly renders as GraphQL") { + val expected = """schema { + | query: Query + |} + | + |type Item { + | name: String! + |} + | + |type ItemConnection { + | pageInfo: PageInfo! + | edges: [ItemEdge!]! + |} + | + |type ItemEdge { + | cursor: String! + | node: Item! + |} + | + |type PageInfo { + | hasNextPage: Boolean! + | hasPreviousPage: Boolean! + | startCursor: String + | endCursor: String + |} + | + |type Query { + | connection(first: Int, last: Int, before: String, after: String): ItemConnection + |}""".stripMargin + + assertTrue(api.render == expected) + }, + suite("Pagination")( + testM("successfully returns a Pagination case class") { + val res = Args( + first = Some(1), + last = None, + after = Some(Cursor[Base64Cursor].encode(Base64Cursor(1))), + before = None + ).toPagination + + assertM(res)( + equalTo( + Pagination( + count = PaginationCount.First(1), + cursor = PaginationCursor.After(Base64Cursor(1)) + ) + ) + ) + }, + testM("cursor can be null") { + val res = Args( + first = Some(1), + last = None, + after = None, + before = None + ).toPagination + + assertM(res)( + equalTo( + Pagination( + count = PaginationCount.First(1), + cursor = PaginationCursor.NoCursor + ) + ) + ) + }, + testM("both cursors and counts can't be set") { + val res = Args( + first = Some(1), + last = Some(1), + after = Some("dummy"), + before = Some("dummy") + ).toPagination.run + + assertM(res)( + fails( + hasMessage( + containsString("first and last cannot both be set") && + containsString("before and after cannot both be set") + ) + ) + ) + }, + testM("must set first or last") { + val res = Pagination[Base64Cursor]( + first = None, + last = None, + after = None, + before = None + ).run + + assertM(res)( + fails( + hasMessage(equalTo("first and last cannot both be empty")) + ) + ) + } + ) + ) +} diff --git a/vuepress/docs/docs/relay-connections.md b/vuepress/docs/docs/relay-connections.md new file mode 100644 index 0000000000..74b4ccb006 --- /dev/null +++ b/vuepress/docs/docs/relay-connections.md @@ -0,0 +1,132 @@ +# Relay Connections + +The *GraphQL Cursors Connection Specification* is an additional spec that extends GraphQL to support paginating over collections in a standardized way, defined by facebook's [Relay GraphQL client](https://relay.dev/). + + +The spec defines several types: + +* Connections - the paginated 1:N relationship itself +* PageInfo - an object describing the pagination information of the current relation +* Edge - a type describing each item in the pagination +* Node - the type that's being paginated over + +An example query for a connection field looks something like this: + +```graphql +{ + queryName(first: 5, after: "cursor") { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + id # additional fields + } + } + } +} +``` + +The field can be paginated forwards by using `first` (number of items) and `after` (the current cursor), or backwards by using `last` (numbmer of items) and `before` (the cursor). + +Caliban ships with a set of abstract classes to make it easier to use Relay connections in your schema: + +```scala +import caliban.relay._ + +// The entity you want to paginate over +case class Item(name: String) + +// The specific edge type for your connection. +// The default cursor implementation is a base64 encoded index offset, +// but you can easily implement your own cursor to support e.g +// cursor based pagination from your database. +case class ItemEdge(cursor: Base64Cursor, node: Item) extends Edge[Base64Cursor, Item] + +object ItemEdge { + def apply(x: Item, i: Int): ItemEdge = ItemEdge(Base64Cursor(i), x) +} + +// The top level connection itself +case class ItemConnection( + pageInfo: PageInfo, + edges: List[ItemEdge] +) extends Connection[ItemEdge] + +object ItemConnection { + val fromList = + Connection.fromList(ItemConnection.apply)(ItemEdge.apply)(_, _) +} + + +// The arguments for your resolver. +// These are the minimal set of fields needed, +// but you can easily customize it to add e.g +// sorting or filtering. +case class Args( + first: Option[Int], + last: Option[Int], + before: Option[String], + after: Option[String] +) extends PaginationArgs[Base64Cursor] + + +case class Query(connection: Args => ZIO[Any, CalibanError, ItemConnection]) +val api = GraphQL.graphQL( + RootResolver( + Query(args => + for { + pageInfo <- Pagination(args) + items = ItemConnection.fromList(List(Item("1"), Item("2"), Item("3")), pageInfo) + } yield items + ) + ) +) +``` + +## Cursors +It's possible to implement your own cursor type to match with the underlying data source you have. This may be a database cursor, a date offset or something else which you use to efficiently filter your result set. + +Start off by implementing a case class to represent your cursor: + +```scala +case class ElasticCursor(value: String) +``` + +To turn your case class into a usable cursor, you need to do two things: implement the `Cursor` trait and specify a schema for the case class to make sure it's always serialized as a scalar value. + +First, let's implement the trait: +```scala +case class ElasticCursor(value: String) +object ElasticCursor { + lazy val decoder = Base64.getDecoder() + lazy val encoder = Base64.getEncoder() + + implicit val cursor: Cursor[ElasticCursor] = new Cursor[ElasticCursor] { + type T = String + def encode(a: ElasticCursor): String = { + encoder.encodeToString(s"cursor:${a.value}".getBytes("UTF-8")) + } + def decode(s: String): Either[String, ElasticCursor] = + Try( + ElasticCursor( + new String(decoder.decode(s), "UTF-8").replaceFirst("cursor:", "") + ) + ).toEither.left.map(t => t.toString()) + + def value(c: ElasticCursor): T = c.value + } +} +``` + +and the schema: + +```scala + implicit val schema: Schema[Any, ElasticCursor] = Schema.stringSchema.contramap( + Cursor[ElasticCursor].encode + ) +``` \ No newline at end of file