davegurnell / checklist   0.6.0

Apache License 2.0 GitHub

Validation library for Scala.

Scala versions: 2.13 2.12
Scala.js versions: 1.x

Checklist

Library for reading and validating data, with support for hard and soft constraints. Pre-alpha. Not ready for use.

Copyright 2016-20 Dave Gurnell. Licensed Apache 2.

Build Status Coverage status Maven Central

Getting It

Add one of the following to your build.sbt:

// For regular Scala projects:
libraryDependencies += "com.davegurnell" %% "checklist" % "<<VERSION>>"

// For ScalaJS projects:
libraryDependencies += "com.davegurnell" %%% "checklist" % "<<VERSION>>"

// Optional refinement module for Scala projects:
libraryDependencies += "com.davegurnell" %% "checklist-refinement" % "<<VERSION>>"

// Optional refinement module for ScalaJS projects:
libraryDependencies += "com.davegurnell" %%% "checklist-refinement" % "<<VERSION>>"

Publishing It

These are some notes-to-self about publishing until I get around to automating it:

  • Release by running the release command.

  • The gpg command line seems to ignore the --passphrase switch supplied by sbt-pgp. No settings in SBT will work around this. GPG passphrase entry has to be manual.

  • gpg puts a fancy dialog on-screen for password entry, which as two knock-on effects:

    • it requires an environment variable called GPG_TTY that I've set using direnv (export GPG_TTY=$(tty));
    • it is messed up by SBT Super Shell, so I've disabled it in build.sbt.

Synopsis

Checklist is a library for validating data in applications and inputs to applications. Key features include:

  • hard and soft validation ("errors" and "warnings");
  • accumulation of errors (as opposed to fail-fast error handling);
  • the ability to transform values when validating them;
  • recording the location of errors within an ADT using "Paths";
  • a convenient shorthand syntax for tip-down validation of existing data.

The main concepts are as follows:

The main unit of code is a function-like Rule type. A Rule[A, B] validates a value of type A and returns a value of type B.

Because validation can fail, the actual return type is Checked[B], which is a type alias for Ior[NonEmptyList[Message], B].

A Message is a data structure containing a String and a Path describing the location of the error.

Hard vs Soft Validation

Messages come in two varieties:

  • ErrorMessages represent "fatal" errors (hard validation);
  • WarningMessages represent advisory messages (soft validation).

Here's an example of a hard validation rule:

import checklist._, Message.errors

val parseInt: Rule[String, Int] =
  Rule.pure { str =>
    Xor.catchNonFatal(str.toInt).fold(
      exn => Ior.left(errors("Must be an integer"))
      num => Ior.right(num)
    )
  }

and a soft rule:

import checklist._, .Message.warnings

def tooManyCoffees(recommendedLimit: Int): Rule[Int, Int] =
  Rule.pure { coffees =>
    if(coffees > recommendedLimit) {
      Ior.both(warnings("Hands shaking yet?"), coffees)
    } else {
      Ior.right(coffees)
    }
  }

Built-in Rules

There are a host of built-in rules that do useful things for example:

import checklist._, Rule._

val greaterThanZero: Rule[Int, Int] =
  gte(0)

val nonEmptyString: Rule[String, String] =
  nonEmpty[String]

def nonEmptyList[A]: Rule[List[A], List[A]] =
  nonEmpty[List[A]]

The built-in rules provide default English error messages that make sense in a suitable context (e.g. below a control on a web form). However, you can specify your own messages for each:

import checklist._, Rule._

val greaterThanZero: Rule[Int, Int] =
  gte(0, Message.errors("Pull up!"))

val nonEmptyString: Rule[String, String] =
  nonEmpty[String](Message.errors("Talk to me!"))

def nonEmptyList[A]: Rule[List[A], List[A]] =
  nonEmpty[List[A]](Message.errors("Not enough data!"))

There are also a handful of useful constructor methods for building your own rules:

import checklist._, Rule.test, Message.errors

val evenNumber: Rule[Int, Int] =
  test[Int](errors("That's odd...")) { num =>
    num % 2 == 0
  }

Combining Rules

Checklist is built on top of Cats. Rule has an instance of Cats' Applicative type class, so we can combine them in parallel using Cartesian syntax:

import checklist._, Rule._
import cats.syntax.cartesian._

type Data = Map[String, String]

case class Coord(x: Int, y: Int)

val readCoord: Rule[Data, Coord] = (
  mapValue("x").andThen(parseInt).andThen(gte(0)) |@|
  mapValue("y").andThen(parseInt).andThen(gte(0))
).map(Coord.apply)

