Search code examples
jsonscalascalazargonaut

Scalaz validation with Argonaut


I have a case class and companion object:

case class Person private(name: String, age: Int)

object Person {

  def validAge(age: Int) = {
    if (age > 18) age.successNel else "Age is under 18".failureNel
  }

  def validName(name: String) = {
    name.successNel
  }

  def create(name: String, age: Int) = (validAge(age) |@| validName(name))(Person.apply)

}

I want to use Argonaut to parse some JSON and return a Person OR some errors, as a list. So I need to:

  1. Read the JSON from a string, and validate the string is correctly formed
  2. Decode the JSON into a Person, or List of error strings.

I want to return errors in the form of something I can turn into some more JSON like:

{
  errors: ["Error1", "Error2"]
}

I first tried using Argonauts decodeValidation method, which returns a Validation[String, X]. Unfortunately, I need a List of errors.

Any suggestions?


Solution

  • I'm adding this as an answer because it's how I'd solve the problem off the top of my head, but I haven't been keeping up closely with Argonaut development for a while, and I'd love to hear that there's a better way. First for the setup, which fixes a few little issues in yours, and adds a condition for validity on names to make the examples later more interesting:

    import scalaz._, Scalaz._
    
    case class Person private(name: String, age: Int)
    
    object Person {
      def validAge(age: Int): ValidationNel[String, Int] =
        if (age > 18) age.successNel else "Age is under 18".failureNel
    
      def validName(name: String): ValidationNel[String, String] =
        if (name.size >= 3) name.successNel else "Name too short".failureNel
    
      def create(name: String, age: Int) =
        (validName(name) |@| validAge(age))(Person.apply)
    }
    

    And then I'd decode the JSON into a (String, Int) pair before creating the Person:

    import argonaut._, Argonaut._
    
    def decodePerson(in: String): ValidationNel[String, Person] =
      Parse.decodeValidation(in)(
        jdecode2L((a: String, b: Int) => (a, b)
      )("name", "age")).toValidationNel.flatMap {
        case (name, age) => Person.create(name, age)
      }
    

    And then:

    scala> println(decodePerson("""{ "name": "", "age": 1 }"""))
    Failure(NonEmptyList(Name too short, Age is under 18))
    

    Note that this doesn't accumulate errors in more complex cases—e.g. if the value of the name field is a number and age is 1, you'll only get a single error (the name one). Making error accumulation work in cases like that would be considerably more complex.

    Relatedly, you'll also see a deprecation warning about the flatMap on Validation, which you can think of as a reminder that accumulation won't happen across the bind. You can tell the compiler that you understand by importing scalaz.Validation.FlatMap._.