Combining Rules

Introduction

We've already explained what a Rule is in the previous chapter. Those examples were only covering simple rules. However most of the time, rules are used to validate and transform complex hierarchical objects, like Json, or Forms.

The validation API allows complex object rules creation by combining simple rules together. This chapter explains how to create complex rules.

Despite examples below are validating Json objects, the API is not dedicated only to Json and can be used on any type. Please refer to Validating Json, Validating Forms, and Supporting new types for more information.

Path

The validation API defines a class named Path. A Path represents the location of a data among a complex object. Unlike JsPath it is not related to any specific type. It's just a location in some data. Most of the time, a Path is our entry point into the Validation API.

A Path is declared using this syntax:

scala> import jto.validation.Path
import jto.validation.Path

scala> val path = Path \ "foo" \ "bar"
path: jto.validation.Path = /foo/bar

Path here is the empty Path object. One may call it the root path.

A path can also reference indexed data, such as a Seq

scala> val pi = Path \ "foo" \ 0
pi: jto.validation.Path = /foo[0]

Extracting data using Path

Consider the following json:

scala> import play.api.libs.json._
import play.api.libs.json._

scala> val js: JsValue = Json.parse("""{
     |   "user": {
     |     "name" : "toto",
     |     "age" : 25,
     |     "email" : "toto@jmail.com",
     |     "isAlive" : true,
     |     "friend" : {
     |       "name" : "tata",
     |       "age" : 20,
     |       "email" : "tata@coldmail.com"
     |     }
     |   }
     | }""")
js: play.api.libs.json.JsValue = {"user":{"name":"toto","age":25,"email":"toto@jmail.com","isAlive":true,"friend":{"name":"tata","age":20,"email":"tata@coldmail.com"}}}

The first step before validating anything is to be able to access a fragment of the complex object.

Assuming you'd like to validate that friend exists and is valid in this json, you first need to access the object located at user.friend (Javascript notation).

The read method

We start by creating a Path representing the location of the data we're interested in:

scala> import jto.validation._
import jto.validation._

scala> val location: Path = Path \ "user" \ "friend"
location: jto.validation.Path = /user/friend

Path has a read[I, O] method, where I represents the input we're trying to parse, and O the output type. For example, (Path \ "foo").read[JsValue, Int], will try to read a value located at path /foo in a JsValue as an Int.

But let's try something much easier for now:

scala> import jto.validation._
import jto.validation._

scala> import play.api.libs.json._
import play.api.libs.json._

scala> val location: Path = Path \ "user" \ "friend"
location: jto.validation.Path = /user/friend

scala> val findFriend: Rule[JsValue, JsValue] = location.read[JsValue, JsValue]
<console>:26: error: No implicit view available from jto.validation.Path => jto.validation.RuleLike[play.api.libs.json.JsValue,play.api.libs.json.JsValue].
       val findFriend: Rule[JsValue, JsValue] = location.read[JsValue, JsValue]
                                                             ^

location.read[JsValue, JsValue] means we're trying to lookup at location in a JsValue, and we expect to find a JsValue there. In fact, we're defining a Rule that is picking a subtree in a JsValue.

If you try to run that code, the compiler gives you the following error:

scala> val findFriend: Rule[JsValue, JsValue] = location.read[JsValue, JsValue]
<console>:26: error: No implicit view available from jto.validation.Path => jto.validation.RuleLike[play.api.libs.json.JsValue,play.api.libs.json.JsValue].
       val findFriend: Rule[JsValue, JsValue] = location.read[JsValue, JsValue]
                                                             ^

The Scala compiler is complaining about not finding an implicit function of type Path => Rule[JsValue, JsValue]. Indeed, unlike the Json API, you have to provide a method to lookup into the data you expect to validate.

Fortunately, such method already exists. All you have to do is to import it:

scala> import jto.validation.playjson.Rules._
import jto.validation.playjson.Rules._

By convention, all useful validation methods for a given type are to be found in an object called Rules. That object contains a bunch of implicits defining how to lookup in the data, and how to coerce some of the possible values of those data into Scala types.

With those implicits in scope, we can finally create our Rule:

val findFriend: Rule[JsValue, JsValue] = location.read[JsValue, JsValue]

Alright, so far we've defined a Rule looking for some data of type JsValue, located at /user/friend in an object of type JsValue.

Now we need to apply this Rule to our data:

scala> findFriend.validate(js)
res0: jto.validation.VA[play.api.libs.json.JsValue] = Valid({"name":"tata","age":20,"email":"tata@coldmail.com"})

If we can't find anything, applying a Rule leads to a Invalid:

scala> (Path \ "foobar").read[JsValue, JsValue].validate(js)
res1: jto.validation.VA[play.api.libs.json.JsValue] = Invalid(List((/foobar,List(ValidationError(List(error.required),WrappedArray())))))

