s_mach.validate is an open-source Scala library that provides methods for easily building reuseable, composable and serialization format agnostic data validators.

s_mach.validate: data validators

Include in SBT

  1. Add to build.sbt

    libraryDependencies += "net.s_mach" %% "validate" % "1.0.0"
  2. For Play JSON support, add to build.sbt

    libraryDependencies ++= Seq(
      "net.s_mach" %% "validate" % "1.0.0",
      "net.s_mach" %% "validate-play-json" % "1.0.0"
    )
    Note
    s_mach.validate is based on blackbox macro support, present only in Scala 2.11+

Overview

s_mach.validate is an open-source Scala library that provides methods for easily building reuseable, composable and serialization format agnostic data validators.

Why do I need this?

  • You want a validation DSL that is light-weight, terse, composable, reuseable and DRY, written exactly once.

  • You want to write validation code that doesn’t require first converting to a specific serialization format.

  • You want to write validation code that can be re-used for any serialization format.

  • You want to be able to display a light-weight human-readable schema derived from the validation code.

Features

  • Create validators that test validation rules using a light-weight and terse DSL.

  • Write DRY validation code, exactly once, that can be re-used, composed and can be applied to all serialization formats.

  • Validate an instance against a validator to produce a human-readable list of validation failures (List[Rule]).

  • Output a human-readable "schema" of all rules tested and the expected type of each primitive value from any validator using Validator.explain.

  • Macro-generate validators for any product type (i.e. case class or tuple) using Validator.forProductType.

  • Constrain value space of value types (e.g. String, Int, etc) using value classes and Validator.forValueClass.

  • Convert List[Explain] or List[Rule] to human-readable Play JSON using prettyPrintJson method.

  • Compose validators with existing Play Format/Reads by using Format.withValidator or Reads.withValidator convenience methods.

Versioning

s_mach.validate uses semantic versioning (http://semver.org/). s_mach.validate does not use the package private modifier. Instead, all code files outside of the s_mach.validate.impl package form the public interface and are governed by the rules of semantic versioning. Code files inside the s_mach.validate.impl package may be used by downstream applications and libraries. However, no guarantees are made as to the stability or interface of code in the s_mach.validate.impl package between versions.

Example

$ sbt
[info] Set current project to validate (in build file:/Users/lancegatlin/Code/s_mach.validate/)
> project validate-play-json
[info] Set current project to validate-play-json (in build file:/Users/lancegatlin/Code/s_mach.validate/)
> test:console
Welcome to Scala version 2.11.6 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_40).
Type in expressions to have them evaluated.
Type :help for more information.

scala> :paste
// Entering paste mode (ctrl-D to finish)

import scala.collection.immutable.StringOps
import s_mach.validate._
import play.api.libs.json._
import s_mach.validate.play_json._

// Use Scala value-class to restrict the value space of String
// Name can be treated as String in code
// See http://docs.scala-lang.org/overviews/core/value-classes.html
implicit class Name(
  val underlying: String
) extends AnyVal with IsValueClass[String]
object Name {
  import scala.language.implicitConversions
  // Because Scala doesn't support recursive implicit resolution, need to
  // add an implicit here to support using Name with StringOps methods such
  // as foreach, map, etc
  implicit def stringOps_Name(name: Name) = new StringOps(name.underlying)
  implicit val validator_Name =
    // Create a Validator[Name] based on a Validator[String]
    Validator.forValueClass[Name, String] {
      import Text._
      // Build a Validator[String] by composing some pre-defined validators
      nonEmpty and maxLength(64) and allLettersOrSpaces
    }

  implicit val format_Name =
    Json
      // Auto-generate a value-class format from the already existing implicit
      // Format[String]
      .forValueClass.format[Name,String](new Name(_))
      // Append the serialization-neutral Validator[Name] to the Play JSON Format[Name]
      .withValidator
}

implicit class Age(
  val underlying: Int
) extends AnyVal with IsValueClass[Int]
object Age {
  implicit val validator_Age = {
    import Validator._
    forValueClass[Age,Int](
      ensure(s"must be between (0,150)") { age =>
        0 <= age && age <= 150
      }
    )
  }
  implicit val format_Age =
    Json.forValueClass.format[Age,Int](new Age(_)).withValidator
}

case class Person(id: Int, name: Name, age: Age)

