aboutsummaryrefslogtreecommitdiff
path: root/src/main/scala/xyz/driver/restquery/rest
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/scala/xyz/driver/restquery/rest')
-rw-r--r--src/main/scala/xyz/driver/restquery/rest/Directives.scala55
-rw-r--r--src/main/scala/xyz/driver/restquery/rest/parsers/DimensionsParser.scala30
-rw-r--r--src/main/scala/xyz/driver/restquery/rest/parsers/PaginationParser.scala23
-rw-r--r--src/main/scala/xyz/driver/restquery/rest/parsers/ParseQueryArgException.scala3
-rw-r--r--src/main/scala/xyz/driver/restquery/rest/parsers/SearchFilterParser.scala178
-rw-r--r--src/main/scala/xyz/driver/restquery/rest/parsers/SortingParser.scala64
6 files changed, 353 insertions, 0 deletions
diff --git a/src/main/scala/xyz/driver/restquery/rest/Directives.scala b/src/main/scala/xyz/driver/restquery/rest/Directives.scala
new file mode 100644
index 0000000..2936f70
--- /dev/null
+++ b/src/main/scala/xyz/driver/restquery/rest/Directives.scala
@@ -0,0 +1,55 @@
+package xyz.driver.restquery.http
+
+import akka.http.scaladsl.server.Directives._
+import akka.http.scaladsl.server._
+import xyz.driver.restquery.domain.{SearchFilterExpr, _}
+import xyz.driver.restquery.http.parsers._
+
+import scala.util._
+
+trait Directives {
+
+ val paginated: Directive1[Pagination] = parameterSeq.flatMap { params =>
+ PaginationParser.parse(params) match {
+ case Success(pagination) => provide(pagination)
+ case Failure(ex) =>
+ reject(ValidationRejection("invalid pagination parameter", Some(ex)))
+ }
+ }
+
+ def sorted(validDimensions: Set[String] = Set.empty): Directive1[Sorting] = parameterSeq.flatMap { params =>
+ SortingParser.parse(validDimensions, params) match {
+ case Success(sorting) => provide(sorting)
+ case Failure(ex) =>
+ reject(ValidationRejection("invalid sorting parameter", Some(ex)))
+ }
+ }
+
+ val dimensioned: Directive1[Dimensions] = parameterSeq.flatMap { params =>
+ DimensionsParser.tryParse(params) match {
+ case Success(dims) => provide(dims)
+ case Failure(ex) =>
+ reject(ValidationRejection("invalid dimension parameter", Some(ex)))
+ }
+ }
+
+ val searchFiltered: Directive1[SearchFilterExpr] = parameterSeq.flatMap { params =>
+ SearchFilterParser.parse(params) match {
+ case Success(sorting) => provide(sorting)
+ case Failure(ex) =>
+ reject(ValidationRejection("invalid filter parameter", Some(ex)))
+ }
+ }
+
+ def StringIdInPath[T]: PathMatcher1[StringId[T]] =
+ PathMatchers.Segment.map((id) => StringId(id.toString))
+
+ def LongIdInPath[T]: PathMatcher1[LongId[T]] =
+ PathMatchers.LongNumber.map((id) => LongId(id))
+
+ def UuidIdInPath[T]: PathMatcher1[UuidId[T]] =
+ PathMatchers.JavaUUID.map((id) => UuidId(id))
+
+}
+
+object Directives extends Directives
diff --git a/src/main/scala/xyz/driver/restquery/rest/parsers/DimensionsParser.scala b/src/main/scala/xyz/driver/restquery/rest/parsers/DimensionsParser.scala
new file mode 100644
index 0000000..7e139db
--- /dev/null
+++ b/src/main/scala/xyz/driver/restquery/rest/parsers/DimensionsParser.scala
@@ -0,0 +1,30 @@
+package xyz.driver.restquery.http.parsers
+
+import scala.util.{Failure, Success, Try}
+
+class Dimensions(private val xs: Set[String] = Set.empty) {
+ def contains(x: String): Boolean = xs.isEmpty || xs.contains(x)
+}
+
+object DimensionsParser {
+
+ @deprecated("play-akka transition", "0")
+ def tryParse(query: Map[String, Seq[String]]): Try[Dimensions] =
+ tryParse(query.toSeq.flatMap {
+ case (key, values) =>
+ values.map(value => key -> value)
+ })
+
+ def tryParse(query: Seq[(String, String)]): Try[Dimensions] = {
+ query.collect { case ("dimensions", value) => value } match {
+ case Nil => Success(new Dimensions())
+
+ case x +: Nil =>
+ val raw: Set[String] = x.split(",").view.map(_.trim).filter(_.nonEmpty).to[Set]
+ Success(new Dimensions(raw))
+
+ case xs =>
+ Failure(new IllegalArgumentException(s"Dimensions are specified ${xs.size} times"))
+ }
+ }
+}
diff --git a/src/main/scala/xyz/driver/restquery/rest/parsers/PaginationParser.scala b/src/main/scala/xyz/driver/restquery/rest/parsers/PaginationParser.scala
new file mode 100644
index 0000000..2b4547b
--- /dev/null
+++ b/src/main/scala/xyz/driver/restquery/rest/parsers/PaginationParser.scala
@@ -0,0 +1,23 @@
+package xyz.driver.restquery.http.parsers
+
+import xyz.driver.restquery.domain.Pagination
+
+import scala.util._
+
+object PaginationParser {
+
+ def parse(query: Seq[(String, String)]): Try[Pagination] = {
+ val IntString = """(\d+)""".r
+ def validate(field: String, default: Int) = query.collectFirst { case (`field`, size) => size } match {
+ case Some(IntString(x)) if x.toInt > 0 => x.toInt
+ case Some(IntString(x)) => throw new ParseQueryArgException((field, s"must greater than zero (found $x)"))
+ case Some(str) => throw new ParseQueryArgException((field, s"must be an integer (found $str)"))
+ case None => default
+ }
+
+ Try {
+ Pagination(validate("pageSize", Pagination.Default.pageSize),
+ validate("pageNumber", Pagination.Default.pageNumber))
+ }
+ }
+}
diff --git a/src/main/scala/xyz/driver/restquery/rest/parsers/ParseQueryArgException.scala b/src/main/scala/xyz/driver/restquery/rest/parsers/ParseQueryArgException.scala
new file mode 100644
index 0000000..096c28f
--- /dev/null
+++ b/src/main/scala/xyz/driver/restquery/rest/parsers/ParseQueryArgException.scala
@@ -0,0 +1,3 @@
+package xyz.driver.restquery.http.parsers
+
+class ParseQueryArgException(val errors: (String, String)*) extends Exception(errors.mkString(","))
diff --git a/src/main/scala/xyz/driver/restquery/rest/parsers/SearchFilterParser.scala b/src/main/scala/xyz/driver/restquery/rest/parsers/SearchFilterParser.scala
new file mode 100644
index 0000000..ce3009b
--- /dev/null
+++ b/src/main/scala/xyz/driver/restquery/rest/parsers/SearchFilterParser.scala
@@ -0,0 +1,178 @@
+package xyz.driver.restquery.http.parsers
+
+import java.util.UUID
+
+import fastparse.all._
+import fastparse.core.Parsed
+import xyz.driver.restquery.domain.{SearchFilterBinaryOperation, SearchFilterExpr, SearchFilterNAryOperation}
+import xyz.driver.restquery.utils.Utils
+import xyz.driver.restquery.utils.Utils._
+
+import scala.util.Try
+
+@SuppressWarnings(Array("org.wartremover.warts.Product", "org.wartremover.warts.Serializable"))
+object SearchFilterParser {
+
+ private object BinaryAtomFromTuple {
+ def unapply(input: (SearchFilterExpr.Dimension, (String, Any))): Option[SearchFilterExpr.Atom.Binary] = {
+ val (dimensionName, (strOperation, value)) = input
+ val updatedValue = trimIfString(value)
+
+ parseOperation(strOperation.toLowerCase).map { op =>
+ SearchFilterExpr.Atom.Binary(dimensionName, op, updatedValue.asInstanceOf[AnyRef])
+ }
+ }
+ }
+
+ private object NAryAtomFromTuple {
+ // Compiler warning: unchecked since it is eliminated by erasure, if we user Seq[String]
+ def unapply(input: (SearchFilterExpr.Dimension, (String, Seq[_]))): Option[SearchFilterExpr.Atom.NAry] = {
+ val (dimensionName, (strOperation, xs)) = input
+ val updatedValues = xs.map(trimIfString)
+
+ if (strOperation.toLowerCase == "in") {
+ Some(
+ SearchFilterExpr.Atom
+ .NAry(dimensionName, SearchFilterNAryOperation.In, updatedValues.map(_.asInstanceOf[AnyRef])))
+ } else {
+ None
+ }
+ }
+ }
+
+ private def trimIfString(value: Any) =
+ value match {
+ case s: String => Utils.safeTrim(s)
+ case a => a
+ }
+
+ private val operationsMapping = {
+ import xyz.driver.restquery.domain.SearchFilterBinaryOperation._
+
+ Map[String, SearchFilterBinaryOperation](
+ "eq" -> Eq,
+ "noteq" -> NotEq,
+ "like" -> Like,
+ "gt" -> Gt,
+ "gteq" -> GtEq,
+ "lt" -> Lt,
+ "lteq" -> LtEq
+ )
+ }
+
+ private def parseOperation(x: String): Option[SearchFilterBinaryOperation] = operationsMapping.get(x)
+
+ private val whitespaceParser = P(CharPred(Utils.isSafeWhitespace))
+
+ val dimensionParser: Parser[SearchFilterExpr.Dimension] = {
+ val identParser = P(
+ CharPred(c => c.isLetterOrDigit)
+ .rep(min = 1)).!.map(s => SearchFilterExpr.Dimension(None, toSnakeCase(s)))
+ val pathParser = P(identParser.! ~ "." ~ identParser.!) map {
+ case (left, right) =>
+ SearchFilterExpr.Dimension(Some(toSnakeCase(left)), toSnakeCase(right))
+ }
+ P(pathParser | identParser)
+ }
+
+ private val commonOperatorParser: Parser[String] = {
+ P(IgnoreCase("eq") | IgnoreCase("like") | IgnoreCase("noteq")).!
+ }
+
+ private val numericOperatorParser: Parser[String] = {
+ P(IgnoreCase("eq") | IgnoreCase("noteq") | ((IgnoreCase("gt") | IgnoreCase("lt")) ~ IgnoreCase("eq").?)).!
+ }
+
+ private val naryOperatorParser: Parser[String] = P(IgnoreCase("in")).!
+
+ private val isPositiveParser: Parser[Boolean] = P(CharIn("-+").!.?).map {
+ case Some("-") => false
+ case _ => true
+ }
+
+ private val digitsParser: Parser[String] = P(CharIn('0' to '9').rep(min = 1).!) // Exclude Unicode "digits"
+
+ private val numberParser: Parser[String] = P(isPositiveParser ~ digitsParser.! ~ ("." ~ digitsParser).!.?).map {
+ case (false, intPart, Some(fracPart)) => s"-$intPart.${fracPart.tail}"
+ case (false, intPart, None) => s"-$intPart"
+ case (_, intPart, Some(fracPart)) => s"$intPart.${fracPart.tail}"
+ case (_, intPart, None) => s"$intPart"
+ }
+
+ private val nAryValueParser: Parser[String] = P(CharPred(_ != ',').rep(min = 1).!)
+
+ private val longParser: Parser[Long] = P(CharIn('0' to '9').rep(min = 1).!.map(_.toLong))
+
+ private val booleanParser: Parser[Boolean] =
+ P((IgnoreCase("true") | IgnoreCase("false")).!.map(_.toBoolean))
+
+ private val hexDigit: Parser[String] = P((CharIn('a' to 'f') | CharIn('A' to 'F') | CharIn('0' to '9')).!)
+
+ private val uuidParser: Parser[UUID] =
+ P(
+ hexDigit.rep(8).! ~ "-" ~ hexDigit.rep(4).! ~ "-" ~ hexDigit.rep(4).! ~ "-" ~ hexDigit.rep(4).! ~ "-" ~ hexDigit
+ .rep(12)
+ .!).map {
+ case (group1, group2, group3, group4, group5) => UUID.fromString(s"$group1-$group2-$group3-$group4-$group5")
+ }
+
+ private val binaryAtomParser: Parser[SearchFilterExpr.Atom.Binary] = P(
+ dimensionParser ~ whitespaceParser ~
+ ((numericOperatorParser.! ~ whitespaceParser ~ (longParser | numberParser.!) ~ End) |
+ (commonOperatorParser.! ~ whitespaceParser ~ (uuidParser | booleanParser | AnyChar.rep(min = 1).!) ~ End))
+ ).map {
+ case BinaryAtomFromTuple(atom) => atom
+ }
+
+ private val nAryAtomParser: Parser[SearchFilterExpr.Atom.NAry] = P(
+ dimensionParser ~ whitespaceParser ~ (
+ naryOperatorParser ~ whitespaceParser ~
+ ((longParser.rep(min = 1, sep = ",") ~ End) | (booleanParser.rep(min = 1, sep = ",") ~ End) |
+ (nAryValueParser.!.rep(min = 1, sep = ",") ~ End))
+ )
+ ).map {
+ case NAryAtomFromTuple(atom) => atom
+ }
+
+ private val atomParser: Parser[SearchFilterExpr.Atom] = P(binaryAtomParser | nAryAtomParser)
+
+ @deprecated("play-akka transition", "0")
+ def parse(query: Map[String, Seq[String]]): Try[SearchFilterExpr] =
+ parse(query.toSeq.flatMap {
+ case (key, values) =>
+ values.map(value => key -> value)
+ })
+
+ def parse(query: Seq[(String, String)]): Try[SearchFilterExpr] = Try {
+ query.toList.collect { case ("filters", value) => value } match {
+ case Nil => SearchFilterExpr.Empty
+
+ case head :: Nil =>
+ atomParser.parse(head) match {
+ case Parsed.Success(x, _) => x
+ case e: Parsed.Failure[_, _] => throw new ParseQueryArgException("filters" -> formatFailure(1, e))
+ }
+
+ case xs =>
+ val parsed = xs.map(x => atomParser.parse(x))
+ val failures: Seq[String] = parsed.zipWithIndex.collect {
+ case (e: Parsed.Failure[_, _], index) => formatFailure(index, e)
+ }
+
+ if (failures.isEmpty) {
+ val filters = parsed.collect {
+ case Parsed.Success(x, _) => x
+ }
+
+ SearchFilterExpr.Intersection.create(filters: _*)
+ } else {
+ throw new ParseQueryArgException("filters" -> failures.mkString("; "))
+ }
+ }
+ }
+
+ private def formatFailure(sectionIndex: Int, e: Parsed.Failure[_, _]): String = {
+ s"section $sectionIndex: ${fastparse.core.ParseError.msg(e.extra.input, e.extra.traced.expected, e.index)}"
+ }
+
+}
diff --git a/src/main/scala/xyz/driver/restquery/rest/parsers/SortingParser.scala b/src/main/scala/xyz/driver/restquery/rest/parsers/SortingParser.scala
new file mode 100644
index 0000000..f2a3c04
--- /dev/null
+++ b/src/main/scala/xyz/driver/restquery/rest/parsers/SortingParser.scala
@@ -0,0 +1,64 @@
+package xyz.driver.restquery.http.parsers
+
+import fastparse.all._
+import fastparse.core.Parsed
+import xyz.driver.restquery.domain.{Sorting, SortingOrder}
+import xyz.driver.restquery.utils.Utils._
+
+import scala.util.Try
+
+object SortingParser {
+
+ private val sortingOrderParser: Parser[SortingOrder] = P("-".!.?).map {
+ case Some(_) => SortingOrder.Descending
+ case None => SortingOrder.Ascending
+ }
+
+ private def dimensionSortingParser(validDimensions: Seq[String]): Parser[Sorting.Dimension] = {
+ P(sortingOrderParser ~ StringIn(validDimensions: _*).!).map {
+ case (sortingOrder, field) =>
+ val prefixedFields = field.split("\\.", 2)
+ prefixedFields.size match {
+ case 1 => Sorting.Dimension(None, toSnakeCase(field), sortingOrder)
+ case 2 =>
+ Sorting.Dimension(Some(prefixedFields.head).map(toSnakeCase),
+ toSnakeCase(prefixedFields.last),
+ sortingOrder)
+ }
+ }
+ }
+
+ private def sequentialSortingParser(validDimensions: Seq[String]): Parser[Sorting.Sequential] = {
+ P(dimensionSortingParser(validDimensions).rep(min = 1, sep = ",") ~ End).map { dimensions =>
+ Sorting.Sequential(dimensions)
+ }
+ }
+
+ @deprecated("play-akka transition", "0")
+ def parse(validDimensions: Set[String], query: Map[String, Seq[String]]): Try[Sorting] =
+ parse(validDimensions, query.toSeq.flatMap {
+ case (key, values) =>
+ values.map(value => key -> value)
+ })
+
+ def parse(validDimensions: Set[String], query: Seq[(String, String)]): Try[Sorting] = Try {
+ query.toList.collect { case ("sort", value) => value } match {
+ case Nil => Sorting.Sequential(Seq.empty)
+
+ case rawSorting :: Nil =>
+ val parser = sequentialSortingParser(validDimensions.toSeq)
+ parser.parse(rawSorting) match {
+ case Parsed.Success(x, _) => x
+ case e: Parsed.Failure[_, _] =>
+ throw new ParseQueryArgException("sort" -> formatFailure(e))
+ }
+
+ case _ => throw new ParseQueryArgException("sort" -> "multiple sections are not allowed")
+ }
+ }
+
+ private def formatFailure(e: Parsed.Failure[_, _]): String = {
+ fastparse.core.ParseError.msg(e.extra.input, e.extra.traced.expected, e.index)
+ }
+
+}