Type coercion

We now are capable of extracting data at a given Path. Let's do it again on a different sub-tree:

val age = (Path \ "user" \ "age").read[JsValue, JsValue]

Let's apply this new Rule:

scala> age.validate(js)
res2: jto.validation.VA[play.api.libs.json.JsValue] = Valid(25)

Again, if the json is invalid:

scala> age.validate(Json.obj())
res3: jto.validation.VA[play.api.libs.json.JsValue] = Invalid(List((/user/age,List(ValidationError(List(error.required),WrappedArray())))))

The Invalid informs us that it could not find /user/age in that JsValue.

That example is nice, but we'd certainly prefer to extract age as an Int rather than a JsValue. All we have to do is to change the output type in our Rule definition:

val age = (Path \ "user" \ "age").read[JsValue, Int]

And apply it:

scala> age.validate(js)
res4: jto.validation.VA[Int] = Valid(25)

If we try to parse something that is not an Int, we get a Invalid with the appropriate Path and error:

scala> (Path \ "user" \ "name").read[JsValue, Int].validate(js)
res5: jto.validation.VA[Int] = Invalid(List((/user/name,List(ValidationError(List(error.number),WrappedArray(Int))))))

So scala automagically figures out how to transform a JsValue into an Int. How does this happen?

It's fairly simple. The definition of read looks like this:

{
  def read[I, O](implicit r: Path => Rule[I, O]): Rule[I, O] = ???
}

So when use (Path \ "user" \ "age").read[JsValue, Int], the compiler looks for an implicit Path => Rule[JsValue, Int], which happens to exist in jto.validation.json.Rules.

Validated

So far we've managed to lookup for a JsValue and transform that JsValue into an Int. Problem is: not every Int is a valid age. An age should always be a positive Int.

val js = Json.parse("""{
  "user": {
    "age" : -33
  }
}""")

val age = (Path \ "user" \ "age").read[JsValue, Int]

Our current implementation of age is rather unsatisfying...

scala> age.validate(js)
res8: jto.validation.VA[Int] = Valid(-33)

We can fix that very simply using from, and a built-in Rule:

val positiveAge = (Path \ "user" \ "age").from[JsValue](min(0))

Let's try that again:

scala> positiveAge.validate(js)
res9: jto.validation.VA[Int] = Invalid(List((/user/age,List(ValidationError(List(error.min),WrappedArray(0))))))

That's better, but still not perfect: 8765 is considered valid:

scala> val js2 = Json.parse("""{ "user": { "age" : 8765 } }""")
js2: play.api.libs.json.JsValue = {"user":{"age":8765}}

scala> positiveAge.validate(js2)
res10: jto.validation.VA[Int] = Valid(8765)

Let's fix our age Rule:

val properAge = (Path \ "user" \ "age").from[JsValue](min(0) |+| max(130))

and test it:

val jsBig = Json.parse("""{ "user": { "age" : 8765 } }""")
properAge.validate(jsBig)

Full example

import jto.validation._
import jto.validation.playjson.Rules._
import play.api.libs.json._

val js = Json.parse("""{
  "user": {
    "name" : "toto",
    "age" : 25,
    "email" : "toto@jmail.com",
    "isAlive" : true,
    "friend" : {
      "name" : "tata",
      "age" : 20,
      "email" : "tata@coldmail.com"
    }
  }
}""")

val age = (Path \ "user" \ "age").from[JsValue](min(0) |+| max(130))
scala> age.validate(js)
res14: jto.validation.VA[Int] = Valid(25)

Combining Rules

So far we've validated only fragments of our json object. Now we'd like to validate the entire object, and turn it into an instance of the User class defined below:

scala> case class User(
     |   name: String,
     |   age: Int,
     |   email: Option[String],
     |   isAlive: Boolean)
defined class User

We need to create a Rule[JsValue, User]. Creating this Rule is simply a matter of combining together the rules parsing each field of the json.

import jto.validation._
import play.api.libs.json._

val userRule: Rule[JsValue, User] = From[JsValue] { __ =>
  import jto.validation.playjson.Rules._
  ((__ \ "name").read[String] ~
   (__ \ "age").read[Int] ~
   (__ \ "email").read[Option[String]] ~
   (__ \ "isAlive").read[Boolean])(User.apply)
}

Important: Note that we're importing Rules._ inside the From[I]{...} block. It is recommended to always follow this pattern, as it nicely scopes the implicits, avoiding conflicts and accidental shadowing.

From[JsValue] defines the I type of the rules we're combining. We could have written:

(Path \ "name").read[JsValue, String] ~
(Path \ "age").read[JsValue, Int] ~
//...

but repeating JsValue all over the place is just not very DRY.

results matching ""

    No results matching ""