object Person {
  implicit val validator_Person = {
    import Validator._

    // Macro generate a Validator for any product type (i.e. case class / tuple)
    // that implicitly resolves all validators for declared fields. For Person,
    // Validator[Int] for the id field, Validator[Name] for the name field and
    // Validator[Age] for the age field are automatically composed into a
    // Validator[Person].
    forProductType[Person] and
    // Compose the macro generated Validator[Person] with an additional condition
    ensure(
      "age plus id must be less than 1000"
      // p.age is used here as if it was an Int here without any extra code
    )(p => p.id + p.age < 1000)
  }

  implicit val format_Person = Json.format[Person].withValidator
}

case class Family(
  father: Person,
  mother: Person,
  children: Seq[Person],
  grandMother: Option[Person],
  grandFather: Option[Person]
)

object Family {
  implicit val validator_Family =
    // Macro generate a Validator for Family. Implicit methods in
    // s_mach.validate.CollectionValidatorImplicits automatically handle creating
    // Validators for Option and any Scala collection that inherits
    // scala.collection.Traversable (as long as the contained type has an implicit
    // Validator).
    // If set to None, Validator[Option[Person]], checks no Validator[Person] rules.
    // For Validator[M[A]] (where M[AA] <: Traversable[AA]) the rules of
    // Validator[Person] are checked for each Person in the collection.
    Validator.forProductType[Family]
      // Add some extra constaints using the optional builder syntax
      .ensure("father must be older than children") { family =>
        family.children.forall(_.age < family.father.age)
      }
      .ensure("mother must be older than children") { family =>
        family.children.forall(_.age < family.mother.age)
      }

  implicit val format_Family = Json.format[Family].withValidator
}

// Exiting paste mode, now interpreting.

import s_mach.validate._
import play.api.libs.json._
import s_mach.validate.play_json._
defined class Name
defined object Name
defined class Age
defined object Age
defined class Person
defined object Person
defined class Family
defined object Family

scala> Person(1,"!!!",200)
res0: Person = Person(1,!!!,200)

scala> res0.validate
res1: List[s_mach.validate.Rule] = List(name: must contain only letters or spaces, age: must be between (0,150))

scala> Json.toJson(res0)
res2: play.api.libs.json.JsValue = {"id":1,"name":"!!!","age":200}

scala> Json.fromJson[Person](res2)
res3: play.api.libs.json.JsResult[Person] = JsError(ArrayBuffer((/age,List(ValidationError(List(must be between (0,150)),WrappedArray()))), (/name,List(ValidationError(List(must contain only letters or spaces),WrappedArray())))))

scala> validator[Person].explain.prettyPrintJson
res4: String =
{
  "this" : "age plus id must be less than 1000",
  "id" : [ "must be integer" ],
  "name" : [ "must be string", "must not be empty", "must not be longer than 64 characters", "must contain only letters or spaces" ],
  "age" : [ "must be integer", "must be between (0,150)" ]
}

scala> validator[Name].explain.prettyPrintJson
res5: String = [ "must be string", "must not be empty", "must not be longer than 64 characters", "must contain only letters or spaces" ]

scala> println(validator[Family].explain.prettyPrintJson)
{
  "this" : [ "father must be older than children", "mother must be older than children" ],
  "father" : {
    "this" : "age plus id must be less than 1000",
    "id" : [ "must be integer" ],
    "name" : [ "must be string", "must not be empty", "must not be longer than 64 characters", "must contain only letters or spaces" ],
    "age" : [ "must be integer", "must be between (0,150)" ]
  },
  "mother" : {
    "this" : "age plus id must be less than 1000",
    "id" : [ "must be integer" ],
    "name" : [ "must be string", "must not be empty", "must not be longer than 64 characters", "must contain only letters or spaces" ],
    "age" : [ "must be integer", "must be between (0,150)" ]
  },
  "children" : {
    "this" : "must be array of zero or more members",
    "member" : {
      "this" : "age plus id must be less than 1000",
      "id" : [ "must be integer" ],
      "name" : [ "must be string", "must not be empty", "must not be longer than 64 characters", "must contain only letters or spaces" ],
      "age" : [ "must be integer", "must be between (0,150)" ]
    }
  },
  "grandMother" : {
    "this" : [ "optional", "age plus id must be less than 1000" ],
    "id" : [ "must be integer" ],
    "name" : [ "must be string", "must not be empty", "must not be longer than 64 characters", "must contain only letters or spaces" ],
    "age" : [ "must be integer", "must be between (0,150)" ]
  },
  "grandFather" : {
    "this" : [ "optional", "age plus id must be less than 1000" ],
    "id" : [ "must be integer" ],
    "name" : [ "must be string", "must not be empty", "must not be longer than 64 characters", "must contain only letters or spaces" ],
    "age" : [ "must be integer", "must be between (0,150)" ]
  }
}