Skip to content

Commit

Permalink
feat: Relay cursors/connections (#1196)
Browse files Browse the repository at this point in the history
  • Loading branch information
frekw authored Jan 14, 2022
1 parent 77b1829 commit 856a83b
Show file tree
Hide file tree
Showing 6 changed files with 562 additions and 0 deletions.
41 changes: 41 additions & 0 deletions core/src/main/scala/caliban/relay/Base64Cursor.scala
Original file line number Diff line number Diff line change
@@ -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)
}
80 changes: 80 additions & 0 deletions core/src/main/scala/caliban/relay/Connection.scala
Original file line number Diff line number Diff line change
@@ -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)
}
}
15 changes: 15 additions & 0 deletions core/src/main/scala/caliban/relay/Cursor.scala
Original file line number Diff line number Diff line change
@@ -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
}
90 changes: 90 additions & 0 deletions core/src/main/scala/caliban/relay/PaginationArgs.scala
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit 856a83b

Please sign in to comment.