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))
}