Play Framework Strange Behavior when Parsing JSON

2019-08-31 03:45发布

问题:

I have a endpoint that takes in a JSON body. I have implicit reads and writes for this JSON format. In the endpoint, I do a validation of the JSON and fold on the result! Here it is:

def updatePowerPlant(id: Int) = Action.async(parse.tolerantJson) { request =>
    request.body.validate[PowerPlantConfig].fold(
      errors => {
        Future.successful(
          BadRequest(
            Json.obj("message" -> s"invalid PowerPlantConfig ${errors.mkString(",")}")
          ).enableCors
        )
      },
      success => {
        dbService.insertOrUpdatePowerPlant(success).runAsync.materialize.map {
          case Failure(ex) =>
            InternalServerError(s"Error updating PowerPlant " +
              s"Reason => ${ex.getMessage}").enableCors
          case Success(result) =>
            result match {
              case Left(errorMessage) =>
                BadRequest(Json.obj("message" -> s"invalid PowerPlantConfig $errorMessage")).enableCors
              case Right(updatedConfig) =>
                Ok(Json.prettyPrint(Json.toJson(updatedConfig))).enableCors
            }
        }
      }
    )
  }

So as it can be seen that I fold on the error and I return a BadRequest. But when I tried writing a unit test, I do not get the HTTP status as BadRequest as I expect, but the test crashes with an exception as below:

JsResultException(errors:List((,List(ValidationError(List(error.expected.jsnumber),WrappedArray())))))
play.api.libs.json.JsResultException: JsResultException(errors:List((,List(ValidationError(List(error.expected.jsnumber),WrappedArray())))))
    at play.api.libs.json.JsReadable$$anonfun$2.apply(JsReadable.scala:23)
    at play.api.libs.json.JsReadable$$anonfun$2.apply(JsReadable.scala:23)
    at play.api.libs.json.JsResult$class.fold(JsResult.scala:73)
    at play.api.libs.json.JsError.fold(JsResult.scala:13)
    at play.api.libs.json.JsReadable$class.as(JsReadable.scala:21)
    at play.api.libs.json.JsDefined.as(JsLookup.scala:132)
    at com.inland24.plantsim.models.package$$anon$1.reads(package.scala:61)
    at play.api.libs.json.JsValue$class.validate(JsValue.scala:18)
    at play.api.libs.json.JsObject.validate(JsValue.scala:76)
    at com.inland24.plantsim.controllers.PowerPlantController$$anonfun$updatePowerPlant1$1.apply(PowerPlantController.scala:64)
    at com.inland24.plantsim.controllers.PowerPlantController$$anonfun$updatePowerPlant1$1.apply(PowerPlantController.scala:63)
    at play.api.mvc.Action$.invokeBlock(Action.scala:498)
    at play.api.mvc.Action$.invokeBlock(Action.scala:495)
    at play.api.mvc.ActionBuilder$$anon$2.apply(Action.scala:458)
    at com.inland24.plantsim.controllers.PowerPlantControllerTest$$anonfun$4$$anonfun$apply$mcV$sp$11.apply(PowerPlantControllerTest.scala:313)
    at com.inland24.plantsim.controllers.PowerPlantControllerTest$$anonfun$4$$anonfun$apply$mcV$sp$11.apply(PowerPlantControllerTest.scala:296)
    at org.scalatest.OutcomeOf$class.outcomeOf(OutcomeOf.scala:85)
    at org.scalatest.OutcomeOf$.outcomeOf(OutcomeOf.scala:104)
    at org.scalatest.Transformer.apply(Transformer.scala:22)
    at org.scalatest.Transformer.apply(Transformer.scala:20)
    at org.scalatest.WordSpecLike$$anon$1.apply(WordSpecLike.scala:1078)
    at org.scalatest.TestSuite$class.withFixture(TestSuite.scala:196)
    at com.inland24.plantsim.controllers.PowerPlantControllerTest.withFixture(PowerPlantControllerTest.scala:40)
    at org.scalatest.WordSpecLike$class.invokeWithFixture$1(WordSpecLike.scala:1075)
    at org.scalatest.WordSpecLike$$anonfun$runTest$1.apply(WordSpecLike.scala:1088)
    at org.scalatest.WordSpecLike$$anonfun$runTest$1.apply(WordSpecLike.scala:1088)
    at org.scalatest.SuperEngine.runTestImpl(Engine.scala:289)
    at org.scalatest.WordSpecLike$class.runTest(WordSpecLike.scala:1088)
    at com.inland24.plantsim.controllers.PowerPlantControllerTest.runTest(PowerPlantControllerTest.scala:40)
    at org.scalatest.WordSpecLike$$anonfun$runTests$1.apply(WordSpecLike.scala:1147)
    at org.scalatest.WordSpecLike$$anonfun$runTests$1.apply(WordSpecLike.scala:1147)
    at org.scalatest.SuperEngine$$anonfun$traverseSubNodes$1$1.apply(Engine.scala:396)
    at org.scalatest.SuperEngine$$anonfun$traverseSubNodes$1$1.apply(Engine.scala:384)
    at scala.collection.immutable.List.foreach(List.scala:392)
    at org.scalatest.SuperEngine.traverseSubNodes$1(Engine.scala:384)
    at org.scalatest.SuperEngine.org$scalatest$SuperEngine$$runTestsInBranch(Engine.scala:373)
    at org.scalatest.SuperEngine$$anonfun$traverseSubNodes$1$1.apply(Engine.scala:410)
    at org.scalatest.SuperEngine$$anonfun$traverseSubNodes$1$1.apply(Engine.scala:384)
    at scala.collection.immutable.List.foreach(List.scala:392)
    at org.scalatest.SuperEngine.traverseSubNodes$1(Engine.scala:384)
    at org.scalatest.SuperEngine.org$scalatest$SuperEngine$$runTestsInBranch(Engine.scala:379)
    at org.scalatest.SuperEngine.runTestsImpl(Engine.scala:461)
    at org.scalatest.WordSpecLike$class.runTests(WordSpecLike.scala:1147)
    at com.inland24.plantsim.controllers.PowerPlantControllerTest.runTests(PowerPlantControllerTest.scala:40)
    at org.scalatest.Suite$class.run(Suite.scala:1147)
    at com.inland24.plantsim.controllers.PowerPlantControllerTest.org$scalatest$WordSpecLike$$super$run(PowerPlantControllerTest.scala:40)
    at org.scalatest.WordSpecLike$$anonfun$run$1.apply(WordSpecLike.scala:1192)
    at org.scalatest.WordSpecLike$$anonfun$run$1.apply(WordSpecLike.scala:1192)
    at org.scalatest.SuperEngine.runImpl(Engine.scala:521)
    at org.scalatest.WordSpecLike$class.run(WordSpecLike.scala:1192)
    at com.inland24.plantsim.controllers.PowerPlantControllerTest.org$scalatest$BeforeAndAfterAll$$super$run(PowerPlantControllerTest.scala:40)
    at org.scalatest.BeforeAndAfterAll$class.liftedTree1$1(BeforeAndAfterAll.scala:213)
    at org.scalatest.BeforeAndAfterAll$class.run(BeforeAndAfterAll.scala:210)
    at com.inland24.plantsim.controllers.PowerPlantControllerTest.run(PowerPlantControllerTest.scala:40)
    at org.scalatest.tools.SuiteRunner.run(SuiteRunner.scala:45)
    at org.scalatest.tools.Runner$$anonfun$doRunRunRunDaDoRunRun$1.apply(Runner.scala:1340)
    at org.scalatest.tools.Runner$$anonfun$doRunRunRunDaDoRunRun$1.apply(Runner.scala:1334)
    at scala.collection.immutable.List.foreach(List.scala:392)
    at org.scalatest.tools.Runner$.doRunRunRunDaDoRunRun(Runner.scala:1334)
    at org.scalatest.tools.Runner$$anonfun$runOptionallyWithPassFailReporter$2.apply(Runner.scala:1011)
    at org.scalatest.tools.Runner$$anonfun$runOptionallyWithPassFailReporter$2.apply(Runner.scala:1010)
    at org.scalatest.tools.Runner$.withClassLoaderAndDispatchReporter(Runner.scala:1500)
    at org.scalatest.tools.Runner$.runOptionallyWithPassFailReporter(Runner.scala:1010)
    at org.scalatest.tools.Runner$.run(Runner.scala:850)
    at org.scalatest.tools.Runner.run(Runner.scala)
    at org.jetbrains.plugins.scala.testingSupport.scalaTest.ScalaTestRunner.runScalaTest2(ScalaTestRunner.java:138)
    at org.jetbrains.plugins.scala.testingSupport.scalaTest.ScalaTestRunner.main(ScalaTestRunner.java:28)

Here is my unit test:

