简体   繁体   中英

Play Json API: Convert a JsArray to a JsResult[Seq[Element]]

I have a JsArray which contains JsValue objects representing two different types of entities - some of them represent nodes , the other part represents edges .

On the Scala side, there are already case classes named Node and Edge whose supertype is Element . The goal is to transform the JsArray (or Seq[JsValue] ) to a collection that contains the Scala types, eg Seq[Element] (=> contains objects of type Node and Edge ).

I have defined Read for the case classes:

implicit val nodeReads: Reads[Node] = // ...

implicit val edgeReads: Reads[Edge] = // ...

Apart from that, there is the first step of a Read for the JsArray itself:

implicit val elementSeqReads = Reads[Seq[Element]](json => json match {
  case JsArray(elements) => ???
  case _ => JsError("Invalid JSON data (not a json array)")
})

The part with the question marks is responsible for creating a JsSuccess(Seq(node1, edge1, ...) if all elements of the JsArray are valid nodes and edges or a JsError if this is not the case.

However, I'm not sure how to do this in an elegant way.

The logic to distinguish between nodes and edges could look like this:

def hasType(item: JsValue, elemType: String) =
   (item \ "elemType").asOpt[String] == Some(elemType)

val result = elements.map {
  case n if hasType(n, "node") => // use nodeReads
  case e if hasType(e, "edge") => // use edgeReads
  case _ => JsError("Invalid element type")
}

The thing is that I don't know how to deal with nodeReads / edgeReads at this point. Of course I could call their validate method directly, but then result would have the type Seq[JsResult[Element]] . So eventually I would have to check if there are any JsError objects and delegate them somehow to the top (remember: one invalid array element should lead to a JsError overall). If there are no errors, I still have to produce a JsSuccess[Seq[Element]] based on result .

Maybe it would be a better idea to avoid the calls to validate and work temporarily with Read instances instead. But I'm not sure how to "merge" all of the Read instances at the end (eg in simple case class mappings, you have a bunch of calls to JsPath.read (which returns Read ) and in the end, validate produces one single result based on all those Read instances that were concatenated using the and keyword).

edit: A little bit more information.

First of all, I should have mentioned that the case classes Node and Edge basically have the same structure, at least for now. At the moment, the only reason for separate classes is to gain more type safety.

A JsValue of an element has the following JSON-representation:

{
    "id" : "aet864t884srtv87ae",
    "type" : "node", // <-- type can be 'node' or 'edge'
    "name" : "rectangle",
    "attributes": [],
    ...
}

The corresponding case class looks like this (note that the type attribute we've seen above is not an attribute of the class - instead it's represented by the type of the class -> Node ).

case class Node(
  id: String,
  name: String,
  attributes: Seq[Attribute],
  ...) extends Element

The Read is as follows:

implicit val nodeReads: Reads[Node] = (
      (__ \ "id").read[String] and
      (__ \ "name").read[String] and
      (__ \ "attributes").read[Seq[Attribute]] and
      ....
    ) (Node.apply _)

everything looks the same for Edge , at least for now.

Try defining elementReads as

implicit val elementReads = new Reads[Element]{
    override def reads(json: JsValue): JsResult[Element] =
      json.validate(
        Node.nodeReads.map(_.asInstanceOf[Element]) orElse
        Edge.edgeReads.map(_.asInstanceOf[Element])
      )
}

and import that in scope, Then you should be able to write

json.validate[Seq[Element]]

If the structure of your json is not enough to differentiate between Node and Edge , you could enforce it in the reads for each type.

Based on a simplified Node and Edge case class (only to avoid any unrelated code confusing the answer)

case class Edge(name: String) extends Element
case class Node(name: String) extends Element

The default reads for these case classes would be derived by

Json.reads[Edge]
Json.reads[Node]

respectively. Unfortunately since both case classes have the same structure these reads would ignore the type attribute in the json and happily translate a node json into an Edge instance or the opposite.

Lets have a look at how we could express the constraint on type all by itself :

 def typeRead(`type`: String): Reads[String] = {
    val isNotOfType = ValidationError(s"is not of expected type ${`type`}")
    (__ \ "type").read[String].filter(isNotOfType)(_ == `type`)
  }

This method builds a Reads[String] instance which will attempt to find a type string attribute in the provided json. It will then filter the JsResult using the custom validation error isNotOfType if the string parsed out of the json doesn't matched the expected type passed as argument of the method. Of course if the type attribute is not a string in the json, the Reads[String] will return an error saying that it expected a String.

Now that we have a read which can enforce the value of the type attribute in the json, all we have to do is to build a reads for each value of type which we expect and compose it with the associated case class reads. We can used Reads#flatMap for that ignoring the input since the parsed string is not useful for our case classes.

object Edge {
  val edgeReads: Reads[Edge] = 
    Element.typeRead("edge").flatMap(_ => Json.reads[Edge])
}
object Node {
  val nodeReads: Reads[Node] = 
    Element.typeRead("node").flatMap(_ => Json.reads[Node])
}

Note that if the constraint on type fails the flatMap call will be bypassed.

The question remains of where to put the method typeRead , in this answer I initially put it in the Element companion object along with the elementReads instance as in the code below.

import play.api.libs.json._

trait Element
object Element {
  implicit val elementReads = new Reads[Element] {
    override def reads(json: JsValue): JsResult[Element] =
      json.validate(
        Node.nodeReads.map(_.asInstanceOf[Element]) orElse
        Edge.edgeReads.map(_.asInstanceOf[Element])
      )
  }
  def typeRead(`type`: String): Reads[String] = {
    val isNotOfType = ValidationError(s"is not of expected type ${`type`}")
    (__ \ "type").read[String].filter(isNotOfType)(_ == `type`)
  }
}

This is actually a pretty bad place to define typeRead : - it has nothing specific to Element - it introduces a circular dependency between the Element companion object and both Node and Edge companion objects

I'll let you think up of the correct location though :)

The specification proving it all works together :

import org.specs2.mutable.Specification
import play.api.libs.json._
import play.api.data.validation.ValidationError

class ElementSpec extends Specification {

  "Element reads" should {
    "read an edge json as an edge" in {
      val result: JsResult[Element] = edgeJson.validate[Element]
      result.isSuccess should beTrue
      result.get should beEqualTo(Edge("myEdge"))
    }
    "read a node json as an node" in {
      val result: JsResult[Element] = nodeJson.validate[Element]
      result.isSuccess should beTrue
      result.get should beEqualTo(Node("myNode"))
    }
  }
  "Node reads" should {
    "read a node json as an node" in {
      val result: JsResult[Node] = nodeJson.validate[Node](Node.nodeReads)
      result.isSuccess should beTrue
      result.get should beEqualTo(Node("myNode"))
    }
    "fail to read an edge json as a node" in {
      val result: JsResult[Node] = edgeJson.validate[Node](Node.nodeReads)
      result.isError should beTrue
      val JsError(errors) = result
      val invalidNode = JsError.toJson(Seq(
        (__ \ "type") -> Seq(ValidationError("is not of expected type node"))
      ))
      JsError.toJson(errors) should beEqualTo(invalidNode)
    }
  }

  "Edge reads" should {
    "read a edge json as an edge" in {
      val result: JsResult[Edge] = edgeJson.validate[Edge](Edge.edgeReads)
      result.isSuccess should beTrue
      result.get should beEqualTo(Edge("myEdge"))
    }
    "fail to read a node json as an edge" in {
      val result: JsResult[Edge] = nodeJson.validate[Edge](Edge.edgeReads)
      result.isError should beTrue
      val JsError(errors) = result
      val invalidEdge = JsError.toJson(Seq(
        (__ \ "type") -> Seq(ValidationError("is not of expected type edge"))
      ))
      JsError.toJson(errors) should beEqualTo(invalidEdge)
    }
  }

  val edgeJson = Json.parse(
    """
      |{
      |  "type":"edge",
      |  "name":"myEdge"
      |}
    """.stripMargin)

  val nodeJson = Json.parse(
    """
      |{
      |  "type":"node",
      |  "name":"myNode"
      |}
    """.stripMargin)
}

if you don't want to use asInstanceOf as a cast you can write the elementReads instance like so :

implicit val elementReads = new Reads[Element] {
  override def reads(json: JsValue): JsResult[Element] =
    json.validate(
      Node.nodeReads.map(e => e: Element) orElse
      Edge.edgeReads.map(e => e: Element)
    )
}

unfortunately, you can't use _ in this case.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM