3
votes

I have the following case classes and JSON combinators:

case class Commit(
    sha: String,
    username: String,
    message: String
)

object Commit {
    implicit val format = Json.format[Commit]
}

case class Build(
    projectName: String,
    parentNumber: String,
    commits: List[Commit]
)

val buildReads: Reads[Build] =
    for {
        projectName <- (__ \ "buildType" \ "projectName").read[String]
        name <- (__ \ "buildType" \ "name").read[String]
        parentNumber <- ((__ \ "artifact-dependencies" \ "build")(0) \ "number").read[String]
        changes <- (__ \ "changes" \ "change").read[List[Map[String, String]]]
    } yield {
        val commits = for {
            change <- changes
            sha <- change.get("version")
            username <- change.get("username")
            comment <- change.get("comment")
        } yield Commit(sha, username, comment)
        Build(s"$projectName::$name", parentNumber, commits)
    }

My JSON reads combinator for Build will handle incoming JSON such as:

{
    "buildType": {
        "projectName": "foo",
        "name": "bar"
    },
    "artifact-dependencies": {
        "build": [{
            "number": "1"
        }]
    },
    "changes": {
        "change": [{
            "verison": "1",
            "username": "bob",
            "comment": "foo"
        }]
    }
}

However, if artifact-dependencies is missing, it will fall over. I would like this to be optional.

Should I use readNullable? I have tried to do so, but this fails because it is a nested property.

Does this look pragmatic, or am I abusing JSON combinators to parse my JSON into a case class?

2
Off the top of my head: could you use .asOpt instead of .read? - mfirry
That's not available on the JsPath object. - user1082754
Could you please paste some sample json? My sense is that you are making this unnecessarily complicated. - LuxuryMode
Hi @LuxuryMode, there is already sample JSON in my original post? - user1082754
@OliverJosephAsh doh. Don't know how I missed that. - LuxuryMode

2 Answers

6
votes

Currently the Format[Commit] in its companion object isn't being used. There's no reason we can't use simple combinators for that, and separate the logic.

case class Commit(sha: String, username: String, message: String)

object Commit {

    implicit val reads: Reads[Commit] = (
        (__ \ "version").read[String] and 
        (__ \ "username").read[String] and 
        (__ \ "comment").read[String]
    )(Commit.apply _)

}

Then, if "artifact-dependencies" can be missing, we should make parentNumber an Option[String] in Build.

 case class Build(projectName: String, parentNumber: Option[String], commits: List[Commit])

I split the Reads that combines project names into a separate one to make the Reads[Build] look a little more clean.

val nameReads: Reads[String] = for {
    projectName <- (__ \ "projectName").read[String]
    name <- (__ \ "name").read[String]
} yield s"$projectName::$name"

Then, for when "artifact-dependencies" is missing, we can use orElse and Reads.pure(None) to fill it with None when that entire branch (or sub-branch) is not there. In this case, that would be simpler than mapping each step of the way.

implicit val buildReads: Reads[Build] = (
    (__ \ "buildType").read[String](nameReads) and
    ((__ \ "artifact-dependencies" \ "build")(0) \ "number").readNullable[String].orElse(Reads.pure(None)) and
    (__ \ "changes" \ "change").read[List[Commit]]
)(Build.apply _)

val js2 = Json.parse("""
{
    "buildType": {
        "projectName": "foo",
        "name": "bar"
    },
    "changes": {
        "change": [{
            "version": "1",
            "username": "bob",
            "comment": "foo"
        }]
    }
}
""")

scala> js2.validate[Build]
res6: play.api.libs.json.JsResult[Build] = JsSuccess(Build(foo::bar,None,List(Commit(1,bob,foo))),)
0
votes

I try to have my formats match the json as closely as possible. Admittedly, in this case it's a bit awkward, but that's because the json schema is kind of weird. Here's how I would do it given those limitations:

import play.api.libs.functional.syntax._
import play.api.libs.json._
case class Build(buildType: BuildType, `artifact-dependencies`: Option[ArtifactDependencies], changes: Changes)
case class BuildType(projectName: String, name: String)
case class ArtifactDependencies(build: List[DependencyInfo])
case class DependencyInfo(number: String)
case class Changes(change: List[Commit])
case class Commit(version: String, username: String, comment: String)

object BuildType {
  implicit val buildTypeReads: Reads[BuildType] = (
    (JsPath \ "projectName").read[String] and
    (JsPath \ "name").read[String]
  )(BuildType.apply _)

}

object ArtifactDependencies {
  implicit val artifactDependencyReads: Reads[ArtifactDependencies] =
    (JsPath \ "build").read[List[DependencyInfo]].map(ArtifactDependencies.apply)
}

object DependencyInfo {
  implicit val dependencyInfoReads: Reads[DependencyInfo] =
    (JsPath \ "number").read[String].map(DependencyInfo.apply)

}

object Changes {
  implicit val changesReads: Reads[Changes] =
    (JsPath \ "change").read[List[Commit]].map(Changes.apply)
}

object Commit {
  implicit val commitReads: Reads[Commit] = (
    (JsPath \ "version").read[String] and
    (JsPath \ "username").read[String] and
    (JsPath \ "comment").read[String]
  )(Commit.apply _)
}
object Build {

  implicit val buildReads: Reads[Build] = (
    (JsPath \ "buildType").read[BuildType] and
    (JsPath \ "artifact-dependencies").readNullable[ArtifactDependencies] and
    (JsPath \ "changes").read[Changes]
  )(Build.apply _)

  def test() = {
    val js = Json.parse(
      """
        |{
        |    "buildType": {
        |        "projectName": "foo",
        |        "name": "bar"
        |    },
        |    "changes": {
        |        "change": [{
        |            "version": "1",
        |            "username": "bob",
        |            "comment": "foo"
        |        }]
        |    }
        |}
      """.stripMargin)

    println(js.validate[Build])

    val js1 = Json.parse(
      """
        |{
        |    "buildType": {
        |        "projectName": "foo",
        |        "name": "bar"
        |    },
        |    "artifact-dependencies": {
        |        "build": [{
        |            "number": "1"
        |        }]
        |    },
        |    "changes": {
        |        "change": [{
        |            "version": "1",
        |            "username": "bob",
        |            "comment": "foo"
        |        }]
        |    }
        |}
      """.stripMargin)

    println(js1.validate[Build])
  }
}

The output is:

[info] JsSuccess(Build(BuildType(foo,bar),None,Changes(List(Commit(1,bob,foo)))),)
[info] JsSuccess(Build(BuildType(foo,bar),Some(ArtifactDependencies(List(DependencyInfo(1)))),Changes(List(Commit(1,bob,foo)))),)

Note that the slightly awkward

(JsPath \ "change").read[List[Commit]].map(Changes.apply)

is necessary for single argument case classes.

EDIT:

The crucial part I missed is that parentNumber now becomes a method defined on Build as follows:

case class Build(buildType: BuildType, `artifact-dependencies`: Option[ArtifactDependencies], changes: Changes) {
  def parentNumber: Option[String] = `artifact-dependencies`.flatMap(_.build.headOption.map(_.number))
}