"not update for an invalid PowerPlantConfig JSON" in {
      // We are updating the PowerPlant with id = 101, Notice that the powerPlantId is invalid
      val jsBody =
        """
          |{
          |   "powerPlantId":"invalidId",
          |   "powerPlantName":"joesan 1",
          |   "minPower":100,
          |   "maxPower":800,
          |   "rampPowerRate":20.0,
          |   "rampRateInSeconds":"2 seconds",
          |   "powerPlantType":"RampUpType"
          |}
        """.stripMargin

      val result: Future[Result] =
        controller.updatePowerPlant(101)
          .apply(
            FakeRequest().withBody(Json.parse(jsBody))
          )
      result.materialize.map {
        case Success(succ) =>
          assert(succ.header.status === BAD_REQUEST)
        case Failure(_) =>
          fail("Unexpected test failure when Updating a PowerPlant! Please Analyze!")
      }
    }

Any idea why I'm not getting the expected behavior? I'm expecting that I get a HTTP BadRequest back!

EDIT: To get rid of the unexpected exception, I had to wrap my code into a Try block and I do not want that. So this piece of code gets rid of the error:

def updatePowerPlant(id: Int) = Action.async(parse.tolerantJson) { request =>
    scala.util.Try(request.body.validate[PowerPlantConfig]) match {
      case Failure(fail) =>
        Future.successful(InternalServerError(s"Error updating PowerPlant " +
          s"Reason => ${fail.getMessage}").enableCors)
      case Success(succ) =>
        succ.fold(
          errors => {
            Future.successful(
              BadRequest(
                Json.obj("message" -> s"invalid PowerPlantConfig ${errors.mkString(",")}")
              ).enableCors
            )
          },
          success => {
            dbService.insertOrUpdatePowerPlant(success).runAsync.materialize.map {
              case Failure(ex) =>
                InternalServerError(s"Error updating PowerPlant " +
                  s"Reason => ${ex.getMessage}").enableCors
              case Success(result) =>
                result match {
                  case Left(errorMessage) =>
                    BadRequest(Json.obj("message" -> s"invalid PowerPlantConfig $errorMessage")).enableCors
                  case Right(updatedConfig) =>
                    Ok(Json.prettyPrint(Json.toJson(updatedConfig))).enableCors
                }
            }
          }
        )
    }
  }

But as it can be seen that there is this additional Try(....) block and I do not want this!

Here is my definition of PowerPlantConfig:

sealed trait PowerPlantConfig {
  def id: Int
  def name: String
  def minPower: Double
  def maxPower: Double
  def powerPlantType: PowerPlantType
}
object PowerPlantConfig {

  case class OnOffTypeConfig(
    id: Int,
    name: String,
    minPower: Double,
    maxPower: Double,
    powerPlantType: PowerPlantType
  ) extends PowerPlantConfig

  case class RampUpTypeConfig(
    id: Int,
    name: String,
    minPower: Double,
    maxPower: Double,
    rampPowerRate: Double,
    rampRateInSeconds: FiniteDuration,
    powerPlantType: PowerPlantType
  ) extends PowerPlantConfig

  case class UnknownConfig(
    id: Int = -1,
    name: String,
    minPower: Double,
    maxPower: Double,
    powerPlantType: PowerPlantType
  ) extends PowerPlantConfig

  // represents all the PowerPlant's from the database
  case class PowerPlantsConfig(
    snapshotDateTime: DateTime,
    powerPlantConfigSeq: Seq[PowerPlantConfig]
  )
}

Here is my JSON reads and writes:

implicit val powerPlantCfgFormat: Format[PowerPlantConfig] = new Format[PowerPlantConfig] {
    def reads(json: JsValue): JsResult[PowerPlantConfig] = {
      val powerPlantTyp = PowerPlantType.fromString((json \ "powerPlantType").as[String])
      powerPlantTyp match {
        case PowerPlantType.OnOffType =>
         JsSuccess(OnOffTypeConfig(
            id = (json \ "powerPlantId").as[Int],
            name = (json \ "powerPlantName").as[String],
            minPower = (json \ "minPower").as[Double],
            maxPower = (json \ "maxPower").as[Double],
            powerPlantType = powerPlantTyp
          ))
        case PowerPlantType.RampUpType =>
          JsSuccess(RampUpTypeConfig(
            id = (json \ "powerPlantId").as[Int],
            name = (json \ "powerPlantName").as[String],
            minPower = (json \ "minPower").as[Double],
            rampPowerRate = (json \ "rampPowerRate").as[Double],
            rampRateInSeconds = Duration.apply((json \ "rampRateInSeconds").as[String]).asInstanceOf[FiniteDuration],
            maxPower = (json \ "maxPower").as[Double],
            powerPlantType = powerPlantTyp
          ))
        case _ =>
          JsSuccess(UnknownConfig(
            id = (json \ "powerPlantId").as[Int],
            name = (json \ "powerPlantName").as[String],
            minPower = (json \ "minPower").as[Double],
            maxPower = (json \ "maxPower").as[Double],
            powerPlantType = powerPlantTyp
          ))
      }
    }

    def writes(o: PowerPlantConfig): JsValue = {
      if (o.powerPlantType == RampUpType) {
        Json.obj(
          "powerPlantId" -> o.id,
          "powerPlantName" -> o.name,
          "minPower" -> o.minPower,
          "maxPower" -> o.maxPower,
          "rampPowerRate" -> o.asInstanceOf[RampUpTypeConfig].rampPowerRate,
          "rampRateInSeconds" -> o.asInstanceOf[RampUpTypeConfig].rampRateInSeconds.toString(),
          "powerPlantType" -> PowerPlantType.toString(o.powerPlantType)
        )
      }
      else {
        Json.obj(
          "powerPlantId" -> o.id,
          "powerPlantName" -> o.name,
          "minPower" -> o.minPower,
          "maxPower" -> o.maxPower,
          "powerPlantType" -> PowerPlantType.toString(o.powerPlantType)
        )
      }
    }
  }

