Skip to content

Instantly share code, notes, and snippets.

@jeroenr
Last active August 30, 2016 08:53
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jeroenr/8956587 to your computer and use it in GitHub Desktop.
Save jeroenr/8956587 to your computer and use it in GitHub Desktop.
Parameter validation with Play 2 framework
object ApplicationBuild extends Build {
val appName = "my-app"
val appVersion = "1-SNAPSHOT"
val appDependencies = Seq(
jdbc,
cache
)
val main = play.Project(appName, appVersion, appDependencies)
.settings(
routesImport += "binders._",
)
}
package binders
import play.api.mvc.QueryStringBindable
import util.{Try, Success, Failure}
import services.ParamValidator
import play.api.data.validation._
import play.api.i18n.Messages
case class Pager(offset: Int, size: Int)
object Pager {
val NUM = "num"
val SIZE = "size"
val DEFAULT_NUM = 1
val DEFAULT_SIZE = 30
val CONSTRAINTS = Seq(ParamValidator.MIN_0, Constraints.max(50000000))
implicit def queryStringBinder(implicit intBinder: QueryStringBindable[Int]) = new QueryStringBindable[Pager] {
override def bind(key: String, params: Map[String, Seq[String]]): Option[Either[String, Pager]] = {
val pagingKeys = Seq(s"$key.$NUM", s"$key.$SIZE")
val pagingParams = pagingKeys.filter(params.keys.toSeq.contains(_))
val result = for {
num <- Try(intBinder.bind(pagingKeys(0), params).get).recover {
case e => Right(DEFAULT_NUM)
}
size <- Try(intBinder.bind(pagingKeys(1), params).get).recover {
case e => Right(DEFAULT_SIZE)
}
} yield {
(num.right.toOption, size.right.toOption)
}
result match {
case Success((maybeNum, maybeSize)) =>
ParamValidator(CONSTRAINTS, maybeNum, maybeSize) match {
case Valid =>
Some(Right(Pager(maybeNum.get - 1, maybeSize.get)))
case Invalid(errors) =>
Some(Left(errors.zip(pagingParams).map {
case (ValidationError(message, v), param) => Messages(message, param, v)
}.mkString(", ")))
}
case Failure(e) => Some(Left(s"Invalid paging params: ${e.getMessage}"))
}
}
override def unbind(key: String, pager: Pager): String = {
intBinder.unbind(s"$key.$NUM", pager.offset + 1) + "&" + intBinder.unbind(s"$key.$SIZE", pager.size)
}
}
}
package services
import play.api.data.validation.{Constraint, Invalid, Valid, Constraints}
/**
* Created by jeroen on 2/7/14.
*/
object ParamValidator {
val MIN_0 = Constraints.min(0)
def apply[T](constraints: Iterable[Constraint[T]], optionalParam: Option[T]*) =
optionalParam.flatMap { _.map { param =>
constraints flatMap {
_(param) match {
case i:Invalid => Some(i)
case _ => None
}
}
}
}.flatten match {
case Nil => Valid
case invalids => invalids.reduceLeft {
(a,b) => a ++ b
}
}
}
GET /api/users controllers.UserController.list(page: Pager)
@fedragon
Copy link

Hey Jeroen, nice idea! I wanted to understand how it worked and before I knew it I rewrote it with flatMap, see if you like it! :)

def apply(constraints: Iterable[Constraint[Int]], optionalParams: Option[Int]*) =
    val invalids =
        optionalParams.flatMap { params =>
          params map { param =>
              constraints flatMap { cons =>
                cons(param) match {
                  case i @ Invalid(_) => Some(i)
                  case _ => None
                }
              }
            }
        }

    invalids.flatten.toList match {
      case Nil => Valid
      case xs => xs.reduceLeft((a, b) => a ++ b)
    }
}

Here's a small test I used to verify it behaves as intended:

class IntParamValidatorSpec extends Specification {

  "IntParamValidator" should {

    "accept a valid value" in {
      IntParamValidator(List(Constraints.max(10)), Some(9)) must equalTo (Valid)
    }

    "refuse a value out of range" in {
      val result = Invalid(List(ValidationError("error.max", 10)))
      IntParamValidator(List(Constraints.max(10)), Some(11)) must equalTo (result)
    }

    "validate multiple values" in {
      val constraints = List(Constraints.max(10), Constraints.min(1))
      val values = Array(Some(9), Some(0), Some(5))
      val expected = Invalid(List(ValidationError("error.min", 1)))

      IntParamValidator(constraints, values:_*) must equalTo (expected)
    }
  }
}

@jeroenr
Copy link
Author

jeroenr commented Feb 13, 2014

Great work! I like your solution, because it uses less match statements. I get type erasure warnings when I try to match the Seq[Invalid]

I especially like how you use flatten on the Seq[Option[ValidationResult]] to only keep the Invalid results. Then you don't need to partition :)

@jeroenr
Copy link
Author

jeroenr commented Feb 13, 2014

Btw, it's also easy to make it work for other parameter types

object ParamValidator {
  def apply[T](constraints: Iterable[Constraint[T]], optionalParam: Option[T]*) =
    optionalParam.flatMap { _.map { param =>
        constraints flatMap {
          _(param) match {
            case i:Invalid => Some(i)
            case _ => None
          }
        }
      }
    }.flatten match {
      case Nil => Valid
      case invalids => invalids.reduceLeft {
        (a,b) => a ++ b
      }
    }
}

@fedragon
Copy link

Hahaha I was looking back at the code right now and I had the same idea!

@jeroenr
Copy link
Author

jeroenr commented Feb 17, 2014

Haha great :). I made a pull request for this stuff here playframework/playframework#2377. Would be nice if they merge it in in some way.

@jeroenr
Copy link
Author

jeroenr commented Feb 18, 2014

Added sample of using the validator in a custom query string binder

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment