Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to fail on json with unused fields in play-json scala? [duplicate]

For the validate method on request.body it matches the attribute name and value type of the json object to those defined in the model definition. Now if I were to add an extra attribute to the json object and try to validate it, it passes as a JsSuccess when it shouldn't.

{ 
    "Name": "Bob",
    "Age": 20,
    "Random_Field_Not_Defined_in_Models": "Test"
}

My Person Class is defined as follows

case class Person(name: String, age: Int)
like image 811
Jeff Avatar asked Nov 20 '22 11:11

Jeff


2 Answers

I'm assuming you've been using the built-in Reads[T] or Format[T] converters that Play gives you via Json.reads[T], e.g.:

import play.api.libs.json._

val standardReads = Json.reads[Person]

While these are super-handy, if you need additional validation, you'll have to define a custom Reads[Person] class; but fortunately we can still leverage the built-in JSON-to-case-class macro to do the basic checking and conversion, and then add an extra layer of custom checks if things seem OK:

val standardReads = Json.reads[Person]

val strictReads = new Reads[Person] {
  val expectedKeys = Set("name", "age")

  def reads(jsv:JsValue):JsResult[Person] = {
    standardReads.reads(jsv).flatMap { person =>
      checkUnwantedKeys(jsv, person)
    }
  }

  private def checkUnwantedKeys(jsv:JsValue, p:Person):JsResult[Person] = {
    val obj = jsv.asInstanceOf[JsObject]
    val keys = obj.keys
    val unwanted = keys.diff(expectedKeys)
    if (unwanted.isEmpty) {
      JsSuccess(p)
    } else {
      JsError(s"Keys: ${unwanted.mkString(",")} found in the incoming JSON")
    }
  } 
} 

Note how we utilize standardReads first, to make sure we're dealing with something that can be converted to a Person. No need to reinvent the wheel here.

We use flatMap to effectively short-circuit the conversion if we get a JsError from standardReads - i.e. we only call checkUnwantedKeys if needed.

checkUnwantedKeys just uses the fact that a JsObject is really just a wrapper around a Map, so we can easily check the names of the keys against a whitelist.

Note that you could also write that flatMap using a for-comprehension, which starts to look a lot cleaner if you need even more checking stages:

for {
    p <- standardReads.reads(jsv)
    r1 <- checkUnexpectedFields(jsv, p)
    r2 <- checkSomeOtherStuff(jsv, r1)
    r3 <- checkEvenMoreStuff(jsv, r2)
} yield r3
like image 145
millhouse Avatar answered Apr 05 '23 23:04

millhouse


If you want to avoid too much boilerplate it is possible to make a more generic solution using a little bit of scala reflection:

import play.api.libs.json._
import scala.reflect.runtime.universe._

def checkedReads[T](underlyingReads: Reads[T])(implicit typeTag: TypeTag[T]): Reads[T] = new Reads[T] {

    def classFields[T: TypeTag]: Set[String] = typeOf[T].members.collect {
      case m: MethodSymbol if m.isCaseAccessor => m.name.decodedName.toString
    }.toSet

    def reads(json: JsValue): JsResult[T] = {
      val caseClassFields = classFields[T]
      json match {
        case JsObject(fields) if (fields.keySet -- caseClassFields).nonEmpty =>
          JsError(s"Unexpected fields provided: ${(fields.keySet -- caseClassFields).mkString(", ")}")
        case _ => underlyingReads.reads(json)
      }
    }

  }

Then you can specify your reads instances as:

implicit val reads = checkedReads(Json.reads[Person])

This leverages a fair bit of Scala type magic and also the reflection library (that lets you look at fields on classes).

Rather than relying on a fixed set of fields the classFields method gets all of the fields dynamically for the case class (type param T). It looks at all of the members and collects only the case class accessors (otherwise we'd pick up methods like toString). It returns a Set[String] of field names.

You'll notice that the checkedReads takes an implicit TypeTag[T]. This is supplied by the compiler at compile time and used by the typeOf method.

The remaining code is fairly self explanatory. If the incoming json matches our first case (it is a JsObject and there are fields not on the case class) then we return a JsError. Otherwise we pass it on to the underlying reader.

like image 41
sihil Avatar answered Apr 05 '23 22:04

sihil