The error handling semantics are to gather as many errors as possible, and construct a result value if possible. The result is an Ior containing errors and/or the result value as appropriate:

readCoord(Map.empty)
// res0: checklist.Checked[Coord] = Left(
//   NonEmptyList(
//     ErrorMessage(Value not found,Path(x)),
//     ErrorMessage(Value not found,Path(y))
//   )
// )

readCoord(Map("x" -> "-1", "y" -> "-1"))
// res1: checklist.Checked[Coord] = Both(
//   NonEmptyList(
//     ErrorMessage(Must be greater than or equal to 0,Path()),
//     ErrorMessage(Must be greater than or equal to 0,Path())
//   ),
//   Coord(-1,-1)
// )

readCoord(Map("x" -> "0", "y" -> "0"))
// res2: checklist.Checked[Coord] = Right(Coord(0,0))

Checklist contains built-in functionality for calculating error Paths from a variety of data types, including Strings, Ints, and other Paths:

import checklist._, Rule._
import cats.instances.list._ // for Traverse[List]

val readNestedLists: Rule[List[List[Int]], List[List[String]]] =
  gte(0).map(_ + "!").seq[List].seq[List]

readNestedLists(List(List(1, -2, 3), List(-4, 5, -6)))
// res3: checklist.Checked[List[List[String]]] = Both(
//   NonEmptyList(
//     ErrorMessage(Must be greater than or equal to 0,Path(0/1)),
//     ErrorMessage(Must be greater than or equal to 0,Path(1/0)),
//     ErrorMessage(Must be greater than or equal to 0,Path(1/2))
//   ),
//   List(List(1!, -2!, 3!), List(-4!, 5!, -6!))
// )

Top-Down Syntax

Checklist uses macros and Monocle lenses to make it easy to validate and clean existing data. Here's an example:

import checklist._, Rule._

// The `field` macro below makes use of higher kinded types:
import scala.language.higherKinds

case class Address(house: Int, street: String)

implicit val checkAddress: Rule[Address, Address] =
  Rule[Address]
    .field(_.house)(gte(1))
    .field(_.street)(trimString andThen nonEmpty)

This rule picks up errors in input values:

checkAddress(Address(-1, ""))
// res4: checklist.Checked[Address] = Both(
//   NonEmptyList(
//     ErrorMessage(Must be greater than or equal to 1,Path(house)),
//     ErrorMessage(Must not be empty,Path(street))
//   ),
//   Address(-1,)
// )

and cleans data as it goes (note the trimmed street name):

checkAddress(Address(29, "   Acacia Road   "))
// res5: checklist.Checked[Address] = Right(Address(29,Acacia Road))

It can even pick up street names that are empty after trimming:

checkAddress(Address(29, "   "))
// res6: checklist.Checked[Address] = Both(
//   NonEmptyList(
//     ErrorMessage(Must not be empty,Path(street))
//   ),
//   Address(29,)
// )

A Complete Example

Here's a more complete example involving nested case classes. Note that the Rule for Address is picked up implicitly when defining the Rule for Person:

import checklist._, Rule._
import scala.language.higherKinds

case class Address(house: Int, street: String)
case class Person(name: String, age: Int, address: Address)

implicit val checkAddress: Rule[Address, Address] =
  Rule[Address]
    .field(_.house)(gte(1))
    .field(_.street)(nonEmpty)

implicit val checkPerson: Rule[Person, Person] =
  Rule[Person]
    .field(_.name)(nonEmpty)
    .field(_.age)(gte(1))
    .field(_.address)

Also note that the paths in the error messages take into account their absolute position within the data being validated:

checkAddress(Address(0, ""))
// res7: checklist.Checked[Address] = Both(
//   NonEmptyList(
//     ErrorMessage(Must be greater than or equal to 1,Path(house)),
//     ErrorMessage(Must not be empty,Path(street))
//   ),
//   Address(0,)
// )

checkPerson(Person("", 0, Address(0, "")))
// res8: checklist.Checked[Person] = Both(
//   NonEmptyList(
//     ErrorMessage(Must not be empty,Path(name)),
//     ErrorMessage(Must be greater than or equal to 1,Path(age)),
//     ErrorMessage(Must be greater than or equal to 1,Path(address/house)),
//     ErrorMessage(Must not be empty,Path(address/street))
//   ),
//   Person(,0,Address(0,))
// )

checkPerson(Person("Eric Wimp", 11, Address(29, "Acacia Road")))
// res9: checklist.Checked[Person] = Right(Person(Eric Wimp,11,Address(29,Acacia Road)))
~~~