From 985ee69beed836b97f3476306736d3f15ce37e1c Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Wed, 2 Aug 2017 13:38:49 -0700 Subject: Add parsers to common --- .../pdsuicommon/parsers/DimensionsParser.scala | 33 +++++ .../pdsuicommon/parsers/ListRequestParser.scala | 20 +++ .../pdsuicommon/parsers/PaginationParser.scala | 60 +++++++++ .../parsers/ParseQueryArgException.scala | 3 + .../pdsuicommon/parsers/SearchFilterParser.scala | 145 +++++++++++++++++++++ .../driver/pdsuicommon/parsers/SortingParser.scala | 55 ++++++++ 6 files changed, 316 insertions(+) create mode 100644 src/main/scala/xyz/driver/pdsuicommon/parsers/DimensionsParser.scala create mode 100644 src/main/scala/xyz/driver/pdsuicommon/parsers/ListRequestParser.scala create mode 100644 src/main/scala/xyz/driver/pdsuicommon/parsers/PaginationParser.scala create mode 100644 src/main/scala/xyz/driver/pdsuicommon/parsers/ParseQueryArgException.scala create mode 100644 src/main/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParser.scala create mode 100644 src/main/scala/xyz/driver/pdsuicommon/parsers/SortingParser.scala (limited to 'src/main/scala/xyz/driver/pdsuicommon/parsers') 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) + } + +} -- cgit v1.2.3