回答1:

According your stacktrace (line I marked)

JsResultException(errors:List((,List(ValidationError(List(error.expected.jsnumber),WrappedArray())))))
play.api.libs.json.JsResultException: JsResultException(errors:List((,List(ValidationError(List(error.expected.jsnumber),WrappedArray())))))
at play.api.libs.json.JsReadable$$anonfun$2.apply(JsReadable.scala:23)
at play.api.libs.json.JsReadable$$anonfun$2.apply(JsReadable.scala:23)
at play.api.libs.json.JsResult$class.fold(JsResult.scala:73)
at play.api.libs.json.JsError.fold(JsResult.scala:13)
--> at play.api.libs.json.JsReadable$class.as(JsReadable.scala:21)
at play.api.libs.json.JsDefined.as(JsLookup.scala:132)
at com.inland24.plantsim.models.package$$anon$1.reads(package.scala:61)
at play.api.libs.json.JsValue$class.validate(JsValue.scala:18)
at play.api.libs.json.JsObject.validate(JsValue.scala:76)
at com.inland24.plantsim.controllers.PowerPlantController$$anonfun$updatePowerPlant1$1.apply(PowerPlantController.scala:64)
at com.inland24.plantsim.controllers.PowerPlantController$$anonfun$updatePowerPlant1$1.apply(PowerPlantController.scala:63)

you used as[Int] in your Read format for id field for PowerPlantConfig. When you call as[Int], you are trying to force the given json path to type Int. It throws an exception if it cannot (as in your test). You can read on difference betweem as, asOpt and validate here for example

Update

If you look into implementation of as, asOpt and validate you will see that all these three do at first the same thing, but than differs in a way:

validate - I do need result either result or info on failure wrapped (just call reads of implicit arg on json)

asOpt - I need either result or none, if reads under used for resolution return parse error it is ignored as not set at all

as - I need either result, or exception. In other words "I'm sure, that this is always such type, if not, than it is general error"

Both as and asOpt are "extended validate" with interpreting result.

Example

Example how to move from as to validate in hierarchy (two Formats - one as yours with as which will throw exception, and another with validate which will not throw exception):

sealed trait PowerPlantConfig {
  def id: Int
}
case class RampUpTypeConfig(id: Int) extends PowerPlantConfig

implicit val powerPlantCfgFormat: Format[PowerPlantConfig] = new Format[PowerPlantConfig] {
  def reads(json: JsValue): JsResult[PowerPlantConfig] = {
    JsSuccess(RampUpTypeConfig(
      id = (json \ "powerPlantId").as[Int]
    ))
  }
  def writes(o: PowerPlantConfig): JsValue = {
    Json.obj(
      "powerPlantId" -> o.id)
  }
}
val powerPlantCfgFormatFixed: Format[PowerPlantConfig] = new Format[PowerPlantConfig] {
  def reads(json: JsValue): JsResult[PowerPlantConfig] = {
    for {
      id <- (json \ "powerPlantId").validate[Int]
    } yield {
      RampUpTypeConfig(
        id = id
      )
    }

  }
  def writes(o: PowerPlantConfig): JsValue = {
    Json.obj(
      "id" -> o.id)
  }
}


Json.parse("""{"powerPlantId":"123"}""").validate[PowerPlantConfig](powerPlantCfgFormatFixed)

And output will be not an exception but JsFailure as expected

res1: play.api.libs.json.JsResult[PowerPlantConfig] = JsError(List((,List(ValidationError(error.expected.jsnumber,WrappedArray())))))