Validation
For any given interact[Tell, Ask]
the validation data structure is
List[List[Rule[Ask]]]
.
Each inner groups of Rules is carried out ‘concurrently’ (error accumulating) before sequentially passing validation on to the next stage (fail-fast).
This means that error checking can be applied in-order, all together or some mix of the two. For example, you might want to check that a user supplies valid data on all the fields in an address. It would be annoying for the user if they corrected an error on the first field then resubmitted before seeing an error on the second field. In this case you’d want the errors to accumulate. Once all the initial checks pass you might want to then run a check afterwards, for example to ensure the address actually exists or can be delivered to.
Lets start with an example with no validation at all -
import ltbs.uniform._, validation._
case class Address(
line1: String,
line2: String,
line3: String,
line4: String,
postcode: String
) {
def lines: List[String] =
List(line1, line2, line3, line4, postcode)
}
def askAddress1 = ask[Address]("post-to")
We can start with a single rule, a simple regex check against a postcode -
import cats.data.NonEmptyList
val regex = "^[A-Z]{1,2}\\d[A-Z\\d]? ?\\d[A-Z]{2}$"
val postcodeCheck = Rule.cond[Address](_.postcode.matches(regex), "bad-postcode")
Here we have given a scenario for an error as a partial function. It is important to remember we are dealing with negatives - matching against a bad input rather than giving a predicate for a good record.
The regex is used as a guard and an ErrorMsg
is supplied
along with a non-empty path to where on the form/input data structure
the error applies. There can be several paths because in some cases
the error may be applicable to several fields - if the town doesn’t
match the postcode we might want to display the error on both those
fields.
We can now test our rule on the REPL or in a unit test -
val testAddress: Address = Address(
"12 The Street",
"Genericford",
"Madeupshire",
"",
"BAD POSTCODE"
)
// testAddress: Address = Address(
// "12 The Street",
// "Genericford",
// "Madeupshire",
// "",
// "BAD POSTCODE"
// )
postcodeCheck.apply(testAddress)
// res0: cats.data.Validated[collection.immutable.ListMap[NonEmptyList[List[String]], NonEmptyList[ErrorMsg]], Address] = Invalid(
// ListMap(
// NonEmptyList(List(), List()) -> NonEmptyList(ErrorMsg("bad-postcode", WrappedArray()), List())
// )
// )
If we want to apply our validation rule to a step in a journey we simply supply it as a parameter.
def askAddress2 =
ask[Address]("post-to", validation = postcodeCheck)
In this case we only have a single Rule
applied to the validation
parameter.
If we wanted to check both a postcode against a Regex and that the 1st
line starts with a number we can either do this sequentially using
followedBy
-
val sequentialChecks: Rule[Address] =
postcodeCheck followedBy
Rule.cond[Address](_.line1.head.isDigit, "line-must-start-with-number")