Validating and transforming data
Introduction
The API is designed around the concept of Rule
. A Rule[I, O]
defines a way to validate and coerce data, from type I
to type O
. It's basically a function I => Validated[O]
, where I
is the type of the input to validate, and O
is the expected output type.
A simple example
Let's say you want to coerce a String
into an Float
.
All you need to do is to define a Rule
from String to Float:
import jto.validation._
def isFloat: Rule[String, Float] = ???
When a String
is parsed into an Float
, two scenarios are possible, either:
- The
String
can be parsed as aFloat
. - The
String
can NOT be parsed as aFloat
In a typical Scala application, you would use Float.parseFloat
to parse a String
. On an "invalid" value, this method throws a NumberFormatException
.
When validating data, we'd certainly prefer to avoid exceptions, as the failure case is expected to happen quite often.
Furthermore, your application should handle it properly, for example by sending a nice error message to the end user. The execution flow of the application should not be altered by a parsing failure, but rather be part of the process. Exceptions are definitely not the appropriate tool for the job.
Back, to our Rule
. For now we'll not implement isFloat
, actually, the validation API comes with a number of built-in Rules, including the Float
parsing Rule[String, Float]
.
All you have to do is import the default Rules.
import jto.validation._
object Rules extends GenericRules with ParsingRules
Rules.floatR
Let's now test it against different String values:
scala> Rules.floatR.validate("1")
res1: jto.validation.VA[Float] = Valid(1.0)
scala> Rules.floatR.validate("-13.7")
res2: jto.validation.VA[Float] = Valid(-13.7)
scala> Rules.floatR.validate("abc")
res3: jto.validation.VA[Float] = Invalid(List((/,List(ValidationError(List(error.number),WrappedArray(Float))))))
Rule
is typesafe. You can't apply aRule
on an unsupported type, the compiler won't let you:
scala> Rules.floatR.validate(Seq(32)) <console>:20: error: type mismatch; found : Seq[Int] required: String Rules.floatR.validate(Seq(32)) ^
"abc" is not a valid Float
but no exception was thrown. Instead of relying on exceptions, validate
is returning an object of type Validated
(here VA
is just a fancy alias for a special kind of validation).
Validated
represents possible outcomes of Rule application, it can be either :
- A
Valid
, holding the value being validated When we useRule.float
on "1", since "1" is a valid representation of aFloat
, it returnsValid(1.0)
- A
Invalid
, containing all the errors. When we useRule.float
on "abc", since "abc" is not a valid representation of aFloat
, it returnsInvalid(List((/,List(ValidationError(validation.type-mismatch,WrappedArray(Float))))))
. ThatInvalid
tells us all there is to know: it give us a nice message explaining what has failed, and even gives us a parameter"Float"
, indicating which type theRule
expected to find.
Note that
Validated
is a parameterized type. Just likeRule
, it keeps track of the input and output types. The methodvalidate
of aRule[I, O]
always return aVA[I, O]
Defining your own Rules
Creating a new Rule
is almost as simple as creating a new function.
All there is to do is to pass a function I => Validated[I, O]
to Rule.fromMapping
.
This example creates a new Rule
trying to get the first element of a List[Int]
.
In case of an empty List[Int]
, the rule should return a Invalid
.
val headInt: Rule[List[Int], Int] = Rule.fromMapping {
case Nil => Invalid(Seq(ValidationError("error.emptyList")))
case head :: _ => Valid(head)
}
scala> headInt.validate(List(1, 2, 3, 4, 5))
res5: jto.validation.VA[Int] = Valid(1)
scala> headInt.validate(Nil)
res6: jto.validation.VA[Int] = Invalid(List((/,List(ValidationError(List(error.emptyList),WrappedArray())))))
We can make this rule a bit more generic:
def head[T]: Rule[List[T], T] = Rule.fromMapping {
case Nil => Invalid(Seq(ValidationError("error.emptyList")))
case head :: _ => Valid(head)
}
scala> head.validate(List('a', 'b', 'c', 'd'))
res7: jto.validation.VA[Char] = Valid(a)
scala> head.validate(List[Char]())
res8: jto.validation.VA[Char] = Invalid(List((/,List(ValidationError(List(error.emptyList),WrappedArray())))))
Composing Rules
Rules composition is very important in this API. Rule
composition means that given two Rule
a
and b
, we can easily create a new Rule c
.
There two different types of composition
"Sequential" composition
Sequential composition means that given two rules a: Rule[I, J]
and b: Rule[J, O]
, we can create a new rule c: Rule[I, O]
.
Consider the following example: We want to write a Rule
that given a List[String]
, takes the first String
in that List
, and try to parse it as a Float
.
We already have defined:
head: Rule[List[T], T]
returns the first element of aList
float: Rule[String, Float]
parses aString
into aFloat
We've done almost all the work already. We just have to create a new Rule
the applies the first Rule
and if it returns a Valid
, apply the second Rule
.
It would be fairly easy to create such a Rule
"manually", but we don't have to. A method doing just that is already available:
val firstFloat: Rule[List[String], Float] = head.andThen(Rules.floatR)
scala> firstFloat.validate(List("1", "2"))
res9: jto.validation.VA[Float] = Valid(1.0)
scala> firstFloat.validate(List("1.2", "foo"))
res10: jto.validation.VA[Float] = Valid(1.2)
If the list is empty, we get the error from head
scala> firstFloat.validate(List())
res11: jto.validation.VA[Float] = Invalid(List((/,List(ValidationError(List(error.emptyList),WrappedArray())))))
If the first element is not parseable, we get the error from Rules.float
.
scala> firstFloat.validate(List("foo", "2"))
res12: jto.validation.VA[Float] = Invalid(List((/,List(ValidationError(List(error.number),WrappedArray(Float))))))
Of course everything is still typesafe:
scala> firstFloat.validate(List(1, 2, 3))
<console>:22: error: type mismatch;
found : Int(1)
required: String
firstFloat.validate(List(1, 2, 3))
^
<console>:22: error: type mismatch;
found : Int(2)
required: String
firstFloat.validate(List(1, 2, 3))
^
<console>:22: error: type mismatch;
found : Int(3)
required: String
firstFloat.validate(List(1, 2, 3))
^
Improving reporting.
All is fine with our new Rule
but the error reporting when we parse an element is not perfect yet.
When a parsing error happens, the Invalid
does not tell us that it happened on the first element of the List
.
To fix that, we can pass an additionnal parameter to andThen
:
val firstFloat2: Rule[List[String],Float] = head.andThen(Path \ 0)(Rules.floatR)
scala> firstFloat2.validate(List("foo", "2"))
res14: jto.validation.VA[Float] = Invalid(List(([0],List(ValidationError(List(error.number),WrappedArray(Float))))))
"Parallel" composition
Parallel composition means that given two rules a: Rule[I, O]
and b: Rule[I, O]
, we can create a new rule c: Rule[I, O]
.
This form of composition is almost exclusively used for the particular case of rules that are purely constraints, that is, a Rule[I, I]
checking a value of type I
satisfies a predicate, but does not transform that value.
Consider the following example: We want to write a Rule
that given an Int
, check that this Int
is positive and even.
The validation API already provides Rules.min
, we have to define even
ourselves:
val positive: Rule[Int,Int] = Rules.min(0)
val even: Rule[Int,Int] = Rules.validateWith[Int]("error.even"){ _ % 2 == 0 }
Now we can compose those rules using |+|
val positiveAndEven: Rule[Int,Int] = positive |+| even
Let's test our new Rule
:
scala> positiveAndEven.validate(12)
res15: jto.validation.VA[Int] = Valid(12)
scala> positiveAndEven.validate(-12)
res16: jto.validation.VA[Int] = Invalid(ArrayBuffer((/,List(ValidationError(List(error.min),WrappedArray(0))))))
scala> positiveAndEven.validate(13)
res17: jto.validation.VA[Int] = Invalid(ArrayBuffer((/,List(ValidationError(List(error.even),WrappedArray())))))
scala> positiveAndEven.validate(-13)
res18: jto.validation.VA[Int] = Invalid(ArrayBuffer((/,List(ValidationError(List(error.min),WrappedArray(0)), ValidationError(List(error.even),WrappedArray())))))
Note that both rules are applied. If both fail, we get two ValidationError
.