aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJakob Odersky <jakob@driver.xyz>2017-08-02 13:38:49 -0700
committerJakob Odersky <jakob@driver.xyz>2017-08-16 19:26:10 -0700
commit985ee69beed836b97f3476306736d3f15ce37e1c (patch)
tree73a5ecb25c3162d574febe5d39cbb1fafaace699
parent322ea28ecf5ad5f65d3376f3e97e004d229d4736 (diff)
downloadrest-query-985ee69beed836b97f3476306736d3f15ce37e1c.tar.gz
rest-query-985ee69beed836b97f3476306736d3f15ce37e1c.tar.bz2
rest-query-985ee69beed836b97f3476306736d3f15ce37e1c.zip
Add parsers to common
-rw-r--r--build.sbt1
-rw-r--r--src/main/scala/xyz/driver/pdsuicommon/parsers/DimensionsParser.scala33
-rw-r--r--src/main/scala/xyz/driver/pdsuicommon/parsers/ListRequestParser.scala20
-rw-r--r--src/main/scala/xyz/driver/pdsuicommon/parsers/PaginationParser.scala60
-rw-r--r--src/main/scala/xyz/driver/pdsuicommon/parsers/ParseQueryArgException.scala3
-rw-r--r--src/main/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParser.scala145
-rw-r--r--src/main/scala/xyz/driver/pdsuicommon/parsers/SortingParser.scala55
7 files changed, 317 insertions, 0 deletions
diff --git a/build.sbt b/build.sbt
index 8801de7..83602c8 100644
--- a/build.sbt
+++ b/build.sbt
@@ -10,6 +10,7 @@ lazy val core = (project in file("."))
.settings(libraryDependencies ++= Seq(
"com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.8.3",
"com.github.pureconfig" %% "pureconfig" % "0.7.2",
+ "com.lihaoyi" %% "fastparse" % "0.3.7",
"com.typesafe.akka" %% "akka-http" % "10.0.9",
"com.typesafe.play" %% "play" % "2.5.15",
"com.typesafe.scala-logging" %% "scala-logging" % "3.5.0",
diff --git a/src/main/scala/xyz/driver/pdsuicommon/parsers/DimensionsParser.scala b/src/main/scala/xyz/driver/pdsuicommon/parsers/DimensionsParser.scala
new file mode 100644
index 0000000..147102b
--- /dev/null
+++ b/src/main/scala/xyz/driver/pdsuicommon/parsers/DimensionsParser.scala
@@ -0,0 +1,33 @@
+package xyz.driver.server.parsers
+
+import play.api.libs.json._
+import play.api.routing.sird._
+import xyz.driver.pdsuicommon.utils.WritesUtils
+
+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 {
+
+ private class DimensionsWrapper[T](dimensions: Dimensions)(implicit orig: Writes[T]) extends Writes[T] {
+ private val filteredWrites = WritesUtils.filterKeys[T](dimensions.contains)
+ override def writes(o: T): JsValue = filteredWrites.writes(o)
+ }
+
+ def tryParse(queryString: QueryString): Try[Dimensions] = {
+ val rawDimensions = queryString.getOrElse("dimensions", Seq.empty)
+ rawDimensions 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/pdsuicommon/parsers/ListRequestParser.scala b/src/main/scala/xyz/driver/pdsuicommon/parsers/ListRequestParser.scala
new file mode 100644
index 0000000..617a77e
--- /dev/null
+++ b/src/main/scala/xyz/driver/pdsuicommon/parsers/ListRequestParser.scala
@@ -0,0 +1,20 @@
+package xyz.driver.server.parsers
+
+import xyz.driver.pdsuicommon.db.{Pagination, SearchFilterExpr, Sorting}
+import play.api.mvc._
+
+import scala.util.Try
+
+final case class ListRequestParameters(filter: SearchFilterExpr, sorting: Sorting, pagination: Pagination)
+
+class ListRequestParser(validSortingFields: Set[String]) {
+
+ def tryParse(request: Request[AnyContent]): Try[ListRequestParameters] = {
+ for {
+ queryFilters <- SearchFilterParser.parse(request.queryString)
+ sorting <- SortingParser.parse(validSortingFields, request.queryString)
+ pagination <- PaginationParser.parse(request.queryString)
+ } yield ListRequestParameters(queryFilters, sorting, pagination)
+ }
+
+}
diff --git a/src/main/scala/xyz/driver/pdsuicommon/parsers/PaginationParser.scala b/src/main/scala/xyz/driver/pdsuicommon/parsers/PaginationParser.scala
new file mode 100644
index 0000000..a6a7fae
--- /dev/null
+++ b/src/main/scala/xyz/driver/pdsuicommon/parsers/PaginationParser.scala
@@ -0,0 +1,60 @@
+package xyz.driver.server.parsers
+
+import xyz.driver.pdsuicommon.db.Pagination
+import xyz.driver.server.parsers.errors.ParseQueryArgException
+import play.api.data.validation._
+import play.api.routing.sird.QueryString
+import xyz.driver.pdsuicommon.validation.AdditionalConstraints
+
+import scala.util.Try
+
+object PaginationParser {
+
+ private val oneQueryArgConstraint: Constraint[Seq[String]] = {
+ Constraint("query.oneArg") {
+ case Nil => Valid
+ case x +: Nil => Valid
+ case xs =>
+ Invalid(new ValidationError(Seq(s"must be one argument, but there are multiple: '${xs.mkString(", ")}'")))
+ }
+ }
+
+ private val pageSizeCheckConstraint: Constraint[Seq[String]] = {
+ Constraint("pagination.pageSize") { args =>
+ oneQueryArgConstraint(args) match {
+ case x: Invalid => x
+ case Valid => AdditionalConstraints.positivePrintedNumber(args.head)
+ }
+ }
+ }
+
+ private val pageNumberCheckConstraint: Constraint[Seq[String]] = {
+ Constraint("pagination.pageNumber") { args =>
+ oneQueryArgConstraint(args) match {
+ case x: Invalid => x
+ case Valid => AdditionalConstraints.positivePrintedNumber(args.head)
+ }
+ }
+ }
+
+ def parse(queryString: QueryString): Try[Pagination] = Try {
+ val rawPageSizes = queryString.getOrElse("pageSize", Seq(Pagination.Default.pageSize.toString))
+ val rawPageNumbers = queryString.getOrElse("pageNumber", Seq(Pagination.Default.pageNumber.toString))
+
+ val validation = Seq(
+ "pageSize" -> pageSizeCheckConstraint(rawPageSizes),
+ "pageNumber" -> pageNumberCheckConstraint(rawPageNumbers)
+ )
+
+ val validationErrors = validation.collect {
+ case (fieldName, e: Invalid) => (fieldName, e.errors.mkString("; "))
+ }
+
+ if (validationErrors.isEmpty) {
+ Pagination(Integer.parseInt(rawPageSizes.head), Integer.parseInt(rawPageNumbers.head))
+ } else {
+ throw new ParseQueryArgException(validationErrors: _*)
+ }
+ }
+
+}
diff --git a/src/main/scala/xyz/driver/pdsuicommon/parsers/ParseQueryArgException.scala b/src/main/scala/xyz/driver/pdsuicommon/parsers/ParseQueryArgException.scala
new file mode 100644
index 0000000..c3ab020
--- /dev/null
+++ b/src/main/scala/xyz/driver/pdsuicommon/parsers/ParseQueryArgException.scala
@@ -0,0 +1,3 @@
+package xyz.driver.server.parsers.errors
+
+class ParseQueryArgException(val errors: (String, String)*) extends Exception(errors.mkString(","))
diff --git a/src/main/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParser.scala b/src/main/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParser.scala
new file mode 100644
index 0000000..c6ea2e1
--- /dev/null
+++ b/src/main/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParser.scala
@@ -0,0 +1,145 @@
+package xyz.driver.server.parsers
+
+import xyz.driver.server.parsers.errors.ParseQueryArgException
+import xyz.driver.pdsuicommon.utils.Implicits.{toCharOps, toStringOps}
+import fastparse.all._
+import fastparse.core.Parsed
+import fastparse.parsers.Intrinsics.CharPred
+import play.api.routing.sird._
+import xyz.driver.pdsuicommon.db.{SearchFilterBinaryOperation, SearchFilterExpr, SearchFilterNAryOperation}
+
+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, String))): Option[SearchFilterExpr.Atom.Binary] = {
+ val (dimensionName, (strOperation, value)) = input
+ parseOperation(strOperation.toLowerCase).map { op =>
+ SearchFilterExpr.Atom.Binary(dimensionName, op, value.safeTrim)
+ }
+ }
+ }
+
+ 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
+ if (strOperation.toLowerCase == "in") {
+ val values = xs.asInstanceOf[Seq[String]].map(_.safeTrim)
+ Some(SearchFilterExpr.Atom.NAry(dimensionName, SearchFilterNAryOperation.In, values))
+ } else {
+ None
+ }
+ }
+ }
+
+ private val operationsMapping = {
+ import xyz.driver.pdsuicommon.db.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(_.isSafeWhitespace))
+
+ val dimensionParser: Parser[SearchFilterExpr.Dimension] = {
+ val identParser = P(
+ CharPred(c => c.isLetterOrDigit)
+ .rep(min = 1)).!.map(SearchFilterExpr.Dimension(None, _))
+ val pathParser = P(identParser.! ~ "." ~ identParser.!) map {
+ case (left, right) => SearchFilterExpr.Dimension(Some(left), right)
+ }
+ P(pathParser | identParser)
+ }
+
+ private val commonOperatorParser: Parser[String] = {
+ P(IgnoreCase("eq") | IgnoreCase("like") | IgnoreCase("noteq")).!
+ }
+
+ private val numericOperatorParser: Parser[String] = {
+ P((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
+ }
+
+ // Exclude Unicode "digits"
+ private val digitsParser: Parser[String] = P(CharIn('0' to '9').rep(min = 1).!)
+
+ // @TODO Make complex checking here
+ 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 binaryAtomParser: Parser[SearchFilterExpr.Atom.Binary] = P(
+ dimensionParser ~ whitespaceParser ~ (
+ (commonOperatorParser.! ~/ whitespaceParser ~/ AnyChar.rep(min = 1).!)
+ | (numericOperatorParser.! ~/ whitespaceParser ~/ numberParser.!)
+ ) ~ End
+ ).map {
+ case BinaryAtomFromTuple(atom) => atom
+ }
+
+ private val nAryAtomParser: Parser[SearchFilterExpr.Atom.NAry] = P(
+ dimensionParser ~ whitespaceParser ~ (
+ naryOperatorParser ~/ whitespaceParser ~/ nAryValueParser.!.rep(min = 1, sep = ",")
+ ) ~ End
+ ).map {
+ case NAryAtomFromTuple(atom) => atom
+ }
+
+ private val atomParser: Parser[SearchFilterExpr.Atom] = P(binaryAtomParser | nAryAtomParser)
+
+ def parse(queryString: QueryString): Try[SearchFilterExpr] = Try {
+ queryString.getOrElse("filters", Seq.empty) 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: ${ParseError.msg(e.extra.input, e.extra.traced.expected, e.index)}"
+ }
+
+}
diff --git a/src/main/scala/xyz/driver/pdsuicommon/parsers/SortingParser.scala b/src/main/scala/xyz/driver/pdsuicommon/parsers/SortingParser.scala
new file mode 100644
index 0000000..c042211
--- /dev/null
+++ b/src/main/scala/xyz/driver/pdsuicommon/parsers/SortingParser.scala
@@ -0,0 +1,55 @@
+package xyz.driver.server.parsers
+
+import xyz.driver.server.parsers.errors.ParseQueryArgException
+import xyz.driver.pdsuicommon.db.{Sorting, SortingOrder}
+import fastparse.all._
+import fastparse.core.Parsed
+import play.api.routing.sird._
+
+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, field, sortingOrder)
+ case 2 => Sorting.Dimension(Some(prefixedFields.head), 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)
+ }
+ }
+
+ def parse(validDimensions: Set[String], queryString: QueryString): Try[Sorting] = Try {
+ queryString.getOrElse("sort", Seq.empty) 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 xs => throw new ParseQueryArgException("sort" -> "multiple sections are not allowed")
+ }
+ }
+
+ private def formatFailure(e: Parsed.Failure): String = {
+ ParseError.msg(e.extra.input, e.extra.traced.expected, e.index)
+ }
+
+}