Search code examples
jsonscalaplayframework

Custom validations based on another validation


We would like to base some of our JSON validations to the result of the validation of a previous validation.

case class InsideObject(name: Option[String], companyName: Option[String], eType: Int)

We have the case class above and we would like to introduce a JSON validation that will make the name required if eType = 1 or make the companyName required if eType = 2 plus some more specific validations per field that we already have in place while reading the JSON object.

implicit val insideObjectReads: Reads[InsideObject] = (
        (JsPath \ "name").readNullable[String]
          .filter(JsonValidationError("must be 10 digits"))(name => name.getOrElse("").length == 10) and
        (JsPath \ "companyName").readNullable[String]
          .filter(JsonValidationError("must not be optional"))(companyName => companyName.isDefined) and
    (JsPath \ "eType").read[Int]
          .filter(JsonValidationError("eType can only take values 1 or 2"))(eType => eType == 1 || eType == 2)
    )(InsideObject.apply _)

We can do these validation inside the apply method but we would like to do it while reading the JSON object. Any suggestions?

Sample code: https://scastie.scala-lang.org/wTuhI1zCSJSWKu9Lltcqbw


Solution

  • You could chain the Reads using orElse.

    val reads1: Reads[InsideObject] = (
      (JsPath \ "name").readNullable[String]
        .filter(JsonValidationError("must not be optional"))(name => name.isDefined) and
        (JsPath \ "companyName").readNullable[String] and
        (JsPath \ "eType").read[Int]
          .filter(JsonValidationError("eType can only take values 1 or 2"))(eType => eType == 1)
      ) (InsideObject.apply _)
    
    implicit val reads2: Reads[InsideObject] = (
      (JsPath \ "name").readNullable[String] and
        (JsPath \ "companyName").readNullable[String]
          .filter(JsonValidationError("must not be optional"))(companyName => companyName.isDefined) and
        (JsPath \ "eType").read[Int]
          .filter(JsonValidationError("eType can only take values 1 or 2"))(eType => eType == 2)
      ) (InsideObject.apply _).orElse(reads1)
    

    Here, in the first Reads, companyName is required with eType as 2. In case that results in JsError, the second runs where name is required and eType is 1

    --Edit--

    Assuming your case class has extra fields like below:

    case class InsideObject(age: Option[Int], location: Option[String], name: Option[String], companyName: Option[String], eType: Int)
    
    object InsideObject {
      implicit val writesPublicLeadFormRequest: OFormat[InsideObject] = Json.format[InsideObject]
    
      def apply(age: Option[Int], location: Option[String],name: Option[String], companyName: Option[String], eType: Int) =
      new InsideObject(age, location, name, companyName, eType)
    }
    

    You can combine the Reads

    val reads0 = (JsPath \ "age").readNullable[Int] and
      (JsPath \ "location").readNullable[String]
    
    val reads1: Reads[InsideObject] = (
      reads0 and
      (JsPath \ "name").readNullable[String]
        .filter(JsonValidationError("must not be optional"))(name => name.isDefined) and
        (JsPath \ "companyName").readNullable[String] and
        (JsPath \ "eType").read[Int]
          .filter(JsonValidationError("eType can only take values 1 or 2"))(eType => eType == 1)
      ) (InsideObject.apply _)
    
    implicit val reads2: Reads[InsideObject] = (
      reads0 and
      (JsPath \ "name").readNullable[String] and
        (JsPath \ "companyName").readNullable[String]
          .filter(JsonValidationError("must not be optional"))(companyName => companyName.isDefined) and
        (JsPath \ "eType").read[Int]
          .filter(JsonValidationError("eType can only take values 1 or 2"))(eType => eType == 2)
      ) (InsideObject.apply _).orElse(reads1)
    

    Hopefully, this will address the comment.

    --Edit-2--

    You can also pattern match on the json, which I guess would give better flexibility in this particular case. Sample code:

    implicit val reads3: Reads[InsideObject] = {
      case JsObject(map @ Seq(("age", JsNumber(age)), ("location", JsString(location)), ("name", JsString(name)), ("companyName", JsString(companyName)), ("eType", JsNumber(eType)))) =>
        if (eType == 1 && name.nonEmpty)
          JsSuccess(InsideObject(Some(age.intValue), Some(location), Some(name), Some(companyName), 1))
        else if (eType == 2 && companyName.nonEmpty)
          JsSuccess(InsideObject(Some(age.intValue), Some(location), Some(name), Some(companyName), 2))
        else
          JsError("Custom Error - wrong eType?")
      case _ => JsError("Custom Error - wrong number of fields?")
    }
    

    Instead of pattern matching on Seq you could also use the underlying Map for further validation. When using only Seq, double-check on the number of fields, ie, a missing field will lead to JsError in the above snippet.

    --Edit-3--

    On further inspection, I noticed that Edit-2 doesn't work as expected. I was testing using an older play-json and scala versions. In the scastie snippet, I noticed you were using 2.10.0-RC5. The below solution was tested with 2.10.0-RC5.

    implicit val reads4: Reads[InsideObject] = {
      case JsObject(map) =>
        val opt: Option[(Option[String], Option[String], Int)] = for {
          eType <- map.get("eType").map(_.as[Int])
          name = map.get("name").flatMap(_.asOpt[String])
          company = map.get("companyName").flatMap(_.asOpt[String])
        } yield (name, company, eType)
        opt match {
          case Some((name, company, eType)) =>
            if (eType == 1 && name.isDefined)
              JsSuccess(InsideObject(name, company, 1))
            else if (eType == 2 && company.isDefined)
              JsSuccess(InsideObject(name, company, 2))
            else
              JsError("Custom Error - wrong eType?")
          case None => JsError("Custom Error - wrong number of fields")
        }
    
      case _ => JsError("Custom Error - wrong type?")
    }
    

    The updated version can be seen here.