glarkou
glarkou

Reputation: 7101

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

Upvotes: 2

Views: 222

Answers (1)

Johny T Koshy
Johny T Koshy

Reputation: 3922

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.

Upvotes: 1

Related Questions